Compare commits

...

51 Commits

Author SHA1 Message Date
nexxo 1e22d66639 Release v1.3.64
tests / pest (push) Failing after 8m53s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Two features reach a tag at once, because the host-takeover branch was cut from
the operating-mode branch and carries all of it.

The operating mode: a test and a live setting for the whole installation, every
credential with a test slot beside its live one, a stored key that says which
mode it belongs to, and a Stripe catalogue that refuses to act on objects from
the other account.

The host takeover: the bootstrap script that turns a machine in a rescue system
into a finished Proxmox host, the archive that serves it, the one-time enrolment
code, and the six-step guide in the console under Hosts and Add host. None of
the script's steps has been accepted on real hardware yet — every hardware step
in its plan is still unticked, and the console work is what this release makes
visible.

Also in here, found while writing the takeover: host names were being built from
the CUSTOMER zone. RegisterHostDns says clupilot.com in its own docblock, and on
this installation a host was actually called fsn-01.node.clupilot.cloud. There
is a platform_zone now and the step uses it.

2017 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:22:00 +02:00
nexxo 447d6fe0f3 Merge main into the host-takeover branch
One real conflict, in SyncStripeCatalogue::handle(), and both sides were right.

Main's parallel work resolves the adoption singletons and takes "before" counts
so a run can tell created from adopted. The operating-mode work refuses a
catalogue whose stored objects belong to the other mode, and records which mode
the catalogue now belongs to.

Kept both, with the mode guard first. It REFUSES, so it must not run behind
anything that has already built state; the counters only need to be in place
before the create loop, and they still are. Reversing that order would have the
command resolve singletons and take counts on a catalogue it is about to reject.

2017 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:21:45 +02:00
nexxo ba4996f316 Keep the platform on .com and the customers on .cloud
The owner caught me putting the archive on clupilot.cloud. That is the customer
zone, and looking into it turned up the same confusion already sitting in the
code — in the one place that matters most.

RegisterHostDns says "fsn-01.node.clupilot.com" in its own docblock. The
validation comment in Datacenters says it. ServicesTest writes it out verbatim.
The step itself built the name from config('provisioning.dns.zone') — the
CUSTOMER zone — so on this installation a host was actually called
fsn-01.node.clupilot.cloud. Three places asserting one thing and the code doing
another.

OfficialDomains explains why that matters and is worth not weakening: two
registrable domains by design, the company's for site, portal and console, the
instance zone for customer workloads. A Nextcloud is third-party software that
strangers sign into, and on the same registrable domain as the portal it shares
cookie scope with it. A host name in that zone does not break the separation,
but it puts it in question, and the next slip is more expensive.

So there is now a platform_zone, derived from APP_URL when unset, and
RegisterHostDns uses it.

My own archiveUrl was broken for a second reason. It fell back to url() when
APP_HOST is empty — and APP_HOST is empty on most installations, because empty
means "the portal answers on any hostname" and that is the default. Called from
the console, url() would have produced the CONSOLE hostname, and the line would
have 404'd on a machine that is not allowed to reach the admin area at all. It
takes the host from APP_URL now.

The script no longer guesses its own name. It used reverse DNS, then the tunnel
address, then a hard-coded clupilot.net — a third domain that appears nowhere
else in this project and was simply invented. PrepareBaseSystem has the same
invention. A guessed name does not stay guessed: it ends up in /etc/hostname, in
/etc/hosts, in every log line and in every certificate request the machine ever
makes. CluPilot knows the name because it just assigned it, so it passes --fqdn
and the script refuses without it. The value now also survives the reboot in the
arguments file, which it would not have.

The ACME contact moved to .com for the same reason it was wrong: the operator
does not live in the customer zone.

Open, and NOT decided here: the owner also wants the host to get a public DNS
record and a certificate on the .com name. RegisterHostDns deliberately writes
host names only into the tunnel's dnsmasq, and says why — publishing them hands
every scanner the internal subnet and roughly how many hosts sit behind it.
Nothing in the current design needs a public certificate for a host's own name;
Traefik serves customer domains, not this one. Reversing that is a security
decision and belongs to the owner, not to this commit.

1992 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:04:08 +02:00
nexxo 2194ab8079 Put the instructions where somebody reads them before they start
The previous commit shipped the guide AFTER the host was created. Its first two
steps happen at the provider — order the machine, boot the rescue system — and
whoever reads them there has the one-time code already in the clipboard with its
24-hour window running. The instructions were correct and in the wrong place,
which is its own kind of wrong.

Six steps now, not three, and the two provider ones are named rather than
assumed. They are marked "first" so the list does not read as something you can
start at the top of and work down: step 2 is half an hour of waiting, and doing
it inside a running code is exactly the mistake the page should prevent.

All six are visible the whole time, including the done ones and the ones still
to come. A guide that shows only the current step cannot answer "how much is
left", which is the question somebody has while a server they are paying for
sits in a rescue system.

It is on the hosts list too, collapsed. Somebody standing there may not have
ordered the machine yet — and that is step 1. Requiring them to click "add host"
to find out what the procedure is puts the answer behind the action it describes.

One component, two places, so the archive address in the instructions is the same
string the command line will carry. Two copies of that would drift, and the
difference would surface on a server that has already been paid for.

The step-4 command box moved into the guide rather than sitting beside it, so
after creating a host the whole procedure stays on screen. Somebody stuck at
step 5 should not be looking at a page that has become a single box.

1991 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:51:47 +02:00
nexxo 903ebdd2b2 Give the operator one line to copy and three steps around it
The console could describe a takeover it had no way to start. This is the
vertical slice that closes that: a one-time code, the archive the rescue system
fetches, and the page that says what to do with both.

The command carries EVERYTHING the script needs before the tunnel exists,
because there is nothing to fetch — that is the whole point of spec §5. Which
means CluPilot generates the WireGuard keypair and admits the peer at the hub
before the machine has ever booted, and hands the private half over in the line.
It is worthless within minutes: task 9 of the script replaces it with one
generated on the machine.

Shown exactly once. The database holds only the code's hash and never the
private key, so leaving the page does not bring it back — it mints a new code,
which invalidates the old one. That is deliberate: a glance at somebody's screen
should be worth nothing an hour later.

Which is also why save() no longer redirects. Sending the operator to the host
detail page sends them away from the only value they need, and an existing test
asserted that redirect — it now asserts the opposite, with the reason written
next to it.

SHA-256 rather than bcrypt for the code, and the reason is not speed. Both
endpoints have to FIND the host by the code; with bcrypt that means trying every
row. The code is 32 characters of CSPRNG output, so it has the entropy that
stretching exists to manufacture.

resolve() and claim() are separate because progress reports arrive BEFORE
registration. If reporting consumed the code, a host could never register after
its first message.

The archive URL is always the public hostname. The console runs under admin.…,
but this line executes on a machine that must not reach the admin area — it is
locked down for exactly that reason — so route() from the console would emit a
hostname that 404s on a server only reachable through the provider's console.

The page warns about missing tunnel settings BEFORE the host is created, not
after. An empty hub key produces a line that looks clean, copies fine, runs, and
ends in a tunnel that never handshakes — discovered on the machine, after
somebody has already paid for it.

The three steps lead with the rescue system, because that is the one nobody
knows by heart, and it says enabling is not the same as booting into it — the
script refuses a running production machine, which is what a half-done switch
looks like from the inside.

1986 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:40:19 +02:00
nexxo 668ed67de4 Write down what to do when the bootstrap stops
One entry per section: how you notice, where to look, what it usually is. Above
all of them the rule the plan already states — a half installed machine is
reinstalled, not repaired — with the reason, which is not convenience. The
script is built so a second run on a freshly imaged machine always comes out the
same; a run on a machine somebody straightened out by hand is no longer that.
Repairing trades a repeatable result for a one-off, and the difference only
shows up on the next host.

rescue_checked is the stated exception, because nothing has been written yet. It
is the only section where fixing in place is right, and the runbook says so
rather than leaving the reader to infer it.

Two things that are easy to get wrong are answered up front. Whether the reboot
has happened is one `findmnt -no FSTYPE /` — tmpfs or overlay means still in the
rescue system, zfs means the first-boot hook already resumed. And when a host is
unreachable, the first question is whether the firewall had even run: it is
applied last, so if `registered` is not done, the cause is the network and not
the rules.

The installer can be watched while it runs. If QEMU is still up, forwarding 5900
over SSH shows what the ISO is doing, and an input mask there means the answer
file was not accepted and the installer has dropped into interactive mode where
it will sit until the hour expires. That failure otherwise arrives as a timeout
saying nothing.

The last heading is the honest one: everything here is written from the script
and the findings behind it, not from incidents. What actually stops on the first
real run belongs in this file afterwards, quoted as it appeared in the console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:22:32 +02:00
nexxo f45f290f00 Hand over the token and let the console take it from here
The last section carries three things, and none of them could sit anywhere
else: the registration and key swap, Traefik's token plus the proof that a route
table actually arrives, and the nftables lockdown that ran last in the old
pipeline for a reason.

`|| true` is gone from the pveum calls, and not by deleting it. `role add ||
true` applied the privilege list only on the run that first created the role, so
a privilege added later reached new hosts and no existing one — which is exactly
how Sys.Modify was missing everywhere, surfacing much later and somewhere else
as a 403 on POST /cluster/backup that failed a paying customer at
register_backup. So: add, and on failure modify, as two separate commands so a
failure can be attributed. For the user and the ACL the tolerated `|| true` is
replaced by looking: try, and if it fails, check whether the desired state holds
anyway. A `|| true` hides "already there" and "went wrong" equally well.

The privilege list is copied verbatim from config/provisioning.php and checked
against it — 18 privileges, byte-identical, Sys.Modify included. It is granted on
/ because both endpoints that need it check / and nothing narrower satisfies
them.

The key swap follows the four steps this plan was amended to require. Register
first and hold the answer, switch wg0 to the new private key, PROVE a fresh
handshake, and only then let the old key go. If the handshake does not come, it
puts the old key back and fails loudly. Ordering alone says when to discard, not
whether the new key carries — and a host that locks itself out on the final step
is the one failure nobody fixes remotely.

Traefik's token is written here because here is where it exists, and the route
table is then fetched directly with that token and that URL. Section 7 could not
give this proof; it is not skipped, it is given where it can be.

The lockdown comes dead last, with the full ruleset from SecureHostFirewall
including the ICMP correction — ICMPv6 neighbour discovery and packet-too-big,
IPv4 fragmentation-needed — because a bare policy drop takes IPv6 down within
minutes and black-holes large transfers instead of failing them. The ruleset is
validated with `nft -c` before it is applied, since a partially loaded ruleset
means policy drop is in place and the exceptions are not. There is no automatic
reopening: a firewall that reopens itself under failure is not a firewall, so the
emergency release is a script for the provider's console.

Verified without hardware: all seven files dash-clean; json_field reads
full-tokenid, value and host_token without jq; the privilege list is identical to
the config's; and the rendered ruleset carries policy drop, both ICMP families,
80/443, the DHCP rebind rule and the tunnel-only 22/8006. Step 2 unticked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:20:20 +02:00
nexxo 5d25018cc6 Build the golden template so nobody has to remember how
All three Block A traps are CHECKED here rather than assumed, because each one
has already cost a paid order and none of them shows a symptom until it is too
late.

Trap 2 is checked before anything is built: virt-filesystems is asked whether
the image uses LVM and whether root is the last partition, and the section
refuses if either is wrong. Debian's cloud image satisfies both by construction,
which is exactly why it is the base — but "satisfies it by construction" is a
claim about an artefact somebody else builds, and it gets verified rather than
trusted.

Traps 1 and 3 are checked AFTER the image is customised, because virt-customize
reports success even when apt quietly did not install something. qemu-guest-agent
has to be in /usr/bin or every provisioning run hangs in WaitForGuestAgent until
it times out, and the compose file inside the image has to carry `user:
www-data` or every occ call fails — including the acceptance check that decides
whether a customer's instance is usable.

The template is verified by its ATTRIBUTE, not its existence. VerifyVmTemplate
checks only that 9000 is there, which a VM that merely happens to be numbered
9000 also passes; `template: 1` is passed only by a template.

Docker comes from Docker's own repository, not Debian's. docker.io ships no
compose plugin and Debian's docker-compose is the old Python one, which does not
read this file at all.

The compose file is deliberately customer-independent: no password, no name, no
domain. Everything variable arrives in /opt/nextcloud/.env, written into the
guest by cloud-init at clone time. Baking a password into an image copies it
onto every host and into every instance, and leaves it there long after the
customer has changed it.

OVERWRITEPROTOCOL, OVERWRITEHOST and TRUSTED_PROXIES are set because TLS ends at
Traefik on the host. Without them Nextcloud builds its own URLs with http://,
the login loops, and WebDAV clients get handed an address that does not exist.

Storage is discovered rather than named: local-zfs is what the PVE installer
creates on ZFS, local-lvm on ext4, and `local` frequently cannot hold disks at
all — an importdisk there fails only after the copy.

Verified without hardware: dash-clean; the compose file parses, carries `user:
www-data` on the app service, publishes 80, and every credential is a ${...}
reference rather than a literal. Step 2 unticked, and it is the one the plan is
most insistent about: the template must actually be cloned, started, and asked
`occ status` as www-data. Without that clone it is not proven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:16:50 +02:00
nexxo a25e9c2fe6 Run Traefik as a service that fetches its own routes
Binary plus systemd unit, no Docker on the hypervisor: nftables stays the only
owner of the host's firewall rules and a Docker daemon would bring its own
chain.

The names were read out of SshTraefikWriter::render() rather than guessed, which
is what the handoff asks for in as many words. The route endpoint emits
entryPoints ["websecure"] and certResolver "letsencrypt", so the static config
declares exactly those. Name them differently here and the routers point at
nothing while Traefik still reports a clean start — a failure with no symptom
at the place it happens.

An ordering problem this plan had not resolved: the http provider needs the
durable host token, and that token only exists after POST /host/register, two
sections later. Rather than reorder the section keys — they are the contract
with the platform plan — the static config is written twice. Here with an empty
token, so the service stands and holds 80 and 443, and again in Task 9 with the
real one. The proof does not disappear, it moves to where it can be given.

That proof asks the endpoint directly with the same token and URL the config
carries, instead of counting Traefik's routers. A fresh host has no customers,
so its table is legitimately empty, and "zero routers" would mean both "fine"
and "never fetched".

Ports are checked separately from the service. "Running" and "listening" are two
claims, and Traefik starts cleanly even when a typo means an entryPoint was
never created.

The binary is checksummed against the release's own checksums file, for the same
reason the ISO is: what listens on 80 and 443 and holds every customer's
certificate does not get taken off the network unverified. The version is
discovered at runtime, because a pinned number becomes a 404 mid-takeover.

acme.json is created at 600 before Traefik ever runs. It holds the private keys
of every customer certificate, and Traefik refuses wider permissions — rightly.
The unit runs with CAP_NET_BIND_SERVICE and nothing else, ProtectSystem=strict,
NoNewPrivileges: this is the one process on the box reachable from the open
internet.

Verified without hardware: dash-clean, the generated config parses as YAML, and
it carries web/websecure/traefik as entryPoints, letsencrypt as the resolver,
the Bearer header on the http provider, web redirecting to websecure, and 640 on
the config with 600 on acme.json and the token file. Step 2 unticked — it wants a
fetched route set, and that is Task 9's to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:13:54 +02:00
nexxo c345f21e7d Join the tunnel, then tell the console everything so far
The three interface states come over from ConfigureWireguard.php exactly as
that file works them out, because the one line they replace got two of them
wrong. `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` brought the
interface up via its fallback WITHOUT the systemd enablement, so the tunnel did
not come back after a reboot; and on the next attempt both halves failed with
"wg0 already exists", so it retried forever against a tunnel that was working.
Enable before start, because enable is the half that survives a reboot, and it
is repaired even when the interface is already up by other means.

The configuration is compared before it is written. The file used to be
rewritten unconditionally with nothing reloading it, so correcting a wrong hub
key showed a new file and identical behaviour — the running interface still
held the old peer. Knowing whether it CHANGED is what lets an unchanged, working
tunnel be left alone and a corrected one actually be applied. A change means a
restart rather than `wg syncconf`, because syncconf applies peers only and a
corrected Address or AllowedIPs would silently do nothing.

AllowedIPs is computed, not copied. Writing the host's own address there would
let the tunnel handshake and leave every other participant unreachable — a fault
that looks like a routing problem somewhere else entirely.

The handshake target is derived from --api rather than taking its own argument.
CluPilot's tunnel address is already in there, and a second value meaning the
same thing is a second value that eventually disagrees with the first.

The tunnel counts only when it has been PROVEN, never when the file is on disk.
A stored tunnel address without proof made every later connection attempt
useless and the recovery was hand-editing the database. Once it is proven,
flush_reports runs and the whole history from the rescue system onwards reaches
the console carrying its original timestamps.

Verified without hardware: dash-clean; the subnet arithmetic is right on octet
boundaries and off them (192.168.5.130/25 gives 192.168.5.128/25, 172.16.4.9/12
gives 172.16.0.0/12); the API host parser handles a bare address, a scheme, a
port and a path; and wg0.conf renders with AllowedIPs on the network rather than
the host. Step 2 unticked — it wants all five earlier sections showing up in the
console with real timestamps, and that needs a hub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:08:39 +02:00
nexxo 9e485efe87 Write the firewall's running order into the plan, not just the commit
The previous commit moved the nftables lockdown out of Task 5 and explained why
in its message. A commit message is not where the next person looks. Task 5 now
carries it: the bridge is normally already there because the ISO installer
writes it, the lockdown belongs in Task 9 because SecureHostFirewall ran last
for a reason, and "check from outside" cannot mean what it says before the
tunnel exists — so the script checks inside-out and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:06:48 +02:00
nexxo e9b2862331 Bridge the network, and take it back if the host goes quiet
Reading ConfigureProxmox.php changed what this section does. The missing vmbr0
that killed the first paid order is a consequence of installing Proxmox ON TOP
OF DEBIAN — "only the ISO installer writes that bridge into
/etc/network/interfaces". This design installs from the ISO, so the bridge is
normally already there. The section verifies it instead of building it: exists,
has an address, and actually carries the default route. The old step checked
only the first of those, threw the result away, and its comment claimed it
recorded the absence.

When there is no bridge, it builds one, and only then is this the dangerous
section the plan calls it. Backup, five-minute rollback timer, switch, look,
cancel — and the timer is a systemd unit rather than a backgrounded sleep,
because a background job dies with its session and the session is exactly what
breaks when the switch goes wrong. It is set BEFORE the change; setting it after
would mean setting it at the moment the connection is already gone.

The three provider shapes differ in one line, and that line decides whether the
machine is still there afterwards. A routed single address needs pointopoint,
because its gateway is outside its own subnet and without it the kernel has no
route to reach it. Which shape applies is decided by asking `ip route get` about
the gateway rather than by doing subnet arithmetic here — same logic the kernel
will apply later.

The reachability check is named for what it is: inside-out. A true outside-in
proof would need a counterpart, and before the tunnel there is none. It is
enough because a bridge that takes the host off the network takes both
directions with it, and what it does not cover is precisely what the timer
covers.

One deviation from the plan, written into it: the nftables lockdown does NOT
belong here. SecureHostFirewall ran LAST in the old pipeline, deliberately, so
the tunnel had already carried every earlier step before SSH-to-the-world was
closed. Here it would sit BEFORE wireguard_joined and shut port 22 with no
handshake yet proven — the ordering that makes a host permanently unreachable.
It moves to Task 9, where it used to be. The self-rollback stays here and covers
the network change; the firewall keeps its manual emergency release, because a
firewall that reopens itself under failure is not a firewall.

The datacenter firewall comes over verbatim, including why `enable 1` alone is a
trap: the management ipset is seeded from the public subnet, so enabling it
blindly drops the tunnel address that every later step arrives on. Three
settings in order, then read back — a pvesh set returning 0 is not the same as
the value being in cluster.fw, and a write that lost quorum is exactly the case
where every customer VM's rules stay inert while onboarding reports success.

Verified without hardware: all four files dash-clean, and the generated
interfaces file is correct for subnet, routed (with pointopoint) and dhcp.
Step 2 unticked — the self-rollback has to be triggered on purpose on real
hardware, and the plan is right that without that it is an assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:06:18 +02:00
nexxo f214713113 Point Proxmox at the sources this Debian actually has
PVE arrives with the image now, so this section accepts it rather than installs
it — but it needs every line of knowledge from InstallProxmoxVe.php to do that,
just pointed the other way.

The kernel is the claim, not the package. An installed proxmox-ve running on a
Debian kernel is exactly the half state RebootIntoPveKernel exists because of,
so uname is what gets checked.

The codename table now verifies a pairing instead of creating one. An image
whose PVE major does not match its Debian base was built wrong, and no amount
of fixing package sources heals a PVE compiled against a different libc — that
machine gets reinstalled with the right image. The other half of the table,
codename to PVE major, is written down here because the ISO route needs it and
the original file only had the suite side.

Removing the subscription repositories is the part that everything after this
depends on. A Proxmox image ships pve-enterprise, and without a subscription it
fails every apt-get update — after which no package install in any later
section succeeds. Both spellings are handled, because PVE 8 used one-line .list
files and PVE 9 moved to deb822, and ceph is in the list because the same trap
is set there a second time. The proof is a clean apt-get update at the end,
which is the only thing that tells removal apart from overwriting.

One thing changed after the first draft: when pveversion cannot be parsed, the
pairing check used to be skipped in silence. That is the fake that R19 records
as worse than no check at all, because it stops the next person from looking.
It now says "Paarung UNGEPRÜFT" in the report and in the log.

Verified without hardware: dash-clean; bookworm maps to PVE 8, trixie to PVE 9,
forky is refused; the pveversion parser reads 9 and 8 out of both real formats
and yields nothing for garbage; and against a copy of /etc/apt/sources.list.d
holding all five subscription files plus an unrelated one, exactly the five go
and the unrelated one stays. Step 2 unticked — there is no Proxmox here to
accept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:00:01 +02:00
nexxo 0f563545fd Write the image and survive the first reboot
The official Proxmox ISO, an answer file baked into it, and QEMU running it
against the physical disks from the rescue system. Options were looked up
rather than recalled: prepare-iso takes --fetch-from iso, --answer-file and
--on-first-boot, and it names its own output, so the finished ISO is found by
glob afterwards instead of being handed an --output flag I would have invented.

validate-answer runs BEFORE the ISO is baked. An answer file with a typo drops
the installer into interactive mode, where it then hangs invisibly inside QEMU
until the hour runs out — the failure would arrive as a timeout and say nothing
about the cause.

The codename table comes over verbatim from InstallProxmoxVe.php, which the
platform plan deletes. It is used here to install the assistant into the RESCUE
system, so it keys off the rescue system's own codename. An unknown one aborts;
not "trixie is newest, so trixie", because the next Debian will be unknown too
and by then nobody is watching.

The ISO is checksummed against the mirror's SHA256SUMS. Booting an operating
system that could have become anything in transit is not a risk worth taking,
and a truncated download is enough to cause it — malice is not required.

The reboot carry-across is the gap this plan was amended for, and [first-boot]
takes exactly one executable. So that one file carries everything: the script,
its lib/, the progress file and the arguments, as a base64 archive in its own
body. That also avoids importing the fresh ZFS pool from the rescue system to
copy files into it.

Two bugs found by running it rather than reading it. The staging copy put the
work directory inside the state directory, so cp refused to copy a directory
into itself — and had it not refused, the gigabyte ISO would have been base64'd
into the hook that gets baked into that same ISO. The fix is not a better
exclude: the two directories have opposite lifetimes, so they now live in
different places, and a guard refuses loudly if anyone points them at each other
again. Structure beats a rule someone has to remember.

Verified without hardware: both files pass sh and dash. The generated answer
file parses as TOML with the expected disk-list, zfs.raid, zfs.arc-max,
first-boot and network.filter, and a 64-character root password. The codename
table accepts bookworm and trixie and refuses bullseye and forky. The shim comes
out at 26 KB with a 3 MB dummy ISO sitting in the work directory, proving it
stayed out. Run in a fresh debian:13-slim container it unpacks to
/opt/clupilot/bootstrap and /var/lib/clupilot, restores the progress file with
its original timestamp, has 700 on the directory and 600 on the arguments that
hold the WireGuard key, skips rescue_checked as already done, and fails cleanly
on the missing qemu. Step 2 stays unticked: a container is not a rescue system,
and nothing here has yet written to a disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:57:29 +02:00
nexxo 0b26e45147 Install Proxmox the way Proxmox says to, not the way I guessed
The previous commit put installimage with a Proxmox image into Task 3. That
image does not exist. Hetzner's installimage carries no Proxmox and lists it as
unsupported, so anyone following that instruction finds only Debian and ends up
back at the intermediate base system the same commit had just removed. Looked
up rather than remembered, which is what should have happened before writing it
down.

The mechanism the owner described does exist and is the official one since PVE
8.2: build the real Proxmox ISO with an embedded answer file via
proxmox-auto-install-assistant, run it under QEMU against the physical disks
from the rescue system, reboot into a finished node. Hetzner publishes a
tutorial for exactly this. No Debian, no VNC, no click — which is what the
owner said in the first place.

Two things fall out of the answer file that improve the plan.

The hand-written systemd resume service is gone. [first-boot] with
source = "from-iso" is the supported way to have the script pick itself up
after the reboot, and it is the only variant that can work before the tunnel
exists — a from-url hook would be pointing at a CluPilot it cannot reach yet.
The carry-across of the progress file stays necessary regardless: the rescue
system's copy still lives in RAM.

Trap 2 was being applied to the wrong machine. "Root partition last, not on
LVM" comes from the template traps, and the reason it exists is
GrowGuestFilesystem, which grows a GUEST filesystem. The template is built in
Task 8 from a Debian cloud image, not from the host's own layout, so the
justification written into Task 3 ("it applies to the host too, because the
template is built from here") does not hold. It remains binding for the
template, where it belongs. The host gets ZFS, and that it happens to involve
no LVM is a side effect rather than the reason — with arc-max pinned, because
the default takes half the RAM a host needs for its guests.

Also recorded: the answer file needs an fqdn and a root password that the
command line does not carry. The password is generated and thrown away, because
the platform no longer logs in over SSH and the plan already says a half
installed machine is reinstalled rather than repaired — keeping a secret for an
emergency the plan does not have only creates another place a secret lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:45:29 +02:00
nexxo 8f943f3d1b Take the Debian step out of a plan that never needed one
The owner contradicted the plan, and the plan was wrong. It had the script
write Debian 13 and then had Task 4 turn that into a Proxmox host. In reality
you boot the provider's rescue system and install Proxmox directly — there is
no intermediate Debian for anyone to convert.

The section keys do NOT change. They are the only agreement with the platform
plan, and they are not worth touching for a rename — especially since they
still fit: Proxmox VE is Debian with the PVE kernel and packages on top. What
changes is what they mean, written down so the console labels them honestly:
debian_installed is "base system written", proxmox_installed is "PVE proven to
run".

Task 3 now uses installimage with a Proxmox image, and says the image name gets
pinned during acceptance instead of guessed here — the list belongs to the
provider and moves. No debootstrap and no hand-rolled partitioner: the provider
knows its own hardware, and a self-built boot path on someone else's metal only
comes back through their console. Hetzner dedicated only; a netcup path gets
written when there is a netcup machine to accept it on, because an unproven
fallback is exactly the assertion this plan avoids everywhere else (R22).

Task 4 keeps every line of knowledge from InstallProxmoxVe.php and needs all of
it — it just applies it to an image instead of a bare base. The codename table
now VERIFIES the pairing rather than creating it, and the enterprise repo that
ships with a Proxmox image has to go, because without a subscription it fails
every apt-get update and therefore every package install in the sections that
follow. Its acceptance now includes a clean apt-get update, which is the only
way to tell removal from overwriting.

Delivery is settled too, and was written down in neither document: a tar.gz
unpacked to /opt/clupilot/bootstrap. A curl | sh cannot carry a lib/, and the
separate files are load-bearing. A static archive is not an endpoint that
answers questions, so spec §5 stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:41:10 +02:00
nexxo 25bcab345d Refuse a machine that cannot do the job
Four checks, all of them before a single byte is written, because this is the
only moment the script still has the option of not overwriting a disk.

The expensive one is "is this actually a rescue system". It demands POSITIVE
evidence rather than the absence of a counter-argument: a rescue system runs
from RAM, so its root is tmpfs, an overlay or a ramdisk, and an installed
system has a real partition there. A running pveversion is refused outright —
that is a hypervisor with customers on it. Mounted partitions of real disks are
refused too, because at that point the script cannot tell an empty machine from
someone's data.

There is deliberately no flag to skip the check. Such a flag gets used exactly
once, on the evening it should not have been. A rescue system this does not
recognise belongs in the runbook, not in a bypass.

The disk floor is written as arithmetic rather than a number, because the repo
has no threshold to borrow — plans live in the database, not in
config/provisioning.php. Template ~20 GB, Proxmox and its swap and backups
~20 GB, one smallest customer ~50 GB, rounded up to 100. It is explicitly not
capacity planning; HostCapacity and reserve_pct do that after registration.
This only turns away the machine the whole thing cannot fit on.

Network and clock come from one plain-HTTP HEAD, deliberately without TLS —
otherwise a wrong clock would be checking itself and would report a certificate
error instead of the time. Where `date -d` is missing (busybox), the clock is
NOT checked and the report says so: a check that silently waves things through
is worse than none, because it stops the next person from looking. That is the
same lesson R19 records about ->timezone(config('app.timezone')).

Verified here, in both directions where a direction existed. On this VM (root
on /dev/sda1, 80 GB, /dev/kvm present): refuses with three findings at once,
exits 1, writes nothing. In a debian:13-slim container (root on overlay, no
/dev/kvm): the overlay root is accepted as rescue-like and the missing
/dev/kvm is caught, which is the abort case the plan names. Against a receiver
serving a Date header two years off: 63074141 seconds of drift, refused, with
the exact `date -u -s` line to fix it. Step 2 stays unticked — none of these is
a rescue system on a dedicated server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:36:15 +02:00
nexxo af284d7d26 Give the bootstrap a spine and a way to report
Argument parsing, the state directory, and the half of the script that knows
the way back to CluPilot. No sections are hooked up yet; main lists the nine it
will get, in the order from spec §7, so what is missing stays visible.

report() writes locally first and only then tries to send, because before
wireguard_joined there is no path at all — the lines queue up and flush_reports
delivers them later WITH THEIR OWN TIMESTAMPS. That last part is the whole
point: a twenty-minute install that arrives in one batch looks like one second
in the console, and nobody can see which section was slow.

Every network operation is explicitly caught. The script runs under `set -e`,
and a failing send is the normal case for the first five sections, not an
error — a report that aborts the run would be the diagnosis that kills the
patient.

What is sent had no agreed shape. The section keys were the only agreement
between the two plans; the envelope around them was not written down anywhere,
so it is written down here, at the top of report.sh, for the platform side to
read once. There is deliberately no "running" state: the console derives "open"
from the absence of a report (spec §7), and a third state would be a second
truth about the same thing.

The sent-marker is a line count in its own file rather than a flag rewritten
into progress.jsonl. That keeps the log append-only, so a power cut mid-write
costs at most a partial last line instead of a rewritten file — and after the
reboot in Task 3 that file is the only thing that remembers anything.

Verified here, without hardware: both syntax checks pass; two reports against
an unreachable CluPilot queue up without aborting and leave progress.sent at 0;
a receiver brought up afterwards gets both in one batch, carrying 17:28:55 and
17:28:57 rather than the 17:29:22 they arrived at; a message containing quotes,
a backslash, a tab and a newline survives as valid JSON; and section_done
counts a `done` as done and a `failed` as not. Step 2 of this task is NOT
ticked — none of that is a rescue system, and the plan is right to insist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:30:19 +02:00
nexxo 61c2501136 Close three gaps before they become three bugs in the script
Read on review, before a line of shell was written. All three would have been
found on hardware instead — two of them late, and one of them only by noticing
something that was silently absent.

The progress file did not survive the reboot. Task 1 writes it in the rescue
system, whose root lives in RAM; Task 3 installs Debian and reboots. The plan
said the resuming service uses "the same progress file" without saying how it
gets there. Because flush_reports first runs in Task 6, AFTER the reboot,
rescue_checked and debian_installed would simply never have reached the console
— and nothing would have pointed at the reboot as the reason. Task 3 now carries
the file, the script, and the arguments across, the last one root-readable only
because the WireGuard key is in it.

RebootIntoPveKernel.php was on the platform plan's delete list but not on this
plan's read list. Six files were named; seven are deleted. This is the one that
proves the boot path BEFORE issuing the one irreversible command in the whole
run: a -pve kernel image present, its initramfs present (a full /boot leaves
exactly the half-written one), and update-grub checked on its exit status, which
its predecessor discarded. Losing that would have cost a machine that does not
come back, and on a dedicated server that means the provider's console.

Reading it also corrected a rule this plan states. Task 9 rightly refuses
`pveum ... || true`, but that is not a rule against `|| true` — RebootIntoPveKernel
tolerates one deliberately, with the reason written next to it, and a script
that applies the ban literally breaks the kernel removal in Task 3. The rule is
now stated as what it is: no `|| true` over a command whose failure means
something.

The key swap in Task 9 had no proven handshake. Task 6 insists a tunnel counts
only with one, then Task 9 re-keys that same tunnel and discards the old key on
ordering alone. Ordering says when to discard, not whether the new key carries.
Four numbered steps now, with the proof third and the discard fourth, and a
fallback to the old key if the handshake does not come — the one failure nobody
fixes remotely, at the end of a run that did everything else right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:24:15 +02:00
nexxo fe07fa2e77 Break the host takeover into two plans that can run side by side
Platform: ten tasks, all provable by the suite. Enrolment code and key pair,
the tunnel-only restriction (its test is the reason the design looks the way it
does), register, progress, routes, retiring SshTraefikWriter, the console, the
customer's own subdomain, shrinking the pipeline to six steps that only look,
and pulling the readiness checks along.

Script: ten tasks, none provable by the suite — a script that installs an
operating system cannot be run in SQLite. Each one ends on real hardware
instead, and two of them insist on it: the bridge section must have its
self-revert triggered on purpose, and the template must actually be cloned and
booted before it counts.

The one real coupling is written into both: platform task 9 DELETES the seven
step files the script plan reads its knowledge from. Do not run it before that
knowledge has been lifted — git log keeps them, but nobody reads history they
do not know to look for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:13:04 +02:00
nexxo 32016d9b80 Write down the network boundary, the subdomain, and two decided follow-ups
The route pull stays inside the tunnel. The operator's criterion was not
availability but "publicly reachable, yes or no" — and an endpoint that survives
a tunnel outage must by definition be reachable outside it. So nothing new opens
from outside, on the host or here.

The subdomain becomes the customer's choice at checkout, with suggestions from
their own name and a sentence saying the address is permanently public: every
Let's Encrypt certificate lands in the Certificate Transparency logs, which are
public, searchable and never deleted.

Also recorded, both decided and both getting their own spec: resetting a
Nextcloud to factory state with a 14-day safety backup, and an outage mail to
the operator after two consecutive failed checks — measured today that neither
an incident nor a mail exists, so a customer VM can fail unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:05:23 +02:00
nexxo c0cd367a0e Drop the public endpoint: the command line already carries what the host needs
The operator was right to challenge POST /enrol. The three values a script needs
before the tunnel exists — hub key, hub address, assigned tunnel IP — are all
known to the console when the host is created, so they travel in the copied
command instead of being fetched.

The one thing that forced an endpoint was the hub needing the host's public key
before the tunnel is up. So the console generates the pair and hands it over,
and the host swaps in a freshly generated key through the tunnel afterwards.
The key that passed through a clipboard lives for minutes.

Net effect: the network boundary is unchanged. No new way in from outside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:25:02 +02:00
nexxo 0f1f397823 Design a host that installs itself and a platform that only takes it over
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:16:14 +02:00
nexxo 880b5f1998 Merge main into the operating-mode branch
Three real conflicts, and one file that did not conflict and mattered more.

Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.

billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.

ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.

HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.

HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.

StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.

1966 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:53:20 +02:00
nexxo 2d915be003 Put back the docblock space pint took on the way past
One character in a @return line of webhooks(), untouched by anything this
branch does. It was only reformatted because the file happened to be on
the pint path of the prefix-rule commit. A style fix to an unrelated line
is noise in a diff a reviewer reads line by line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:31:04 +02:00
nexxo 786318b6d4 Never tell anyone to delete a catalogue their contracts bill on
The refusal added in the previous commit had a state it read wrongly, and
the wrong reading was destructive.

record() sat at the END of handle() and in no try/finally, while
createProduct() and ensure() throw uncaught. A run that died after the
first object left hasStoredObjects() true and recorded() null. The same
state arises with no failure at all: CheckoutController → PlanPrices::
ensure() and BookAddon → SyncStripeAddonItems → AddonPrices::ensure()
mint missing prices and never call record().

In that state the next run — in the SAME mode — refused with the foreign
account message and its "Clear plan_families.stripe_product_id,
plan_prices.stripe_price_id and the … registers first", and
billing.catalogue_synced blocked with the same text. The objects were
from the account in force. The right move was to resume, which is what
the idempotency keys exist for; instead an operator was handed a delete
instruction for a catalogue live contracts are billed on.

Two changes:

  1. record() moves ahead of the creation loop, right behind the refusal.
     There it is already proved that either nothing is stored or what is
     stored belongs to the active account, so the moment carries the
     claim just as well — and a run that dies part-way can no longer
     leave a state that contradicts itself.
  2. "Origin never recorded" gets its own sentence and its own cure,
     separate from "established: other account".
     StripeCatalogueMode::matchesActiveMode() becomes
     belongsToAnotherMode(), which is only true where the other account
     is fact. The check still blocks — the origin cannot be proved — but
     it says "run the sync again", and it names no register to empty.

Red first:

  ⨯ it takes up a catalogue whose origin was never written down
      Failed asserting that 1 matches expected 0.
  ⨯ it leaves no half-built catalogue that contradicts itself when a run dies
      Failed asserting that null is identical to an object of class "App\Support\OperatingMode".
  ⨯ it tells an unrecorded origin apart from a foreign account

The two states are held apart by assertion, not by wording: only the
foreign-account sentence may name stripe_plan_prices, and the unrecorded
one must name stripe:sync-catalogue instead. The existing foreign-account
test keeps its teeth.

Full suite: 1817 passed, 6366 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:56 +02:00
nexxo 26cf1a72a0 Remember which Stripe account the catalogue was built in
The mode switches the credentials. It does not switch what was made from
them: a Price id and a Product id belong to the account that issued them,
and test and live are two accounts. Every credential got two slots on this
branch; the ids derived from them sit in single-valued columns.

So the planned sequence of this installation ended in the exact false
green this page exists to rule out — sync in test, store the live key,
switch, and billing.catalogue_synced went on reporting satisfied because
it only asked whether the column was filled. "Bereit für Livebetrieb",
and the first real order got "No such price".

Detection, not repair. Stripe does not put the account in the id —
prod_… and price_… look the same in both, only KEYS carry _test_/_live_
— so the origin cannot be read back out of a stored id, and asking
Stripe is out: this page reaches nothing over the network on a page load.
What is left is to write it down at sync time, which is what
App\Support\StripeCatalogueMode does.

One setting for the whole catalogue is only honest because
stripe:sync-catalogue now REFUSES a run into a catalogue that belongs to
the other account. Without that, the run would skip every row that already
carries an id, answer "already in step", and record an account it never
touched — the same lie one level down. The registers count as stored
objects too: inStep() takes a registered row as proof on its own for the
reverse-charge half.

The `breaks` sentence says what happens (checkout fails, no order) and
what actually helps, including the awkward half: re-running the sync is
not enough, the pointers and both registers have to be cleared first.

The slot migration backfills the one case it can prove: whatever is at
Stripe was made with the one key this installation has ever stored, so it
belongs to the account that key opens. Otherwise a long-synced catalogue
would read as "origin unknown" and the page would demand a re-sync nobody
needs.

Red first:

  ⨯ it does not call the catalogue synced when its ids belong to the other account
  ⨯ it says the sale is refused and a fresh sync is needed, not that a column is empty
  ⨯ it records the mode its objects were created in
  ⨯ it refuses to work into a catalogue that belongs to the other account
  ⨯ it syncs into the new account once the stale ids are cleared

Full suite: 1814 passed, 6357 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:12:56 +02:00
nexxo 823eeaf413 Refuse a key that does not belong to the slot it sits in
put('stripe.secret', 'sk_live_REALMONEY') in test mode was accepted,
get() handed it out, billing.stripe_secret reported satisfied, and the
badge above it said "Testbetrieb". The strict rule closed the automatic
route into that state (no fallback); the typed one stayed open.

The prefix decides it without touching the network, and that rule now
lives in ONE place — OperatingMode::ofStripeKey() — called by the three
that were answering it separately: the slot migration (unchanged verdict,
`?? Live` for a value it cannot place), StripeCheck's `live` flag
(unchanged verdict, null stays false), and the readiness check, which
never asked at all. A key it cannot place is not reported as a
contradiction: this check only says what it can prove.

The two directions get their own `breaks` sentence, because the
consequences are opposite — real money moving while the console says
test, versus no money moving while the order looks paid.

The "Prüfen" button no longer contradicts the check either: the page
rendered only ok/reason, so a live key in the test slot answered
"Geprüft: in Ordnung". It now names the account the key belongs to.

Red first:

  ⨯ it refuses a live key sitting in the test slot
  ⨯ it refuses a test key sitting in the live slot
  ⨯ it says what the wrong key does, not that a field is empty

Guard tests (ConfirmInModal, ModalHeight, IconLayout, DisplayTimezone)
run with the blade change: green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:05:58 +02:00
nexxo 5094f70c19 Say which slot the value in force is really coming from
Mode test, dns.token only in the :live slot, HETZNER_DNS_TOKEN also in the
.env — the state this installation is in the moment the migration runs:

  get()      -> "live-token-value"   (the stored live row is in force)
  source()   -> "environment"        -> card reads "Aus der Serverdatei"
  outline()  -> null                 -> no "Hinterlegt" block at all

So the console sent an operator to the .env to rotate a token that the
database supplies, where the edit would have had no effect. R19's dummy,
literally: a display that reads like an answer, is wrong, and stops the
next person looking.

source(), outline() and updatedAt() now ask the same one place get() does
(inForce()/fallback()), and the answer gets a THIRD value, 'stored_live'.
Not 'stored': that shows the Vergessen button, which would then have
pointed at the empty test slot. Strict entries (Stripe) still have no
fallback in either direction — pinned by its own test.

Red first (before the vault changed):

  ⨯ it names the live slot as the source when it is the one in force
    -'stored_live'
    +'environment'
  ⨯ it shows the outline and the date of the value that is actually in force

Two existing expectations moved from 'none' to 'stored_live' with the
reason written beside them: 'none' meant "nichts hinterlegt" about a
credential that was in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:01:33 +02:00
nexxo 507c976035 Pin the one question that must never throw
isConfigured() had no coverage at all: replacing its body with main's
`filled($this->secret())` left all 1795 tests green, while at runtime the
same mutation throws StripeNotConfigured. Six callers ask that question
precisely to avoid an exception — CheckoutController:87 would answer a
customer with a 500 instead of a sentence.

The merge makes it urgent rather than merely untidy: git auto-merges
HttpStripeClient.php without a conflict marker, so nothing forces anyone
to look at the one line where the two branches disagree.

Proved red by that exact mutation before it went in, then reverted:

  ⨯ it answers the configured question instead of throwing it
    StripeNotConfigured: No Stripe secret is stored for the [test] operating mode.
    at app/Services/Stripe/HttpStripeClient.php:376

Http::fake() + assertNothingSent(): the question is asked on every
checkout and may only read the vault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:58:22 +02:00
nexxo 49d528dbea Fix round: point at the real tab, hide the button nobody can press, prove the quiet page load
Fixes four Important findings from the review of Task 12.

Corrected nine check `tab` values at the source (BillingChecks was already
correct; OnboardingChecks/ProvisioningChecks/DeliveryChecks all carried the
pre-redesign 'integrations' value, which is not a member of
Integrations::TABS): four onboarding checks now point at 'platform' or 'env'
depending on where their field actually is, five provisioning/delivery
checks point at 'services'. checkUrl()'s match-block resolver — praised as
correct for the six checks that point at a genuinely different admin page —
is unchanged; its `default` arm is now a pure safety net, not a route any
check actually relies on.

Wrapped the "Prüfen" button in @can('secrets.manage'): mount() admits
hosts.manage OR secrets.manage, but runCheck() requires secrets.manage alone,
so an Admin-role operator could see a button that 403s on press.

Fixed a docblock on HEARTBEAT_KEYS that asserted a staleness threshold was
kept in sync with OperationChecks::STALE_AFTER_MINUTES — that constant is a
key/settings-name map with no threshold of its own, and no such
synchronisation exists.

Three new tests, each confirmed red against a deliberately reintroduced
version of the bug it covers before being confirmed green: every check's tab
value against where its field actually lives, the run-check button hidden
from an operator who cannot press it, and Http::assertNothingSent() after a
plain page load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:26:10 +02:00
nexxo e83b17ec37 Put every missing field on one page before a customer pays
Admin\Readiness renders App\Support\Readiness::byGroup() end to end: state,
label, breaks sentence and a fix link for every check, a button on the four
that have to ask a real service (DnsTokenCheck, WireguardEndpointCheck,
VmTemplateCheck, StripeCheck) and only ever run on demand, and one headline
line — ready for the operator's current mode, or how many blocking items are
still open. Wired into the nav and into Overview's own notice list beside the
same gate as Admin\Integrations, since both halves it configures come back
together on this page.

Fixed two check keys the guard test caught for real: onboarding.ssh_key and
delivery.inbound_password did not textually contain the vault key they check
(ssh.private_key, inbound_mail.password), which is exactly the kind of silent
gap this whole task exists to catch. Renamed to onboarding.ssh_private_key
and delivery.inbound_mail_password, with the two existing test files that
referenced the old keys updated to match.

Also fixed the guard test's own assertion: Pest's toContain() is variadic, so
a second string argument is a second required substring, not a failure
message (the same trap tests/Feature/ModalHeightTest.php already documents
about itself) — it failed on every entry, including ones already covered.
Replaced with str_contains()+toBeTrue(), which keeps the check and the
message both.

Two pre-existing Overview tests assumed a bare install has nothing to report;
it now correctly always starts with a readiness gap, so both were narrowed to
what they actually test — no invented data, no invented incident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:59:34 +02:00
nexxo fe1110de71 Fix round: name the slot on the card, and cover the modal that already worked
Names which of the two slots a card actually reads and writes right now, at
the card an operator types into (not only the page-top badge) — save() and
render() resolve without a mode argument, so the card looked identical in
both modes while quietly acting on whichever slot was active. Also adds the
ConfirmSwitchMode coverage IntegrationsPageTest already carries for
ConfirmSaveSecret/ConfirmForgetSecret: a modal is reachable without the
page's route middleware, and reading the code is not the same as testing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:32:16 +02:00
nexxo 96f171b3b3 Put the switch where it governs, and say when it is thrown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:09:38 +02:00
nexxo 494a3a817e Fix round: guard the parse, bound the future, name the real tab
Covers the queue assignment the second heartbeat exists to prove, stops an
unreadable heartbeat value from taking the whole readiness page down with it,
rejects a heartbeat dated into the future, and points both checks at the tab
that actually restarts the scheduler and provisioning worker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:49:06 +02:00
nexxo 6ca3e6ef4a Notice when nobody is picking up the queue
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:32:09 +02:00
nexxo 8321e825eb Fix round: CGNAT, guarded write/delete, and a silent host is not a missing template
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:19:44 +02:00
nexxo 2eff3a5a3c Tell a read-only token apart from one that can write
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:56:28 +02:00
nexxo 0f4226c488 Fix round: read the mailer against NON_DELIVERING, not against 'log' alone
delivery.mailer_not_log now reads MailboxTransport::NON_DELIVERING (made
public) instead of a narrower `!== 'log'`, so 'array' — this suite's own
MAIL_MAILER — and an unset default no longer pass as ready when nothing
would actually be delivered. provisioning.monitoring_token's severity now
follows provisioning.monitoring.required the same way RegisterMonitoring
itself does, instead of asserting a fixed 'warning' the pipeline does not
always honour. Two breaks sentences (dns_zone, mail_templates) now name a
concrete failure mechanism instead of a correct-but-vague description.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:27:14 +02:00
nexxo 325cee50ca Check the machines, the DNS and the post as well
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:05:18 +02:00
nexxo b1e5bf8023 Fix round: match the actual selection IssueInvoice and sync use
Two of the five billing checks measured something close to, but not
the same as, what actually gates the flow they report on:

- billing.invoice_series counted distinct kinds without filtering on
  `active`, but IssueInvoice draws its series with
  where('kind', ...)->where('active', true) (IssueInvoice.php:373,
  :444) — a deactivated row is invisible to it exactly like a missing
  one. An installation with a switched-off 'invoice' series was
  reported ready while no invoice could draw a number: false-green.

- billing.catalogue_synced counted every PlanPrice with a null
  stripe_price_id, but stripe:sync-catalogue only ever mirrors
  PUBLISHED versions (SyncStripeCatalogue's own header comment). A
  draft's unsynced price is an ordinary state, not a gap; the old
  query kept this check permanently unsatisfied on any installation
  that had ever drafted a next version — an alarm always on and
  therefore never read.

Both queries now mirror the condition the real gate uses instead of a
similar-looking one of their own. Two tests added that were red before
the fix: an inactive required series that must not read as ready, and
an unpublished draft's unsynced price that must not raise an alarm.

Also strengthens the weakest breaks sentence: invoice_series_breaks
named the mechanism ("a document cannot draw a number") rather than
the consequence, unlike its four siblings. It now says what the
customer and operator actually experience — a running cloud with no
invoice, the failure only logged — matching company_details_breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:42:09 +02:00
nexxo d9c0ad0e4a Say what breaks, not just which field is empty
Task 7: the readiness collector (App\Support\Readiness) and its first
group, billing (App\Support\Readiness\BillingChecks). Reports missing
prerequisites — the active mode's Stripe key, its webhook signing
secret, complete company details, an invoice series per document kind,
a fully Stripe-synced catalogue — without enforcing anything itself;
the real locks (IssueInvoice, the checkout's Stripe-key guard) stay
where they are.

The webhook check uses App\Support\StripeWebhookSecret::current()
rather than querying config() by mode a second time, and the company
check calls CompanyProfile::missingForInvoicing() rather than
duplicating its list — two sources for one question is how they drift
apart.

Added lang/de/readiness.php and lang/en/readiness.php with every
`readiness.*` key this group uses, each `_breaks` sentence naming the
actual downstream failure rather than repeating the field name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:25:27 +02:00
nexxo a1e3484fba Verify webhooks against the secret of the mode we are in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:13:55 +02:00
nexxo 32a8bd9a88 Refuse the sale instead of reaching Stripe without a key
Task 5: Billing::purchase() now checks SecretVault for a stripe.secret before
touching anything else — no customer, no plan lookup, no Order row — and the
customer reads a plain sentence instead of a 500 page. HttpStripeClient's own
secret() throws the same StripeNotConfigured exception rather than sending a
request with an empty bearer token and letting Stripe's 401 stand in for the
real cause.

isConfigured() was fixed alongside it: it used to call secret() too, which
would have turned every "is Stripe set up?" check (the checkout controller,
the catalogue sync commands) into an uncaught exception the moment a key went
missing — the opposite of what this task is for. It now reads the vault
directly.

The new guard is unconditional, including for the cart-only purchase types
(storage, addon, plan changes) that never call Stripe themselves — a cart
order nobody can ever pay for is pointless to create. That broke every
existing Billing/Cart/Downgrade test that calls purchase() without a stored
key; each affected fixture now deposits one via the new withStripeSecret()
Pest helper. A few of those tests (CustomDomainAccessTest, DowngradeTest) were
passing already but for the wrong reason — the new guard, not the check they
were named for — since both only assert an order was NOT created; they get
the same fixture fix so they still prove what they claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:58:01 +02:00
nexxo ac44717d1b Cover the unreadable branch and make rollback pick live deterministically
The only test that reached SecretCipher::decrypt() failing was an accident of
a fixture bug fixed in the previous commit: it encrypted with the wrong key,
so decryption threw and the migration's catch(Throwable) branch ran without
anyone meaning to test it. Fixing that fixture silently removed the only
coverage of a branch that decides a payment key's mode, so it gets its own
test with a deliberately unreadable row.

down() picked whichever of the :live/:test rows an unordered get() happened to
return first to restore to the bare key, and deleted the other — so which
credential survived a rollback depended on row order, not a decision. It now
processes :live rows first unconditionally, then :test rows only where the
bare key is still free, so the live credential always wins when both exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:32:58 +02:00
nexxo 727f2e41db Make source() respect strict entries like get() does
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:21:07 +02:00
nexxo ecf948f30f Let the stored key say which mode it belongs to
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:16:15 +02:00
nexxo 59e8689630 Never spend real money while the switch says test
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:01:33 +02:00
nexxo bc6eee4dc3 Give every credential a test slot beside its live one
Each entry in the vault now stores under key:mode instead of a bare
key, and get() resolves the active mode internally — falling back
from an empty test slot to live (never the reverse) so test operation
works before anyone has filled in test credentials, but live operation
never silently reaches for a test one.

get()'s signature is unchanged on purpose: its three callers
(HttpStripeClient, StripeCheck, SshTraefikWriter) have no business
knowing which mode is active. put()/forget()/source()/outline()/
updatedAt() take an optional trailing $mode for the console, which
will use it in a later task.

SecretVaultTest's raw app_secrets queries assumed the old bare key and
needed the :live suffix plus a pinned OperatingMode — otherwise they'd
depend on whatever mode an earlier test file left in the array cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:53:17 +02:00
nexxo 3cf17e5cb0 Give the installation a test mode and a live mode
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:41:23 +02:00
nexxo 88d6dc620b Stop the suite from reading the operator's own .env for the DNS zone
HostStepsTest asserted fsn-01.node.clupilot.com and was green only because
this server's .env carried the wrong zone. Correcting the .env turned it red
without a line of production code changing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:38:12 +02:00
109 changed files with 11125 additions and 135 deletions

View File

@ -94,7 +94,11 @@ MAIL_FROM_NAME=CluPilot
# ── Stripe (payments) ────────────────────────────────────────────────────
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
# Outside local/testing a missing secret makes the webhook fail closed.
# Stripe hands out a SEPARATE signing secret per operating mode (test/live) —
# fill in the one matching each. Which one applies follows platform.mode
# (App\Support\StripeWebhookSecret), not this file.
STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET_TEST=
# Not read yet — needed once the Checkout flow is built (currently the portal
# only records a purchase intent):
STRIPE_KEY=

View File

@ -1 +1 @@
1.3.63
1.3.64

View File

@ -13,6 +13,8 @@ use App\Services\Billing\AdoptStripeProduct;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
use Illuminate\Console\Command;
/**
@ -61,6 +63,14 @@ use Illuminate\Console\Command;
* Product for it would be a price list entry for something that may never
* exist.
*
* **A run belongs to ONE Stripe account, and it says which.** Products and
* Prices are account-bound, and the operating mode picks the key so the mode
* a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into
* a catalogue that belongs to the other account is refused rather than allowed
* to skip its way to "already in step". Without that, switching to live left
* every stored id pointing at test objects while the readiness page went on
* reporting a synced catalogue, and the first real order met "No such price".
*
* **Modules were missing from this entirely**, which is why a booked module was
* charged in the month it was booked and never again: nothing in Stripe existed
* to put on the subscription. They are pushed at today's catalogue price on both
@ -85,6 +95,39 @@ class SyncStripeCatalogue extends Command
return self::FAILURE;
}
if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) {
$this->error($this->staleCatalogueMessage());
// Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was
// FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt
// schon eine ID, nur eine aus dem falschen Konto. „0 object(s)
// would be created" wäre die beruhigende Antwort auf die
// gefährliche Lage.
return self::FAILURE;
}
// VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass
// entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört —
// der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von
// handle() trug er sie NICHT: createProduct() und ensure() werfen
// ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt
// stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für
// ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren,
// an dem laufende Verträge abgerechnet werden — statt schlicht
// wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind.
if (! $dryRun) {
if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) {
$this->line(' note taking up a catalogue whose account was never recorded; nothing is replaced.');
}
StripeCatalogueMode::record();
}
// Beide Seiten setzen hier an, und beide werden gebraucht.
// Die Modus-Sperre oben steht zuerst, weil sie VERWEIGERT: sie darf
// nicht hinter etwas laufen, das schon Zustand aufgebaut hat. Die
// Zähler darunter müssen nur vor der Anlegeschleife stehen, und das
// tun sie hier auch.
// Resolved once, as singletons: each counts across the WHOLE run, and a
// count taken before ensure()/the adoption call runs can never itself
// distinguish "created" from "adopted" — see AdoptStripePrice.
@ -207,6 +250,40 @@ class SyncStripeCatalogue extends Command
return self::SUCCESS;
}
/**
* Why a run is refused outright rather than left to skip its way through.
*
* A Stripe Product and a Stripe Price belong to the ACCOUNT that issued
* them, and a test account and a live account are two accounts. This
* command skips every row that already carries an id (PlanPrices::inStep()
* asks the register, not Stripe), so a run after the switch would report
* "already in step" and change nothing while recording that the
* catalogue now belongs to the account it never touched.
*
* The registers have to go too, not only the two columns: inStep() takes
* a registered row as proof on its own for the reverse-charge half, so a
* cleared pointer with the register left behind would leave exactly that
* half pointing at the old account, and nothing would say so.
*
* Only reached where the OTHER account is established fact. A catalogue
* whose account was never recorded is a different state with a different
* cure see StripeCatalogueMode::belongsToAnotherMode(). Telling that
* operator to clear anything would be telling them to delete a catalogue
* their live contracts bill on.
*/
private function staleCatalogueMessage(): string
{
return sprintf(
'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. '
.'Stripe objects belong to the account that issued them, and this command skips every row that '
.'already carries an id — so it cannot repair this by running again. Clear '
.'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / '
.'stripe_addon_prices registers first, then run it. Nothing was created.',
StripeCatalogueMode::recorded()?->value,
OperatingMode::current()->value,
);
}
/**
* The Stripe Prices one priced catalogue row is sold on, brought into step.
*

View File

@ -0,0 +1,20 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Der aktive Betriebsmodus hat keinen Stripe-Schlüssel.
*
* Eine eigene Klasse, damit Aufrufer und Tests genau diesen Fall greifen können
* statt „irgendetwas ging schief" — und damit die Kasse ihn in einen Satz
* übersetzen kann, den ein Kunde lesen kann.
*/
class StripeNotConfigured extends RuntimeException
{
public static function forMode(string $mode): self
{
return new self("No Stripe secret is stored for the [{$mode}] operating mode.");
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\Process\Process;
/**
* Liefert das Bootstrap-Skript als Archiv aus.
*
* Das ist der einzige Weg, auf dem das Skript auf eine nackte Maschine kommt:
* ein Rettungssystem hat nichts als curl. Es ist eine STATISCHE Datei und kein
* Endpunkt, der Auskunft gibt Spec §5 bleibt damit unangetastet. Es steht
* nichts Vertrauliches darin; alle Geheimnisse reist der Betreiber in der
* Befehlszeile mit, nicht im Archiv.
*
* Als Archiv und nicht als eine Datei, weil `curl … | sh` keine `lib/` haben
* kann und der Skript-Plan getrennte Dateien verlangt: `network.sh` und
* `template.sh` sollen einzeln nachlesbar und wiederholbar bleiben, wenn auf
* einer Maschine um drei Uhr morgens etwas klemmt.
*
* Gebaut wird beim ersten Abruf und danach nur, wenn sich unter `deploy/bootstrap`
* etwas geändert hat. Ein Archiv, das im Repo läge, wäre die Fassung von dem
* Tag, an dem jemand zuletzt daran gedacht hat, es neu zu bauen.
*/
class BootstrapArchiveController extends Controller
{
public function __invoke(Request $request): BinaryFileResponse
{
$archive = storage_path('app/bootstrap.tar.gz');
if ($this->isStale($archive)) {
$this->build($archive);
}
return response()
->file($archive, [
'Content-Type' => 'application/gzip',
'Content-Disposition' => 'attachment; filename="bootstrap.tar.gz"',
]);
}
/**
* Neuer als alles, was drinsteckt?
*
* Über die Änderungszeit und nicht über einen Hash: das Verzeichnis ist
* klein, der Vergleich läuft bei jedem Abruf, und ein Hash über alle Dateien
* kostete bei jedem Abruf mehr als das Packen gelegentlich kostet.
*/
private function isStale(string $archive): bool
{
if (! is_file($archive)) {
return true;
}
$built = filemtime($archive);
foreach ($this->sourceFiles() as $file) {
if (filemtime($file) > $built) {
return true;
}
}
return false;
}
/** @return list<string> */
private function sourceFiles(): array
{
$root = base_path('deploy/bootstrap');
if (! is_dir($root)) {
return [];
}
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$files[] = $file->getPathname();
}
}
return $files;
}
/**
* Mit GNU tar und nicht mit PharData: `phar.readonly` steht in diesem
* Container auf 1, und das Ausführbar-Bit soll erhalten bleiben.
*
* Der oberste Eintrag heißt `bootstrap/`, damit die kopierte Befehlszeile
* mit `tar xz -C /opt/clupilot` genau `/opt/clupilot/bootstrap` ergibt
* der Ort, an dem das Skript sich selbst und seine Bibliothek erwartet.
*/
private function build(string $archive): void
{
@mkdir(dirname($archive), 0755, true);
$process = new Process(
['tar', 'czf', $archive, '-C', base_path('deploy'), 'bootstrap'],
timeout: 60,
);
$process->mustRun();
}
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use App\Support\StripeWebhookSecret;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -20,7 +21,7 @@ class StripeWebhookController extends Controller
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
$secret = (string) config('services.stripe.webhook_secret');
$secret = StripeWebhookSecret::current();
if (blank($secret)) {
// Fail closed: an unconfigured secret must not authorize provisioning

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Support\OperatingMode;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before the operating mode changes (R23).
*
* Wortgleiches Muster zu ConfirmSaveSecret: dieses Modal mutiert nichts selbst
* es ruft `OperatingMode::set()` nirgends auf. Bestätigen löst nur ein
* Ereignis aus, das Integrations::switchMode() auffängt; deren eigene Sperre
* (secrets.manage + kürzlich bestätigtes Passwort) bleibt die einzige Stelle,
* die tatsächlich etwas schreibt. Ein Modus, der hier gefälscht hereinkäme,
* muss trotzdem switchMode()s eigenes tryFrom() und guardSecrets() passieren.
*/
class ConfirmSwitchMode extends ModalComponent
{
public string $mode;
public function mount(string $mode): void
{
$this->authorize('secrets.manage');
// Ein Wert aus dem Aufrufort dieses Modals — dieselbe Prüfung, die
// switchMode() gleich noch einmal macht: ein unbekannter Modus 404t
// hier, statt eine Bestätigung für etwas zu zeigen, das nachher
// ohnehin still nichts tut.
abort_if(OperatingMode::tryFrom($mode) === null, 404);
$this->mode = $mode;
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('mode-switch-confirmed', mode: $this->mode);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-switch-mode');
}
}

View File

@ -3,6 +3,8 @@
namespace App\Livewire\Admin;
use App\Actions\StartHostOnboarding;
use App\Support\HostEnrolment;
use App\Support\HostTakeoverCommand;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -10,6 +12,21 @@ use Livewire\Component;
#[Layout('layouts.admin')]
class HostCreate extends Component
{
/**
* Die fertige Befehlszeile gesetzt, sobald der Host angelegt ist.
*
* Sie wird GENAU EINMAL gezeigt. In der Datenbank steht nur der Hash des
* Codes, und der private Schlüssel steht dort überhaupt nicht; wer die Seite
* neu lädt, bekommt sie nicht wieder, sondern legt einen neuen Code an. Das
* ist kein Versehen, sondern der Grund, warum ein abgefangener Blick auf
* einen Bildschirm später nichts mehr wert ist.
*/
public ?string $command = null;
public ?string $createdUuid = null;
public ?string $createdName = null;
#[Validate('required|string|max:255')]
public string $name = '';
@ -34,13 +51,20 @@ class HostCreate extends Component
$host = $action->run($data);
return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true);
// Kein Weiterleiten mehr. Die Befehlszeile gibt es nur hier und nur
// jetzt — wer den Betreiber auf eine andere Seite schickt, schickt ihn
// von dem einzigen Wert weg, den er braucht.
$this->command = HostTakeoverCommand::for($host, HostEnrolment::issueWithKeys($host));
$this->createdUuid = $host->uuid;
$this->createdName = $host->name;
}
public function render()
{
return view('livewire.admin.host-create', [
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
'archiveUrl' => HostTakeoverCommand::archiveUrl(),
'missingSettings' => HostTakeoverCommand::missingSettings(),
]);
}
}

View File

@ -53,6 +53,10 @@ class Hosts extends Component
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'],
'total' => Host::query()->count(),
// Für die aufklappbare Anleitung: dieselbe Adresse, die später in
// der Befehlszeile steht, damit hier nichts anderes behauptet wird
// als dort getan wird.
'archiveUrl' => \App\Support\HostTakeoverCommand::archiveUrl(),
]);
}
}

View File

@ -9,6 +9,7 @@ use App\Services\Env\InvalidEnvContentException;
use App\Services\Mail\InboundMailbox;
use App\Services\Mail\InboundMailStatus;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Artisan;
@ -190,6 +191,34 @@ class Integrations extends Component
}
}
// ---- The operating mode — governs every vault entry below it. ----
/**
* Den Betriebsmodus umlegen.
*
* Hinter derselben Sperre wie der Tresor `secrets.manage` plus bestätigtes
* Passwort weil der Wechsel auf Live der Moment ist, ab dem echtes Geld
* fließt. Das ist keine Umschaltfläche zum Danebenklicken.
*/
#[On('mode-switch-confirmed')]
public function switchMode(string $mode): void
{
$this->guardSecrets();
// Ein String aus dem Browser. tryFrom, nicht from.
$target = OperatingMode::tryFrom($mode);
if ($target === null) {
return;
}
OperatingMode::set($target);
$this->dispatch('notify', message: __('integrations.mode_switched', [
'mode' => __('readiness.mode.'.$target->value),
]));
}
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
public function saveInfra(): void
@ -493,6 +522,7 @@ class Integrations extends Component
'canInfra' => $canInfra,
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'mode' => OperatingMode::current(),
'restart' => $restart,
'entries' => collect(SecretVault::REGISTRY)
->map(fn (array $meta, string $key) => [

View File

@ -3,15 +3,18 @@
namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Mail\Transport\MailboxTransport;
use App\Models\Customer;
use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
use App\Support\AdminArea;
use App\Support\Settings;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Provisioning\HostCapacity;
use App\Support\AdminArea;
use App\Support\Readiness;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
@ -234,7 +237,7 @@ class Overview extends Component
$capacity = app(HostCapacity::class);
try {
$sellable = app(\App\Services\Billing\PlanCatalogue::class)->sellable();
$sellable = app(PlanCatalogue::class)->sellable();
} catch (\Throwable) {
// A catalogue that cannot be read has its own alarm. It does not
// get to take the console's front page down with it.
@ -391,7 +394,7 @@ class Overview extends Component
// reports success (MailboxTester builds its own transport on purpose), and
// the mail sits in storage/logs. Somebody registering and waiting for a
// verification mail has no way to find that out. Asked exactly that way.
if (in_array(config('mail.default'), ['log', 'array', null], true)) {
if (in_array(config('mail.default'), MailboxTransport::NON_DELIVERING, true)) {
$notices[] = [
'level' => 'warning',
'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]),
@ -420,6 +423,19 @@ class Overview extends Component
];
}
// What is not yet set up at all, as opposed to everything above (what
// IS set up but currently unhealthy). Readiness::blocking() is the
// same list App\Livewire\Admin\Readiness renders in full — this is
// only the count and a link to it, not a second copy of the reasons.
$blockingReadiness = count(Readiness::blocking());
if ($blockingReadiness > 0) {
$notices[] = [
'level' => 'warning',
'text' => trans_choice('admin.notice.readiness_blocking', $blockingReadiness, ['n' => $blockingReadiness]),
'route' => 'admin.readiness',
];
}
return $notices;
}

View File

@ -0,0 +1,181 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Dns\DnsTokenCheck;
use App\Services\Proxmox\VmTemplateCheck;
use App\Services\Stripe\StripeCheck;
use App\Services\Vpn\WireguardEndpointCheck;
use App\Support\OperatingMode;
use App\Support\Readiness as ReadinessRegistry;
use App\Support\Readiness\Check;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The one page an operator setting up a live server goes through top to
* bottom, until nothing blocking is left App\Support\Readiness rendered,
* with a button on the handful of checks that have to ask a real service
* instead of just reading a field.
*
* Aliased on import (`ReadinessRegistry`) rather than `use App\Support\
* Readiness;` bare: this class is ALSO named Readiness, in the same PHP
* process, and an unaliased reference here would resolve to itself which
* has no byGroup()/blocking()/isReady() rather than to the registry.
*
* mount() checks the same gate as Admin\Integrations
* (Gate::any(['hosts.manage', 'secrets.manage'])), because this page is where
* both halves it configures App\Support\Settings and SecretVault come
* back together as one list of what is missing.
*/
#[Layout('layouts.admin')]
class Readiness extends Component
{
/**
* Prüfschlüssel Klasse. Die Liste IST die Erlaubnis.
*
* runCheck() receives its argument from wire:click in the browser a
* string a stranger could type into devtools just as easily as an
* operator clicking the real button. Without this fixed map, that
* argument would be a way to instantiate and run ANY class through app().
* Four entries: the three on-demand checks Task 9 built (each asks a real
* service instead of only reading a field) plus StripeCheck, which
* already had this exact run() shape from Admin\Integrations' own
* "Testen" button.
*/
private const RUNNABLE = [
'provisioning.dns_token' => DnsTokenCheck::class,
'onboarding.wg_hub' => WireguardEndpointCheck::class,
'provisioning.vm_template' => VmTemplateCheck::class,
'billing.stripe_secret' => StripeCheck::class,
];
/**
* Check::key the App\Support\Settings row it is written under.
*
* Display only, and carries no threshold of its own: whether a heartbeat
* counts as fresh is decided exactly once, by
* App\Support\Readiness\OperationChecks::isFresh(), and shows up here
* only as the check's own `satisfied`/severity this map exists solely
* so heartbeatDisplay() knows which stored value to format for which
* check. (A previous version of this comment claimed a staleness
* threshold was kept in sync with OperationChecks::STALE_AFTER_MINUTES
* there is no such value here to keep in sync. CLAUDE.md's R19 names this
* exact shape of mistake a call that reads as an assurance and is not
* one as worse than no comment at all, because it stops the next
* reader from checking for themselves.)
*/
private const HEARTBEAT_KEYS = [
'operation.scheduler' => 'heartbeat.scheduler',
'operation.queue_provisioning' => 'heartbeat.queue_provisioning',
];
/**
* The on-demand checks' own answers, keyed by Check::key.
*
* Never pre-filled in mount(): DnsTokenCheck writes a real TXT record to
* the production DNS zone the moment it runs, so nothing here may run
* except in direct response to an operator's own click.
*
* @var array<string, array<string, mixed>>
*/
public array $results = [];
public function mount(): void
{
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
}
public function runCheck(string $key): void
{
$this->authorize('secrets.manage');
// Der Schlüssel kommt aus dem Browser. Ohne diese Zeile wäre das ein
// Weg, beliebige Klassen aufzurufen.
$class = self::RUNNABLE[$key] ?? null;
if ($class === null) {
return;
}
$this->results[$key] = app($class)->run();
}
/**
* Where an operator goes to actually fix ONE entry.
*
* Not always Admin\Integrations: six of the nine `tab` values used across
* the five check groups name a DIFFERENT admin page entirely 'hosts',
* 'plans', 'datacenters', 'mail', 'templates' and 'company' are pages of
* their own, not Integrations tabs, and forwarding them as `?tab=` would
* either 404 or (since Integrations::mount() falls back silently to its
* own first tab for an unrecognised value) land quietly on the wrong
* section. Every OTHER `tab` value that exists in the five check group
* files today (checked at the source, Fix-Runde) really IS one of
* Integrations::TABS nine of them were not, until that same Fix-Runde
* corrected them there rather than papering over it here (see
* BillingChecks/OnboardingChecks/ProvisioningChecks/DeliveryChecks). The
* `default` arm below is therefore a pure safety net for a value nobody
* currently produces, not a route any check relies on kept so a future
* typo degrades to the Integrations landing tab instead of an
* UnhandledMatchError taking the whole page down.
*/
public function checkUrl(Check $check): string
{
if (in_array($check->tab, Integrations::TABS, true)) {
return route('admin.integrations', ['tab' => $check->tab]);
}
return match ($check->tab) {
'hosts' => route('admin.hosts'),
'plans' => route('admin.plans'),
'datacenters' => route('admin.datacenters'),
'mail' => route('admin.mail'),
'templates' => route('admin.templates'),
// Company details AND the invoice series both live on
// Admin\Finance — there is no dedicated "company" page.
'company' => route('admin.finance'),
default => route('admin.integrations'),
};
}
public function render()
{
return view('livewire.admin.readiness', [
'groups' => ReadinessRegistry::byGroup(),
'blockingCount' => count(ReadinessRegistry::blocking()),
'ready' => ReadinessRegistry::isReady(),
'mode' => OperatingMode::current(),
'runnable' => array_keys(self::RUNNABLE),
'heartbeats' => collect(self::HEARTBEAT_KEYS)
->map(fn (string $settingsKey) => $this->heartbeatDisplay($settingsKey))
->all(),
]);
}
/**
* The heartbeat's own stored moment, in the operator's zone (R19) or
* null for "nothing stored" AND for "unreadable", the same forgiving
* either-way OperationChecks::isFresh() already uses. A readiness page
* whose only job is to calmly report what is missing must not itself go
* down over a garbled timestamp (see that method's own docblock for the
* incident this already caused once, inside Readiness::all() itself).
*/
private function heartbeatDisplay(string $settingsKey): ?string
{
$raw = Settings::get($settingsKey);
if (blank($raw)) {
return null;
}
try {
return Carbon::parse($raw)->local()->isoFormat('DD.MM.YYYY HH:mm:ss');
} catch (\Throwable) {
return null;
}
}
}

View File

@ -17,6 +17,7 @@ use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\StorageAllowance;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\DiskUsageProbe;
use App\Services\Secrets\SecretVault;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
@ -60,6 +61,15 @@ class Billing extends Component
*/
public function purchase(string $type, ?string $key = null, int $quantity = 1): void
{
// Vor allem anderen: ohne Schlüssel des aktiven Modus entsteht kein
// Auftrag. Nach der Zahlung wäre es zu spät, und ein halb angelegter
// Auftrag ohne Stripe-Sitzung ist genau die Leiche, die niemand findet.
if (blank(app(SecretVault::class)->get('stripe.secret'))) {
$this->addError('purchase', __('billing.stripe_not_configured'));
return;
}
$customer = $this->requireCustomer();
if ($customer === null) {
return;

View File

@ -40,8 +40,14 @@ class MailboxTransport implements TransportInterface
* not the exact string 'log'. `null` is here because a stored setting can
* decode to it just as easily as to a real value (see the port note in
* delegate()): an unconfigured mailer must fail SAFE, not fail open.
*
* Public: this is the one place that knows what "does not really send"
* means, and App\Support\Readiness\DeliveryChecks reads it directly
* rather than carrying a second, narrower copy of the same rule (a
* `!== 'log'` check once passed under this suite's own MAIL_MAILER=array,
* which delegate() below has always treated as just as inert as 'log').
*/
private const NON_DELIVERING = ['log', 'array', null];
public const NON_DELIVERING = ['log', 'array', null];
private ?TransportInterface $delegate = null;

View File

@ -0,0 +1,39 @@
<?php
namespace App\Provisioning\Jobs;
use App\Support\Settings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* A sign of life from the provisioning worker.
*
* The scheduler enqueues it, the worker executes it so it proves exactly what
* the scheduler heartbeat (routes/console.php) does NOT prove: that someone is
* actually pulling jobs off the `provisioning` queue. The two fail
* independently, and "scheduler up, worker down" is the silent case: paid
* orders pile up on the queue, and nothing reports anything, because the
* scheduler itself is doing exactly what it is supposed to do.
*
* Written through Settings, not the cache: a heartbeat that vanishes on a Redis
* restart would report an outage that never happened, which is worse than no
* check at all nobody trusts it after the second false alarm.
*/
class RecordProvisioningHeartbeat implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct()
{
$this->onQueue('provisioning');
}
public function handle(): void
{
Settings::set('heartbeat.queue_provisioning', now()->toIso8601String());
}
}

View File

@ -45,7 +45,17 @@ class RegisterHostDns extends HostStep
// lock with only a candidate in hand let two concurrent onboardings pick
// the same name and overwrite each other's record.
$name = $host->dns_name ?: $this->reserveName($host);
$fqdn = $name.'.node.'.config('provisioning.dns.zone', 'clupilot.com');
// Die PLATTFORM-Zone, nicht die Kundenzone. Diese Zeile las bis hierher
// `provisioning.dns.zone` — die Zone der Kundeninstanzen —, und auf
// dieser Installation hieß ein Host damit `fsn-01.node.clupilot.cloud`.
// Der Kopfkommentar oben, die Prüfregel in Datacenters und der Test in
// ServicesTest sagen alle drei `.com`; nur diese Zeile sagte etwas
// anderes. Die zwei Zonen sind laut OfficialDomains ausdrücklich
// getrennt — eine Nextcloud ist fremde Software, bei der sich Fremde
// anmelden, und sie teilt sich deshalb keinen Cookie-Geltungsbereich mit
// dem Portal. Ein Hostname in dieser Zone hebt die Trennung nicht auf,
// aber er stellt sie in Frage, und der nächste Griff daneben ist teurer.
$fqdn = $name.'.node.'.config('provisioning.dns.platform_zone');
try {
$this->dns->write($name, $fqdn, $host->wg_ip);

View File

@ -0,0 +1,107 @@
<?php
namespace App\Services\Dns;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
/**
* Whether the configured Hetzner DNS token can actually WRITE to the zone,
* not merely read it.
*
* Listing the zone proves nothing: a read-only token lists it just as
* cleanly as a read-write one, and looks identical in the console it only
* fails once a customer VM needs its A record, after the customer has
* already paid. So this writes a throwaway TXT record and deletes it again
* immediately; the write (and its rejection) is the only thing that actually
* tells the two kinds of token apart.
*
* Deliberately does its own HTTP instead of going through HttpHetznerDnsClient:
* that client always reads the STORED token from the vault, and this check
* has to be able to test a candidate value before it is ever saved exactly
* the same reason StripeCheck does not go through HttpStripeClient.
*/
final class DnsTokenCheck
{
/** @return array<string, mixed> */
public function run(?string $candidate = null): array
{
$token = $candidate ?: app(SecretVault::class)->get('dns.token');
$zone = ProvisioningSettings::dnsZone();
if (blank($token) || blank($zone)) {
return ['ok' => false, 'reason' => 'missing'];
}
try {
$zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->get('https://dns.hetzner.com/api/v1/zones');
} catch (Throwable) {
return ['ok' => false, 'reason' => 'unreachable'];
}
if ($zones->status() === 401 || $zones->status() === 403) {
return ['ok' => false, 'reason' => 'rejected'];
}
$zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null;
if ($zoneId === null) {
return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone];
}
// The actual point of this whole check. A read-only token gets this
// far and looks in the console like a working one; it fails only when
// a customer VM needs its A record — after payment.
//
// Guarded the same way as the GET above: this call is just as capable
// of hitting a broken network as the first one, and "the network
// died mid-write" must stay distinct from "the token cannot write"
// (read_only, below) — both are failures, but only one of them says
// anything about the token.
try {
$probe = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->post('https://dns.hetzner.com/api/v1/records', [
'zone_id' => $zoneId,
'type' => 'TXT',
'name' => '_clupilot-write-probe-'.Str::lower(Str::random(12)),
'value' => 'clupilot readiness probe',
'ttl' => 60,
]);
} catch (Throwable) {
return ['ok' => false, 'reason' => 'unreachable'];
}
if (! $probe->successful()) {
return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()];
}
// The write already proved the token can write — that is the whole
// point of this check, and nothing past this line changes that
// verdict. Always attempt to clean up, and guard the attempt itself:
// a delete that throws is handled exactly like one that comes back
// non-2xx, because both leave the same TXT record sitting in the
// zone. Named in the result either way, so someone can remove it by
// hand instead of a record nobody knows exists.
$recordId = $probe->json('record.id');
try {
$removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->delete('https://dns.hetzner.com/api/v1/records/'.$recordId)
->successful();
} catch (Throwable) {
$removed = false;
}
$result = ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed];
if (! $removed) {
$result['leftover_record_id'] = $recordId;
}
return $result;
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Services\Proxmox;
use App\Models\Host;
use App\Models\PlanVersion;
use Throwable;
/**
* Whether every published plan's VM template actually EXISTS on a node, not
* merely that a `template_vmid` is filled in somewhere.
*
* Asks the same question as the onboarding step VerifyVmTemplate, but over
* ALL active hosts instead of one, and BEFORE a purchase instead of after:
* ProvisioningChecks::vm_template (Task 8) is a field check only it cannot
* tell a `template_vmid` that was typed by mistake from one that is really
* there, and asking Proxmox on every page load does not belong on a
* readiness page. This is the on-demand button that actually asks.
*/
final class VmTemplateCheck
{
public function __construct(private ProxmoxClient $pve) {}
/** @return array<string, mixed> */
public function run(): array
{
$required = PlanVersion::query()
->whereNotNull('published_at')
->whereNotNull('template_vmid')
->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now()))
->pluck('template_vmid')->map(fn ($v) => (int) $v)->unique()->values()->all();
// A catalogue with no published version requires no template. The
// same case VerifyVmTemplate explicitly lets through.
if ($required === []) {
return ['ok' => true, 'reason' => 'nothing_published'];
}
$hosts = Host::query()->where('status', 'active')->whereNotNull('api_token_ref')->get();
if ($hosts->isEmpty()) {
return ['ok' => false, 'reason' => 'no_usable_host'];
}
$missing = [];
$unverifiable = [];
foreach ($required as $vmid) {
$found = false;
$aHostFailedToAnswer = false;
foreach ($hosts as $host) {
try {
if ($this->pve->forHost($host)->vmExists($host->node ?? 'pve', $vmid)) {
$found = true;
break;
}
} catch (Throwable) {
// A host that is not answering right now is not proof of
// a missing template — it might be sitting on exactly
// this host. Keep looking at the others; a later "yes"
// still wins outright (see $found above).
$aHostFailedToAnswer = true;
continue;
}
}
if ($found) {
continue;
}
// Two different verdicts, not one: every host answered "no" is a
// confirmed gap (build the template); some host never answered
// is merely unverified (ask again once it is back). Telling an
// operator to build something that may already exist on a host
// that is simply not responding right now sends them to fix the
// wrong problem.
if ($aHostFailedToAnswer) {
$unverifiable[] = $vmid;
} else {
$missing[] = $vmid;
}
}
if ($missing !== []) {
$result = ['ok' => false, 'reason' => 'template_missing', 'vmids' => $missing];
if ($unverifiable !== []) {
// Named separately, never merged into vmids: one is
// confirmed and actionable, the other merely unanswered.
$result['unverifiable'] = $unverifiable;
}
return $result;
}
if ($unverifiable !== []) {
return ['ok' => false, 'reason' => 'hosts_unreachable', 'vmids' => $unverifiable];
}
return ['ok' => true, 'reason' => 'present'];
}
}

View File

@ -4,6 +4,7 @@ namespace App\Services\Secrets;
use App\Models\Operator;
use App\Services\Stripe\StripeCheck;
use App\Support\OperatingMode;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@ -79,6 +80,12 @@ final class SecretVault
'label' => 'secrets.item.stripe_secret',
'check' => StripeCheck::class,
'env_key' => 'STRIPE_SECRET',
// Kein Rückfall, in keine Richtung, und auch nicht auf die Umgebung.
// Ohne das benutzt ein Testkauf bei fehlendem Testschlüssel still den
// Live-Schlüssel und bucht echtes Geld ab, während die Konsole
// „Testbetrieb aktiv" anzeigt. Jeder andere Eintrag hier bezeichnet
// dasselbe Konto in beiden Modi; dieser nicht.
'strict' => true,
],
'dns.token' => [
'config' => 'provisioning.dns.token',
@ -112,6 +119,10 @@ final class SecretVault
/**
* The stored value, or the configured one when nothing is stored.
*
* Signature unchanged from before the vault grew a test/live split the
* three callers (HttpStripeClient, StripeCheck, SshTraefikWriter) have no
* business knowing which mode is active; that is resolved in here.
*
* Falls back only when there is genuinely no row never on a decryption
* failure. Falling back then would quietly resume using an old key from the
* environment while the console shows the new one.
@ -119,9 +130,15 @@ final class SecretVault
public function get(string $key): ?string
{
$this->assertKnown($key);
$row = $this->row($key);
$row = $this->inForce($key, OperatingMode::current());
if ($row === null) {
// Strict entries never see the environment: see isStrict() below.
if ($this->isStrict($key)) {
return null;
}
$configured = config(self::REGISTRY[$key]['config']);
return $configured === null ? null : (string) $configured;
@ -136,29 +153,51 @@ final class SecretVault
}
}
/** Where the value in force comes from: stored, environment, or nowhere. */
public function source(string $key): string
/**
* Where the value in force under this mode comes from four answers now.
*
* 'stored_live' is the third, and it exists because the second-best answers
* were both lies. The measured case: mode test, dns.token only in the :live
* slot, HETZNER_DNS_TOKEN also in the .env. get() hands out the stored live
* value (the fallback it has always had), while this method reported
* 'environment' the console then read "Aus der Serverdatei" over a
* credential that is in force from the database, and an operator changing
* the .env line would have seen no effect at all. That is R19's dummy in
* miniature: a display that reads like an answer, is wrong, and stops the
* next person looking.
*
* Returning 'stored' instead would trade one lie for another: the console
* shows the "Vergessen" button only for 'stored', and it would then have
* pointed at the EMPTY test slot, which forget() would happily delete
* nothing from.
*
* The $mode argument names which mode is IN FORCE, not which row to look
* at same question get() answers, so that the two cannot disagree.
*/
public function source(string $key, ?OperatingMode $mode = null): string
{
$this->assertKnown($key);
return match (true) {
$this->row($key) !== null => 'stored',
$this->row($key, $mode) !== null => 'stored',
$this->fallback($key, $mode) !== null => 'stored_live',
$this->isStrict($key) => 'none',
filled(config(self::REGISTRY[$key]['config'])) => 'environment',
default => 'none',
};
}
public function outline(string $key): ?string
public function outline(string $key, ?OperatingMode $mode = null): ?string
{
return $this->row($key)?->outline;
return $this->inForce($key, $mode)?->outline;
}
public function updatedAt(string $key): ?string
public function updatedAt(string $key, ?OperatingMode $mode = null): ?string
{
return $this->row($key)?->updated_at;
return $this->inForce($key, $mode)?->updated_at;
}
public function put(string $key, string $value, Operator $by): void
public function put(string $key, string $value, Operator $by, ?OperatingMode $mode = null): void
{
$this->assertKnown($key);
@ -176,18 +215,18 @@ final class SecretVault
// created_at only on the first write: rotating a key must not rewrite
// when it was first set, which is the one piece of audit metadata that
// answers "how long has this been here?".
if ($this->row($key) === null) {
if ($this->row($key, $mode) === null) {
$attributes['created_at'] = now();
}
DB::table('app_secrets')->updateOrInsert(['key' => $key], $attributes);
DB::table('app_secrets')->updateOrInsert(['key' => $this->rowKey($key, $mode)], $attributes);
}
/** Remove the stored value, falling back to whatever the environment says. */
public function forget(string $key): void
public function forget(string $key, ?OperatingMode $mode = null): void
{
$this->assertKnown($key);
DB::table('app_secrets')->where('key', $key)->delete();
DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->delete();
}
/** Is storing credentials possible at all on this installation? */
@ -196,9 +235,54 @@ final class SecretVault
return app(SecretCipher::class)->isUsable();
}
private function row(string $key): ?object
/** The storage key for one entry: an entry has two slots, test and live. */
private function rowKey(string $key, ?OperatingMode $mode = null): string
{
return DB::table('app_secrets')->where('key', $key)->first();
return $key.':'.($mode ?? OperatingMode::current())->value;
}
private function row(string $key, ?OperatingMode $mode = null): ?object
{
return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
}
/**
* The stored row that actually applies under this mode: its own slot, or
* the live slot standing in for it.
*
* ONE place, called by get(), outline() and updatedAt() and asked by
* source() through fallback() because these four answering the same
* question separately is precisely how the console came to say "nothing
* stored" about a credential the provisioning run was using.
*/
private function inForce(string $key, ?OperatingMode $mode = null): ?object
{
return $this->row($key, $mode) ?? $this->fallback($key, $mode);
}
/**
* The live slot filling in for an empty test slot.
*
* ONLY from test to live, never the other way: using a test credential in
* live operation because the real one is missing is the dangerous
* direction, and must stay an explicit, visible gap instead. Strict entries
* (Stripe) have no fallback at all, in either direction.
*/
private function fallback(string $key, ?OperatingMode $mode = null): ?object
{
$mode ??= OperatingMode::current();
if (! $mode->isTest() || $this->isStrict($key)) {
return null;
}
return $this->row($key, OperatingMode::Live);
}
/** Does this entry carry the strict trait? Populated in task 3. */
private function isStrict(string $key): bool
{
return (bool) (self::REGISTRY[$key]['strict'] ?? false);
}
private function assertKnown(string $key): void

View File

@ -2,7 +2,9 @@
namespace App\Services\Stripe;
use App\Exceptions\StripeNotConfigured;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
@ -16,7 +18,12 @@ class HttpStripeClient implements StripeClient
{
public function isConfigured(): bool
{
return filled($this->secret());
// Reads the vault directly rather than through secret(): that method
// now THROWS when the key is missing (see below), and every caller
// here — the checkout controller, the catalogue sync commands — asks
// this question to decide whether to proceed gracefully. Routing it
// through secret() would turn "is it configured?" into an exception.
return filled(app(SecretVault::class)->get('stripe.secret'));
}
public function createCheckoutSession(
@ -459,13 +466,9 @@ class HttpStripeClient implements StripeClient
private function request(?string $idempotencyKey = null): PendingRequest
{
$secret = (string) $this->secret();
if (blank($secret)) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
$request = Http::withToken($secret)->acceptJson()->timeout(20);
// secret() itself throws when the key is missing, so there is nothing
// blank left to check here — see its docblock.
$request = Http::withToken($this->secret())->acceptJson()->timeout(20);
// Stripe replays the original response for a repeated key instead of
// creating a second object — but ONLY for a call repeated exactly; a
@ -481,16 +484,25 @@ class HttpStripeClient implements StripeClient
}
/**
* The key in force: the one stored in the console if there is one, else the
* environment.
* The key in force: the one stored in the console for the active operating
* mode. Throws rather than returning blank see StripeNotConfigured.
*
* Resolved HERE, at the point of use, rather than overlaid onto config at
* boot an overlay would add a query to every request including the public
* site, and a queue worker would keep whatever was true when it started.
*/
private function secret(): ?string
private function secret(): string
{
return app(SecretVault::class)->get('stripe.secret');
$secret = app(SecretVault::class)->get('stripe.secret');
// Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen
// und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel
// zuordnet. Hier abzubrechen sagt, was fehlt.
if (blank($secret)) {
throw StripeNotConfigured::forMode(OperatingMode::current()->value);
}
return $secret;
}
private function url(string $path): string

View File

@ -3,6 +3,7 @@
namespace App\Services\Stripe;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use Illuminate\Support\Facades\Http;
use Throwable;
@ -50,7 +51,11 @@ final class StripeCheck
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
// Stripe does not label the key; the mode is inferred from the
// prefix, which is the only thing that is true of both key kinds.
'live' => str_starts_with($secret, 'sk_live_') || str_starts_with($secret, 'rk_live_'),
// Asked of OperatingMode, not spelled out again here: the same
// question decides which slot the migration files a key into and
// whether the readiness page reports a key sitting in the wrong
// one. Unrecognisable (null) stays false, exactly as before.
'live' => OperatingMode::ofStripeKey($secret) === OperatingMode::Live,
'restricted' => str_starts_with($secret, 'rk_'),
'webhooks' => $this->webhooks($secret),
];

View File

@ -0,0 +1,125 @@
<?php
namespace App\Services\Vpn;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Str;
/**
* Whether the WireGuard hub endpoint an onboarding host is told to dial is
* even CAPABLE of being reached from outside this network.
*
* Deliberately makes no network call. A UDP port cannot be knocked on
* cleanly from the outside there is no handshake to observe without a real
* peer, and this class runs on every readiness page load, not on demand. What
* IS decidable without touching the wire: whether the address configured is
* one a host on the internet could even attempt to reach. `10.10.90.185` and
* `203.0.113.11` (RFC 5737) both look "set" and are unreachable from any host
* outside this operator's own network or a documentation example generator
* exactly the two shapes seen in this installation's configuration on
* 2026-07-30.
*/
final class WireguardEndpointCheck
{
/**
* IPv4 ranges FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE does NOT
* reject, checked one by one against this PHP build (`php -r
* 'var_dump(filter_var($ip, FILTER_VALIDATE_IP,
* FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE));'`) so the next
* reader does not have to repeat the research:
*
* Included below, because each is a shape a real installation can
* actually end up with:
* - 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (RFC 5737 "TEST-NET"):
* documentation/example addresses seed data and copy-pasted examples
* land here. This was the original gap (Task 9 review).
* - 100.64.0.0/10 (RFC 6598, Carrier-Grade NAT): an ISP hands this out to
* a real customer connection; it is exactly as unreachable from the
* outside as RFC 1918, just not covered by NO_PRIV_RANGE. Confirmed
* passing filter_var's flags before this fix (fix-round finding).
* - 224.0.0.0/4 (multicast): not a shape a *mistake* produces, but never
* a single host's own reachable address either — no WireGuard peer's
* endpoint can legitimately be a multicast address, so it belongs in
* the same bucket as the others rather than being let through by
* omission.
*
* Already rejected by the filter_var flags above WITHOUT this list, so
* deliberately NOT duplicated here (verified empirically, not assumed):
* 0.0.0.0/8, 127.0.0.0/8 (loopback), 169.254.0.0/16 (link-local),
* 240.0.0.0/4 (reserved this range also covers the 255.255.255.255
* broadcast address, confirmed separately).
*
* Deliberately left UNCHECKED, a considered omission and not an
* oversight: 192.0.0.0/24 (IETF protocol assignments), 192.88.99.0/24
* (deprecated 6to4 relay anycast), 198.18.0.0/15 (RFC 2544 benchmark
* testing). All three pass filter_var's flags today, but none of them is
* a shape an operator's real configuration plausibly lands in by
* accident or by a real provider's assignment unlike the TEST-NET
* ranges (copy-pasted examples) and CGNAT (an ISP genuinely assigns it).
* Adding checks for ranges nobody will ever hit is effort spent on the
* task's shape, not its risk (R22).
*
* @var array<int, string>
*/
private const NON_PUBLIC_RANGES = [
'192.0.2.0/24', // TEST-NET-1 (RFC 5737)
'198.51.100.0/24', // TEST-NET-2 (RFC 5737)
'203.0.113.0/24', // TEST-NET-3 (RFC 5737)
'100.64.0.0/10', // Carrier-Grade NAT (RFC 6598)
'224.0.0.0/4', // Multicast
];
/** @return array<string, mixed> */
public function run(?string $endpoint = null): array
{
$endpoint ??= ProvisioningSettings::wgEndpoint();
if (blank($endpoint)) {
return ['ok' => false, 'reason' => 'missing'];
}
if (! str_contains($endpoint, ':')) {
return ['ok' => false, 'reason' => 'no_port'];
}
$host = Str::beforeLast($endpoint, ':');
// No valid IP means: a name. It can resolve publicly, and guessing
// here would be worse than letting it through — the check states what
// it knows, not what it suspects.
if (! filter_var($host, FILTER_VALIDATE_IP)) {
return ['ok' => true, 'reason' => 'hostname', 'address' => $host];
}
// NO_PRIV_RANGE covers RFC 1918, NO_RES_RANGE covers loopback and
// link-local among others — exactly the private address that stood
// at one host on 2026-07-30.
$public = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($public === false || $this->isKnownNonPublicRange($host)) {
return ['ok' => false, 'reason' => 'not_public', 'address' => $host];
}
return ['ok' => true, 'reason' => 'public', 'address' => $host];
}
private function isKnownNonPublicRange(string $ip): bool
{
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$long = ip2long($ip);
foreach (self::NON_PUBLIC_RANGES as $cidr) {
[$subnet, $bits] = explode('/', $cidr);
$mask = -1 << (32 - (int) $bits);
if (($long & $mask) === (ip2long($subnet) & $mask)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Support;
use App\Models\Host;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\Str;
/**
* Der Einmal-Code und das Schlüsselpaar, mit denen ein Host sich übernehmen
* lässt.
*
* Das Henne-Ei-Problem und seine Auflösung stehen in §5 der Spec: damit der Hub
* den Host hereinlässt, muss er dessen öffentlichen Schlüssel kennen erzeugte
* der Host ihn selbst, müsste er ihn melden, BEVOR der Tunnel steht, und genau
* dafür wäre ein öffentlicher Endpunkt nötig. Also erzeugt CluPilot das Paar
* hier, nimmt den Peer sofort auf und gibt den privaten Teil in der kopierten
* Befehlszeile mit.
*
* Der private Schlüssel wird NICHT gespeichert. Er steht in der Zwischenablage
* des Betreibers und sonst nirgends, und Task 9 des Bootstrap-Skripts tauscht
* ihn ohnehin gegen einen frisch auf der Maschine erzeugten danach ist er
* wertlos. Ihn aufzubewahren hieße, ein Geheimnis mit einer Lebensdauer von
* Minuten dauerhaft zu lagern.
*/
final class HostEnrolment
{
/**
* Großzügig, weil der Code nichts nach außen öffnet: er ist nur aus dem
* WireGuard-Subnetz überhaupt verwendbar (Spec §5). Er begrenzt, wie lange
* ein Betreiber zwischen „Host anlegen" und „Rettungssystem starten" Zeit
* hat, und einen Server zu bestellen dauert manchmal einen Nachmittag.
*/
private const LIFETIME_HOURS = 24;
/**
* SHA-256 statt bcrypt, mit Absicht.
*
* Der Code sind 32 Zeichen aus einem kryptografischen Zufallsgenerator
* er hat die Entropie, die ein Passwort erst durch Streckung bekommt, und
* ist gegen Erraten nicht zu verstärken. Entscheidend ist die andere Seite:
* beide Endpunkte müssen den Host ÜBER den Code finden. Mit bcrypt ginge das
* nur, indem man jede Zeile der Tabelle durchprobiert; mit einem Hash ist es
* ein indizierter Zugriff.
*/
private static function hash(string $code): string
{
return hash('sha256', $code);
}
/** Legt Code, Schlüsselpaar und Peer an und gibt den Klartext-Code zurück. */
public static function issue(Host $host): string
{
return self::issueWithKeys($host)['code'];
}
/**
* Wie `issue()`, gibt aber auch den privaten Schlüssel zurück den braucht
* die Befehlszeile, und er ist danach nirgends mehr zu holen.
*
* @return array{code: string, private_key: string, public_key: string, wg_ip: string}
*/
public static function issueWithKeys(Host $host): array
{
$hub = app(WireguardHub::class);
$keypair = Keypair::generate();
// Die Tunneladresse bleibt, wenn es schon eine gibt. Ein zweiter
// Übernahmeversuch soll denselben Host an derselben Adresse ergeben —
// die Spalte ist eindeutig indiziert, und eine neue Adresse hieße, die
// alte für immer belegt zu lassen.
$ip = $host->wg_ip ?: $hub->allocateIp();
// Der Reihenfolge nach: erst den neuen Peer aufnehmen, dann den alten
// entfernen. Dieselbe Regel wie beim Schlüsseltausch in §6 — dazwischen
// darf es keinen Moment geben, in dem gar kein Peer eingetragen ist.
$previous = $host->wg_pubkey;
$hub->addPeer($keypair->publicKey, $ip);
if (filled($previous) && $previous !== $keypair->publicKey) {
$hub->removePeer($previous);
}
$code = Str::random(32);
$host->forceFill([
'wg_ip' => $ip,
'wg_pubkey' => $keypair->publicKey,
'enrolment_code_hash' => self::hash($code),
'enrolment_expires_at' => now()->addHours(self::LIFETIME_HOURS),
// Ein neuer Code macht den alten wertlos. Sonst hätte eine Maschine,
// die im Rettungssystem hängengeblieben ist, weiter einen gültigen
// Ausweis für einen Host, den der Betreiber gerade neu übernimmt.
'enrolment_used_at' => null,
])->save();
return [
'code' => $code,
'private_key' => $keypair->privateKey,
'public_key' => $keypair->publicKey,
'wg_ip' => $ip,
];
}
/**
* Löst einen gültigen, unverbrauchten Code auf, OHNE ihn zu verbrauchen.
*
* Das ist der Weg für `/host/progress`: die Fortschrittsmeldungen kommen vor
* der Registrierung, und würden sie den Code verbrauchen, könnte der Host
* sich nach seiner ersten Meldung nie mehr registrieren.
*/
public static function resolve(string $code): ?Host
{
if ($code === '') {
return null;
}
return Host::query()
->where('enrolment_code_hash', self::hash($code))
->whereNull('enrolment_used_at')
->where('enrolment_expires_at', '>', now())
->first();
}
/** Löst einen Code auf und verbraucht ihn. Der Weg für `/host/register`. */
public static function claim(string $code): ?Host
{
$host = self::resolve($code);
if ($host === null) {
return null;
}
$host->forceFill(['enrolment_used_at' => now()])->save();
return $host;
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Support;
use App\Models\Host;
/**
* Die eine Zeile, die der Betreiber im Rettungssystem einfügt.
*
* An einer Stelle gebaut, weil sie an zwei Stellen gezeigt wird (beim Anlegen
* und beim Neuausstellen eines Codes) und weil jeder ihrer sieben Werte aus
* einer anderen Ecke kommt. Zwei Fassungen davon würden auseinanderlaufen, und
* der Unterschied fiele erst auf einem Server auf, der schon bestellt ist.
*
* Sie trägt ALLES, was das Skript vor dem Tunnel braucht (Spec §5): es holt
* nichts ab, es gibt nichts abzurufen. Deshalb steht der private
* WireGuard-Schlüssel darin mit einer Lebensdauer von Minuten, denn Task 9
* des Skripts tauscht ihn gegen einen auf der Maschine erzeugten.
*/
final class HostTakeoverCommand
{
/** Wohin das Archiv ausgepackt wird. Das Skript sucht sich dort selbst. */
public const INSTALL_DIR = '/opt/clupilot';
/**
* @param array{code: string, private_key: string, public_key: string, wg_ip: string} $enrolment
*/
public static function for(Host $host, array $enrolment): string
{
return implode(' ', [
'mkdir -p '.self::INSTALL_DIR,
'&& curl -fsSL '.self::archiveUrl(),
'| tar xz -C '.self::INSTALL_DIR,
'&& sh '.self::INSTALL_DIR.'/bootstrap/clupilot-bootstrap.sh',
'--code '.$enrolment['code'],
// Einfache Anführungszeichen: WireGuard-Schlüssel sind base64 und
// enthalten +, / und =. Ohne sie zerlegt die Shell die Zeile an
// einer Stelle, die der Betreiber nicht sieht.
"--wg-private '".$enrolment['private_key']."'",
'--wg-ip '.$enrolment['wg_ip'].'/'.self::subnetPrefix(),
"--hub-pubkey '".config('provisioning.wireguard.hub_public_key')."'",
'--hub-endpoint '.config('provisioning.wireguard.endpoint'),
'--api http://'.config('provisioning.wireguard.hub_address'),
'--fqdn '.self::fqdnFor($host),
]);
}
/**
* Immer über den ÖFFENTLICHEN Hostnamen.
*
* Die Konsole läuft unter `admin.…`, aber diese Zeile wird auf einer
* Maschine ausgeführt, die den Adminbereich nicht erreichen darf und soll
* er ist genau dafür abgeriegelt. `route()` von der Konsole aus lieferte den
* Konsolen-Hostnamen, und die Zeile liefe in eine 404 auf einem Server, an
* den man dann nur noch über die Anbieterkonsole kommt.
*/
public static function archiveUrl(): string
{
$host = (string) config('admin_access.app_host');
// APP_HOST ist auf den meisten Installationen NICHT gesetzt — leer heißt
// dort „das Portal antwortet auf jedem Hostnamen", und das ist die
// Vorgabe. Der Rückfall auf `url()` war deshalb falsch: aufgerufen wird
// diese Methode aus der Konsole, also hätte `url()` den KONSOLEN-Namen
// geliefert, und die Zeile liefe auf einer Maschine, die den
// Adminbereich nicht erreichen darf, in eine 404.
//
// Also aus APP_URL, denn das ist die Adresse der Plattform und auf jeder
// Installation gesetzt.
if ($host === '') {
$host = (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '');
}
return $host === ''
? url('/bootstrap.tar.gz')
: 'https://'.$host.'/bootstrap.tar.gz';
}
/**
* Der Name, den CluPilot für diesen Host führt: `<rz>-<nn>.node.<zone>`.
*
* Mitgegeben statt vom Skript geraten. Die erste Fassung leitete ihn auf der
* Maschine aus der Rückwärtsauflösung ab und fiel notfalls auf einen fest
* verdrahteten Namen zurück der Installer verlangt einen FQDN, und ein
* geratener steht danach in `/etc/hosts`, im Zertifikatsantrag und in jeder
* Protokollzeile des Hosts.
*
* Die PLATTFORM-Zone, nicht die Kundenzone: `clupilot.com`, nicht
* `clupilot.cloud`. Die zwei sind laut OfficialDomains getrennt, und ein
* Host gehört auf die Seite der Plattform.
*/
public static function fqdnFor(Host $host): string
{
$name = $host->dns_name ?: $host->name;
return $name.'.node.'.config('provisioning.dns.platform_zone');
}
/** Die Präfixlänge des Tunnel-Subnetzes, damit `--wg-ip` vollständig ist. */
private static function subnetPrefix(): string
{
$subnet = (string) config('provisioning.wireguard.subnet', '10.66.0.0/24');
return str_contains($subnet, '/') ? explode('/', $subnet)[1] : '24';
}
/**
* Was in der Zeile fehlt, damit sie überhaupt funktionieren kann.
*
* Ein leerer Hub-Schlüssel oder ein leerer Endpunkt ergibt eine Zeile, die
* sauber aussieht, kopiert wird, läuft und in einem Tunnel endet, der nie
* einen Handshake hat. Das fiele erst auf der Maschine auf, nach der
* Installation. Lieber hier sagen, was fehlt.
*
* @return list<string>
*/
public static function missingSettings(): array
{
$missing = [];
if (blank(config('provisioning.wireguard.hub_public_key'))) {
$missing[] = 'CLUPILOT_WG_HUB_PUBKEY';
}
if (blank(config('provisioning.wireguard.endpoint'))) {
$missing[] = 'CLUPILOT_WG_ENDPOINT';
}
if (blank(config('provisioning.wireguard.hub_address'))) {
$missing[] = 'CLUPILOT_WG_HUB_ADDRESS';
}
return $missing;
}
}

View File

@ -94,6 +94,10 @@ final class Navigation
// never "all of these" (contrast a single string elsewhere in
// this file, which x-shell.nav checks with plain ->can()).
['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']],
// Every check App\Support\Readiness knows about, gathered on
// one page — same gate as Integrations just above, because
// both halves it configures come back together here.
['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']],
// Two-factor enrolment used to sit here as an entry of its
// own. It is a tab of Settings now — it is a setting, and it
// was the one place an operator had to leave the settings page

View File

@ -0,0 +1,72 @@
<?php
namespace App\Support;
/**
* Test oder Live welcher Satz Zugangsdaten gilt.
*
* In `Settings` und nicht in der `.env`, weil `Settings` seinen Zwischenspeicher
* beim Schreiben verwirft: ein Umlegen wirkt sofort, auch in den
* Warteschlangen-Prozessen, die seit Stunden laufen. Eine `.env`-Variable
* bräuchte einen Containerneustart genau die Falle, die am 2026-07-30 eine
* korrigierte DNS-Zone acht Stunden lang wirkungslos gelassen hat.
*
* Live ist die Vorgabe. Eine Installation, die nichts gespeichert hat, soll
* nicht in einem Modus stehen, der stillschweigend andere Zugangsdaten benutzt.
*/
enum OperatingMode: string
{
case Test = 'test';
case Live = 'live';
public const SETTING = 'platform.mode';
public static function current(): self
{
// tryFrom, nicht from: ein unbekannter Wert in der Zeile ist ein
// Datenfehler und darf nicht jede Anfrage in eine Ausnahme werfen.
return self::tryFrom((string) Settings::get(self::SETTING, self::Live->value))
?? self::Live;
}
public static function set(self $mode): void
{
Settings::set(self::SETTING, $mode->value);
}
/**
* Zu welchem Modus ein Stripe-Schlüssel gehört am Präfix, ohne Netzverkehr.
*
* Stripe beschriftet den Schlüssel nicht; das Präfix ist das Einzige, was
* bei beiden Schlüsselarten (`sk_`, und der eingeschränkte `rk_`) wahr ist.
* Das reicht, und es muss reichen: die Bereitschaftsseite darf beim
* Seitenaufruf nichts erreichen, und die Migration läuft, bevor irgendein
* Dienst antwortet.
*
* `null` heißt „nicht entscheidbar", nicht „live". Wer daraus eine
* Vorgabe braucht, sagt sie an seiner Stelle selbst die Migration setzt
* `?? Live` (der Zustand vor ihr, also Fortsetzung statt neuer Behauptung),
* die Bereitschaftsprüfung meldet dagegen NUR einen Widerspruch, den sie
* beweisen kann.
*
* Diese eine Stelle, weil dieselbe Frage vorher an drei Orten unabhängig
* beantwortet wurde: in der Migration, in StripeCheck und gar nicht auf
* der Bereitschaftsseite. Drei Formulierungen einer Frage sind der Weg, auf
* dem sie auseinanderlaufen.
*/
public static function ofStripeKey(?string $secret): ?self
{
$secret = trim((string) $secret);
return match (true) {
str_starts_with($secret, 'sk_test_'), str_starts_with($secret, 'rk_test_') => self::Test,
str_starts_with($secret, 'sk_live_'), str_starts_with($secret, 'rk_live_') => self::Live,
default => null,
};
}
public function isTest(): bool
{
return $this === self::Test;
}
}

66
app/Support/Readiness.php Normal file
View File

@ -0,0 +1,66 @@
<?php
namespace App\Support;
use App\Support\Readiness\BillingChecks;
use App\Support\Readiness\Check;
use App\Support\Readiness\DeliveryChecks;
use App\Support\Readiness\OnboardingChecks;
use App\Support\Readiness\OperationChecks;
use App\Support\Readiness\ProvisioningChecks;
/**
* Jedes Pflichtfeld dieser Installation an einer Stelle, gruppiert nach dem,
* was es blockiert nicht danach, wo der Wert gespeichert ist.
*
* Ein Betreiber, der eine Installation befüllt, denkt in „was kann ich noch
* nicht", nicht in „liegt das im Tresor oder in den Einstellungen".
*
* Diese Klasse SPERRT NICHTS. Die harten Sperren bleiben, wo sie stehen
* IssueInvoice bei unvollständigen Firmendaten, VerifyVmTemplate bei fehlender
* Vorlage, die Kasse bei fehlendem Stripe-Schlüssel. Eine Bereitschaftsseite,
* die selbst sperrt, wäre eine zweite Wahrheit neben diesen Prüfungen.
*/
final class Readiness
{
/**
* Die Gruppen, angehängt. Jede Gruppe kennt nur ihre eigenen
* Abhängigkeiten (Stripe, Proxmox, DNS, Mail) eine Datei, die alle
* Gruppen importiert, wäre in jedem einzelnen Gruppentest die ganze Welt.
*
* @return array<int, Check>
*/
public static function all(): array
{
return [
...BillingChecks::all(),
...OnboardingChecks::all(),
...ProvisioningChecks::all(),
...DeliveryChecks::all(),
...OperationChecks::all(),
];
}
/** @return array<int, Check> */
public static function blocking(): array
{
return array_values(array_filter(self::all(), fn (Check $c) => $c->isBlocking()));
}
public static function isReady(): bool
{
return self::blocking() === [];
}
/** @return array<string, array<int, Check>> */
public static function byGroup(): array
{
$grouped = [];
foreach (self::all() as $check) {
$grouped[$check->group][] = $check;
}
return $grouped;
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Support\Readiness;
use App\Models\InvoiceSeries;
use App\Models\PlanPrice;
use App\Services\Secrets\SecretVault;
use App\Support\CompanyProfile;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
use App\Support\StripeWebhookSecret;
/**
* Was gesetzt sein muss, damit überhaupt ein Auftrag entstehen und ein Beleg
* herauskommen kann.
*/
final class BillingChecks
{
public const GROUP = 'billing';
/** @return array<int, Check> */
public static function all(): array
{
$mode = OperatingMode::current();
// get() löst den aktiven Modus selbst auf UND ist für Stripe strikt —
// ein Live-Schlüssel im LIVE-Platz zählt im Testbetrieb nicht als
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
$secret = app(SecretVault::class)->get('stripe.secret');
// Was im Platz des aktiven Modus liegt, kann trotzdem der Schlüssel des
// anderen Kontos sein — eingetippt, nicht zurückgefallen. Am Präfix
// ohne Netzverkehr entscheidbar; `null` heißt „nicht entscheidbar" und
// wird hier bewusst NICHT als Widerspruch gemeldet: diese Prüfung sagt
// nur, was sie beweisen kann.
$keyMode = OperatingMode::ofStripeKey($secret);
$wrongSlot = $keyMode !== null && $keyMode !== $mode;
// stripe:sync-catalogue (SyncStripeCatalogue::handle()) only ever
// touches PUBLISHED versions — "a draft has promised nothing, and a
// Product for it would be a price list entry for something that may
// never exist" (its own header comment). A draft's own unsynced price
// is therefore not a gap; counting it anyway turns this check into an
// alarm that never clears on any installation that has ever drafted a
// next version, which stops it being read at all.
$published = PlanPrice::query()
->whereHas('version', fn ($query) => $query->whereNotNull('published_at'));
$unsyncedPrices = (clone $published)->whereNull('stripe_price_id')->exists();
// Eine gefüllte Spalte beweist nur, dass IRGENDEIN Konto diese ID
// ausgestellt hat. Welches, steht seit dem Abgleich in einer eigenen
// Zeile (StripeCatalogueMode) — ohne sie meldete diese Prüfung nach
// dem Umschalten auf Live weiter „erfüllt", und die erste echte
// Bestellung bekam von Stripe *No such price*. Ohne Netzverkehr
// entschieden: die Bereitschaftsseite erreicht beim Aufruf nichts.
//
// ZWEI Fälle, nicht einer. „Steht fest: anderes Konto" verlangt das
// Leeren der Zeiger und Register; „Herkunft nie festgehalten" verlangt
// nur einen neuen Lauf. Der zweite entsteht ohne jeden Kontowechsel —
// ein abgebrochener Abgleich, oder eine Bestellung, die sich ihren
// fehlenden Preis selbst geholt hat — und die Leeranweisung wäre dort
// die Aufforderung, einen Katalog zu löschen, an dem laufende Verträge
// abgerechnet werden.
$somethingSynced = (clone $published)->whereNotNull('stripe_price_id')->exists();
$wrongAccount = $somethingSynced && StripeCatalogueMode::belongsToAnotherMode();
$unknownOrigin = $somethingSynced && StripeCatalogueMode::recorded() === null;
return [
new Check(
key: 'billing.stripe_secret',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]),
// Zwei Ausfälle, zwei Folgen — und „ohne ihn nimmt die Kasse
// keine Bestellung an" wäre über einen Schlüssel, der DA ist,
// schlicht falsch.
breaks: $wrongSlot
? __('readiness.billing.stripe_secret_'.$mode->value.'_slot_breaks')
: __('readiness.billing.stripe_secret_breaks'),
tab: 'services',
satisfied: filled($secret) && ! $wrongSlot,
),
new Check(
key: 'billing.webhook_secret',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.webhook_secret'),
breaks: __('readiness.billing.webhook_secret_breaks'),
tab: 'env',
// Die Auswahl nach Modus liegt schon in StripeWebhookSecret —
// hier noch einmal per config() nach Modus zu unterscheiden
// wäre eine zweite Stelle, die dieselbe Entscheidung trifft.
satisfied: filled(StripeWebhookSecret::current()),
),
new Check(
key: 'billing.company_details',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.company_details'),
breaks: __('readiness.billing.company_details_breaks'),
tab: 'company',
// Die vorhandene Liste wird gerufen, nicht verdoppelt: zwei
// Quellen für eine Frage ist der Weg, auf dem sie auseinanderlaufen.
satisfied: CompanyProfile::missingForInvoicing() === [],
),
new Check(
key: 'billing.invoice_series',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.invoice_series'),
breaks: __('readiness.billing.invoice_series_breaks'),
tab: 'company',
// IssueInvoice draws a series with `where('kind', $kind)
// ->where('active', true)` (IssueInvoice.php:373, :444) — a
// deactivated row is invisible to it exactly like a missing
// one, so the same `active` filter belongs here too. Without
// it, an installation with a switched-off 'invoice' series
// still counted as ready, the false-green this page exists to
// rule out.
satisfied: InvoiceSeries::query()
->whereIn('kind', ['invoice', 'credit_note', 'cancellation'])
->where('active', true)
->distinct()
->count('kind') === 3,
),
new Check(
key: 'billing.catalogue_synced',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.catalogue_synced'),
// Der bewiesene Widerspruch zuerst: „noch nicht abgeglichen"
// wäre über einen Katalog, der abgeglichen IST — nur im anderen
// Konto — die falsche Anweisung, und es ist die teure Richtung
// (der Betreiber ruft den Befehl, bekommt „already in step"
// und glaubt, es sei erledigt). Danach die unbekannte Herkunft,
// deren Kur ein Lauf ist und kein Löschen. Zuletzt die Lücke.
breaks: match (true) {
$wrongAccount => __('readiness.billing.catalogue_account_breaks', [
'mode' => __('readiness.mode.'.$mode->value),
]),
$unknownOrigin => __('readiness.billing.catalogue_origin_breaks'),
default => __('readiness.billing.catalogue_synced_breaks'),
},
tab: 'services',
satisfied: ! $unsyncedPrices && ! $wrongAccount && ! $unknownOrigin,
),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Support\Readiness;
/**
* Ein Befund der Bereitschaftsprüfung.
*
* `breaks` ist das Feld, an dem diese Seite hängt. Eine Liste von Feldnamen ist
* keine Auskunft sie sagt, was fehlt, nicht was daraus folgt. Am 2026-07-30
* war jedes fehlende Stück in der Konsole unsichtbar, und mehrere brachen die
* Kette erst NACH der Zahlung. Genau das gehört hier hinein.
*/
final class Check
{
public const SEVERITY_BLOCKING = 'blocking';
public const SEVERITY_WARNING = 'warning';
public function __construct(
public string $key,
public string $group,
public string $severity,
public string $label,
public string $breaks,
public string $tab,
public bool $satisfied,
) {}
public function isBlocking(): bool
{
return $this->severity === self::SEVERITY_BLOCKING && ! $this->satisfied;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Support\Readiness;
use App\Mail\Transport\MailboxTransport;
use App\Models\Mailbox;
use App\Models\MailTemplate;
use App\Services\Secrets\SecretVault;
/**
* What has to be in place for the customer to actually find out any of this
* happened: a mail transport that leaves the server, an address to send
* from, and for support a way to answer and to hear back.
*/
final class DeliveryChecks
{
public const GROUP = 'delivery';
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
key: 'delivery.mailer_not_log',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.delivery.mailer_not_log'),
breaks: __('readiness.delivery.mailer_not_log_breaks'),
tab: 'mail',
// Reads the transport's own definition of "does not really
// send" rather than a second, narrower one. `!== 'log'` alone
// called 'array' — the MAIL_MAILER this whole suite runs
// under — delivered, which MailboxTransport::delegate() has
// never agreed with.
satisfied: ! in_array(config('mail.default'), MailboxTransport::NON_DELIVERING, true),
),
new Check(
key: 'delivery.mailbox',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.delivery.mailbox'),
breaks: __('readiness.delivery.mailbox_breaks'),
tab: 'mail',
satisfied: Mailbox::query()->exists(),
),
new Check(
key: 'delivery.mail_templates',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.delivery.mail_templates'),
breaks: __('readiness.delivery.mail_templates_breaks'),
tab: 'templates',
satisfied: MailTemplate::query()->exists(),
),
new Check(
// Named after the vault entry it checks (inbound_mail.password),
// not a shorthand — see the identical reasoning on
// onboarding.ssh_private_key in OnboardingChecks.php.
key: 'delivery.inbound_mail_password',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.delivery.inbound_password'),
breaks: __('readiness.delivery.inbound_password_breaks'),
// The inbound-mail card (host/port/user/folder + this
// password) is on the 'services' tab — 'integrations' is not
// a member of Integrations::TABS at all (Fix-Runde, Befund 1).
tab: 'services',
satisfied: filled(app(SecretVault::class)->get('inbound_mail.password')),
),
];
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Support\Readiness;
use App\Models\Datacenter;
use App\Services\Secrets\SecretVault;
use App\Services\Wireguard\ConfigVault;
use App\Support\ProvisioningSettings;
/**
* What has to be in place before a single host can be onboarded at all: the
* SSH identity CluPilot deploys to every host, the two encryption keys the
* vault and the VPN config store need to work at all, the WireGuard hub a new
* host joins, and somewhere to onboard it into.
*/
final class OnboardingChecks
{
public const GROUP = 'onboarding';
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
// Named after the vault entry it checks (ssh.private_key), not
// a shorthand: ReadinessPageTest's guard walks every
// SecretVault::REGISTRY key and demands a Check key that
// CONTAINS it (dots turned to underscores) — 'onboarding.
// ssh_key' does not contain 'ssh_private_key' and slipped
// through unnoticed until that guard existed (Task 12).
key: 'onboarding.ssh_private_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.ssh_key'),
breaks: __('readiness.onboarding.ssh_key_breaks'),
// The SSH identity card lives on the 'platform' tab
// (integrations.blade.php: "own machines"), beside the
// public-key setting it pairs with — not 'integrations',
// which is not a member of Integrations::TABS at all and
// sent an operator clicking this exact blocking check to the
// wrong tab (Fix-Runde, Befund 1).
tab: 'platform',
// EstablishSshTrust only deploys the PUBLIC half. Every step
// after it (HostStep's own shell helper), and later every
// Traefik write on a host already onboarded (SshTraefikWriter),
// connects with THIS private key.
satisfied: filled(app(SecretVault::class)->get('ssh.private_key')),
),
new Check(
key: 'onboarding.secrets_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.secrets_key'),
breaks: __('readiness.onboarding.secrets_key_breaks'),
// SECRETS_KEY has no field anywhere on Integrations' 'services'
// or 'platform' tabs — the only place an operator can actually
// set it is the raw .env editor on the 'env' tab.
tab: 'env',
// isUsable() rather than a bare filled() on the config value —
// SecretCipher's own docblock warns that a second, shorter
// rule that only checks emptiness lets a malformed-but-non-
// empty key sail through, exactly the gap that once let two
// other call sites reach an uncaught RuntimeException.
satisfied: app(SecretVault::class)->isUsable(),
),
new Check(
key: 'onboarding.vpn_config_key',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.vpn_config_key'),
breaks: __('readiness.onboarding.vpn_config_key_breaks'),
// Same reasoning as onboarding.secrets_key just above:
// VPN_CONFIG_KEY has no console field either, only the raw
// .env editor.
tab: 'env',
// Same reuse-not-reimplement reasoning as isUsable() above:
// ConfigVault::available() is the one place that already knows
// what counts as a usable key.
satisfied: ConfigVault::available(),
),
new Check(
key: 'onboarding.wg_hub',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.wg_hub'),
breaks: __('readiness.onboarding.wg_hub_breaks'),
// Two of the three values this check reads (wgHubPubkey,
// wgEndpoint) are 'platform'-tab fields; only the third
// (subnet) is .env-only and cannot be changed from the
// console at all (see the docblock on satisfied: below). The
// tab an operator can actually act on is 'platform'.
tab: 'platform',
// Two of three is no half a tunnel, it is none: ConfigureWireguard
// writes the peer config from all three at once. Subnet is read
// straight from config(), never through Settings/ProvisioningSettings
// — that class's own docblock explains why: the compose file
// (vpn-dns, vpn-gateway) reads the same CLUPILOT_WG_SUBNET at
// container boot, so a console-only override would let the
// console show one value while the running containers use another.
satisfied: filled(ProvisioningSettings::wgHubPublicKey())
&& filled(ProvisioningSettings::wgEndpoint())
&& filled((string) config('provisioning.wireguard.subnet')),
),
new Check(
key: 'onboarding.datacenter',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.onboarding.datacenter'),
breaks: __('readiness.onboarding.datacenter_breaks'),
tab: 'datacenters',
satisfied: Datacenter::query()->exists(),
),
];
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Support\Readiness;
use App\Support\Settings;
use Illuminate\Support\Carbon;
/**
* Whether anything is actually running this installation, right now as
* opposed to every other group in this file, which asks whether it is
* CONFIGURED to run at all.
*
* Queue-driven delivery has no error path for "nobody is picking up the
* queue": a paid order just sits there, forever, with nothing in the console
* to tell an operator apart from a quiet afternoon. Two heartbeats because the
* scheduler and the provisioning worker fail independently the dangerous
* case is the scheduler running and enqueueing jobs that nobody executes, and
* a single heartbeat written by the scheduler alone would call that healthy.
*/
final class OperationChecks
{
public const GROUP = 'operation';
/**
* A heartbeat older than this is as good as none: the process that was
* writing it has gone quiet. Five minutes is generous slack against a
* `->everyMinute()` schedule a deploy restarting the container, a GC
* pause, one skipped tick without also covering up a worker that has
* actually stopped.
*/
private const STALE_AFTER_MINUTES = 5;
/**
* Fix-Runde (Befund 3): a heartbeat this far into the future is as
* untrustworthy as one from the past a clock skewed between the web,
* scheduler and worker containers, or a corrupted value that still
* happens to parse. Without an upper bound, `now()->addHour()` written
* once by a confused clock would read as healthy forever, since it would
* never age past STALE_AFTER_MINUTES from a `greaterThan()`-only check.
* One minute is generous slack for the same reason five is above:
* everything that writes these timestamps runs on the same host clock,
* so more than a minute of "future" is a fault, not jitter.
*/
private const FUTURE_TOLERANCE_MINUTES = 1;
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
key: 'operation.scheduler',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.operation.scheduler'),
breaks: __('readiness.operation.scheduler_breaks'),
// Fix-Runde (Befund 4): 'integrations' is not a member of
// Integrations::TABS (['services', 'platform', 'env']) — a
// link to it would go nowhere. 'env' is where saveEnv() lives,
// and saveEnv() is the actual remedy: it restarts the
// scheduler and queue-provisioning workers (see its own
// docblock), which is exactly what an operator would do about
// either check in this group.
tab: 'env',
satisfied: self::isFresh('heartbeat.scheduler'),
),
new Check(
key: 'operation.queue_provisioning',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.operation.queue_provisioning'),
breaks: __('readiness.operation.queue_provisioning_breaks'),
tab: 'env', // Same fix as above; same remedy (saveEnv()).
// Written by RecordProvisioningHeartbeat, which the scheduler
// enqueues but only the worker actually runs. A fresh
// timestamp here is proof of a running worker, not just of a
// running scheduler.
satisfied: self::isFresh('heartbeat.queue_provisioning'),
),
];
}
private static function isFresh(string $key): bool
{
$recordedAt = Settings::get($key);
if (blank($recordedAt)) {
return false;
}
// Fix-Runde (Befund 2): reproduced live — a garbled value in the
// heartbeat row (never written by RecordProvisioningHeartbeat or the
// scheduler call themselves, but reachable by anyone editing
// app_settings directly) made Carbon::parse() throw, which propagated
// out of Readiness::all() and took the ENTIRE readiness page down
// with it, not just this one check. A page whose only job is to
// report calmly what is missing must not join the outage itself. An
// unreadable value is treated exactly like a missing one: not
// satisfied, nothing thrown.
try {
$parsed = Carbon::parse($recordedAt);
} catch (\Throwable) {
return false;
}
// Not diffInMinutes(): App\Models\Incident::durationMinutes() already
// documents that it is SIGNED, and `now()->diffInMinutes($recordedAt)`
// with $recordedAt in the past returns a NEGATIVE number — which
// would compare as "less than 5" for a heartbeat six hours stale, and
// report a dead worker as healthy. A direct threshold comparison has
// no sign to get backwards.
//
// Bounded on both sides (Befund 3): stale in the past beyond
// STALE_AFTER_MINUTES, but ALSO not more than FUTURE_TOLERANCE_MINUTES
// ahead of now — otherwise a heartbeat from a skewed clock would read
// as healthy indefinitely, since "in the future" always satisfies a
// one-sided "not older than five minutes" comparison.
return $parsed->greaterThan(now()->subMinutes(self::STALE_AFTER_MINUTES))
&& $parsed->lessThanOrEqualTo(now()->addMinutes(self::FUTURE_TOLERANCE_MINUTES));
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Support\Readiness;
use App\Models\Host;
use App\Models\PlanVersion;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
/**
* What has to be in place for a PAID order to actually turn into a running,
* reachable machine: somewhere to put it, a name for it, and a router that
* knows about it.
*/
final class ProvisioningChecks
{
public const GROUP = 'provisioning';
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
key: 'provisioning.dns_token',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.dns_token'),
breaks: __('readiness.provisioning.dns_token_breaks'),
// The DNS card (token + zone + Traefik path) is on the
// 'services' tab — 'integrations' is not a member of
// Integrations::TABS at all (Fix-Runde, Befund 1).
tab: 'services',
satisfied: filled(app(SecretVault::class)->get('dns.token')),
),
new Check(
key: 'provisioning.dns_zone',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.dns_zone'),
breaks: __('readiness.provisioning.dns_zone_breaks'),
// Same DNS card as provisioning.dns_token just above.
tab: 'services',
satisfied: filled(ProvisioningSettings::dnsZone()),
),
new Check(
key: 'provisioning.traefik_path',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.provisioning.traefik_path'),
breaks: __('readiness.provisioning.traefik_path_breaks'),
// Same DNS card — traefikDynamicPath is a field on it.
tab: 'services',
satisfied: filled(ProvisioningSettings::traefikDynamicPath()),
),
new Check(
key: 'provisioning.usable_host',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.usable_host'),
breaks: __('readiness.provisioning.usable_host_breaks'),
tab: 'hosts',
// `active` ALONE is not enough. A host without a readable
// token is still selectable to ReserveResources, and the
// order then dies in CloneVirtualMachine — after payment.
satisfied: Host::query()
->where('status', 'active')
->whereNotNull('api_token_ref')
->exists(),
),
new Check(
key: 'provisioning.vm_template',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.vm_template'),
breaks: __('readiness.provisioning.vm_template_breaks'),
tab: 'plans',
// A field check only. Whether the template actually EXISTS on
// a node is answered by the button in Task 9 — that has to
// ask Proxmox, and a live API call does not belong on every
// page load of this readiness page.
satisfied: PlanVersion::query()
->whereNotNull('published_at')
->whereNull('template_vmid')
->doesntExist(),
),
new Check(
key: 'provisioning.monitoring_token',
group: self::GROUP,
// Follows the same condition RegisterMonitoring itself reads
// (RegisterMonitoring.php: `if (config('provisioning.
// monitoring.required', false)) { throw $e; }`). Warning by
// default — monitoring degrades, then the run continues — but
// an operator who has switched that setting on made
// monitoring a real gate, and a fixed severity would then
// claim a looser rule than the pipeline actually enforces.
severity: config('provisioning.monitoring.required', false)
? Check::SEVERITY_BLOCKING
: Check::SEVERITY_WARNING,
label: __('readiness.provisioning.monitoring_token'),
breaks: __('readiness.provisioning.monitoring_token_breaks'),
// The monitoring card is on the 'services' tab too.
tab: 'services',
satisfied: filled(app(SecretVault::class)->get('monitoring.token')),
),
];
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Support;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
/**
* In welchem Stripe-KONTO die gespeicherten Objekt-IDs entstanden sind.
*
* Der Betriebsmodus schaltet die Zugangsdaten um. Er schaltet nicht um, was aus
* ihnen entstanden ist: eine Stripe-Price-ID und eine Product-ID gehören dem
* Konto, das sie ausgestellt hat, und ein Testkonto und ein Livekonto sind zwei
* Konten. Jedes Zugangsdatum hat auf dieser Installation zwei Plätze; die
* daraus abgeleiteten IDs stehen in einwertigen Spalten
* (`plan_families.stripe_product_id`, `plan_prices.stripe_price_id`) und in den
* zwei Registern (`stripe_plan_prices`, `stripe_addon_prices`).
*
* Ohne diese Zeile lief der geplante Ablauf dieser Installation ins Leere:
* abgleichen im Testbetrieb, Live-Schlüssel hinterlegen, umschalten und
* `billing.catalogue_synced` meldete weiter „erfüllt", weil es nur prüfte, ob
* die Spalte gefüllt ist. Die Seite sagte „Bereit für Livebetrieb", und die
* erste echte Bestellung schickte eine Test-Preis-ID an die Live-API: *No such
* price*, kein Auftrag. Genau die falsche Grünmeldung, gegen die diese Seite
* gebaut ist, ausgelöst durch genau den Schalter, den dieses Vorhaben einführt.
*
* **Warum eine Einstellungszeile und nicht die Objekte selbst.** Stripe schreibt
* die Kontoart nicht in die ID: `prod_…` und `price_…` sehen im Test- und im
* Livekonto gleich aus (nur SCHLÜSSEL tragen `_test_`/`_live_` im Präfix das
* ist OperatingMode::ofStripeKey()). Die Herkunft ist aus einer gespeicherten ID
* also nicht ableitbar, und nachfragen dürfte die Bereitschaftsseite nicht: sie
* erreicht beim Seitenaufruf nichts über das Netz. Bleibt: beim Abgleich
* festhalten, in welchem Modus die IDs entstanden sind.
*
* **Warum EINE Zeile für den ganzen Katalog.** Ein Lauf von
* stripe:sync-catalogue benutzt genau einen Schlüssel, also genau ein Konto. Und
* der Befehl weigert sich, in einen Katalog hineinzuarbeiten, der zu einem
* anderen Konto gehört (siehe dort) ohne diese Weigerung könnte ein Lauf
* einen halb gemischten Zustand hinterlassen, über den eine einzelne Zeile dann
* lügen würde.
*/
final class StripeCatalogueMode
{
public const SETTING = 'billing.catalogue_mode';
/** Der Modus, in dem der Katalog zuletzt angelegt wurde — null: noch nie. */
public static function recorded(): ?OperatingMode
{
$stored = Settings::get(self::SETTING);
return $stored === null ? null : OperatingMode::tryFrom((string) $stored);
}
/** Festhalten, wessen Konto die gerade angelegten IDs gehören. */
public static function record(?OperatingMode $mode = null): void
{
Settings::set(self::SETTING, ($mode ?? OperatingMode::current())->value);
}
/**
* Steht fest, dass der gespeicherte Katalog einem ANDEREN Konto gehört?
*
* Nur dann nicht schon, wenn die Herkunft unbekannt ist. Das sind zwei
* Zustände mit zwei Handlungsanweisungen, und sie zusammenzuwerfen war ein
* teurer Fehler: „Herkunft nie festgehalten" entsteht ganz ohne Kontowechsel,
* nämlich wenn ein Abgleich mitten im Anlegen abbricht oder wenn eine
* Bestellung sich ihren fehlenden Preis selbst holt (CheckoutController
* PlanPrices::ensure(), BookAddon SyncStripeAddonItems
* AddonPrices::ensure() keiner der beiden ruft record()). Die Objekte
* stammen dann aus genau dem geltenden Konto, und wer in diesem Zustand die
* Leeranweisung für einen Kontowechsel liest, löscht einen Katalog, an dem
* laufende Verträge abgerechnet werden.
*
* Unbekannte Herkunft ist trotzdem kein Freibrief: die Bereitschaftsseite
* meldet sie weiter blockierend, nur mit dem Satz, der dazu passt („lauf den
* Abgleich noch einmal"). Beweisen kann sie die Herkunft nicht.
*/
public static function belongsToAnotherMode(): bool
{
$recorded = self::recorded();
return $recorded !== null && $recorded !== OperatingMode::current();
}
/**
* Liegt überhaupt eine Stripe-Objekt-ID in der Datenbank?
*
* Alle vier Orte, an denen der Abgleich etwas hinterlässt auch die zwei
* Register, weil PlanPrices::inStep() für den Netto-Preis eines
* Reverse-Charge-Kunden ALLEIN das Register fragt: eine geleerte Spalte in
* `plan_prices` ohne gelöschte Registerzeile ließe genau diese Hälfte des
* Katalogs still auf das alte Konto zeigen.
*/
public static function hasStoredObjects(): bool
{
return PlanFamily::query()->whereNotNull('stripe_product_id')->exists()
|| PlanPrice::query()->whereNotNull('stripe_price_id')->exists()
|| StripePlanPrice::query()->exists()
|| StripeAddonPrice::query()->exists();
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Support;
/**
* Der Signaturschlüssel des aktiven Betriebsmodus.
*
* Beide Werte bleiben in der `.env`, nicht im Tresor (siehe Kopfkommentar von
* App\Services\Secrets\SecretVault): dieser Wert wird bei jedem eingehenden
* Zahlungsereignis gelesen, und ein Datenbankproblem würde die
* Signaturprüfung still fehlschlagen lassen die eine Fehlerart, die laut
* bleiben muss.
*
* Die AUSWAHL fragt allerdings die Datenbank (OperatingMode::current()), was
* diese Abhängigkeit auf einem Umweg wieder einführt. Entschärft durch die
* Richtung des Ausfalls: `Settings::get()` liefert bei unerreichbarer Tabelle
* seinen Vorgabewert `live`, also wird der Live-Schlüssel benutzt. Ein echtes
* Zahlungsereignis wird weiter korrekt geprüft, ein Testereignis scheitert
* laut. Andersherum wäre es die gefährliche Richtung.
*
* Eigene Klasse statt einer privaten Methode auf dem Controller: die Auswahl
* selbst ist der Prüfgegenstand (siehe StripeWebhookSecretByModeTest), und ein
* Test soll dafür nicht per Reflection ins Controller-Innere greifen müssen.
*/
final class StripeWebhookSecret
{
public static function current(): string
{
return OperatingMode::current()->isTest()
? (string) config('services.stripe.webhook_secret_test')
: (string) config('services.stripe.webhook_secret');
}
}

View File

@ -354,6 +354,33 @@ return [
// googleusercontent.com or vercel.app.
'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.cloud'),
/*
| Die Zone der PLATTFORM Website, Portal, Konsole und die Namen der
| Hosts selbst. Nicht die Kundenzone darüber.
|
| Sie musste eigens entstehen, weil genau diese Trennung im Code schon
| überall behauptet und an einer Stelle gebrochen wurde: der Kopfkommentar
| von RegisterHostDns nennt `fsn-01.node.clupilot.com`, die Prüfregel in
| Datacenters nennt denselben Namen, und der Test in ServicesTest schreibt
| ihn wörtlich der Schritt selbst baute den Namen aber aus `zone` oben,
| und die steht auf der KUNDENZONE. Auf dieser Installation hieß ein Host
| damit `fsn-01.node.clupilot.cloud`: in der Zone, die absichtlich von der
| Plattform getrennt ist, mit derselben Begründung, die in
| OfficialDomains steht.
|
| Ohne Angabe aus APP_URL abgeleitet, letzte zwei Labels. Das trifft
| `app.dev.clupilot.com` `clupilot.com` und liegt bei mehrteiligen
| Endungen wie `co.uk` daneben dort wird CLUPILOT_PLATFORM_ZONE gesetzt.
*/
'platform_zone' => env('CLUPILOT_PLATFORM_ZONE') ?: (function () {
$host = parse_url((string) env('APP_URL', ''), PHP_URL_HOST) ?: '';
$labels = array_filter(explode('.', $host));
return count($labels) >= 2
? implode('.', array_slice($labels, -2))
: 'clupilot.com';
})(),
// Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns
// container's dnsmasq --hostsdir. NOT the public Hetzner zone above:
// a host's tunnel address must never be resolvable outside the tunnel,

View File

@ -37,6 +37,11 @@ return [
'stripe' => [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
// Stripe issues a SEPARATE signing secret per operating mode. Which of
// the two applies is decided by App\Support\StripeWebhookSecret, not
// here — see its header comment for why that lookup still touches the
// database despite both values living in the .env.
'webhook_secret_test' => env('STRIPE_WEBHOOK_SECRET_TEST'),
// Secret key for the REST API. Blank means "not connected": the
// catalogue sync says so and does nothing, rather than half-creating
// products against an account that is not there.

View File

@ -0,0 +1,126 @@
<?php
use App\Services\Secrets\SecretCipher;
use App\Support\OperatingMode;
use App\Support\Settings;
use App\Support\StripeCatalogueMode;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Jedes Zugangsdatum bekommt zwei Plätze und die vorhandene Zeile wird
* einsortiert statt umbenannt.
*
* Für vier der fünf Einträge ist die Antwort dieselbe: was da liegt, wurde für
* den echten Betrieb hinterlegt, also `:live`. Nur Stripe kann es selbst sagen,
* weil sein Schlüssel sein Präfix trägt und daran hängt mehr als eine Zeile:
* liegt dort ein Testschlüssel, war diese Installation erkennbar im
* Testbetrieb, und der Modus folgt daraus statt aus einer Vorgabe.
*/
return new class extends Migration
{
public function up(): void
{
$stripeMode = null;
foreach (DB::table('app_secrets')->get() as $row) {
// Schon einsortiert (etwa bei einem zweiten Lauf): unangetastet.
if (Str::contains($row->key, ':')) {
continue;
}
$mode = OperatingMode::Live;
if ($row->key === 'stripe.secret') {
$mode = $this->stripeModeOf($row->value);
$stripeMode = $mode;
}
DB::table('app_secrets')
->where('id', $row->id)
->update(['key' => $row->key.':'.$mode->value]);
}
// Nur wenn ein Stripe-Schlüssel da war. Ohne ihn bleibt die Vorgabe
// stehen, und die ist live.
if ($stripeMode !== null) {
Settings::set(OperatingMode::SETTING, $stripeMode->value);
// Und derselbe Schluss noch einmal, eine Ebene weiter: was bei
// Stripe an Produkten und Preisen angelegt wurde, wurde mit
// GENAU DIESEM Schlüssel angelegt — ein anderer war auf dieser
// Installation nie hinterlegt. Die IDs gehören also dem Konto, das
// dieser Schlüssel öffnet, und das ist ab jetzt festgehalten
// (StripeCatalogueMode). Ohne diesen Nachtrag stünde ein längst
// abgeglichener Katalog als „Herkunft unbekannt" da, und die
// Bereitschaftsseite verlangte einen neuen Abgleich, den niemand
// braucht. Nur wenn wirklich IDs da sind: eine Zeile über einen
// Katalog, den es nicht gibt, wäre eine Behauptung ohne Gegenstand.
if (StripeCatalogueMode::hasStoredObjects()) {
Settings::set(StripeCatalogueMode::SETTING, $stripeMode->value);
}
}
}
private function stripeModeOf(string $stored): OperatingMode
{
try {
$secret = app(SecretCipher::class)->decrypt($stored);
} catch (Throwable) {
// Unlesbar heißt: wir wissen es nicht. `:live` ist der Zustand vor
// dieser Migration, also die Fortsetzung des Status quo statt einer
// neuen Behauptung. Die Bereitschaftsseite meldet ihn ohnehin.
return OperatingMode::Live;
}
// Die Präfixregel steht in OperatingMode und wird hier gerufen, nicht
// wiederholt: dieselbe Frage entscheidet auf der Bereitschaftsseite, ob
// ein Schlüssel im falschen Platz liegt, und in StripeCheck, was der
// Konsole als Kontoart gemeldet wird. `?? Live` ist wie oben der
// Status quo für einen Wert, der sich nicht zuordnen lässt.
return OperatingMode::ofStripeKey($secret) ?? OperatingMode::Live;
}
public function down(): void
{
$rows = DB::table('app_secrets')->get();
// Zwei Durchläufe über dieselbe Menge, nicht einer — und die
// Reihenfolge zwischen den beiden ist die Entscheidung, nicht eine
// Nebenwirkung. `:live` geht zuerst und beansprucht den nackten
// Schlüssel bedingungslos; `:test` sieht ihn danach schon vergeben und
// weicht. Ein einziger Durchlauf, der beide Endungen im selben Schritt
// behandelt, hätte stattdessen der Zeilenreihenfolge der Datenbank
// überlassen, welcher der beiden Schlüssel überlebt — bei einem
// Zahlungsschlüssel die falsche Art von Zufall.
foreach ($rows as $row) {
if (Str::endsWith($row->key, ':live')) {
DB::table('app_secrets')->where('id', $row->id)->update(['key' => Str::beforeLast($row->key, ':')]);
}
}
foreach ($rows as $row) {
if (! Str::endsWith($row->key, ':test')) {
continue;
}
$bare = Str::beforeLast($row->key, ':');
// Eine Zeile je Eintrag kann zurück; die zweite hat im alten Schema
// keinen Platz und würde am Eindeutigkeitsindex scheitern. Existiert
// der nackte Schlüssel schon, hat ihn der Live-Durchlauf oben gerade
// erst belegt — der Testwert wird verworfen, nie der Live-Wert.
if (DB::table('app_secrets')->where('key', $bare)->exists()) {
DB::table('app_secrets')->where('id', $row->id)->delete();
continue;
}
DB::table('app_secrets')->where('id', $row->id)->update(['key' => $bare]);
}
Settings::forget(OperatingMode::SETTING);
Settings::forget(StripeCatalogueMode::SETTING);
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Der Einmal-Code, mit dem ein Host sich zurückmeldet.
*
* Nur der Hash liegt hier. Der Klartext wird beim Anlegen genau einmal gezeigt,
* steht danach in der Zwischenablage des Betreibers und in keiner Datenbank
* er ist ein Ausweis für den Rückweg, kein Schlüssel zu einer Auskunft (Spec §5).
*
* Der Hash ist indiziert, weil beide Endpunkte (`/host/progress` und
* `/host/register`) den Host darüber auflösen und das der einzige Weg dorthin
* ist.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->string('enrolment_code_hash', 64)->nullable()->index();
$table->timestamp('enrolment_expires_at')->nullable();
$table->timestamp('enrolment_used_at')->nullable();
});
}
public function down(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->dropIndex(['enrolment_code_hash']);
$table->dropColumn(['enrolment_code_hash', 'enrolment_expires_at', 'enrolment_used_at']);
});
}
};

View File

@ -0,0 +1,76 @@
# Die Nextcloud-Aufstellung, die in der goldenen Vorlage liegt.
#
# Landet als /opt/nextcloud/docker-compose.yml im Abbild. Sie ist bewusst
# KUNDENUNABHÄNGIG: kein Kennwort, kein Name, keine Domain steht hier. Alles
# Veränderliche kommt aus /opt/nextcloud/.env, die die Pipeline beim Klonen über
# cloud-init schreibt.
#
# Ein Passwort in ein Abbild zu backen hieße, es auf jeden Host und in jede
# Kundeninstanz zu kopieren — und es dort auch dann noch liegen zu haben, wenn
# der Kunde längst ein anderes gesetzt hat.
services:
db:
image: mariadb:11.4
restart: always
# READ-COMMITTED und Zeilen-Binlog sind die Einstellungen, die Nextcloud
# selbst verlangt; ohne sie meldet die Verwaltungsübersicht dauerhaft eine
# Warnung und einzelne Sperren verhalten sich anders als dokumentiert.
command: >-
--transaction-isolation=READ-COMMITTED
--binlog-format=ROW
--log-bin=binlog
volumes:
- db:/var/lib/mysql
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD fehlt in .env}
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 12
app:
image: nextcloud:apache
restart: always
# FALLE 1 aus Block A, nicht verhandelbar: ohne diese Zeile schlagen ALLE
# occ-Aufrufe fehl. Die Bereitschaftsprüfung der Pipeline ruft `occ status`,
# und ein Abbild ohne diese Zeile besteht sie nie.
user: www-data
ports:
- "80:80"
depends_on:
db:
condition: service_healthy
volumes:
- nextcloud:/var/www/html
environment:
MYSQL_HOST: db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD fehlt in .env}
# Sind diese beiden gesetzt, installiert sich Nextcloud beim ersten Start
# selbst. Genau dafür sind sie da — der Kunde soll keine
# Einrichtungsmaske sehen.
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER:?NEXTCLOUD_ADMIN_USER fehlt in .env}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD:?NEXTCLOUD_ADMIN_PASSWORD fehlt in .env}
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS:-}
# TLS endet bei Traefik auf dem Host, nicht hier. Ohne diese drei baut
# Nextcloud seine eigenen Adressen mit http:// — die Anmeldung dreht sich
# dann im Kreis, und WebDAV-Zugänge zeigen auf eine Adresse, die es so
# nicht gibt.
OVERWRITEPROTOCOL: https
OVERWRITEHOST: ${NEXTCLOUD_PRIMARY_DOMAIN:-}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-172.16.0.0/12}
volumes:
db:
nextcloud:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,552 @@
# shellcheck shell=sh
#
# Die Brücke, die Selbstrücknahme und die Datacenter-Firewall.
#
# ---------------------------------------------------------------------------
# Was sich durch den ISO-Weg geändert hat
# ---------------------------------------------------------------------------
#
# `ConfigureProxmox.php` hält als eigenen Fund fest: **Proxmox auf Debian legt
# keine `vmbr0` an — nur der ISO-Installer schreibt sie in
# `/etc/network/interfaces`.** Genau daran starb der erste bezahlte Auftrag:
# die Übernahme meldete `active` auf einem Host ohne Brücke, an die man eine
# Kunden-VM hätte hängen können.
#
# Dieser Entwurf installiert über die ISO. Die Brücke ist also im Regelfall
# schon da, und dieser Abschnitt **prüft sie, statt sie zu bauen**. Das ist kein
# Wegkürzen: dieselbe Datei begründet ausführlich, warum sie eine Brücke über die
# primäre Netzkarte NICHT aus der Ferne baut — ein Fehler dabei nimmt die
# Maschine dauerhaft vom Netz, und zurück kommt sie nur über die Anbieterkonsole.
#
# Bleibt der Fall, dass doch keine da ist. Dann — und nur dann — wird gebaut, und
# zwar mit der Selbstrücknahme aus Block B des Handoffs: sichern, Zeitgeber
# stellen, umstellen, nachsehen, abbestellen.
#
# ---------------------------------------------------------------------------
# Was hier NICHT passiert
# ---------------------------------------------------------------------------
#
# Die nftables-Sperre des Hosts gehört **nicht** in diesen Abschnitt, obwohl der
# Plan sie in seiner Leseliste führt. `SecureHostFirewall.php` sagt, warum, und
# es ist eine Reihenfolgefrage: dieser Schritt lief im alten Ablauf ZULETZT,
# „so the tunnel has already carried every prior step through a reboot before
# SSH-to-the-world is ever closed". Hier stünde er VOR `wireguard_joined` — und
# schlösse Port 22 zu einem Zeitpunkt, zu dem noch kein Tunnel bewiesen ist. Das
# ist die Reihenfolge, die einen Host dauerhaft unerreichbar macht.
#
# Die Sperre steht deshalb in Abschnitt `registered` (Task 9), an derselben
# Stelle wie früher: nach dem bewiesenen Handshake, nach Traefik.
CLUPILOT_ROLLBACK_UNIT='clupilot-network-rollback'
CLUPILOT_NET_BACKUP='/var/lib/clupilot/interfaces.vor-der-bruecke'
# ---------------------------------------------------------------------------
# Feststellen, was da ist
# ---------------------------------------------------------------------------
bridge_exists() {
ip link show "${1:-vmbr0}" >/dev/null 2>&1
}
# Trägt die Brücke wirklich den Verkehr, oder existiert sie nur?
#
# Eine `vmbr0` ohne Adresse und ohne Vorgaberoute ist eine Brücke im Sinne von
# `ip link show` und sonst nichts. Der alte Schritt prüfte genau das und warf das
# Ergebnis weg; sein Kommentar behauptete, er halte die Abwesenheit fest, und er
# hielt nichts fest.
bridge_carries_default_route() {
_dev="$(ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')"
[ "$_dev" = "${1:-vmbr0}" ]
}
bridge_has_address() {
[ -n "$(ip -4 -o addr show dev "${1:-vmbr0}" scope global 2>/dev/null | awk '{ print $4; exit }')" ]
}
# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall
# richtig ist, nimmt den anderen vom Netz.
#
# - `dhcp` — die Adresse kommt per DHCP (Cloud-Produkte).
# - `routed` — geroutete Einzeladresse, Gateway AUSSERHALB des eigenen Subnetzes
# (Hetzner-dediziert mit /32). Braucht eine pointopoint-Route,
# sonst findet der Kernel das Gateway nicht.
# - `subnet` — gewöhnliches Subnetz, Gateway darin.
detect_network_style() {
_iface="${1:-}"
[ -n "$_iface" ] || _iface="$(detect_primary_interface)"
if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" /etc/network/interfaces /etc/network/interfaces.d/* 2>/dev/null; then
printf 'dhcp'
return 0
fi
_cidr="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
_gw="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
_prefix="${_cidr##*/}"
# /32 heißt: das eigene Subnetz besteht aus der eigenen Adresse. Ein Gateway
# darin kann es nicht geben.
if [ "$_prefix" = '32' ]; then
printf 'routed'
return 0
fi
# Liegt das Gateway im eigenen Subnetz? `ip route get` beantwortet das, ohne
# dass dieses Skript Netzmasken rechnen muss — und rechnet dabei mit
# derselben Logik, die der Kernel später anwendet.
if [ -n "$_gw" ] && ip -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then
printf 'subnet'
return 0
fi
printf 'routed'
}
# ---------------------------------------------------------------------------
# Die Selbstrücknahme
# ---------------------------------------------------------------------------
#
# Das Muster aus Block B, unverändert, nur im Skript statt in der Pipeline:
# sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen.
#
# Der Zeitgeber ist eine systemd-Einheit und kein `sleep &`: ein Hintergrundlauf
# stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt, wenn die
# Umstellung schiefgeht. Er ist der einzige Grund, warum dieser Abschnitt die
# Netzkonfiguration überhaupt anfassen darf.
backup_network_config() {
mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")"
tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \
-C / etc/network/interfaces $( [ -d /etc/network/interfaces.d ] && printf 'etc/network/interfaces.d' ) \
2>/dev/null
}
# Stellt einen Zeitgeber, der die gesicherte Konfiguration zurückspielt und das
# Netz neu startet, falls ihn niemand abbestellt.
schedule_network_rollback() {
_minutes="${1:-5}"
cat > "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" <<EOF
#!/bin/sh
# Von CluPilots Bootstrap gestellt. Spielt die Netzkonfiguration von VOR der
# Brücke zurück, falls der Bootstrap sie nicht binnen ${_minutes} Minuten
# abbestellt hat — weil er das nur kann, wenn die Maschine noch erreichbar ist.
set -e
tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C /
if command -v ifreload >/dev/null 2>&1; then
ifreload -a
else
systemctl restart networking
fi
logger -t clupilot 'Netzkonfiguration zurückgespielt: die Brücke hat den Host vom Netz genommen.'
EOF
chmod 700 "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh"
cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" <<EOF
[Unit]
Description=CluPilot: Netzkonfiguration zurückspielen, wenn die Brücke den Host vom Netz nimmt
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh
EOF
cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" <<EOF
[Unit]
Description=CluPilot: Frist für die Selbstrücknahme der Netzumstellung
[Timer]
OnActiveSec=${_minutes}min
AccuracySec=1s
EOF
systemctl daemon-reload
systemctl start "${CLUPILOT_ROLLBACK_UNIT}.timer"
log "Selbstrücknahme steht: ohne Abbestellung läuft sie in ${_minutes} Minuten"
}
cancel_network_rollback() {
systemctl stop "${CLUPILOT_ROLLBACK_UNIT}.timer" 2>/dev/null || true
rm -f "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" \
"/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" \
"/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh"
systemctl daemon-reload 2>/dev/null || true
log 'Selbstrücknahme abbestellt'
}
# Antwortet die Maschine noch?
#
# Ehrlich benannt: das hier prüft von INNEN nach außen. Ein Beweis, dass die
# Maschine von außen erreichbar ist, ginge nur über einen Gegenüber — und den
# gibt es vor dem Tunnelbeitritt nicht. Es reicht trotzdem, weil eine Brücke, die
# den Host vom Netz nimmt, in aller Regel beide Richtungen nimmt: Gateway
# unerreichbar, Namensauflösung tot, kein Paket kommt durch.
#
# Was es NICHT abdeckt, deckt der Zeitgeber ab. Deshalb steht er da.
host_still_reachable() {
_gw="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
[ -n "$_gw" ] || return 1
ping -c2 -W2 "$_gw" >/dev/null 2>&1 || return 1
# Zweite Stufe: irgendetwas jenseits des Gateways. Ein erreichbares Gateway
# bei totem Weiterweg ist genau der Zustand, den eine falsche Route erzeugt.
http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1
}
# Schreibt eine Brücke über die primäre Netzkarte.
#
# Läuft NUR, wenn der ISO-Installer keine hinterlassen hat — und dann unter dem
# Zeitgeber. Die drei Formen unterscheiden sich in genau der Zeile, die
# entscheidet, ob die Maschine danach noch da ist.
build_bridge() {
_iface="$1"
_style="$2"
_cidr="$3"
_gw="$4"
_ip="${_cidr%%/*}"
case "$_style" in
dhcp)
_inet='iface vmbr0 inet dhcp'
_addr=''
;;
routed)
# Gateway außerhalb des eigenen Subnetzes. Ohne `pointopoint` findet
# der Kernel keinen Weg dorthin: die Route zeigte auf ein Netz, in
# dem das Gateway nicht liegt, und die Maschine wäre still weg.
_inet='iface vmbr0 inet static'
_addr=" address ${_ip}/32
pointopoint ${_gw}
gateway ${_gw}"
;;
*)
_inet='iface vmbr0 inet static'
_addr=" address ${_cidr}
gateway ${_gw}"
;;
esac
cat > /etc/network/interfaces <<EOF
# Von CluPilots Bootstrap geschrieben, weil der Installer keine vmbr0
# hinterlassen hat. Anbieterform: ${_style}.
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
iface ${_iface} inet manual
auto vmbr0
${_inet}
${_addr}
bridge-ports ${_iface}
bridge-stp off
bridge-fd 0
EOF
if command -v ifreload >/dev/null 2>&1; then
ifreload -a
else
systemctl restart networking
fi
}
# ---------------------------------------------------------------------------
# WireGuard
# ---------------------------------------------------------------------------
# Netzadresse zu einer Adresse mit Präfix: 10.66.0.11/24 -> 10.66.0.0/24
#
# Gerechnet statt geraten. `AllowedIPs` mit der Wirtsadresse statt der
# Netzadresse ließe den Tunnel handshaken und danach jeden anderen Teilnehmer
# unerreichbar — ein Fehler, der aussieht wie ein Routingproblem irgendwo sonst.
ipv4_network() {
_ip="${1%%/*}"
_prefix="${1##*/}"
IFS=. read -r _a _b _c _d <<EOF
$_ip
EOF
_num=$(( (_a << 24) + (_b << 16) + (_c << 8) + _d ))
_mask=$(( (0xFFFFFFFF << (32 - _prefix)) & 0xFFFFFFFF ))
_net=$(( _num & _mask ))
printf '%d.%d.%d.%d/%d' \
$(( (_net >> 24) & 255 )) $(( (_net >> 16) & 255 )) \
$(( (_net >> 8) & 255 )) $(( _net & 255 )) "$_prefix"
}
# Die Tunneladresse von CluPilot — dieselbe, die `--api` trägt.
#
# Sie ist auch das Ziel für den Handshake-Beweis: wenn CluPilot über den Tunnel
# antwortet, steht der Tunnel. Ein eigener Parameter dafür wäre ein zweiter Wert,
# der dasselbe meint und irgendwann auseinanderläuft.
api_host() {
printf '%s' "$CLUPILOT_API" | sed -e 's#^[a-z]*://##' -e 's#[:/].*##'
}
render_wireguard_config() {
_subnet="$(ipv4_network "$CLUPILOT_WG_IP")"
cat <<EOF
[Interface]
Address = ${CLUPILOT_WG_IP}
PrivateKey = ${CLUPILOT_WG_PRIVATE}
[Peer]
PublicKey = ${CLUPILOT_HUB_PUBKEY}
Endpoint = ${CLUPILOT_HUB_ENDPOINT}
AllowedIPs = ${_subnet}
PersistentKeepalive = 25
EOF
}
# Bringt wg0 in den Zustand, den die Übernahme braucht: jetzt oben UND nach
# einem Neustart oben, mit der Konfiguration, die gerade geschrieben wurde.
#
# Drei Zustände, getrennt behandelt, weil die eine Zeile, die das hier ersetzt —
# `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` — zwei davon falsch
# machte: der Rückfall brachte die Schnittstelle hoch OHNE die systemd-Freigabe,
# also kam der Tunnel nach dem Neustart nicht wieder; und beim nächsten Versuch
# scheiterten beide Hälften an „wg0 already exists", also lief der Schritt ewig
# gegen einen Tunnel, der in Wahrheit funktionierte.
#
# **enable vor start**: `enable` ist das, was den Neustart überlebt, und es wird
# auch dann geprüft und nachgeholt, wenn die Schnittstelle schon anderweitig
# oben ist.
bring_up_wireguard() {
_config_changed="$1"
_unit='wg-quick@wg0'
_present=0
ip link show wg0 >/dev/null 2>&1 && _present=1
# `is-enabled` wird nach seinem Rückgabewert gefragt, nicht nach seinem
# Wortlaut: eine nie freigegebene Template-Unit („disabled", 1) und eine, die
# es noch nicht gibt, werden damit gleich behandelt.
if ! systemctl is-enabled --quiet "$_unit" 2>/dev/null; then
if ! systemctl enable "$_unit" >/dev/null 2>&1; then
log "konnte ${_unit} nicht freigeben — wg0 überlebte keinen Neustart"
return 1
fi
fi
if [ "$_present" -eq 0 ]; then
systemctl start "$_unit" >/dev/null 2>&1 || { log 'wg0 ließ sich nicht starten'; return 1; }
return 0
fi
# Vorhanden und unverändert: die laufende Schnittstelle passt schon zur
# Datei. Hier neu zu starten ist das, was einen funktionierenden Tunnel
# kaputt aussehen ließ.
[ "$_config_changed" -eq 0 ] && return 0
# Vorhanden mit geänderter Konfiguration: Neustart, NICHT `wg syncconf`.
# syncconf wendet nur Peers an; eine korrigierte `Address` oder `AllowedIPs`
# bliebe still wirkungslos — genau die Sorte „die Datei hat sich geändert und
# nichts ist passiert".
systemctl restart "$_unit" >/dev/null 2>&1 || { log 'Neustart von wg0 mit der korrigierten Konfiguration fehlgeschlagen'; return 1; }
return 0
}
# Der Tunnel gilt erst mit BEWIESENEM Handshake, nicht mit geschriebener Datei.
#
# Eine gespeicherte Tunneladresse ohne Beweis machte jeden weiteren
# Verbindungsversuch unbrauchbar, und die Rettung war Datenbank-Handarbeit.
wireguard_handshake_proven() {
_target="$(api_host)"
[ -n "$_target" ] || return 1
_tries="${1:-10}"
while [ "$_tries" -gt 0 ]; do
if ping -c1 -W2 "$_target" >/dev/null 2>&1; then
return 0
fi
_tries=$((_tries - 1))
sleep 3
done
return 1
}
# ---------------------------------------------------------------------------
# Die Host-Firewall (nftables)
# ---------------------------------------------------------------------------
#
# Läuft ZULETZT, in Abschnitt `registered`. Der Grund steht in
# `SecureHostFirewall.php` und ist eine Reihenfolgefrage: erst wenn der Tunnel
# jeden vorigen Schritt getragen hat, darf SSH nach außen zugehen. Früher
# geschlossen, und ein Host hinge mitten in der Übernahme fest.
#
# nftables statt pve-firewall, und nur die INPUT-Kette: FORWARD und OUTPUT
# bleiben auf der Kernel-Vorgabe, damit der Verkehr der Kunden-VMs über die
# Brücken nie von einer Host-Regel berührt wird. Zwei Filter, die beide den
# Eingang besitzen wollen, sind der Weg zu einem unerreichbaren Host — deshalb
# schaltet `enable_datacenter_firewall` die Node-Firewall ausdrücklich ab.
#
# **Keine automatische Wiederöffnung.** Kein Zeitgeber, kein „nach N Minuten ohne
# Handshake wieder auf". Eine Firewall, die sich unter Störung selbst öffnet, ist
# keine Firewall. Für den Notfall liegt ein Skript bereit, das von der
# Anbieterkonsole aus gestartet wird.
CLUPILOT_EMERGENCY_SCRIPT='/usr/local/sbin/clupilot-emergency-open-firewall.sh'
render_nftables_config() {
_wg_subnet="$1"
cat <<NFT
#!/usr/sbin/nft -f
# Von CluPilots Bootstrap geschrieben.
# Nicht von Hand ändern — der nächste Lauf überschreibt die Datei.
# Um den Host im Notfall vollständig zu öffnen, von der Anbieterkonsole aus
# ${CLUPILOT_EMERGENCY_SCRIPT} starten.
flush ruleset
table inet clupilot_filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state invalid drop
ct state established,related accept
# ICMPv6 ist nicht optional. Neighbour Discovery (135/136) und Router
# Advertisements kommen in der INPUT-Kette an, ein blankes policy drop
# nimmt IPv6 also mit, sobald der Neighbour-Cache abläuft — Minuten, nicht
# Tage. packet-too-big ist die andere Hälfte: daran lernt die Gegenstelle
# einer TLS-Verbindung die Pfad-MTU, und es zu verwerfen lässt große
# Übertragungen ins Leere laufen, statt sie scheitern zu lassen. Eine
# Regel pro Zeile, absichtlich: ein Regelwerk, das man zeilenweise lesen
# kann, kann man auch zeilenweise prüfen.
meta nfproto ipv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld-listener-query, mld-listener-report, mld-listener-done } accept
# IPv4-ICMP, aus demselben Grund: destination-unreachable trägt
# fragmentation-needed (Code 4), worauf die IPv4-Pfad-MTU-Erkennung
# beruht. echo ist dabei, damit der Host auf ein ping antwortet — auch auf
# das des Hubs.
meta nfproto ipv4 icmp type { destination-unreachable, time-exceeded, parameter-problem, echo-request, echo-reply } accept
# Öffentliches Web — Traefik bedient hier den Kundenverkehr.
tcp dport { 80, 443 } accept
# DHCP-Antworten. Manche Anbieter vergeben die IPv4-Adresse per DHCP, und
# ein REBIND kommt als frisches eingehendes Paket an, für das conntrack
# keinen Eintrag hat — verworfen, die Lease läuft ab, und der Host
# verliert die Adresse, über die er erreicht wird. Bewusst eng: nur eine
# Antwort vom Server-Port an den Client-Port.
udp sport 67 udp dport 68 accept
# Verwaltungszugänge: nur über den Tunnel.
ip saddr ${_wg_subnet} tcp dport { 22, 8006 } accept
}
}
NFT
}
render_emergency_script() {
cat <<'SH'
#!/bin/sh
# CluPilots Notöffnung der Firewall.
#
# NUR von der Anbieterkonsole (KVM/seriell) starten, wenn dieser Host über das
# Netz nicht mehr erreichbar ist. Es tut genau eine Sache: nftables abschalten
# und alle Regeln verwerfen, sodass der Host auf jedem Port wieder annimmt.
#
# Bewusst von Hand. Nichts hier öffnet die Firewall selbsttätig, auf einen
# Zeitgeber hin oder weil ein Handshake eine Weile alt aussah — eine Firewall,
# die sich unter Störung selbst öffnet, ist keine Firewall. Die Regeln kommen
# zurück mit:
# nft -f /etc/nftables.conf && systemctl enable --now nftables
set -e
systemctl disable --now nftables 2>/dev/null || true
nft flush ruleset 2>/dev/null || true
echo "Firewallregeln verworfen: dieser Host nimmt jetzt auf jedem Port an."
SH
}
# Sperrt den Host ab. Verlangt VORHER einen bewiesenen Tunnel und fasst sonst
# nichts an.
apply_host_firewall() {
if ! wireguard_handshake_proven 3; then
log 'Tunnel nicht bewiesen — die Host-Firewall wird nicht angewendet'
return 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get install -y nftables >/dev/null 2>&1 || { log 'nftables ließ sich nicht installieren'; return 1; }
render_nftables_config "$(ipv4_network "$CLUPILOT_WG_IP")" > /etc/nftables.conf
render_emergency_script > "$CLUPILOT_EMERGENCY_SCRIPT"
chmod 700 "$CLUPILOT_EMERGENCY_SCRIPT"
# Erst prüfen, dann anwenden. Ein Regelwerk mit einem Tippfehler wird von
# `nft -f` teilweise geladen — und „teilweise" heißt hier: policy drop steht,
# die Ausnahmen nicht.
if ! nft -c -f /etc/nftables.conf >/dev/null 2>&1; then
log 'nftables-Regelwerk ist nicht gültig — es wird nichts angewendet'
return 1
fi
systemctl enable --now nftables >/dev/null 2>&1 || { log 'nftables ließ sich nicht aktivieren'; return 1; }
systemctl reload-or-restart nftables >/dev/null 2>&1 || nft -f /etc/nftables.conf
return 0
}
# ---------------------------------------------------------------------------
# Die Datacenter-Firewall
# ---------------------------------------------------------------------------
#
# Wörtlich aus `ConfigureProxmox.php`. Ohne sie sind die „nur 80/443"-Regeln,
# die für jede Kunden-VM geschrieben werden, **wirkungslos** — auf jedem Host,
# den diese Pipeline je übernommen hat.
#
# `enable 1` allein ist eine Falle. Die Eingangsrichtlinie steht auf DROP, die
# Node-Firewall auf ENABLED, und die einzigen Quellen, die dann noch an 8006 und
# 22 kommen, sind die Mitglieder des automatisch erzeugten `management`-ipsets —
# gespeist aus dem ÖFFENTLICHEN Subnetz. Die Tunneladresse steht nicht darin.
#
# Also drei Einstellungen, in dieser Reihenfolge, jede mit einer Aufgabe:
#
# 1. `policy_in`/`policy_out` auf ACCEPT — das ist die HOST-Richtlinie. Ein Gast
# liest sein eigenes `vm.fw`, das für jede Kunden-VM ausdrücklich auf DROP
# gesetzt wird; ACCEPT hier lockert also keine einzige Kundenregel.
# 2. Node-Firewall AUS. Die Host-Richtlinie liegt in nftables. Zwei Filter, die
# beide den Eingang besitzen wollen, sind der Weg zu einem Host, der aus
# einem Grund unerreichbar ist, den niemand findet: ein Paket müsste von
# BEIDEN angenommen werden.
# 3. `enable 1` zuletzt, wenn keines der beiden mehr beißen kann.
#
# `policy_forward` bleibt absichtlich unangetastet — es greift nur auf dem
# nftables-Hintergrund von pve-firewall, der hier aus ist.
enable_datacenter_firewall() {
for _cmd in \
'pvesh set /cluster/firewall/options --policy_in ACCEPT --policy_out ACCEPT' \
'pvesh set /nodes/"$(hostname)"/firewall/options --enable 0' \
'pvesh set /cluster/firewall/options --enable 1'
do
if ! eval "$_cmd" >/dev/null 2>&1; then
log "Datacenter-Firewall: ${_cmd} fehlgeschlagen"
return 1
fi
done
# Zurücklesen. `pvesh set` mit Rückgabewert 0 heißt nicht, dass es in
# `cluster.fw` steht: /etc/pve ist ein repliziertes Dateisystem, und ein
# Schreibvorgang ohne Quorum ist genau der Fall, in dem die Regeln jeder
# Kunden-VM wirkungslos blieben, während die Übernahme Erfolg meldet.
if ! pvesh get /cluster/firewall/options --output-format json 2>/dev/null \
| tr -d ' \n' | grep -q '"enable":1'; then
log 'Datacenter-Firewall liest sich nicht als eingeschaltet zurück'
return 1
fi
return 0
}

View File

@ -0,0 +1,557 @@
# shellcheck shell=sh
#
# Proxmox: die Installation (Abschnitte `debian_installed` und `rebooted`) und
# die Paketquellen (`proxmox_installed`).
#
# ---------------------------------------------------------------------------
# Warum eine ISO unter QEMU und nicht installimage
# ---------------------------------------------------------------------------
#
# Hetzners `installimage` führt kein Proxmox — es gilt dort als nicht
# unterstützt. Wer es darüber versucht, installiert Debian und baut es danach
# um; genau das Zwischensystem, das dieser Entwurf abgeschafft hat.
#
# Der reguläre Weg ist seit PVE 8.2 die offizielle ISO mit eingebetteter
# Antwortdatei: `proxmox-auto-install-assistant prepare-iso` backt eine
# `answer.toml` hinein, QEMU startet sie im Rettungssystem gegen die *echten*
# Platten, und der Eintrag „Automated Installation" wird nach zehn Sekunden von
# selbst gewählt. Kein VNC, kein Klick.
#
# ---------------------------------------------------------------------------
# Die Codename-Tabelle
# ---------------------------------------------------------------------------
#
# Wörtlich übernommen aus `app/Provisioning/Steps/Host/InstallProxmoxVe.php`,
# das der Plattform-Plan löscht. Sie ist aus einem echten Fehler entstanden: ein
# fest verdrahtetes `bookworm` hätte auf Debian 13 PVE-8-Quellen gesetzt, also
# ein PVE gegen eine andere libc.
#
# Jede Ausgabe hat ihren EIGENEN Schlüssel und ihren eigenen Dateinamen —
# Proxmox signiert jede Suite einzeln und hat die Datei zwischen PVE 8 und PVE 9
# umbenannt. Ein gemeinsamer Name zeigte irgendwann auf den falschen Schlüssel,
# ohne dass etwas falsch aussähe.
#
# Bullseye/PVE 7 fehlt absichtlich: eine Debian-11-Maschine heute bedeutet, dass
# jemand das falsche Abbild gewählt hat, und ein lautes Scheitern ist die
# bessere Antwort als ein Hypervisor ohne Unterstützung.
# proxmox_release_for <codename> — gibt "suite|key_url|keyring" aus, oder 1.
proxmox_release_for() {
case "$1" in
bookworm)
printf 'bookworm|https://enterprise.proxmox.com/debian/proxmox-release-bookworm.gpg|proxmox-release-bookworm.gpg'
;;
trixie)
printf 'trixie|https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg|proxmox-archive-keyring.gpg'
;;
*)
return 1
;;
esac
}
# Liest ID und VERSION_CODENAME aus /etc/os-release.
#
# Gesourct statt gegreppt, damit die Shell das Entwerten übernimmt, und mit
# einem Trenner ausgegeben, der in keinem der Werte vorkommen kann.
os_release_field() {
(
# shellcheck disable=SC1091
. /etc/os-release 2>/dev/null || exit 1
case "$1" in
id) printf '%s' "${ID:-}" ;;
codename) printf '%s' "${VERSION_CODENAME:-}" ;;
pretty) printf '%s' "${PRETTY_NAME:-}" ;;
esac
)
}
# Schreibt die Proxmox-Paketquelle im deb822-Format.
#
# `Signed-By` begrenzt den Schlüssel auf GENAU diese Quelle. Ein Schlüssel in
# /etc/apt/trusted.gpg.d/ bürgt für JEDE Quelle der Maschine — der
# Proxmox-Schlüssel vouchte dann auch für alles, was später in `sources.list`
# auftaucht. Genau davor warnt apt seit mehreren Ausgaben.
write_proxmox_sources() {
_suite="$1"
_keyring_path="$2"
_file="$3"
cat > "$_file" <<EOF
# Written by CluPilot's bootstrap script.
# The suite is derived from /etc/os-release on this host, not assumed.
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: ${_suite}
Components: pve-no-subscription
Architectures: amd64
Signed-By: ${_keyring_path}
EOF
}
# Die Abonnement-Quellen, die ein Proxmox-Abbild mitbringt.
#
# Sie scheitern ohne Abonnement bei JEDEM `apt-get update` — und danach schlägt
# jede Paketinstallation der folgenden Abschnitte fehl. Das ist keine
# Schönheitskorrektur, sondern die Voraussetzung dafür, dass Traefik, WireGuard
# und die Vorlage überhaupt installiert werden können.
#
# Beide Schreibweisen, weil PVE 8 die einzeiligen `.list` benutzte und PVE 9 auf
# deb822 umgestellt hat. Und `ceph`, weil dieselbe Falle dort ein zweites Mal
# steht.
remove_subscription_sources() {
for _f in \
/etc/apt/sources.list.d/pve-enterprise.list \
/etc/apt/sources.list.d/pve-enterprise.sources \
/etc/apt/sources.list.d/ceph.list \
/etc/apt/sources.list.d/ceph.sources \
/etc/apt/sources.list.d/pve-install-repo.list
do
if [ -f "$_f" ]; then
rm -f "$_f"
log "entfernt: ${_f}"
fi
done
}
# ---------------------------------------------------------------------------
# Was diese Maschine gerade ist: Bootmodus, Netz, Platten
# ---------------------------------------------------------------------------
detect_boot_mode() {
if [ -d /sys/firmware/efi ]; then
printf 'uefi'
else
printf 'bios'
fi
}
# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die
# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und
# eine nicht.
detect_primary_interface() {
ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }'
}
# Setzt CLUPILOT_NET_CIDR, _GATEWAY, _DNS, _MAC aus dem, was das Rettungssystem
# gerade benutzt.
#
# Diese Werte gehen in die Antwortdatei, weil auf einer dedizierten Maschine
# kein DHCP antwortet. Was hier herauskommt, ist die Konfiguration des
# INSTALLIERTEN Systems — Task 5 baut sie danach zur Brücke um, und genau dort
# unterscheiden sich die Anbieter.
detect_network() {
_iface="$(detect_primary_interface)"
if [ -z "$_iface" ]; then
return 1
fi
CLUPILOT_NET_IFACE="$_iface"
CLUPILOT_NET_CIDR="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
CLUPILOT_NET_GATEWAY="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
CLUPILOT_NET_MAC="$(cat "/sys/class/net/${_iface}/address" 2>/dev/null | tr -d ':')"
CLUPILOT_NET_DNS="$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)"
# Ohne Namensauflösung im installierten System käme es nie an die
# Paketquellen. Ein öffentlicher Rückfall ist besser als ein leeres Feld,
# das der Installer stillschweigend übergeht.
[ -n "$CLUPILOT_NET_DNS" ] || CLUPILOT_NET_DNS='9.9.9.9'
[ -n "$CLUPILOT_NET_CIDR" ] && [ -n "$CLUPILOT_NET_GATEWAY" ] && [ -n "$CLUPILOT_NET_MAC" ]
}
# Alle physischen Platten, kleinste zuerst nicht — Reihenfolge egal, Anzahl
# zählt. Wechseldatenträger, Schleifen und RAM-Platten fliegen raus.
detect_install_disks() {
_disks=''
for _dev in /sys/block/*; do
_name="${_dev##*/}"
case "$_name" in
loop*|ram*|sr*|dm-*|md*|zram*|fd*) continue ;;
esac
[ -r "${_dev}/size" ] || continue
[ "$(cat "${_dev}/size" 2>/dev/null || echo 0)" -gt 0 ] || continue
# Wechseldatenträger: ein eingesteckter USB-Stick ist keine Systemplatte.
[ "$(cat "${_dev}/removable" 2>/dev/null || echo 0)" = '0' ] || continue
_disks="${_disks}${_disks:+ }${_name}"
done
printf '%s' "$_disks"
}
# ZFS-Aufstellung nach Plattenzahl.
#
# Eine Platte: raid0, weil es nichts zu spiegeln gibt — und weil ein Host mit
# einer Platte ohnehin ein Ausfall pro Platte ist. Zwei: raid1. Vier oder mehr,
# gerade: raid10. Drei oder eine ungerade Zahl über vier: raid1 über die ersten
# zwei, und der Rest bleibt liegen, statt eine Aufstellung zu raten, die
# hinterher niemand erwartet hat.
zfs_raid_for() {
case "$1" in
1) printf 'raid0' ;;
2) printf 'raid1' ;;
4|6|8|10) printf 'raid10' ;;
*) printf 'raid1' ;;
esac
}
# ---------------------------------------------------------------------------
# Werkzeug und ISO
# ---------------------------------------------------------------------------
# `proxmox-auto-install-assistant` ins RETTUNGSSYSTEM.
#
# Die Quelle richtet sich nach dem Codename des Rettungssystems, nicht nach dem
# des Ziels: das Werkzeug läuft hier, nicht dort. Unbekannter Codename bricht ab
# — nicht „trixie ist das neueste, also trixie". Das nächste Debian ist auch
# unbekannt, und dann sieht niemand hin.
# Welche PVE-Hauptversion zu welchem Debian gehört.
#
# Die andere Hälfte der Tabelle: `proxmox_release_for` sagt, welche Suite zu
# einem Debian passt; das hier sagt, welche PVE-Version dabei herauskommen muss.
# Gebraucht wird es, um ein ABBILD abzunehmen — bei dem niemand die Suite
# gewählt hat, sondern Proxmox das Paar schon zusammengestellt hatte. Passt es
# nicht, ist das Abbild falsch, und kein Nachbessern der Quellen heilt das.
pve_major_for_codename() {
case "$1" in
bookworm) printf '8' ;;
trixie) printf '9' ;;
*) return 1 ;;
esac
}
# Legt den Proxmox-Release-Schlüssel ab und gibt seinen Pfad aus.
#
# Nach /etc/apt/keyrings/ und über `Signed-By` benannt, NICHT nach
# /etc/apt/trusted.gpg.d/ — dort bürgte er für jede Quelle der Maschine.
install_proxmox_key() {
_key_url="$1"
_keyring="$2"
_keyring_path="/etc/apt/keyrings/${_keyring}"
mkdir -p /etc/apt/keyrings
if ! http_download "$_key_url" "$_keyring_path"; then
return 1
fi
chmod 644 "$_keyring_path"
printf '%s' "$_keyring_path"
}
# Zerlegt "suite|key_url|keyring" in CLUPILOT_PVE_SUITE, _KEY_URL, _KEYRING.
split_proxmox_release() {
CLUPILOT_PVE_SUITE="${1%%|*}"
_rest="${1#*|}"
CLUPILOT_PVE_KEY_URL="${_rest%%|*}"
CLUPILOT_PVE_KEYRING="${_rest##*|}"
}
ensure_autoinstall_assistant() {
if command -v proxmox-auto-install-assistant >/dev/null 2>&1; then
log 'proxmox-auto-install-assistant ist vorhanden'
return 0
fi
_id="$(os_release_field id)"
_codename="$(os_release_field codename)"
if [ "$_id" != 'debian' ] || ! _release="$(proxmox_release_for "$_codename")"; then
log "Rettungssystem meldet ID=${_id:-(leer)}, VERSION_CODENAME=${_codename:-(leer)} ($(os_release_field pretty))"
return 1
fi
split_proxmox_release "$_release"
if ! _keyring_path="$(install_proxmox_key "$CLUPILOT_PVE_KEY_URL" "$CLUPILOT_PVE_KEYRING")"; then
log "Proxmox-Release-Schlüssel für ${_codename} nicht ladbar: ${CLUPILOT_PVE_KEY_URL}"
return 1
fi
write_proxmox_sources "$CLUPILOT_PVE_SUITE" "$_keyring_path" '/etc/apt/sources.list.d/pve-install-repo.sources'
export DEBIAN_FRONTEND=noninteractive
apt-get update >/dev/null 2>&1 || return 1
apt-get -y install proxmox-auto-install-assistant >/dev/null 2>&1 || return 1
command -v proxmox-auto-install-assistant >/dev/null 2>&1
}
# Neueste PVE-9-ISO aus der Liste des Anbieters.
#
# Nicht fest verdrahtet: die Liste gehört Proxmox und wächst. Ein fester Name
# wäre in ein paar Monaten eine 404 mitten in einer Übernahme.
discover_pve_iso() {
_listing="$(http_get "${CLUPILOT_ISO_MIRROR}/" || true)"
[ -n "$_listing" ] || return 1
printf '%s' "$_listing" \
| grep -oE 'proxmox-ve_9[0-9.\-]*\.iso' \
| sort -u -V \
| tail -1
}
# Lädt die ISO und prüft ihre SHA256 gegen die Liste des Anbieters.
#
# Ohne diese Prüfung startet die Maschine ein Betriebssystem, das unterwegs
# irgendetwas geworden sein kann. Eine abgebrochene Übertragung reicht dafür
# schon; Bösartigkeit braucht es gar nicht.
fetch_proxmox_iso() {
_name="$1"
_target="$2"
log "lade ${_name}"
if ! http_download "${CLUPILOT_ISO_MIRROR}/${_name}" "$_target"; then
return 1
fi
_sums="$(http_get "${CLUPILOT_ISO_MIRROR}/SHA256SUMS" || true)"
if [ -z "$_sums" ]; then
log 'SHA256SUMS nicht abrufbar'
return 1
fi
_expected="$(printf '%s\n' "$_sums" | awk -v n="$_name" '$2 == n || $2 == "*" n { print $1; exit }')"
if [ -z "$_expected" ]; then
log "keine Prüfsumme für ${_name} in SHA256SUMS"
return 1
fi
_actual="$(sha256sum "$_target" | awk '{ print $1 }')"
if [ "$_actual" != "$_expected" ]; then
log "Prüfsumme stimmt nicht: erwartet ${_expected}, bekommen ${_actual}"
rm -f "$_target"
return 1
fi
log "Prüfsumme stimmt (${_expected})"
return 0
}
# ---------------------------------------------------------------------------
# Der Wiederanlauf nach dem Neustart
# ---------------------------------------------------------------------------
#
# `[first-boot]` mit `source = "from-iso"` nimmt GENAU EINE ausführbare Datei
# mit. Also trägt diese eine Datei alles: das Skript samt `lib/`, die
# Fortschrittsdatei und die Aufrufargumente — als Archiv in base64 im eigenen
# Rumpf.
#
# Das löst zugleich die Falle, an der „dieselbe Fortschrittsdatei" sonst
# scheitert: das Rettungssystem läuft im Arbeitsspeicher, sein Wurzelverzeichnis
# ist nach dem Neustart weg. Über die ISO kommt der Stand mit, ohne dass jemand
# den frisch angelegten ZFS-Pool aus dem Rettungssystem heraus einhängen müsste.
#
# Der WireGuard-Schlüssel liegt damit in der ISO. Die ISO liegt im
# Arbeitsspeicher des Rettungssystems und ist nach dem Neustart fort; der
# Schlüssel selbst wird in Task 9 ohnehin gegen einen frischen getauscht. Die
# entpackten Argumente bekommen 0600 in einem 0700-Verzeichnis.
build_first_boot_shim() {
_shim="$1"
_payload="${CLUPILOT_WORK_DIR}/payload.tar.gz"
# Argumente für den wiederaufgenommenen Lauf festhalten.
_args_file="${CLUPILOT_STATE_DIR}/arguments"
umask 077
cat > "$_args_file" <<EOF
CLUPILOT_CODE='${CLUPILOT_CODE}'
CLUPILOT_WG_PRIVATE='${CLUPILOT_WG_PRIVATE}'
CLUPILOT_WG_IP='${CLUPILOT_WG_IP}'
CLUPILOT_HUB_PUBKEY='${CLUPILOT_HUB_PUBKEY}'
CLUPILOT_HUB_ENDPOINT='${CLUPILOT_HUB_ENDPOINT}'
CLUPILOT_API='${CLUPILOT_API}'
CLUPILOT_FQDN='${CLUPILOT_FQDN}'
CLUPILOT_ROOT_SSH_KEY='${CLUPILOT_ROOT_SSH_KEY}'
EOF
chmod 600 "$_args_file"
# Laut statt still: läge das Arbeitsverzeichnis im Zustandsverzeichnis,
# kopierte der nächste Schritt ein Verzeichnis in sich selbst und die ISO
# landete im Hook. Beides wäre erst auf echter Hardware aufgefallen.
case "$CLUPILOT_WORK_DIR" in
"$CLUPILOT_STATE_DIR"|"$CLUPILOT_STATE_DIR"/*)
log "Arbeitsverzeichnis ${CLUPILOT_WORK_DIR} liegt im Zustandsverzeichnis ${CLUPILOT_STATE_DIR} — das darf nicht sein"
return 1
;;
esac
# Über ein Staging-Verzeichnis, damit im Archiv die ZIELpfade stehen. Ein
# `tar -C <elternteil> <name>` schriebe `bootstrap/…` und `clupilot/…` hinein
# — ausgepackt nach `/` ergäbe das `/bootstrap` und `/clupilot`, und der Hook
# liefe ins Leere.
_stage="${CLUPILOT_WORK_DIR}/stage"
rm -rf "$_stage"
mkdir -p "${_stage}/opt/clupilot" "${_stage}/var/lib"
cp -a "$CLUPILOT_BOOTSTRAP_DIR" "${_stage}/opt/clupilot/bootstrap"
cp -a "$CLUPILOT_STATE_DIR" "${_stage}/var/lib/clupilot"
tar czf "$_payload" -C "$_stage" opt var
rm -rf "$_stage"
cat > "$_shim" <<'HEADER'
#!/bin/sh
# Von CluPilots Bootstrap erzeugt. Läuft einmal, beim ersten Hochfahren des
# frisch installierten Hosts, und nimmt den Lauf dort wieder auf, wo der
# Neustart ihn unterbrochen hat.
set -eu
mkdir -p /opt/clupilot /var/lib
sed -n '/^__NUTZLAST__$/,$p' "$0" | tail -n +2 | base64 -d | tar xzf - -C /
chmod 700 /var/lib/clupilot
[ -f /var/lib/clupilot/arguments ] && chmod 600 /var/lib/clupilot/arguments
exec /opt/clupilot/bootstrap/clupilot-bootstrap.sh --resume
HEADER
printf '__NUTZLAST__\n' >> "$_shim"
base64 "$_payload" >> "$_shim"
chmod 755 "$_shim"
}
# ---------------------------------------------------------------------------
# Die Antwortdatei
# ---------------------------------------------------------------------------
write_answer_file() {
_file="$1"
_disks="$2"
_raid="$3"
# Als TOML-Liste: ["nvme0n1", "nvme1n1"]
_disk_list="$(printf '%s' "$_disks" | awk '{ for (i = 1; i <= NF; i++) printf "%s\"%s\"", (i > 1 ? ", " : ""), $i }')"
# Wird erzeugt und weggeworfen. Niemand braucht es: die Plattform meldet
# sich nicht mehr per SSH an (Spec §6), und eine halb installierte Maschine
# wird neu aufgesetzt statt nachgebessert. Ein aufbewahrtes Kennwort wäre
# nur eine weitere Stelle, an der ein Geheimnis liegt.
_root_password="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
_ssh_keys=''
if [ -n "${CLUPILOT_ROOT_SSH_KEY:-}" ]; then
_ssh_keys="root-ssh-keys = [\"${CLUPILOT_ROOT_SSH_KEY}\"]"
fi
cat > "$_file" <<EOF
# Von CluPilots Bootstrap erzeugt. Unbeaufsichtigte Installation nach
# https://pve.proxmox.com/wiki/Automated_Installation
[global]
keyboard = "de"
country = "de"
fqdn = "${CLUPILOT_FQDN}"
mailto = "root@localhost"
timezone = "Europe/Vienna"
root-password = "${_root_password}"
${_ssh_keys}
reboot-on-error = false
[network]
source = "from-answer"
cidr = "${CLUPILOT_NET_CIDR}"
gateway = "${CLUPILOT_NET_GATEWAY}"
dns = "${CLUPILOT_NET_DNS}"
filter.ID_NET_NAME_MAC = "*${CLUPILOT_NET_MAC}"
[disk-setup]
filesystem = "zfs"
disk-list = [${_disk_list}]
[disk-setup.zfs]
raid = "${_raid}"
# Die Vorgabe nimmt sich die Hälfte des Arbeitsspeichers. Den braucht ein Host,
# der Kunden trägt, für die Gäste.
arc-max = ${CLUPILOT_ZFS_ARC_MAX_MB}
[first-boot]
source = "from-iso"
# Nach dem Hochfahren, wenn das Netz steht — vorher könnte der Lauf CluPilot
# nicht erreichen und bliebe an seinem eigenen Melden hängen.
ordering = "fully-up"
EOF
}
# ---------------------------------------------------------------------------
# Der Lauf unter QEMU
# ---------------------------------------------------------------------------
# Ein Viertel des Arbeitsspeichers für den Installer, mindestens 2 GB, höchstens
# 8. Mehr bringt nichts, weniger lässt den Installer bei ZFS ins Straucheln
# kommen.
installer_memory_mb() {
_total_kb="$(awk '/^MemTotal:/ { print $2; exit }' /proc/meminfo)"
_mb=$((_total_kb / 1024 / 4))
[ "$_mb" -lt 2048 ] && _mb=2048
[ "$_mb" -gt 8192 ] && _mb=8192
printf '%s' "$_mb"
}
installer_cpus() {
_cpus="$(nproc 2>/dev/null || echo 2)"
[ "$_cpus" -gt 8 ] && _cpus=8
[ "$_cpus" -lt 1 ] && _cpus=1
printf '%s' "$_cpus"
}
# Startet die vorbereitete ISO gegen die echten Platten.
#
# `-no-reboot`: der Installer startet am Ende neu, und QEMU beendet sich dabei.
# Genau daran erkennt dieses Skript, dass er fertig ist — ohne im Gast
# nachzusehen, was von außen nicht ginge.
#
# `-vnc 127.0.0.1:0` ist der Notausgang für den Fehlerfall: bleibt der Installer
# doch einmal in einer Maske stehen, kann der Betreiber über einen SSH-Tunnel ins
# Rettungssystem nachsehen, was auf dem Bildschirm steht. Nur auf localhost,
# damit daraus kein offener Dienst wird.
run_installer_under_qemu() {
_iso="$1"
_disks="$2"
_boot_mode="$3"
_drives=''
for _disk in $_disks; do
_drives="${_drives} -drive file=/dev/${_disk},format=raw,media=disk,if=virtio,cache=none"
done
_firmware=''
if [ "$_boot_mode" = 'uefi' ]; then
for _ovmf in /usr/share/OVMF/OVMF_CODE.fd /usr/share/ovmf/OVMF.fd /usr/share/qemu/OVMF.fd; do
if [ -f "$_ovmf" ]; then
_firmware="-bios ${_ovmf}"
break
fi
done
if [ -z "$_firmware" ]; then
log 'UEFI-Maschine, aber keine OVMF-Firmware gefunden (Paket ovmf)'
return 1
fi
fi
log "QEMU startet: ${_boot_mode}, $(installer_memory_mb) MB, $(installer_cpus) Kerne, Platten: ${_disks}"
# shellcheck disable=SC2086
timeout "$CLUPILOT_INSTALL_TIMEOUT" qemu-system-x86_64 \
-enable-kvm \
-cpu host \
-smp "$(installer_cpus)" \
-m "$(installer_memory_mb)" \
${_firmware} \
-cdrom "$_iso" \
-boot d \
${_drives} \
-netdev user,id=net0 -device virtio-net-pci,netdev=net0 \
-display none \
-vnc 127.0.0.1:0 \
-no-reboot \
> "${CLUPILOT_WORK_DIR}/qemu.log" 2>&1
_rc=$?
if [ "$_rc" -eq 124 ]; then
log "QEMU nach ${CLUPILOT_INSTALL_TIMEOUT} s abgebrochen — der Installer ist nicht fertig geworden"
return 1
fi
if [ "$_rc" -ne 0 ]; then
log "QEMU endete mit ${_rc}; siehe ${CLUPILOT_WORK_DIR}/qemu.log"
return 1
fi
return 0
}

View File

@ -0,0 +1,211 @@
# shellcheck shell=sh
#
# Die Übergabe: Proxmox-Rolle und Token erzeugen, bei CluPilot registrieren, den
# WireGuard-Schlüssel tauschen.
#
# ---------------------------------------------------------------------------
# Die Rolle, und warum `|| true` hier NICHT steht
# ---------------------------------------------------------------------------
#
# `pveum role add … || true` wendet die Rechteliste nur bei dem Lauf an, der die
# Rolle zum ersten Mal anlegt. Ein Recht, das später dazukam, erreichte damit
# neue Hosts und keinen einzigen bestehenden. Genau so fehlte `Sys.Modify`
# überall — mit einem Symptom, das viel später und ganz woanders auftrat: ein
# 403 auf `POST /cluster/backup`, und die Bereitstellung eines zahlenden Kunden
# scheiterte bei `register_backup`.
#
# Deshalb: `role add`, und wenn das scheitert (die Rolle gibt es schon, der
# gewöhnliche Fall), `role modify` — zwei getrennte Befehle, damit ein
# Fehlschlag zuzuordnen ist. `modify` ohne `-append` ERSETZT die Liste, der Host
# passt sich also der Konfiguration an, statt anzusammeln, was irgendeine
# frühere Fassung einmal gewährt hat.
#
# Für Benutzer und ACL galt `|| true` bisher als geduldet, weil „gibt es schon"
# dort der Normalfall ist. Auch das entfällt: statt den Fehler zu verwerfen,
# wird nachgesehen, ob der gewünschte Zustand steht. Ein `|| true` verdeckt
# nicht nur „gibt es schon", sondern auch „ging schief".
CLUPILOT_PVE_ROLE='CluPilotAutomation'
CLUPILOT_PVE_USER='automation@pve'
CLUPILOT_PVE_TOKEN_NAME='clupilot'
# Wörtlich aus `config/provisioning.php`. Sys.Modify ist nicht optional: es steht
# in `PVE/API2/Backup.pm` als `check => ['perm', '/', ['Sys.Modify']]` — an der
# Quelle geprüft, nicht aus einem Forenbeitrag geschlossen — und deckt zugleich
# `PUT /cluster/firewall/options` ab. Gewährt auf `/`, weil beide Endpunkte
# gegen `/` prüfen und nichts Engeres sie zufriedenstellt.
CLUPILOT_PVE_PRIVS='VM.Allocate,VM.Clone,VM.Config.Disk,VM.Config.CPU,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Config.Cloudinit,VM.PowerMgmt,VM.Monitor,VM.Audit,VM.Backup,VM.GuestAgent.Audit,VM.GuestAgent.Unrestricted,Datastore.AllocateSpace,Datastore.Audit,Sys.Audit,Sys.Modify'
# Ein Feld aus flachem JSON. Nimmt `jq`, wenn es da ist, und sonst einen
# eng gefassten Ausdruck.
json_field() {
_key="$1"
_json="$2"
if command -v jq >/dev/null 2>&1; then
printf '%s' "$_json" | jq -r --arg k "$_key" '.[$k] // empty' 2>/dev/null
return 0
fi
printf '%s' "$_json" \
| sed -n "s/.*\"${_key}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" \
| head -1
}
converge_proxmox_role() {
if pveum role add "$CLUPILOT_PVE_ROLE" -privs "$CLUPILOT_PVE_PRIVS" >/dev/null 2>&1; then
log "Proxmox-Rolle ${CLUPILOT_PVE_ROLE} angelegt"
return 0
fi
if pveum role modify "$CLUPILOT_PVE_ROLE" -privs "$CLUPILOT_PVE_PRIVS" >/dev/null 2>&1; then
log "Proxmox-Rolle ${CLUPILOT_PVE_ROLE} auf die aktuelle Rechteliste gebracht"
return 0
fi
log 'Die Rechteliste der Proxmox-Rolle ließ sich nicht angleichen'
return 1
}
# Benutzer und ACL — ohne `|| true`, aber mit Nachsehen.
ensure_proxmox_user() {
if ! pveum user list --output-format json 2>/dev/null | grep -q "\"${CLUPILOT_PVE_USER}\""; then
if ! pveum user add "$CLUPILOT_PVE_USER" >/dev/null 2>&1; then
# Nicht blind schlucken: nachsehen, ob er jetzt da ist. Wenn ja, war
# es ein Wettlauf und kein Fehler.
if ! pveum user list --output-format json 2>/dev/null | grep -q "\"${CLUPILOT_PVE_USER}\""; then
log "Proxmox-Benutzer ${CLUPILOT_PVE_USER} ließ sich nicht anlegen"
return 1
fi
fi
fi
if ! pveum acl modify / -user "$CLUPILOT_PVE_USER" -role "$CLUPILOT_PVE_ROLE" >/dev/null 2>&1; then
# Auch hier: gilt die Zuweisung inzwischen, war es keiner.
if ! pveum acl list --output-format json 2>/dev/null | grep -q "$CLUPILOT_PVE_ROLE"; then
log 'Die Rollenzuweisung auf / ließ sich nicht setzen'
return 1
fi
fi
return 0
}
# Erzeugt den API-Token und gibt "<tokenid>=<secret>" aus.
#
# Das Geheimnis wird genau einmal gezeigt. Ein halb angelegter Token aus einem
# abgebrochenen früheren Lauf wird vorher entfernt, damit ein zweiter Lauf
# gleich ausgeht.
mint_proxmox_token() {
pveum user token remove "$CLUPILOT_PVE_USER" "$CLUPILOT_PVE_TOKEN_NAME" >/dev/null 2>&1 || true
_out="$(pveum user token add "$CLUPILOT_PVE_USER" "$CLUPILOT_PVE_TOKEN_NAME" -privsep 0 --output-format json 2>/dev/null)"
[ -n "$_out" ] || return 1
_secret="$(json_field value "$_out")"
[ -n "$_secret" ] || return 1
_tokenid="$(json_field 'full-tokenid' "$_out")"
[ -n "$_tokenid" ] || _tokenid="${CLUPILOT_PVE_USER}!${CLUPILOT_PVE_TOKEN_NAME}"
printf '%s=%s' "$_tokenid" "$_secret"
}
# Der Fingerabdruck des SSH-Wirtsschlüssels. Nur zur Anzeige — die Plattform
# meldet sich nicht mehr per SSH an (Spec §6).
ssh_host_fingerprint() {
for _key in /etc/ssh/ssh_host_ed25519_key.pub /etc/ssh/ssh_host_rsa_key.pub; do
if [ -f "$_key" ]; then
ssh-keygen -lf "$_key" 2>/dev/null | awk '{ print $2; exit }'
return 0
fi
done
printf ''
}
# Die Eckdaten dieses Knotens, so wie `RegisterCapacity` sie liest.
#
# Der größte Speicher, der Platten aufnimmt — NICHT die Summe. Proxmox führt
# überlappende Pools (local und local-lvm auf derselben Platte), und zu
# summieren hieße, denselben Platz zweimal zu verkaufen.
node_total_gb() {
pvesm status --content images 2>/dev/null \
| awk 'NR > 1 { if ($4 + 0 > max) max = $4 + 0 } END { printf "%d", max / 1048576 }'
}
# Baut den Rumpf für POST /host/register.
render_register_payload() {
_api_token="$1"
_wg_pubkey="$2"
printf '{"code":"%s","api_token":"%s","wg_pubkey":"%s","node":"%s","pve_version":"%s","total_gb":%s,"total_ram_mb":%s,"cpu_cores":%s,"ssh_host_key":"%s"}' \
"$(json_escape "$CLUPILOT_CODE")" \
"$(json_escape "$_api_token")" \
"$(json_escape "$_wg_pubkey")" \
"$(json_escape "$(hostname)")" \
"$(json_escape "$(pveversion 2>/dev/null | head -1)")" \
"$(node_total_gb)" \
"$(awk '/^MemTotal:/ { printf "%d", $2 / 1024; exit }' /proc/meminfo)" \
"$(nproc 2>/dev/null || echo 1)" \
"$(json_escape "$(ssh_host_fingerprint)")"
}
# POST /host/register — gibt die Antwort aus, oder 1.
post_registration() {
_payload="$1"
if command -v curl >/dev/null 2>&1; then
curl -sS -f --connect-timeout 10 --max-time 60 \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-X POST --data "$_payload" \
"${CLUPILOT_API}/host/register" 2>/dev/null
return $?
fi
wget -q -O - --timeout=60 \
--header='Content-Type: application/json' --header='Accept: application/json' \
--post-data="$_payload" \
"${CLUPILOT_API}/host/register" 2>/dev/null
}
# ---------------------------------------------------------------------------
# Der Schlüsseltausch
# ---------------------------------------------------------------------------
#
# Task 6 lässt den Tunnel erst mit bewiesenem Handshake gelten. Hier wird genau
# dieser Tunnel im laufenden Betrieb auf einen neuen Schlüssel umgestellt — also
# gilt dieselbe Regel, und die Reihenfolge allein genügt nicht: sie sagt, WANN
# verworfen wird, nicht OB das Neue trägt.
#
# 1. neues Paar erzeugen, registrieren, Antwort haben
# 2. wg0 auf den neuen privaten Schlüssel umstellen
# 3. erneuten Handshake BEWEISEN
# 4. erst dann den alten verwerfen
#
# Kommt der Handshake nicht, wird zurückgestellt und laut gescheitert. Ein Host,
# der sich im letzten Schritt selbst aussperrt, ist der eine Fall, den niemand
# aus der Ferne repariert — und er passiert am Ende eines Laufs, der bis dahin
# alles richtig gemacht hat.
swap_wireguard_key() {
_new_private="$1"
_old_private="$CLUPILOT_WG_PRIVATE"
CLUPILOT_WG_PRIVATE="$_new_private"
printf '%s\n' "$(render_wireguard_config)" > /etc/wireguard/wg0.conf
chmod 600 /etc/wireguard/wg0.conf
systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true
if wireguard_handshake_proven 10; then
log 'Handshake mit dem neuen Schlüssel bewiesen'
return 0
fi
log 'Kein Handshake mit dem neuen Schlüssel — es wird auf den alten zurückgestellt'
CLUPILOT_WG_PRIVATE="$_old_private"
printf '%s\n' "$(render_wireguard_config)" > /etc/wireguard/wg0.conf
chmod 600 /etc/wireguard/wg0.conf
systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true
return 1
}

View File

@ -0,0 +1,264 @@
# shellcheck shell=sh
#
# Melden und Nachreichen — und die HTTP-Grundlage für alle anderen Abschnitte.
#
# Diese Datei kennt als Einzige den Weg zu CluPilot. Sie hält zusätzlich
# `http_get` und `http_download`, damit der Rückfall von curl auf wget an EINER
# Stelle steht: ein Rettungssystem bringt mal das eine mit und mal das andere,
# und diese Fallunterscheidung fünfmal zu schreiben heißt, sie viermal falsch zu
# schreiben.
#
# Zwei Regeln, aus denen sich alles andere ergibt:
#
# 1. `report` schreibt IMMER zuerst lokal, dann erst der Sendeversuch. Vor
# `wireguard_joined` gibt es keinen Weg zu CluPilot (Spec §5) — die Zeilen
# sammeln sich und werden von `flush_reports` nachgereicht.
# 2. Ein gescheiterter Sendeversuch ist NORMAL und darf den Lauf nie abbrechen.
# Das Skript läuft unter `set -e`; jede Netzoperation hier ist deshalb
# ausdrücklich abgefangen.
#
# Der Zeitstempel ist der des Ereignisses, nicht der der Ankunft. Sonst sieht
# eine zwanzigminütige Installation in der Konsole aus wie eine Sekunde, und
# niemand kann erkennen, welcher Abschnitt lange gedauert hat.
#
# ---------------------------------------------------------------------------
# Der Umschlag, gegen den die Plattform schreibt
# ---------------------------------------------------------------------------
#
# Die Abschnittsschlüssel sind laut Plan die einzige Absprache zwischen den zwei
# Plänen. Der Umschlag darum war nirgends festgelegt — er wird hier festgelegt,
# damit die Plattformseite EINE Stelle zum Nachlesen hat:
#
# POST {api}/host/progress
# Content-Type: application/json
#
# {"code": "<einmal-code>", "events": [
# {"section": "rescue_checked", "state": "done",
# "message": "...", "occurred_at": "2026-07-30T18:04:11Z"}
# ]}
#
# `state` ist `done` oder `failed`. Einen Zustand für „läuft gerade" gibt es
# absichtlich nicht: die Konsole leitet „offen" aus dem Fehlen einer Meldung ab
# (Spec §7), und ein dritter Zustand wäre eine zweite Wahrheit über dieselbe
# Sache.
#
# `message` ist immer vorhanden, notfalls leer — ein Feld, das mal da ist und
# mal nicht, ist die teurere Variante.
# Überschreibbar, damit sich die Melde-Mechanik ohne root und ohne echte
# Maschine durchspielen lässt. Im Betrieb setzt niemand die Variable: der
# Adminbereich schreibt sie nicht in die kopierte Zeile, und das Skript läuft
# ohnehin als root.
CLUPILOT_STATE_DIR="${CLUPILOT_STATE_DIR:-/var/lib/clupilot}"
CLUPILOT_PROGRESS_LOG="${CLUPILOT_STATE_DIR}/progress.jsonl"
CLUPILOT_PROGRESS_SENT="${CLUPILOT_STATE_DIR}/progress.sent"
# Kurz gehalten, weil das hier im Regelfall gegen eine Adresse läuft, die es
# noch nicht gibt. Ein langer Zeitablauf je Abschnitt summiert sich zu Minuten,
# in denen nichts passiert und niemand sieht warum.
CLUPILOT_HTTP_CONNECT_TIMEOUT=5
CLUPILOT_HTTP_MAX_TIME=15
# Legt das Zustandsverzeichnis an. 0700, weil hier später auch die
# Aufrufargumente liegen und der WireGuard-Schlüssel darin steht.
init_reporting() {
mkdir -p "$CLUPILOT_STATE_DIR"
chmod 700 "$CLUPILOT_STATE_DIR"
[ -f "$CLUPILOT_PROGRESS_LOG" ] || : > "$CLUPILOT_PROGRESS_LOG"
[ -f "$CLUPILOT_PROGRESS_SENT" ] || echo 0 > "$CLUPILOT_PROGRESS_SENT"
}
# Macht aus beliebigem Text den Rumpf eines JSON-Strings.
#
# Der Text kommt oft aus der Ausgabe eines fehlgeschlagenen Befehls, ist also
# nicht unter unserer Kontrolle. Reihenfolge zählt: der Backslash zuerst, sonst
# verdoppelt der Lauf danach die gerade gesetzten Fluchtzeichen wieder mit.
# Zeilenumbrüche und Tabulatoren werden zu Leerzeichen — eine Meldung ist eine
# Zeile, und eine eingebettete Zeile zerlegte die JSONL-Datei.
json_escape() {
printf '%s' "$1" \
| tr '\n\r\t' ' ' \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
# Der einzige Zeitstempel-Erzeuger im ganzen Skript. UTC mit `Z`, weil das
# Skript auf Maschinen mit jeder denkbaren Zonen-Einstellung läuft und die
# Konsole nach R19 ohnehin in UTC speichert und erst bei der Anzeige umrechnet.
now_iso() {
date -u '+%Y-%m-%dT%H:%M:%SZ'
}
# report <section> <state> [message]
#
# Gibt immer 0 zurück. Ein Melden, das den Lauf abbricht, wäre die Diagnose, die
# den Patienten umbringt.
report() {
_section="$1"
_state="$2"
_message="${3:-}"
printf '{"section":"%s","state":"%s","message":"%s","occurred_at":"%s"}\n' \
"$(json_escape "$_section")" \
"$(json_escape "$_state")" \
"$(json_escape "$_message")" \
"$(now_iso)" \
>> "$CLUPILOT_PROGRESS_LOG"
log "[${_state}] ${_section}${_message:+ — ${_message}}"
flush_reports
return 0
}
# Reicht alles nach, was noch nicht bestätigt gesendet wurde.
#
# Der Stand steht als Zeilenzahl in einer eigenen Datei, statt die Zeilen in der
# Protokolldatei umzuschreiben: `progress.jsonl` ist damit reines Anhängen und
# übersteht einen Stromausfall mitten im Schreiben ohne Sonderbehandlung.
#
# Gibt immer 0 zurück.
flush_reports() {
[ -f "$CLUPILOT_PROGRESS_LOG" ] || return 0
_sent="$(cat "$CLUPILOT_PROGRESS_SENT" 2>/dev/null || echo 0)"
_total="$(wc -l < "$CLUPILOT_PROGRESS_LOG")"
_sent="$(printf '%s' "$_sent" | tr -cd '0-9')"
[ -n "$_sent" ] || _sent=0
[ "$_total" -gt "$_sent" ] || return 0
_pending="$(sed -n "$((_sent + 1)),\$p" "$CLUPILOT_PROGRESS_LOG")"
[ -n "$_pending" ] || return 0
_events="$(printf '%s' "$_pending" | tr '\n' ',' | sed -e 's/,$//')"
_body="$(printf '{"code":"%s","events":[%s]}' "$(json_escape "$CLUPILOT_CODE")" "$_events")"
if post_json "${CLUPILOT_API}/host/progress" "$_body"; then
# Erst nach der bestätigten Antwort. Andersherum ginge genau der
# Abschnitt verloren, dessen Zustellung gescheitert ist.
echo "$_total" > "$CLUPILOT_PROGRESS_SENT"
log "nachgereicht: $((_total - _sent)) Meldung(en)"
fi
return 0
}
# section_done <section> — 0, wenn dieser Abschnitt schon einmal `done` gemeldet
# hat.
#
# Die Fortschrittsdatei IST der Zustand. Es gibt keine zweite Buchführung
# darüber, was schon lief — eine zweite wäre eine zweite Wahrheit, und nach dem
# Neustart in Task 3 wäre genau sie diejenige, die fehlt.
#
# Bewusst gegen den festen Rumpf geprüft, den `report` schreibt, statt gegen ein
# lose passendes Muster: `grep rescue_checked` fände auch eine Meldung, in deren
# `message` der Name zufällig vorkommt.
section_done() {
[ -f "$CLUPILOT_PROGRESS_LOG" ] || return 1
grep -q "^{\"section\":\"$1\",\"state\":\"done\"," "$CLUPILOT_PROGRESS_LOG"
}
# run_section <section> <funktion> — führt einen Abschnitt aus, wenn er noch
# nicht erledigt ist, und meldet sein Ergebnis.
#
# Das `if` statt `&&` ist Absicht: unter `set -e` bricht ein `&&`-Ausdruck den
# Lauf ab, bevor `report ... failed` je liefe — und dann stünde in der Konsole
# nichts, obwohl genau dieser Fall der ist, für den sie gebaut wurde.
run_section() {
_key="$1"
_fn="$2"
if section_done "$_key"; then
log "übersprungen: ${_key} (schon erledigt)"
return 0
fi
if "$_fn"; then
report "$_key" done "${CLUPILOT_SECTION_NOTE:-}"
CLUPILOT_SECTION_NOTE=''
return 0
fi
report "$_key" failed "${CLUPILOT_SECTION_NOTE:-abgebrochen in ${_key}}"
CLUPILOT_SECTION_NOTE=''
return 1
}
# post_json <url> <body> — 0 bei 2xx, sonst 1. Schreibt nichts nach stdout.
#
# Der Rückfall auf wget ist kein Luxus: Rettungssysteme sind unterschiedlich
# bestückt, und genau hier ist noch keine Gelegenheit gewesen, etwas
# nachzuinstallieren.
post_json() {
_url="$1"
_payload="$2"
if command -v curl >/dev/null 2>&1; then
# `-f` lässt curl bei 4xx/5xx mit Fehler enden, statt die Fehlerseite
# als Erfolg zu behandeln.
curl -sS -f \
--connect-timeout "$CLUPILOT_HTTP_CONNECT_TIMEOUT" \
--max-time "$CLUPILOT_HTTP_MAX_TIME" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-X POST --data "$_payload" \
"$_url" >/dev/null 2>&1
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O /dev/null \
--timeout="$CLUPILOT_HTTP_MAX_TIME" \
--header='Content-Type: application/json' \
--header='Accept: application/json' \
--post-data="$_payload" \
"$_url"
return $?
fi
return 1
}
# http_get <url> — Rumpf nach stdout, 0 bei Erfolg.
#
# Für kleine Antworten: Verzeichnislisten, Prüfsummendateien. Nicht für ISOs;
# dafür gibt es `http_download`, das nicht durch den Speicher geht.
http_get() {
if command -v curl >/dev/null 2>&1; then
curl -sS -fL --connect-timeout 10 --max-time 120 "$1" 2>/dev/null
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O - --timeout=120 "$1" 2>/dev/null
return $?
fi
return 1
}
# http_download <url> <ziel> — 0 bei Erfolg.
#
# Kein `-C -`/`--continue`: eine halb geladene ISO, die beim zweiten Lauf
# fortgesetzt wird, ergibt eine Datei mit richtiger Größe und falschem Inhalt,
# wenn sich die Quelle dazwischen geändert hat. Lieber ganz neu — die Prüfsumme
# fängt es zwar auch ab, aber erst nach dem Herunterladen.
http_download() {
_url="$1"
_target="$2"
rm -f "$_target"
if command -v curl >/dev/null 2>&1; then
curl -sS -fL --connect-timeout 10 --max-time 3600 -o "$_target" "$_url" 2>/dev/null
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O "$_target" --timeout=3600 "$_url" 2>/dev/null
return $?
fi
return 1
}

View File

@ -0,0 +1,252 @@
# shellcheck shell=sh
#
# Die goldene Vorlage: ein Debian-13-Cloud-Abbild, in das Docker, Compose und
# die Nextcloud-Aufstellung eingebaut werden, konvertiert zur Proxmox-Vorlage
# auf VMID 9000.
#
# ---------------------------------------------------------------------------
# Die drei Fallen aus Block A
# ---------------------------------------------------------------------------
#
# Sie sind nicht verhandelbar, und sie werden hier alle drei GEPRÜFT statt
# angenommen — jede von ihnen hat einmal einen bezahlten Auftrag gekostet:
#
# 1. `user: www-data` am `app`-Dienst der Compose-Datei. Ohne sie schlagen ALLE
# `occ`-Aufrufe fehl. Steht in `assets/docker-compose.yml`; hier wird
# nachgesehen, dass sie es auch ins Abbild geschafft hat.
# 2. Die Root-Partition ist die LETZTE auf der Platte und liegt NICHT auf LVM.
# `GrowGuestFilesystem` erkennt LVM absichtlich nicht und schriebe sonst ein
# Kontingent über ein Dateisystem, das nie gewachsen ist.
# 3. `qemu-guest-agent` MUSS im Abbild sein — `WaitForGuestAgent` hängt daran,
# und ohne ihn wartet jede Bereitstellung bis zum Zeitablauf.
#
# ---------------------------------------------------------------------------
# Warum das Cloud-Abbild und nicht der Installer
# ---------------------------------------------------------------------------
#
# Debians Cloud-Abbild bringt genau die Eigenschaften mit, die Falle 2 verlangt:
# eine einzige Root-Partition, als letzte auf der Platte, ohne LVM. Und es hat
# cloud-init, worüber die Pipeline beim Klonen die `.env` in den Gast schreibt.
# Beides selbst zu bauen hieße, es beim ersten Kunden zu prüfen.
CLUPILOT_TEMPLATE_VMID="${CLUPILOT_TEMPLATE_VMID:-9000}"
CLUPILOT_TEMPLATE_NAME='clupilot-nextcloud-vorlage'
CLUPILOT_CLOUD_IMAGE_BASE="${CLUPILOT_CLOUD_IMAGE_BASE:-https://cloud.debian.org/images/cloud/trixie/latest}"
CLUPILOT_CLOUD_IMAGE='debian-13-genericcloud-amd64.qcow2'
# Wohin die Vorlagenplatte kommt. `local-zfs` ist das, was der PVE-Installer auf
# ZFS anlegt; `local-lvm` wäre es bei ext4. Zur Laufzeit gesucht statt geraten.
detect_vm_storage() {
for _candidate in local-zfs local-lvm local; do
if pvesm status --storage "$_candidate" >/dev/null 2>&1; then
# Muss Platten aufnehmen können. `local` kann das oft NICHT — dort
# liegen nur Abbilder und Sicherungen, und ein `qm importdisk`
# dorthin scheitert erst nach dem Kopieren.
if pvesm status --storage "$_candidate" --content images >/dev/null 2>&1; then
printf '%s' "$_candidate"
return 0
fi
fi
done
# Sonst der erste Speicher, der Platten aufnimmt.
pvesm status --content images 2>/dev/null | awk 'NR > 1 { print $1; exit }'
}
# Lädt das Cloud-Abbild und prüft es gegen Debians SHA512SUMS.
fetch_cloud_image() {
_target="$1"
if ! http_download "${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" "$_target"; then
log "Cloud-Abbild nicht ladbar: ${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}"
return 1
fi
_sums="$(http_get "${CLUPILOT_CLOUD_IMAGE_BASE}/SHA512SUMS" || true)"
if [ -z "$_sums" ]; then
log 'SHA512SUMS von cloud.debian.org nicht abrufbar'
return 1
fi
_expected="$(printf '%s\n' "$_sums" | awk -v n="$CLUPILOT_CLOUD_IMAGE" '$2 == n || $2 == "*" n { print $1; exit }')"
if [ -z "$_expected" ]; then
log "keine Prüfsumme für ${CLUPILOT_CLOUD_IMAGE}"
return 1
fi
_actual="$(sha512sum "$_target" | awk '{ print $1 }')"
if [ "$_actual" != "$_expected" ]; then
log 'Prüfsumme des Cloud-Abbilds stimmt nicht'
rm -f "$_target"
return 1
fi
log 'Cloud-Abbild geladen, Prüfsumme stimmt'
return 0
}
# FALLE 2, geprüft statt angenommen.
#
# Zwei Aussagen: kein LVM, und die Root-Partition ist die letzte auf der Platte.
# `virt-filesystems` beantwortet beide, ohne das Abbild einzuhängen.
verify_image_layout() {
_image="$1"
_fs="$(virt-filesystems --long --parts --blkdevs -h -a "$_image" 2>/dev/null || true)"
if [ -z "$_fs" ]; then
log 'virt-filesystems liefert nichts — Abbildaufbau nicht prüfbar'
return 1
fi
if printf '%s' "$_fs" | grep -qiE '(lvm|/dev/[a-z0-9_-]+/[a-z0-9_-]+)'; then
log 'Das Abbild benutzt LVM. GrowGuestFilesystem erkennt LVM absichtlich nicht und schriebe ein Kontingent über ein Dateisystem, das nie gewachsen ist.'
return 1
fi
# Die Wurzel ist die Partition mit der höchsten Nummer, wenn sie die letzte
# ist. Anders gefragt: gibt es hinter der Root-Partition noch eine?
_last_part="$(printf '%s\n' "$_fs" | awk '/^\/dev\/[a-z]+[0-9]+/ { print $1 }' | sort -V | tail -1)"
_root_part="$(virt-filesystems --long -a "$_image" 2>/dev/null | awk '$0 !~ /swap/ && /ext4|xfs|btrfs/ { print $1; }' | sort -V | tail -1)"
if [ -n "$_last_part" ] && [ -n "$_root_part" ] && [ "$_last_part" != "$_root_part" ]; then
log "Die Root-Partition (${_root_part}) ist nicht die letzte auf der Platte (${_last_part}). GrowGuestFilesystem wächst nur die letzte."
return 1
fi
log "Abbildaufbau in Ordnung: kein LVM, Wurzel auf ${_root_part:-(unbestimmt)} als letzte Partition"
return 0
}
# Baut alles in das Abbild ein, was die Vorlage ausmacht.
#
# `virt-customize` statt „VM starten und hineinreden": es braucht kein Netz im
# Gast, keine Anmeldung und keinen laufenden Rechner, und es ist wiederholbar.
customise_cloud_image() {
_image="$1"
_compose="$2"
# Ohne diese Zeile hängt jede Bereitstellung an WaitForGuestAgent bis zum
# Zeitablauf — Falle 3.
_packages='qemu-guest-agent,ca-certificates,curl,gnupg'
if ! virt-customize -a "$_image" \
--install "$_packages" \
--run-command 'systemctl enable qemu-guest-agent' \
>> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1
then
log 'virt-customize: Grundpakete ließen sich nicht einbauen'
return 1
fi
# Docker aus der Quelle von Docker selbst, nicht aus Debian: `docker.io` hat
# kein Compose-Plugin, und `docker-compose` in Debian ist die alte
# Python-Fassung, die diese Compose-Datei nicht liest.
if ! virt-customize -a "$_image" \
--run-command 'install -m 0755 -d /etc/apt/keyrings' \
--run-command 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc' \
--run-command 'chmod a+r /etc/apt/keyrings/docker.asc' \
--run-command 'echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" > /etc/apt/sources.list.d/docker.list' \
--run-command 'apt-get update' \
--install 'docker-ce,docker-ce-cli,containerd.io,docker-buildx-plugin,docker-compose-plugin' \
--run-command 'systemctl enable docker' \
>> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1
then
log 'virt-customize: Docker ließ sich nicht einbauen'
return 1
fi
if ! virt-customize -a "$_image" \
--mkdir /opt/nextcloud \
--copy-in "${_compose}:/opt/nextcloud" \
--run-command 'systemctl enable docker.service containerd.service' \
>> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1
then
log 'virt-customize: die Compose-Datei kam nicht ins Abbild'
return 1
fi
return 0
}
# Prüft im fertigen Abbild nach, dass die drei Fallen wirklich zu sind.
#
# Nach dem Einbauen, nicht davor: `virt-customize` meldet auch dann Erfolg, wenn
# apt ein Paket stillschweigend nicht installiert hat.
verify_image_contents() {
_image="$1"
if ! virt-ls -a "$_image" /usr/bin 2>/dev/null | grep -q '^qemu-ga$'; then
log 'qemu-guest-agent ist NICHT im Abbild (Falle 3) — jede Bereitstellung hinge an WaitForGuestAgent'
return 1
fi
_compose_in_image="$(virt-cat -a "$_image" /opt/nextcloud/docker-compose.yml 2>/dev/null || true)"
if [ -z "$_compose_in_image" ]; then
log '/opt/nextcloud/docker-compose.yml fehlt im Abbild'
return 1
fi
if ! printf '%s' "$_compose_in_image" | grep -qE '^[[:space:]]*user:[[:space:]]*www-data[[:space:]]*$'; then
log 'Die Compose-Datei im Abbild hat kein `user: www-data` (Falle 1) — alle occ-Aufrufe schlügen fehl'
return 1
fi
if ! virt-ls -a "$_image" /usr/libexec/docker/cli-plugins 2>/dev/null | grep -q 'docker-compose'; then
if ! virt-ls -a "$_image" /usr/lib/docker/cli-plugins 2>/dev/null | grep -q 'docker-compose'; then
log 'Das Compose-Plugin ist nicht im Abbild'
return 1
fi
fi
log 'Abbildinhalt in Ordnung: qemu-guest-agent, Compose-Plugin, user: www-data'
return 0
}
# Legt die VM an, hängt die Platte ein und macht eine Vorlage daraus.
create_proxmox_template() {
_image="$1"
_storage="$2"
_vmid="$CLUPILOT_TEMPLATE_VMID"
if qm status "$_vmid" >/dev/null 2>&1; then
log "VMID ${_vmid} existiert bereits — wird ersetzt"
qm destroy "$_vmid" --purge >/dev/null 2>&1 || true
fi
qm create "$_vmid" \
--name "$CLUPILOT_TEMPLATE_NAME" \
--memory 4096 \
--cores 2 \
--net0 "virtio,bridge=vmbr0" \
--scsihw virtio-scsi-single \
--ostype l26 \
--agent enabled=1 \
--serial0 socket \
--vga serial0 \
>> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'qm create fehlgeschlagen'; return 1; }
# Platte importieren und als scsi0 einhängen.
qm importdisk "$_vmid" "$_image" "$_storage" >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \
|| { log 'qm importdisk fehlgeschlagen'; return 1; }
qm set "$_vmid" --scsi0 "${_storage}:vm-${_vmid}-disk-0,discard=on,ssd=1" \
>> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'Platte ließ sich nicht einhängen'; return 1; }
# cloud-init: darüber schreibt die Pipeline beim Klonen die .env in den Gast.
qm set "$_vmid" --ide2 "${_storage}:cloudinit" --boot order=scsi0 \
>> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'cloud-init-Laufwerk ließ sich nicht anlegen'; return 1; }
qm template "$_vmid" >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \
|| { log 'qm template fehlgeschlagen'; return 1; }
return 0
}
# Ist es wirklich eine Vorlage?
#
# `VerifyVmTemplate` prüft heute nur die EXISTENZ von 9000, nicht das Merkmal —
# der Handoff hält das als eigenen Fund fest. Eine VM, die zufällig 9000 heißt,
# bestünde diese Prüfung. `template: 1` besteht nur eine Vorlage.
template_is_really_a_template() {
qm config "$CLUPILOT_TEMPLATE_VMID" 2>/dev/null | grep -qE '^template:[[:space:]]*1[[:space:]]*$'
}

View File

@ -0,0 +1,262 @@
# shellcheck shell=sh
#
# Traefik: Binary plus systemd-Unit. **Kein Docker auf dem Hypervisor** (Handoff
# §1) — nftables bleibt einziger Eigentümer der Host-Firewallregeln, und ein
# Docker-Dienst brächte seine eigene Kette mit.
#
# ---------------------------------------------------------------------------
# Die Namen, die passen müssen
# ---------------------------------------------------------------------------
#
# `SshTraefikWriter::render()` gibt aus, was der Routen-Endpunkt später liefert,
# und die statische Konfiguration hier muss dazu passen. Nachgelesen, nicht
# geraten:
#
# entryPoints: ["websecure"]
# tls.certResolver: "letsencrypt"
# service -> loadBalancer.servers[].url: "http://<backend>:80"
#
# Heißt der Entrypoint hier anders, laufen die Router ins Leere und Traefik
# meldet trotzdem einen sauberen Start.
#
# ---------------------------------------------------------------------------
# Reihenfolge: der Token kommt erst später
# ---------------------------------------------------------------------------
#
# Der `http`-Provider braucht den dauerhaften Host-Token, und den gibt es erst
# mit `POST /host/register` in Abschnitt `registered`. Die statische
# Konfiguration wird deshalb ZWEIMAL geschrieben: hier mit leerem Token, damit
# der Dienst steht und 80/443 belegt sind, und in Task 9 noch einmal mit dem
# echten. Erst dort lässt sich beweisen, dass eine Routentabelle wirklich
# abgerufen wurde.
CLUPILOT_TRAEFIK_BIN='/usr/local/bin/traefik'
CLUPILOT_TRAEFIK_DIR='/etc/traefik'
CLUPILOT_TRAEFIK_TOKEN_FILE='/etc/traefik/host-token'
CLUPILOT_TRAEFIK_API='http://127.0.0.1:8080'
# Neueste stabile v3 aus der Release-Liste.
#
# Nicht fest verdrahtet, aus demselben Grund wie bei der ISO: eine feste Nummer
# ist in ein paar Monaten eine 404 mitten in einer Übernahme.
discover_traefik_version() {
_json="$(http_get 'https://api.github.com/repos/traefik/traefik/releases/latest' || true)"
[ -n "$_json" ] || return 1
printf '%s' "$_json" \
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\(v3[0-9.]*\)".*/\1/p' \
| head -1
}
# Lädt das Binary und prüft es gegen die Prüfsummendatei der Ausgabe.
#
# Dieselbe Begründung wie bei der ISO: was auf 80 und 443 lauscht und die
# Zertifikate aller Kunden hält, wird nicht ungeprüft aus dem Netz übernommen.
install_traefik_binary() {
_version="$1"
_plain="${_version#v}"
_base="https://github.com/traefik/traefik/releases/download/${_version}"
_archive="traefik_${_version}_linux_amd64.tar.gz"
_sums="traefik_${_version}_checksums.txt"
_tmp="${CLUPILOT_WORK_DIR}/traefik"
rm -rf "$_tmp"
mkdir -p "$_tmp"
if ! http_download "${_base}/${_archive}" "${_tmp}/${_archive}"; then
log "Traefik ${_version} nicht ladbar"
return 1
fi
_sumfile="$(http_get "${_base}/${_sums}" || true)"
if [ -z "$_sumfile" ]; then
log "Prüfsummendatei ${_sums} nicht abrufbar"
return 1
fi
_expected="$(printf '%s\n' "$_sumfile" | awk -v n="$_archive" '$2 == n || $2 == "*" n { print $1; exit }')"
if [ -z "$_expected" ]; then
log "keine Prüfsumme für ${_archive}"
return 1
fi
_actual="$(sha256sum "${_tmp}/${_archive}" | awk '{ print $1 }')"
if [ "$_actual" != "$_expected" ]; then
log "Traefik-Prüfsumme stimmt nicht: erwartet ${_expected}, bekommen ${_actual}"
return 1
fi
tar xzf "${_tmp}/${_archive}" -C "$_tmp" traefik
install -m 755 "${_tmp}/traefik" "$CLUPILOT_TRAEFIK_BIN"
rm -rf "$_tmp"
log "Traefik ${_plain} installiert, Prüfsumme stimmt"
return 0
}
# Die statische Konfiguration.
#
# `token` darf leer sein — dann steht der Dienst, holt aber keine Tabelle. Das
# ist der Zustand zwischen Abschnitt 7 und Abschnitt 9.
write_traefik_static_config() {
_token="${1:-}"
_endpoint="${CLUPILOT_API}/host/routes"
mkdir -p "$CLUPILOT_TRAEFIK_DIR"
chmod 755 "$CLUPILOT_TRAEFIK_DIR"
# acme.json hält die privaten Schlüssel aller Kundenzertifikate. Traefik
# weigert sich, sie zu benutzen, wenn die Rechte weiter sind als 600 — und
# das ist gut so.
if [ ! -f "${CLUPILOT_TRAEFIK_DIR}/acme.json" ]; then
: > "${CLUPILOT_TRAEFIK_DIR}/acme.json"
fi
chmod 600 "${CLUPILOT_TRAEFIK_DIR}/acme.json"
cat > "${CLUPILOT_TRAEFIK_DIR}/traefik.yml" <<EOF
# Von CluPilots Bootstrap geschrieben. Nicht von Hand ändern — der nächste Lauf
# überschreibt die Datei.
global:
checkNewVersion: false
sendAnonymousUsage: false
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
# Nur auf der Loopback-Adresse. Der Zustand dieses Dienstes geht niemanden
# außerhalb der Maschine etwas an, und nftables ließe 8080 ohnehin nicht durch.
traefik:
address: "127.0.0.1:8080"
# Die Namen kommen aus SshTraefikWriter::render(): der Routen-Endpunkt liefert
# entryPoints ["websecure"] und certResolver "letsencrypt". Wer sie hier ändert,
# muss sie dort mitändern — sonst laufen die Router ins Leere und Traefik startet
# trotzdem sauber.
certificatesResolvers:
letsencrypt:
acme:
email: "${CLUPILOT_ACME_EMAIL}"
storage: "${CLUPILOT_TRAEFIK_DIR}/acme.json"
httpChallenge:
entryPoint: web
providers:
http:
endpoint: "${_endpoint}"
pollInterval: "30s"
pollTimeout: "10s"
headers:
Authorization: "Bearer ${_token}"
api:
dashboard: false
insecure: true
ping:
entryPoint: traefik
log:
level: INFO
filePath: "/var/log/traefik.log"
accessLog:
filePath: "/var/log/traefik-access.log"
EOF
chmod 640 "${CLUPILOT_TRAEFIK_DIR}/traefik.yml"
if [ -n "$_token" ]; then
umask 077
printf '%s\n' "$_token" > "$CLUPILOT_TRAEFIK_TOKEN_FILE"
chmod 600 "$CLUPILOT_TRAEFIK_TOKEN_FILE"
fi
}
write_traefik_unit() {
cat > /etc/systemd/system/traefik.service <<EOF
[Unit]
Description=Traefik (von CluPilot verwaltet)
Documentation=https://doc.traefik.io/traefik/
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStart=${CLUPILOT_TRAEFIK_BIN} --configFile=${CLUPILOT_TRAEFIK_DIR}/traefik.yml
Restart=on-failure
RestartSec=5
# Braucht 80 und 443 und sonst nichts. Ohne diese Zeile liefe ein Dienst als
# root, der von außen erreichbar ist — die eine Stelle, an der das am wenigsten
# sein darf.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${CLUPILOT_TRAEFIK_DIR} /var/log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
}
traefik_answers_ping() {
_tries="${1:-15}"
while [ "$_tries" -gt 0 ]; do
if http_get "${CLUPILOT_TRAEFIK_API}/ping" >/dev/null 2>&1; then
return 0
fi
_tries=$((_tries - 1))
sleep 2
done
return 1
}
# Lauschen 80 und 443 wirklich?
#
# „Der Dienst läuft" und „der Port ist belegt" sind zwei Aussagen. Traefik
# startet auch dann sauber, wenn ein Entrypoint wegen eines Tippfehlers gar
# nicht angelegt wurde.
traefik_ports_bound() {
for _port in 80 443; do
if ! ss -ltn "sport = :${_port}" 2>/dev/null | grep -q LISTEN; then
log "Port ${_port} ist nicht belegt"
return 1
fi
done
return 0
}
# Holt der Host seine Routentabelle wirklich?
#
# Direkt gefragt, mit demselben Token und derselben Adresse, die in der
# statischen Konfiguration stehen. Über Traefiks eigene Router zu gehen wäre
# hier kein Beweis: ein frischer Host hat noch keine Kunden, also ist die
# Tabelle mit Recht leer, und „null Router" hieße dann sowohl „alles gut" als
# auch „nichts abgerufen".
host_routes_reachable() {
_token="$1"
[ -n "$_token" ] || return 1
if command -v curl >/dev/null 2>&1; then
curl -sS -f --connect-timeout 5 --max-time 15 \
-H "Authorization: Bearer ${_token}" \
"${CLUPILOT_API}/host/routes" >/dev/null 2>&1
return $?
fi
wget -q -O /dev/null --timeout=15 \
--header="Authorization: Bearer ${_token}" \
"${CLUPILOT_API}/host/routes"
}

View File

@ -0,0 +1,305 @@
# Runbook: Wenn die Host-Übernahme stehenbleibt
Der Bootstrap läuft in neun Abschnitten. Bleibt einer davon aus, zeigt die
Konsole **welcher** — nicht „irgendetwas ging schief". Dieses Runbook sagt je
Abschnitt: woran man es merkt, wo man nachsieht, und was der übliche Grund ist.
---
## Die eine Regel, die über allen steht
**Eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert.**
Nicht aus Bequemlichkeit. Der Bootstrap ist so gebaut, dass ein zweiter Lauf auf
einer frisch aufgesetzten Maschine immer gleich ausgeht — ein Lauf auf einer
Maschine, an der jemand von Hand etwas gerade gerückt hat, ist das nicht mehr.
Wer nachbessert, tauscht ein wiederholbares Ergebnis gegen ein einmaliges, und
merkt den Unterschied erst beim nächsten Host.
Der Weg zurück ist immer derselbe:
1. Im Adminbereich einen **neuen** Host anlegen. Der Einmal-Code ist verbraucht;
die Seite zeigt ihn nicht wieder her, und das ist Absicht.
2. Beim Anbieter das **Rettungssystem** starten.
3. Die neue Zeile einfügen.
Der alte Host-Eintrag wird im Adminbereich entfernt. Solange er steht, hält er
seine Tunneladresse belegt.
**Ausnahme:** Bleibt der Lauf in `rescue_checked` stehen, ist noch **nichts**
geschrieben worden. Dort ist Nachbessern richtig — es ist der einzige Abschnitt,
der die Platte nicht angefasst hat.
---
## Wo alles steht
| Was | Wo |
|---|---|
| Fortschritt, Zeile je Abschnitt | `/var/lib/clupilot/progress.jsonl` |
| Was schon gesendet wurde (Zeilenzahl) | `/var/lib/clupilot/progress.sent` |
| Laufprotokoll des Skripts | `/var/lib/clupilot/bootstrap.log` |
| Aufrufargumente (nur root) | `/var/lib/clupilot/arguments` |
| Arbeitsverzeichnis: ISO, Protokolle | `/var/tmp/clupilot-work/` |
| Installer-Protokoll | `/var/tmp/clupilot-work/qemu.log` |
| Antwortdatei des Installers | `/var/tmp/clupilot-work/answer.toml` |
| Traefik | `/var/log/traefik.log`, `/etc/traefik/traefik.yml` |
Die Fortschrittsdatei **ist** der Zustand. Ein Abschnitt, der dort `done`
gemeldet hat, läuft bei einem Wiederanlauf nicht erneut.
---
## Vor dem Neustart oder danach?
Das ist die erste Frage, und sie ist leicht zu beantworten:
```sh
findmnt -no FSTYPE /
```
`tmpfs`, `overlay` oder `ramfs` heißt: noch im Rettungssystem, der Neustart
steht bevor. `zfs` heißt: die Maschine läuft schon auf dem installierten System,
und der `first-boot`-Hook hat den Lauf wieder aufgenommen.
---
## Abschnitt für Abschnitt
### `rescue_checked` — die Eingangsprüfung
**Woran man es merkt:** Das Skript endet sofort mit einer Liste von Befunden.
In der Konsole steht nichts, weil es vor dem Tunnel keinen Weg dorthin gibt.
**Übliche Gründe:**
- *„Das Wurzelverzeichnis liegt auf /dev/… also auf einer echten Partition."*
Die Maschine läuft nicht im Rettungssystem. Beim Anbieter das Rettungssystem
einschalten und **neu starten** — das Einschalten allein genügt nicht.
- *„Die CPU meldet weder vmx noch svm."* Ein Cloud-Produkt ohne verschachtelte
Virtualisierung (Hetzner CPX/CX, teils netcup). Nicht abstellbar; es braucht
eine dedizierte Maschine.
- *„/dev/kvm gibt es nicht."* Virtualisierung im BIOS aus, oder das
Rettungssystem hat das Modul nicht geladen: `modprobe kvm_intel` bzw.
`modprobe kvm_amd`, dann die Zeile noch einmal.
- *„Es sind Partitionen echter Platten eingehängt."* Jemand hat nachgesehen.
`umount` und noch einmal.
- *„Die Uhr geht … Sekunden falsch."* Die Meldung enthält den fertigen Befehl
zum Stellen. Ohne ihn scheitert später jede TLS-Prüfung mit einer Meldung über
Zertifikate statt über die Uhrzeit.
**Hier ist Nachbessern richtig.** Es wurde noch nichts geschrieben.
---
### `debian_installed` — das Grundsystem
Heißt „Grundsystem geschrieben". Es entsteht **kein** Debian, das nachher jemand
umbaut: die offizielle Proxmox-ISO wird mit einer Antwortdatei versehen und
unter QEMU gegen die echten Platten gestartet.
**Woran man es merkt:** Der Lauf steht lange ohne Meldung; im Rettungssystem
läuft ein `qemu-system-x86_64`.
**Wo man nachsieht:** `/var/tmp/clupilot-work/qemu.log`, und die Antwortdatei
daneben. Läuft QEMU noch, kann man dem Installer zusehen — er hört auf
`127.0.0.1:5900`:
```bash
ssh -L 5900:127.0.0.1:5900 root@<öffentliche-ip>
```
Dann mit einem VNC-Betrachter auf `localhost:5900`. Sieht man dort eine
**Eingabemaske**, ist die Antwortdatei nicht angenommen worden — der Installer
ist in den interaktiven Modus gefallen und wartet, bis die Stunde abläuft.
**Übliche Gründe:**
- `answer.toml` abgelehnt. Steht in `/var/tmp/clupilot-work/assistant.log`. Das
Skript prüft sie mit `validate-answer`, bevor es die ISO backt, also ist das
selten — und wenn, dann liegt es an einem Wert aus der Umgebung (Adresse,
Gateway, MAC), den das Rettungssystem anders meldet als erwartet.
- Prüfsumme der ISO stimmt nicht. Abgebrochene Übertragung. Zeile noch einmal.
- Keine passende PVE-9-ISO gefunden. Die Liste unter
`https://enterprise.proxmox.com/iso/` war nicht erreichbar.
---
### `rebooted` — die einzige unumkehrbare Stelle
**Woran man es merkt:** Die Maschine antwortet nach dem Neustart nicht mehr,
oder sie antwortet, meldet aber nichts weiter.
**Übliche Gründe:**
- Die Maschine bootet noch vom Rettungssystem statt von der Platte. Beim
Anbieter das Rettungssystem **abschalten** und neu starten.
- Der `first-boot`-Hook lief nicht. Nachsehen mit
`journalctl -u proxmox-first-boot` und `ls /var/lib/clupilot/`. Fehlt das
Verzeichnis ganz, ist der Hook nicht in die ISO gekommen — dann neu aufsetzen.
**Kommt die Maschine gar nicht zurück:** Anbieterkonsole. Neu aufsetzen ist
schneller als jede Suche.
---
### `proxmox_installed` — abnehmen und Quellen richten
**Woran man es merkt:** Meldung mit `failed` und einem der folgenden Sätze.
**Übliche Gründe:**
- *„pveversion gibt es nicht"* — die ISO hat kein Proxmox installiert. Neu
aufsetzen.
- *„läuft auf …, das ist kein PVE-Kernel"* — die Maschine ist in den
Debian-Kernel gebootet. `uname -r` muss `-pve` enthalten.
- *„erwartet wäre PVE 9"* — Abbild und Unterbau passen nicht zusammen. Mit dem
richtigen Abbild neu aufsetzen; Paketquellen heilen das nicht.
- *„apt-get update schlägt fehl"* — siehe `/var/tmp/clupilot-work/apt.log`. Fast
immer eine Abonnement-Quelle, die wieder aufgetaucht ist:
`ls /etc/apt/sources.list.d/`.
---
### `network_bridged` — der gefährlichste Abschnitt
**Woran man es merkt:** Entweder eine `failed`-Meldung, oder die Maschine ist
für ein paar Minuten weg und kommt dann von selbst zurück.
**Kommt sie zurück, war das die Selbstrücknahme.** Das ist kein Fehler des
Zeitgebers, sondern sein Zweck: die Brücke hätte den Host vom Netz genommen.
Nachsehen mit `journalctl -t clupilot`.
**Übliche Gründe:**
- Falsche Anbieterform erkannt. Bei einer gerouteten Einzeladresse muss
`pointopoint` in `/etc/network/interfaces` stehen; fehlt sie, findet der
Kernel das Gateway nicht.
- *„die Datacenter-Firewall ließ sich nicht einschalten"* — ohne sie sind die
„nur 80/443"-Regeln **jeder** Kunden-VM wirkungslos. Prüfen mit
`pvesh get /cluster/firewall/options`.
**Kommt die Maschine nicht zurück:** Anbieterkonsole, dort
`tar xzf /var/lib/clupilot/interfaces.vor-der-bruecke.tar.gz -C /` und
`ifreload -a`. Danach neu aufsetzen.
---
### `wireguard_joined` — ab hier ist CluPilot erreichbar
**Woran man es merkt:** Bis hierher stand in der Konsole „wartet auf den
Tunnel". Bleibt es dabei, ist dieser Abschnitt der offene.
**Wo man nachsieht:** `wg show` auf dem Host. Steht dort ein Peer ohne
`latest handshake`, steht die Datei und der Tunnel nicht.
**Übliche Gründe:**
- Der Hub kennt den Peer nicht. Sollte nicht vorkommen — CluPilot legt ihn beim
Anlegen des Hosts an —, aber ein gelöschter Host-Eintrag nimmt ihn mit.
- Der Hub-Endpunkt ist von dieser Maschine aus nicht erreichbar. UDP auf dem
Port des Hubs prüfen.
- *„konnte wg-quick@wg0 nicht freigeben"* — der Tunnel überlebte den nächsten
Neustart nicht. Das ist ein harter Fehler, kein Schönheitsfehler.
---
### `traefik_running`
**Woran man es merkt:** `failed` mit einem der Sätze unten. Der Host ist
erreichbar, also lässt sich alles direkt nachsehen.
**Übliche Gründe:**
- *„antwortet nicht am Ping-Endpunkt"*`/var/log/traefik.log` und
`systemctl status traefik`.
- *„80 und 443 sind nicht belegt"* — Traefik läuft, ein Entrypoint fehlt.
`ss -ltnp | grep -E ':80|:443'`. Belegt sie etwas anderes, gehört das nicht auf
diese Maschine.
- Prüfsumme des Binaries stimmt nicht. Zeile noch einmal.
Dass Traefik hier **noch keine Routentabelle** holt, ist richtig so: den Token
dafür gibt es erst mit der Registrierung.
---
### `template_built` — die goldene Vorlage
**Woran man es merkt:** `failed`, meist mit einem Hinweis auf eine der drei
Fallen.
**Wo man nachsieht:** `/var/tmp/clupilot-work/virt-customize.log` und
`qm.log`.
**Übliche Gründe:**
- *„erfüllt Falle 2 nicht"* — das Cloud-Abbild hat LVM oder die Wurzel ist nicht
die letzte Partition. Dann stimmt etwas am Abbild nicht, das dort liegt.
- *„qemu-guest-agent ist NICHT im Abbild"*`virt-customize` hat Erfolg
gemeldet und apt hat das Paket still nicht installiert. Fast immer ein
Netzproblem im Abbild-Umbau.
- *„kein `user: www-data`"* — die Compose-Datei im Abbild ist nicht die aus
`assets/`. Ohne diese Zeile schlagen **alle** `occ`-Aufrufe fehl.
- *„meldet nicht template: 1"* — die VM existiert, ist aber keine Vorlage.
`qm template 9000` von Hand und dann **trotzdem neu aufsetzen**: wenn dieser
Schritt schiefging, ist unklar, was sonst noch.
---
### `registered` — die Übergabe
**Woran man es merkt:** Der Host bleibt in der Konsole auf `onboarding`.
**Übliche Gründe:**
- *„die Proxmox-Rolle ließ sich nicht angleichen"* — ohne `Sys.Modify` stirbt
später **jede** Kundenbereitstellung am Backup-Schritt. Prüfen mit
`pveum role list`.
- *„hat nicht geantwortet"* — CluPilot ist über den Tunnel nicht erreichbar,
obwohl der Handshake stand. Auf der CluPilot-Seite nachsehen, ob die
Tunneladresse in `TRUSTED_RANGES` steht.
- *„kam ohne host_token zurück"* — die Registrierung ist durchgelaufen, die
Antwort war unvollständig. Auf der CluPilot-Seite nachsehen.
- *„der Schlüsseltausch hat keinen Handshake ergeben"* — das Skript hat auf den
alten Schlüssel zurückgestellt, der Host ist also **noch erreichbar**. Der Hub
hat den neuen Peer möglicherweise schon aufgenommen und den alten entfernt;
dann ist der Host beim nächsten Versuch weg. Neu aufsetzen.
- *„die Routentabelle ist nicht abrufbar"* — der Token stimmt nicht oder
`/host/routes` antwortet nicht. Direkt prüfen:
```bash
curl -H "Authorization: Bearer $(cat /etc/traefik/host-token)" http://<tunnel-ip>/host/routes
```
---
## Der Host ist nicht mehr erreichbar
Erst die Frage: **war die Firewall schon dran?** Sie wird als **allerletztes**
angewendet. Steht in der Konsole `registered` noch nicht auf `done`, war sie es
nicht — dann liegt es am Netz, nicht an den Regeln.
War sie dran, gibt es von der Anbieterkonsole aus genau einen Griff:
```bash
/usr/local/sbin/clupilot-emergency-open-firewall.sh
```
Der Host nimmt danach auf jedem Port wieder an. **Es gibt bewusst keine
automatische Wiederöffnung** — kein Zeitgeber, kein „nach N Minuten ohne
Handshake wieder auf". Eine Firewall, die sich unter Störung selbst öffnet, ist
keine Firewall.
Zurück kommen die Regeln mit:
```bash
nft -f /etc/nftables.conf && systemctl enable --now nftables
```
---
## Was dieses Runbook nicht sagt
Alles, was noch nicht auf echter Hardware gelaufen ist. Die Abschnitte oben sind
aus dem Skript und aus den Fundstellen geschrieben, die ihm zugrunde liegen —
nicht aus Vorfällen. Was beim ersten Durchlauf tatsächlich stehenbleibt, gehört
hier ergänzt, mit dem Satz, der in der Konsole stand.

View File

@ -0,0 +1,521 @@
# Host-Übernahme — Bootstrap-Skript, Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ein Skript, das aus einer nackten Maschine im Rettungssystem einen fertigen Proxmox-Host macht — mit Traefik, WireGuard-Beitritt und goldener Nextcloud-Vorlage — und CluPilot nach jedem Abschnitt sagt, wie weit es ist.
**Architecture:** Ein einziges POSIX-Shell-Skript, das der Betreiber mit einer Zeile aus dem Adminbereich startet. Es läuft in Abschnitten; jeder meldet sich zurück, sobald ein Weg zu CluPilot existiert. Abschnitte vor dem Tunnelbeitritt werden lokal mitgeschrieben und nachgereicht. Das Skript ist so gebaut, dass ein zweiter Lauf auf einer frisch aufgesetzten Maschine immer gleich ausgeht.
**Tech Stack:** POSIX sh, Debian 13 (trixie), Proxmox VE 9, WireGuard, Traefik, `qemu-img`, cloud-init, Docker + Compose im Gast.
## Global Constraints
- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md`. Bei Widerspruch gewinnt die Spec.
- **DIE WICHTIGSTE AUFLAGE: das Wissen liegt schon im Repository, erfinde es nicht neu.** Die heutigen Pipeline-Schritte enthalten alles, was hier gebraucht wird — und sie werden vom Plattform-Plan **gelöscht**. Lies sie, **bevor** das passiert:
- `app/Provisioning/Steps/Host/InstallProxmoxVe.php` — die Codename-Tabelle (Debian 13/trixie ↔ PVE 9), die Paketquellen, die Reihenfolge. Der Handoff hält fest, dass ein fest verdrahtetes `bookworm` heute PVE-8-Quellen gegen Debian 13 gesetzt hätte.
- `app/Provisioning/Steps/Host/RebootIntoPveKernel.php` — **der Neustart ist das
einzige Unumkehrbare am ganzen Vorgang.** Die Datei beweist den Bootpfad,
*bevor* sie neu startet, statt ihn aus „apt hat nicht gemeckert" zu
schließen: `/boot/vmlinuz-*-pve` vorhanden, `/boot/initrd.img-*-pve`
vorhanden (ein volles `/boot` hinterlässt genau die halbe Initramfs), und
`update-grub` **an seinem Exit-Status** geprüft — den der Vorgänger verwarf.
Alle drei scheitern hart statt zu wiederholen, weil keine sich von selbst
behebt. Dazu die 15-Minuten-Frist, die ihre eigenen Marker löscht, damit ein
Wiederholungslauf frisch neu startet statt sofort wieder in die abgelaufene
Frist zu laufen. **Diese Datei stand nicht in der ersten Fassung dieser
Liste, steht aber auf der Löschliste des Plattform-Plans.**
- `app/Provisioning/Steps/Host/ConfigureProxmox.php` — die vollständige Diagnose zu `vmbr0`: warum Proxmox auf Debian keine anlegt, was eine falsche Brücke anrichtet, und die Datacenter-Firewall, ohne die die „nur 80/443"-Regeln der Kunden-VMs **wirkungslos** sind.
- `app/Provisioning/Steps/Host/SecureHostFirewall.php` — die nftables-Regeln, samt der Korrektur, dass **nicht jedes ICMP** verworfen werden darf (sonst sind IPv6 und PMTUD gebrochen).
- `app/Provisioning/Steps/Host/ConfigureWireguard.php` — wie `wg0` systemd-aktiviert wird. Der Handoff hält fest, dass ein `||`-Rückfall die fehlende Aktivierung verdeckte und der Neustart eine nicht mehr bootende Maschine hinterlassen konnte.
- `app/Provisioning/Steps/Host/CreateAutomationToken.php` — die Proxmox-Rolle **inklusive `Sys.Modify`**, ohne das jede Kundenbereitstellung am Backup-Schritt stirbt.
- `app/Services/Traefik/SshTraefikWriter.php``render()` gibt die Entrypoint- und certResolver-Namen aus, zu denen die statische Konfiguration passen muss. Der Handoff: *„hier zuerst nachlesen, nicht raten."*
- **Die drei Vorlagen-Fallen aus dem Handoff, Block A**, sind nicht verhandelbar:
1. `user: www-data` am `app`-Dienst der Compose-Datei — sonst schlagen **alle** `occ`-Aufrufe fehl.
2. Die Root-Partition ist die **letzte** auf der Platte und liegt **nicht** auf LVM — `GrowGuestFilesystem` erkennt LVM absichtlich nicht und schriebe sonst ein Kontingent über ein Dateisystem, das nie gewachsen ist.
3. `qemu-guest-agent` **muss** im Image sein — `WaitForGuestAgent` hängt daran.
- **Abschnittsschlüssel**, exakt und in dieser Reihenfolge — die einzige Absprache mit dem Plattform-Plan:
`rescue_checked`, `debian_installed`, `rebooted`, `proxmox_installed`, `network_bridged`, `wireguard_joined`, `traefik_running`, `template_built`, `registered`
- **Kein Docker auf dem Hypervisor.** Traefik ist Binary plus systemd-Unit. nftables bleibt einziger Eigentümer der Host-Firewallregeln (Handoff §1).
- **Commit-Disziplin:** `git add -- <pfade>`, `git commit -F - -- <pfade>`. Nie `-A`, `.`, `-a` oder nackt.
- **Dieser Plan hat keine Testsuite.** Ein Skript, das ein Betriebssystem installiert, lässt sich nicht in SQLite beweisen. Jede Aufgabe endet stattdessen mit einem Lauf auf **echter Hardware** — und der Handoff sagt dazu das Richtige: *„Jeder Schritt, der scheitert, ist ein Fund — und der Server darf dabei kaputtgehen, Debian ist neu eingespielt in Minuten."*
- **Nicht geeignet als Prüfstand:** Hetzner Cloud (CPX/CX) — keine verschachtelte Virtualisierung, also kein `/dev/kvm`, also startet kein Gast. Bei netcup produktabhängig. Entscheidend ist `grep -cE 'vmx|svm' /proc/cpuinfo && ls -l /dev/kvm`.
---
## Dateistruktur
| Datei | Verantwortung |
|---|---|
| `deploy/bootstrap/clupilot-bootstrap.sh` | Das Skript. Ein Einstiegspunkt, Abschnitte als Funktionen. |
| `deploy/bootstrap/lib/report.sh` | Melden und Nachreichen. Kennt als Einziges den Weg zu CluPilot. |
| `deploy/bootstrap/lib/proxmox.sh` | Die Installation (`debian_installed`, `rebooted`) und die Paketquellen (`proxmox_installed`) |
| `deploy/bootstrap/lib/network.sh` | Brücke mit Selbstrücknahme, nftables |
| `deploy/bootstrap/lib/traefik.sh` | Traefik: Binary, statische Konfiguration, Unit |
| `deploy/bootstrap/lib/template.sh` | Die goldene Vorlage |
| `deploy/bootstrap/lib/register.sh` | Rolle, Token, Registrierung, Schlüsseltausch |
| `deploy/bootstrap/assets/docker-compose.yml` | Die Compose-Datei, die in die Vorlage kommt |
| `docs/runbooks/host-bootstrap.md` | Was zu tun ist, wenn das Skript stehenbleibt |
Getrennte Dateien, weil `template.sh` und `network.sh` unabhängig voneinander
geprüft und wiederholt werden — und weil ein Skript, das alles in einer Datei
macht, beim Nachlesen im Fehlerfall unbrauchbar ist.
**Wie es auf die Maschine kommt** (stand in keiner der beiden Fassungen, ist
aber entschieden): als **Archiv**. Die kopierte Zeile aus dem Adminbereich holt
`bootstrap.tar.gz`, packt es nach `/opt/clupilot/bootstrap` aus und startet von
dort — ungefähr so:
```sh
curl -fsSL https://app.clupilot.com/bootstrap.tar.gz | tar xz -C /opt/clupilot \
&& /opt/clupilot/bootstrap/clupilot-bootstrap.sh --code … --wg-private … …
```
Ein `curl … | sh` schied aus: es kann keine `lib/` haben, und die getrennten
Dateien oben sind kein Schmuck. Das Archiv ist eine **statische Datei**, kein
Endpunkt, der Auskunft gibt — Spec §5 bleibt damit unangetastet. Das Ausliefern
selbst gehört zum Plattform-Plan (Task 7 zeigt die Zeile); dieser Plan setzt nur
voraus, dass das Skript samt `lib/` unter `/opt/clupilot/bootstrap` liegt.
---
### Task 1: Gerüst, Melden und Nachreichen
**Files:**
- Create: `deploy/bootstrap/clupilot-bootstrap.sh`, `deploy/bootstrap/lib/report.sh`
**Interfaces:**
- Produces: `report <section> <state> [message]` — schreibt lokal nach `/var/lib/clupilot/progress.jsonl` **mit Zeitstempel** und versucht zu senden. `flush_reports` — reicht alles Ungesendete nach.
- Consumes: aus der Befehlszeile `--code`, `--wg-private`, `--wg-ip`, `--hub-pubkey`, `--hub-endpoint`, `--api`.
- [x] **Step 1: Das Gerüst schreiben**
```sh
#!/bin/sh
set -eu
# Läuft im Rettungssystem des Anbieters, mit genau den Werten, die der
# Adminbereich in die kopierte Zeile geschrieben hat. Es holt nichts ab: alles,
# was vor dem Tunnel gebraucht wird, steht bereits hier (Spec §5).
```
`report` schreibt **immer zuerst lokal**, dann erst der Sendeversuch. Vor
`wireguard_joined` gibt es keinen Weg zu CluPilot; die Zeilen sammeln sich und
werden von `flush_reports` nachgereicht, **mit ihren eigenen Zeitstempeln**.
Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine
Sekunde.
- [ ] **Step 2: Auf echter Hardware prüfen**
Rettungssystem starten, Skript mit erfundenen Werten aufrufen, prüfen dass
`/var/lib/clupilot/progress.jsonl` entsteht und die Sendeversuche sauber
scheitern statt das Skript abzubrechen.
- [x] **Step 3: Committen.** Nachricht: `Give the bootstrap a spine and a way to report`
---
### Task 2: `rescue_checked` — vor allem anderen nachsehen
- [x] **Step 1: Schreiben**
Prüft, bevor irgendetwas geschrieben wird:
- verschachtelte Virtualisierung: `grep -cE 'vmx|svm' /proc/cpuinfo` **und** `ls /dev/kvm`
- Platte groß genug für Vorlage plus Kunden
- Netz erreichbar, Uhr plausibel (ein falsches Datum lässt jede TLS-Prüfung scheitern)
- die Maschine ist wirklich ein Rettungssystem und nicht versehentlich ein
laufender Produktivserver — **der teuerste denkbare Fehlgriff dieses Skripts**
**Bricht ab, statt zu raten.** Ein Skript, das eine Platte überschreibt, hat
genau hier seine einzige Gelegenheit, es nicht zu tun.
- [ ] **Step 2: Auf echter Hardware prüfen**, auch der Abbruchfall: einmal auf einer
Maschine ohne `/dev/kvm` starten und sehen, dass es sich weigert.
- [x] **Step 3: Committen.** `Refuse a machine that cannot do the job`
---
### Task 3: `debian_installed` und `rebooted`
- [x] **Step 1: Schreiben**
> **Korrigiert gegenüber der ersten Fassung.** Dort stand „Debian 13 ins
> Zielsystem", und danach hätte Task 4 daraus einen Proxmox-Host gemacht. Der
> Betreiber hat das bestritten, und er hat recht: aus dem Rettungssystem wird
> **Proxmox direkt** installiert. Es gibt kein Debian-Zwischensystem, das
> nachher jemand umbaut.
>
> **Die Abschnittsschlüssel bleiben trotzdem unverändert** — sie sind die
> einzige Absprache mit dem Plattform-Plan und werden nicht wegen einer
> Umbenennung angefasst. Sie passen auch weiterhin, denn Proxmox VE *ist*
> Debian mit PVE-Kernel und -Paketen:
>
> - `debian_installed` = **Grundsystem geschrieben** (das Abbild liegt auf der
> Platte). Die Beschriftung in der Konsole heißt entsprechend
> „Grundsystem geschrieben", nicht „Debian installiert" — das ist Sache von
> Task 7 des Plattform-Plans (`hosts.section.*`).
> - `proxmox_installed` = **PVE läuft nachweislich** (Task 4).
> **Und ein zweites Mal korrigiert, an der Mechanik.** Hier stand kurzzeitig
> `installimage` mit einem Proxmox-Abbild. **Das gibt es nicht:** Hetzners
> `installimage` führt kein Proxmox, es gilt dort als nicht unterstützt. Wer
> danach sucht, findet nur Debian — und landet wieder beim Zwischensystem, das
> gerade abgeschafft wurde.
Der Weg ist die **offizielle Proxmox-ISO mit eingebetteter Antwortdatei**, seit
PVE 8.2 der reguläre Weg für unbeaufsichtigte Installationen und von Hetzner
selbst als Anleitung geführt:
1. **Bootmodus feststellen** (Legacy oder UEFI) — davon hängt der QEMU-Aufruf ab.
2. `answer.toml` schreiben: `[global]` mit `fqdn`, `country`, `timezone`,
`root-password-hashed` und `root-ssh-keys`; `[network]` mit `source`;
`[disk-setup]` mit dem Dateisystem und der Plattenwahl.
3. **`proxmox-auto-install-assistant prepare-iso`** backt die Antwortdatei in
die ISO. Vorher `validate-answer` — eine ISO, die wegen eines Tippfehlers in
den interaktiven Modus fällt, hängt danach unsichtbar in QEMU.
4. Die ISO **unter QEMU** laufen lassen, die gegen die **echten Platten**
schreibt. Kein VNC, kein Klick: der Eintrag „Automated Installation" wird
nach zehn Sekunden von selbst gewählt.
5. Neustart aus dem Rettungssystem heraus.
**Dateisystem: ZFS** (`filesystem = "zfs"`, `zfs.raid` passend zur Plattenzahl).
Damit ist auch kein LVM im Spiel. `arc-max` gehört ausdrücklich in die
Antwortdatei — die Vorgabe nimmt sich die Hälfte des Arbeitsspeichers, und den
braucht ein Host, der Kunden trägt, für die Gäste.
> **Falle 2 gilt dem Gast, nicht dem Host — hier stand es falsch.** Die frühere
> Begründung („gilt für den Host genauso, weil die Vorlage später von hier aus
> gebaut wird") trägt nicht: `GrowGuestFilesystem` wächst ein *Gast*-Dateisystem,
> und die Vorlage entsteht in **Task 8** aus einem Debian-Cloud-Image, nicht aus
> dem Plattenlayout des Hosts. **Für die Vorlage bleibt Falle 2 unverändert
> verbindlich.** Für den Host ist sie gegenstandslos; dass ZFS ohnehin ohne LVM
> auskommt, ist ein Nebeneffekt und nicht der Grund.
**Anbieter:** heute nur Hetzner-dediziert. Ein zweiter Weg (netcup) wird erst
geschrieben, wenn eine Maschine dasteht, auf der er abgenommen werden kann — ein
ungeprüfter Rückfallpfad ist genau die Behauptung, die dieser Plan sonst überall
vermeidet (R22). Der ISO-Weg ist dabei die bessere Ausgangslage als
`installimage`: er hängt an nichts, was nur einem Anbieter gehört.
**Was der Installer verlangt und die Befehlszeile nicht mitbringt.** Die
Antwortdatei braucht `fqdn` und ein Root-Kennwort. Keins von beidem steht in den
sechs Angaben aus Task 1. Entschieden, bis jemand widerspricht:
- **`fqdn`** wird aus dem Hostnamen gebildet, den CluPilot ohnehin kennt. Bis
der Adminbereich ihn mitgibt, wird er aus der Rückwärtsauflösung der
öffentlichen IP genommen und, wenn auch die nichts hergibt, aus `--wg-ip`
abgeleitet. Der Name wird in Task 9 ohnehin gegen das ersetzt, was CluPilot
führt.
- **Das Root-Kennwort wird zufällig erzeugt und weggeworfen.** Niemand braucht
es: die Plattform meldet sich nicht mehr per SSH an (Spec §6), und der Plan
sagt für den Fehlerfall ausdrücklich, dass eine halb installierte Maschine
**neu aufgesetzt und nicht nachgebessert** wird (Task 10). Ein Kennwort
aufzubewahren, das nur im Notfall gebraucht würde, den es laut Plan nicht
gibt, schafft nur eine weitere Stelle, an der ein Geheimnis liegt.
- **Optional `--root-ssh-key`.** Gibt der Adminbereich einen öffentlichen
Schlüssel mit, landet er als `root-ssh-keys` in der Antwortdatei. Ohne die
Angabe gibt es keinen Weg per SSH auf die Maschine, und das ist die Vorgabe.
Der Neustart ist die erste Stelle, an der das Skript die Kontrolle verliert.
**Der Wiederanlauf ist kein handgeschriebener systemd-Dienst mehr** — die
Antwortdatei hat dafür einen `[first-boot]`-Abschnitt. Mit
`source = "from-iso"` liegt das Skript schon in der ISO und startet nach dem
Hochfahren von selbst; das ist auch der einzige Weg, der vor dem Tunnel
funktioniert, weil ein `from-url`-Hook CluPilot noch nicht erreichen könnte.
**Und genau das ist der Punkt, an dem „dieselbe Fortschrittsdatei" Arbeit ist und
keine Feststellung.** Das Rettungssystem läuft im Arbeitsspeicher; sein
Wurzelverzeichnis ist nach dem Neustart weg. Bevor neu gestartet wird, gehen
deshalb **in das frisch installierte System hinüber**:
- `/var/lib/clupilot/progress.jsonl` — sonst gehen `rescue_checked` und
`debian_installed` verloren.
- das Skript selbst samt `lib/`,
- die Aufrufargumente aus Task 1, in einer Datei, die nur `root` lesen kann —
der WireGuard-Schlüssel steht darin.
Ohne diesen Handgriff meldet das Skript nach dem Neustart bei Null. Und weil
`flush_reports` erst in Task 6 läuft, **also nach dem Neustart**, fiele der
Verlust nirgends auf — die zwei Abschnitte kämen in der Konsole schlicht nie an.
Genau das prüft Task 6 Step 2, wenn er **alle fünf** vorherigen Abschnitte
verlangt.
Zum Neustart selbst: **lies `RebootIntoPveKernel.php`.** Der Bootpfad wird
bewiesen, bevor neu gestartet wird, nicht danach gehofft.
- [ ] **Step 2: Auf echter Hardware prüfen** — und ausdrücklich, dass es nach dem
Neustart von allein weiterläuft. **Dazu: nach dem Hochfahren in
`/var/lib/clupilot/progress.jsonl` nachsehen, dass die Zeilen von *vor* dem
Neustart noch dort stehen, mit ihren ursprünglichen Zeitstempeln.**
- [x] **Step 3: Committen.** `Write the image and survive the first reboot`
(war `Write Debian and survive the first reboot`, bevor klar war, dass kein
Debian-Zwischensystem entsteht)
---
### Task 4: `proxmox_installed`
- [x] **Step 1: Schreiben**
> **Korrigiert mit Task 3.** PVE kommt jetzt schon mit dem Abbild. Dieser
> Abschnitt **installiert** es also nicht mehr, er **nimmt es ab und bringt
> seine Paketquellen in Ordnung** — und genau dafür wird dasselbe Wissen
> gebraucht, nur an anderer Stelle.
**Lies zuerst `InstallProxmoxVe.php`.** Die Codename-Tabelle dort kennt Debian 13
und PVE 9 und ist aus einem echten Fehler entstanden. Übernimm sie, statt die
Paketquellen zu tippen — und übernimm auch, dass ein **unbekanntes Release
abbricht** statt zu raten. Vier Dinge stehen dort, die hier alle gebraucht
werden:
1. **Die Zuordnung Codename → Suite und Release-Schlüssel.** Ein Abbild, dessen
PVE-Suite nicht zu seinem Debian passt, ist ein Fund und kein Schönheitsfehler
— PVE gegen eine andere libc gebaut. Passt es nicht, **abbrechen**.
2. **Ein unbekannter Unterbau bricht ab.** Nicht „trixie ist das neueste, also
trixie". Das nächste Debian ist auch unbekannt, und dann sieht niemand hin.
3. **Die Enterprise-Quelle muss weg.** Ein Proxmox-Abbild bringt
`/etc/apt/sources.list.d/pve-enterprise.list` mit, und die scheitert ohne
Abonnement bei **jedem** `apt-get update`. Das ist keine Schönheitskorrektur:
danach schlägt jede Paketinstallation der folgenden Abschnitte fehl.
4. **`Signed-By` auf die Proxmox-Quelle begrenzen.** Sonst bürgt der
Proxmox-Schlüssel für alles, was später in `sources.list` auftaucht. Deshalb
die `.sources`-Datei (deb822) und nicht die alte `.list`-Zeile — und die alte
dabei entfernen, weil eine stehengebliebene `bookworm`-Zeile auf einer
trixie-Maschine genau die Vermischung ist, die Punkt 1 verbietet.
- [ ] **Step 2: Auf echter Hardware prüfen.** `pveversion` antwortet, der PVE-Kernel
läuft (`uname -r` enthält `-pve`), und **`apt-get update` läuft ohne Fehler
durch** — das ist der Beweis, dass die Enterprise-Quelle wirklich weg ist und
nicht nur überschrieben wurde.
- [x] **Step 3: Committen.** `Point Proxmox at the sources this Debian actually has`
(war `Install Proxmox …`, bevor das Abbild PVE schon mitbrachte)
---
### Task 5: `network_bridged` — der gefährlichste Abschnitt
- [x] **Step 1: Schreiben**
**Lies zuerst `ConfigureProxmox.php`.** Dort steht, warum Proxmox auf Debian keine
`vmbr0` anlegt und was eine falsche Brücke anrichtet.
> **Zwei Korrekturen aus der Umsetzung.**
>
> **Die Brücke ist im Regelfall schon da.** Genau die Stelle, die man hier liest,
> sagt es: Proxmox *auf Debian* legt keine `vmbr0` an — „only the ISO installer
> writes that bridge into `/etc/network/interfaces`". Dieser Entwurf installiert
> über die ISO. Der Abschnitt **prüft** deshalb (existiert sie, hat sie eine
> Adresse, trägt sie die Vorgaberoute) und baut nur im Ausnahmefall — dann unter
> der Selbstrücknahme. Dieselbe Datei begründet ausführlich, warum sie eine
> Brücke über die primäre Netzkarte NICHT aus der Ferne baut.
>
> **Die nftables-Sperre gehört nicht hierher.** `SecureHostFirewall.php` lief im
> alten Ablauf ZULETZT, ausdrücklich damit der Tunnel schon trägt, „before
> SSH-to-the-world is ever closed". An dieser Stelle stünde sie **vor**
> `wireguard_joined` und schlösse Port 22, bevor ein Handshake bewiesen ist —
> die Reihenfolge, die einen Host dauerhaft unerreichbar macht. Sie steht
> deshalb in **Task 9**. Die Selbstrücknahme bleibt hier und gilt der
> Netzumstellung; die Firewall behält ihre manuelle Notöffnung, denn „a firewall
> that reopens itself under failure is not a firewall".
>
> **Zu Punkt 4 unten, „von außen prüfen":** das geht vor dem Tunnelbeitritt
> nicht, es gibt keinen Gegenüber. Geprüft wird von innen nach außen (Gateway
> und ein Ziel dahinter), und das ist im Skript auch so benannt. Was es nicht
> abdeckt, deckt der Zeitgeber ab — deshalb steht er da.
Das Muster aus Block B des Handoffs, unverändert, nur im Skript:
1. Aktuelle Netzkonfiguration sichern.
2. **Systemd-Timer auf fünf Minuten**, der sie zurückspielt und neu startet.
3. Brücke aktivieren.
4. **Von außen** prüfen, ob der Host antwortet.
5. Erst dann den Timer abbestellen.
Der Abschnitt muss **erkennen, was er vorfindet**: DHCP auf einer Schnittstelle
(Cloud) gegen geroutete Einzel-IP mit Gateway außerhalb des Subnetzes
(Hetzner-Dedicated) gegen netcup. Eine Brücke, die für den einen Fall richtig ist,
nimmt den anderen vom Netz.
Dazu die **Datacenter-Firewall einschalten** — ohne sie sind die „nur 80/443"-Regeln
der Kunden-VMs wirkungslos, und `ConfigureProxmox` hielt das als eigenen Fund fest.
- [ ] **Step 2: Auf echter Hardware prüfen — und die Selbstrücknahme absichtlich
auslösen.** Eine Brücke bauen, die die Maschine vom Netz nimmt, und zusehen, wie
sie von selbst zurückkommt. **Ohne diesen Versuch ist der Zeitgeber eine
Behauptung.**
- [x] **Step 3: Committen.** `Bridge the network, and take it back if the host goes quiet`
---
### Task 6: `wireguard_joined` — ab hier ist CluPilot erreichbar
- [x] **Step 1: Schreiben**
`wg0` aus den mitgegebenen Werten, **systemd-aktiviert** — lies
`ConfigureWireguard.php` dazu: ein `||`-Rückfall verdeckte dort die fehlende
Aktivierung, und der Handoff nennt als Folge eine Maschine, die den Neustart nicht
überlebt.
Der Tunnel gilt erst mit **bewiesenem Handshake**, nicht mit geschriebener Datei.
Auch das ist ein Fund aus dem Handoff: eine gespeicherte Tunnel-IP ohne Beweis
machte jeden weiteren Verbindungsversuch unbrauchbar, und die Rettung war
Datenbank-Handarbeit.
Sobald der Handshake steht: `flush_reports` — alles bis hierher wird nachgereicht.
- [ ] **Step 2: Auf echter Hardware prüfen.** In der Konsole müssen jetzt **alle
fünf** bisherigen Abschnitte auftauchen, mit ihren echten Zeitstempeln.
- [x] **Step 3: Committen.** `Join the tunnel, then tell the console everything so far`
---
### Task 7: `traefik_running`
- [x] **Step 1: Schreiben**
Binary plus systemd-Unit, **kein Docker**. Statische Konfiguration mit
`websecure`-Entrypoint und `letsencrypt`-certResolver — **deren Namen zu dem passen
müssen, was der Routen-Endpunkt ausliefert.** Lies dazu `SshTraefikWriter::render()`
im Repository, solange es sie noch gibt.
Der `http`-Provider zeigt auf `GET /host/routes` über die **Tunnel-Adresse** von
CluPilot, mit dem dauerhaften Host-Token aus der Registrierung.
**Prüfen, nicht nur installieren:** Dienst läuft, 80 und 443 sind belegt, der
Ping-Endpunkt antwortet, und **die erste Routentabelle wurde tatsächlich geholt**.
Ein Host, der Traefik installiert hat, aber nie eine Tabelle bekam, ist nicht
fertig.
> **Eine Abhängigkeit, die dieser Plan nicht aufgelöst hatte.** Der
> `http`-Provider braucht den **dauerhaften Host-Token**, und den gibt es erst
> mit `POST /host/register` — also in Abschnitt `registered`, zwei Abschnitte
> später. Der Beweis „die erste Tabelle wurde geholt" lässt sich hier deshalb
> nicht führen.
>
> Aufgelöst, ohne die Abschnittsschlüssel anzufassen: die statische
> Konfiguration wird **zweimal** geschrieben. Hier mit leerem Token — der Dienst
> steht, hält 80 und 443 und antwortet am Ping —, in Task 9 noch einmal mit dem
> echten, gefolgt von einem Neustart und dem Abruf. **Der Beweis entfällt nicht,
> er wandert an die Stelle, an der er zu führen ist.**
>
> Der Abruf wird dort direkt gemacht, mit demselben Token und derselben Adresse
> aus der Konfiguration — nicht über Traefiks eigene Router. Ein frischer Host
> hat noch keine Kunden, seine Tabelle ist also mit Recht leer, und „null
> Router" hieße sowohl „alles in Ordnung" als auch „nichts abgerufen".
- [ ] **Step 2: Auf echter Hardware prüfen**, einschließlich eines abgerufenen
Routensatzes.
- [x] **Step 3: Committen.** `Run Traefik as a service that fetches its own routes`
---
### Task 8: `template_built` — die goldene Vorlage
- [x] **Step 1: Schreiben**
Aus einem Debian-13-Cloud-Image, auf `template_vmid 9000`:
- `qemu-guest-agent` **im Image** (Falle 3)
- Docker und Compose
- `/opt/nextcloud/docker-compose.yml` aus `assets/`, das
`NEXTCLOUD_ADMIN_USER`, `NEXTCLOUD_ADMIN_PASSWORD` und `MYSQL_PASSWORD` aus der
`.env` liest und sich beim ersten Start selbst installiert
- **`user: www-data` am `app`-Dienst** (Falle 1)
- Root-Partition **letzte auf der Platte, kein LVM** (Falle 2)
- als Proxmox-Vorlage konvertieren
**Die Vorlage bleibt kundenunabhängig.** Passwörter werden nie in ein Image
gebacken — die Pipeline schreibt sie beim Klonen in die `.env` des Gastes.
Prüfen: `qm config 9000` meldet `template: 1`. Der Handoff hält als eigenen Fund
fest, dass `VerifyVmTemplate` heute nur die **Existenz** prüft, nicht das Merkmal.
- [ ] **Step 2: Auf echter Hardware prüfen — und einmal wirklich klonen.** Eine VM
aus 9000 erzeugen, starten, `occ status` als `www-data` aufrufen. **Ohne diesen
Klon ist die Vorlage nicht bewiesen**, und der Handoff nennt genau diesen Fall:
der erste bezahlte Auftrag starb sonst *nach* der Zahlung.
- [x] **Step 3: Committen.** `Build the golden template so nobody has to remember how`
---
### Task 9: `registered` — Token übergeben
- [x] **Step 1: Schreiben**
Proxmox-Rolle und Token erzeugen — **`Sys.Modify` nicht vergessen**, siehe
`CreateAutomationToken.php`; ohne das stirbt jede Kundenbereitstellung am
Backup-Schritt.
Dann `POST /host/register` über die Tunnel-Adresse mit Token, **frisch erzeugtem
WireGuard-Pubkey** (der aus der Befehlszeile wird damit wertlos), Fingerabdruck und
Eckdaten. Die Antwort trägt das dauerhafte Host-Token für Traefik.
**Reihenfolge:** erst registrieren und die Antwort haben, dann den alten Schlüssel
verwerfen. Die Gegenseite nimmt erst auf und entfernt dann — aber das Skript darf
sich auch von seiner Seite nicht vorzeitig abschneiden.
**Und der Tausch braucht denselben Beweis wie der Beitritt.** Task 6 lässt den
Tunnel erst mit bewiesenem Handshake gelten; hier wird genau dieser Tunnel unter
laufendem Betrieb auf einen neuen Schlüssel umgestellt. Die Reihenfolge allein
schützt davor nicht — sie sagt nur, wann verworfen wird, nicht ob das Neue
trägt. Deshalb, in dieser Folge:
1. neues Schlüsselpaar erzeugen, registrieren, Antwort haben,
2. `wg0` auf den neuen privaten Schlüssel umstellen,
3. **erneuten Handshake beweisen** — dieselbe Prüfung wie in Task 6, nicht die
geschriebene Datei,
4. **erst dann** den alten Schlüssel verwerfen.
Kommt der Handshake nicht, wird auf den alten Schlüssel zurückgestellt und der
Abschnitt scheitert laut. Ein Host, der sich im letzten Schritt selbst
aussperrt, ist der eine Fall, den niemand aus der Ferne repariert — und er
passiert am Ende eines Laufs, der bis dahin alles richtig gemacht hat.
Zum Schluss: `pveum acl modify … || true` und `pveum user add … || true`
**nicht** übernehmen. Der Handoff nennt sie als offenen Punkt — sie verdecken
Fehler, dieselbe Klasse wie der bereits behobene `role add`.
Das ist aber **keine Regel gegen `|| true` an sich**, und wer sie so liest,
bricht Task 3. `RebootIntoPveKernel.php` toleriert
`apt-get -y remove linux-image-amd64 || true` **mit Begründung**: ein Image, das
das Metapaket nie hatte, lässt apt ohne Fehler in der Sache von Null verschieden
enden — und ob der Debian-Kernel weg ist, ist ohnehin nicht die Frage, sondern
ob ein Proxmox-Kernel bootet, was drei Zeilen später eigenständig geprüft wird.
Die Regel lautet: **kein `|| true` über einem Befehl, dessen Fehlschlag etwas
bedeutet** — und wo eines steht, steht der Grund daneben.
- [ ] **Step 2: Auf echter Hardware prüfen.** Der Host steht in der Konsole auf
`active`, die sechs Schritte der Kette laufen durch, und die Bereitschaftsseite
meldet `provisioning.usable_host` als erfüllt. **Und der Tunnel steht danach
noch** — nach dem Schlüsseltausch einmal `wg show` ansehen und einen frischen
Handshake mit dem *neuen* Schlüssel sehen, nicht den alten Zählerstand.
- [x] **Step 3: Committen.** `Hand over the token and let the console take it from here`
---
### Task 10: Das Runbook
**Files:** `docs/runbooks/host-bootstrap.md`
Was zu tun ist, wenn ein Abschnitt stehenbleibt — je Abschnitt: woran man es
merkt, wo man nachsieht, was der übliche Grund ist. Und ausdrücklich: **eine halb
installierte Maschine wird neu aufgesetzt, nicht nachgebessert.** Ein neuer Code
aus dem Adminbereich, Rettungssystem, noch einmal.
- [x] Schreiben, committen. Nachricht: `Write down what to do when the bootstrap stops`
---
## Der ganze Durchlauf
- [ ] Eine **frisch aufgesetzte** Maschine, Rettungssystem, eine Zeile einfügen, zusehen.
- [ ] Der Host steht am Ende auf `active`, ohne dass jemand etwas abgetippt hat.
- [ ] **Danach ein zweiter Lauf auf einer wieder frisch aufgesetzten Maschine.** Er
muss gleich ausgehen. Ein Skript, das nur einmal funktioniert hat, hat nicht
funktioniert.
## Was danach offen bleibt
- Der Plattform-Plan (`2026-07-30-host-uebernahme-plattform.md`), falls er noch läuft.
- Die Schritte 5 bis 8 der Reihenfolge aus dem Handoff: Testpaket kaufen, eigene
Domain, kündigen, widerrufen. Erst danach ist die Kette bewiesen.

View File

@ -0,0 +1,707 @@
# Host-Übernahme — Plattformseite, Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** CluPilot nimmt einen fertig installierten Proxmox-Host in Betrieb, ohne ihn je zu installieren und ohne dauerhaften Shell-Zugang — der Host meldet sich selbst, holt seine Routen selbst und wird nur noch geprüft.
**Architecture:** Beim Anlegen eines Hosts erzeugt die Konsole ein WireGuard-Schlüsselpaar und einen Einmal-Code und zeigt beides in einer kopierbaren Befehlszeile. Das Bootstrap-Skript (eigener Plan) tritt damit dem Tunnel bei und meldet sich über drei Endpunkte zurück, die **ausschließlich** aus dem WireGuard-Subnetz erreichbar sind. Traefik auf dem Host holt seine Routentabelle über denselben Weg ab, statt sie per SSH geschrieben zu bekommen. Die Host-Kette schrumpft von vierzehn auf sechs Schritte, die nur noch nachsehen.
**Tech Stack:** PHP 8.3, Laravel 13, Livewire 3, Pest 4, MariaDB, Tailwind v3, Docker Compose.
## Global Constraints
- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md`. Bei Widerspruch gewinnt die Spec.
- **Die härteste Bedingung, aus §8a:** Es entsteht **kein einziger neuer Weg von außen** — weder auf den Host noch zu CluPilot. `register`, `progress` und `routes` liegen alle drei hinter der Tunnel-Beschränkung. Ein Task, der einen davon öffentlich erreichbar macht, ist gescheitert, auch wenn seine Tests grün sind.
- **Commit-Disziplin, nicht verhandelbar.** Weitere Sitzungen arbeiten im selben Repository. **Immer** `git add -- <pfade>` und `git commit -F - -- <pfade>`. **Nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`.
- `pint` nur auf eigene Pfade, **nie** `--dirty`, keine Kosmetik an fremden Dateien.
- **R18R24 aus `CLAUDE.md`** gelten und werden per Test erzwungen. R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken.
- **Pest-Fallen, beide heute erlebt:** `toThrow(EinInterface::class)` beweist nichts — immer eine konkrete Klasse. Und `toContain($a, $b)` ist **variadisch**, der zweite Parameter ist kein Meldungstext; dasselbe gilt für `toHaveKey()`.
- **Testisolation:** die Suite läuft mit `CACHE_STORE=array`, der zwischen Testdateien nicht geleert wird. Ein Test, der von `Settings` abhängt, setzt den Wert selbst.
- **Betreiber-Rollen** heißen exakt `Owner`, `Admin`, `Support`, `Billing`, `Read-only`, `Developer`. Fabrik: `Operator::factory()->role('Owner')->create()`. Testpasswort `passwort-fuer-tests`. Guard-Name `'operator'` — bei `Livewire::actingAs($op, 'operator')` **nicht vergessen**.
- **Tests laufen nur im Container**, `docker compose` aus `/home/nexxo/clupilot`:
`docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test`
- **Immer die volle Suite**, nie nur einen Filter. Eine Aufgabe hat heute mit einem Filter gearbeitet und einen Bruch übersehen, der erst zwei Aufgaben später auffiel.
- **Kein `migrate` gegen die Entwicklungsdatenbank.** Die Container bedienen ein anderes Arbeitsverzeichnis; eine Migration von hier aus beschädigt die laufende Installation. Beweise führen die Tests auf SQLite im Speicher.
---
## Was schon da ist
| | |
|---|---|
| `App\Services\Wireguard\WireguardHub` | `allocateIp()`, `addPeer($pubkey, $ip)`, `removePeer($pubkey)`, `endpoint()`, `publicKey()`, `peers()` |
| `config('admin_access.trusted_ranges')` | aus `TRUSTED_RANGES`, Vorgabe `10.66.0.0/24,127.0.0.1` |
| `App\Http\Middleware\RestrictConsoleNetwork` | **das Vorbild** für die Tunnel-Beschränkung — nicht neu erfinden |
| `App\Services\Secrets\SecretVault` | Tresor mit Modus-Plätzen; `stripe.secret` ist strikt |
| `App\Support\Readiness` | fünf Prüfgruppen, Spread-Array, Wächter-Test über die Registry |
| `hosts` | `uuid, name, dns_name, datacenter, public_ip, wg_ip, wg_pubkey, ssh_host_key, api_token_ref, total_gb, total_ram_mb, cpu_cores, pve_version, node, status, last_seen_at` |
| `SshTraefikWriter::write($trafficHost, $subdomain, array $hostnames, $backend)` | die abzulösende Stelle |
| `ConfigureDnsAndTls` | bildet `$hostnames = array_filter([$fqdn, $customDomain])` — beide Namen |
---
### Task 1: Einmal-Code und Schlüsselpaar beim Anlegen
**Files:**
- Create: `database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php`
- Create: `app/Support/HostEnrolment.php`
- Test: `tests/Feature/Host/HostEnrolmentTest.php`
**Interfaces:**
- Consumes: `WireguardHub::allocateIp()`, `publicKey()`, `endpoint()`, `addPeer()`.
- Produces: `HostEnrolment::issue(Host $host): string` — legt Code, Schlüsselpaar und Peer an und gibt den **Klartext-Code** genau einmal zurück. `HostEnrolment::claim(string $code): ?Host` — löst einen gültigen, unverbrauchten Code auf und markiert ihn als verbraucht. Spalten auf `hosts`: `enrolment_code_hash`, `enrolment_expires_at`, `enrolment_used_at`.
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
```php
<?php
use App\Models\Host;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\DB;
/**
* Der Code ist ein Ausweis für den Rückweg, kein Schlüssel zu einer Auskunft:
* er öffnet nichts nach außen (§5 der Spec), sondern verhindert nur, dass ein
* Gerät im Tunnel für einen fremden Host spricht.
*/
it('hands out the code exactly once and stores only its hash', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
expect($code)->toHaveLength(32);
$stored = DB::table('hosts')->where('id', $host->id)->value('enrolment_code_hash');
expect($stored)->not->toBe($code)
->and($stored)->not->toContain($code);
});
it('resolves a valid code to its host and consumes it', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
expect(HostEnrolment::claim($code)?->id)->toBe($host->id);
// Verbraucht. Ein zweiter Lauf braucht einen neuen Code aus der Konsole —
// eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert.
expect(HostEnrolment::claim($code))->toBeNull();
});
it('refuses a code past its expiry', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
$this->travel(25)->hours();
expect(HostEnrolment::claim($code))->toBeNull();
});
it('refuses a code that was never issued', function () {
expect(HostEnrolment::claim(str_repeat('a', 32)))->toBeNull();
});
/**
* Der Hub muss den Peer kennen, BEVOR der Host im Tunnel ist — sonst käme er
* nie hinein und müsste seinen Schlüssel über einen öffentlichen Weg melden.
* Genau das vermeidet dieser Entwurf.
*/
it('allocates a tunnel address and admits the peer up front', function () {
$host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]);
HostEnrolment::issue($host);
$host->refresh();
expect($host->wg_ip)->not->toBeNull()
->and($host->wg_pubkey)->not->toBeNull();
expect(app(\App\Services\Wireguard\WireguardHub::class)->peers())
->toContain($host->wg_pubkey);
});
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Run: `cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test --filter=HostEnrolmentTest`
Expected: FAIL mit `Class "App\Support\HostEnrolment" not found`
- [ ] **Step 3: Migration und Klasse schreiben**
Die Migration legt drei Spalten an `hosts` an — Hash, Ablauf, Verbrauch. Der
private Schlüssel wird **nicht** gespeichert: er wandert einmal in die
Befehlszeile und ist nach dem Tausch (Task 3) wertlos.
```php
public function issue(Host $host): string
{
$hub = app(WireguardHub::class);
// Das Paar entsteht HIER und nicht auf dem Host. Erzeugte der Host es
// selbst, müsste er seinen öffentlichen Schlüssel melden, bevor der Tunnel
// steht — und dafür bräuchte es einen öffentlichen Endpunkt. Der Preis ist
// ein Schlüssel, der Minuten lebt (Task 3 tauscht ihn), der Gewinn ist eine
// Netzgrenze, die unverändert bleibt.
[$privateKey, $publicKey] = self::keypair();
$ip = $hub->allocateIp();
$hub->addPeer($publicKey, $ip);
$code = Str::random(32);
$host->update([
'wg_ip' => $ip,
'wg_pubkey' => $publicKey,
'enrolment_code_hash' => hash('sha256', $code),
'enrolment_expires_at' => now()->addDay(),
'enrolment_used_at' => null,
]);
// Der private Schlüssel wird bewusst nicht abgelegt. Er geht in die
// Befehlszeile und sonst nirgendwohin.
self::$issuedPrivateKey = $privateKey;
return $code;
}
```
`claim()` sucht über den Hash, prüft `enrolment_expires_at > now()` und
`enrolment_used_at === null`, setzt `enrolment_used_at` und gibt den Host zurück.
**Hash statt `Hash::make`:** ein `sha256` über einen 32-Zeichen-Zufallswert reicht
hier und ist suchbar. Ein bcrypt-Hash wäre nicht in einer `where`-Bedingung
auflösbar, ohne alle Zeilen durchzuprobieren.
- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**
Run: wie Step 2
Expected: PASS, 5 Tests
- [ ] **Step 5: Volle Suite**
Run: `cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test`
Expected: PASS
- [ ] **Step 6: Committen**
```bash
git add -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php
git commit -F - -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php <<'EOF'
Let the console admit a host to the tunnel before it exists
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```
---
### Task 2: Die Tunnel-Beschränkung — der wichtigste Test dieses Plans
**Files:**
- Create: `app/Http/Middleware/RestrictTunnelOnly.php`
- Modify: `bootstrap/app.php` (Alias registrieren)
- Test: `tests/Feature/Host/TunnelOnlyTest.php`
**Interfaces:**
- Consumes: `config('admin_access.trusted_ranges')`, das Muster von `RestrictConsoleNetwork`.
- Produces: Middleware-Alias `tunnel-only`.
**Lies zuerst `app/Http/Middleware/RestrictConsoleNetwork.php`.** Es löst dieselbe
Frage bereits — CIDR-Vergleich gegen `trusted_ranges` — und dieser Task soll ihm
folgen, nicht eine zweite Formulierung danebenstellen. Wenn sich die Logik
herausziehen lässt, statt sie zu verdoppeln, tu das.
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
```php
<?php
use App\Models\Host;
/**
* Die härteste Bedingung der Spec (§8a): es entsteht kein einziger neuer Weg von
* außen. Dieser Test ist der Grund, warum der Entwurf so aussieht, wie er
* aussieht — er hält fest, dass alle drei Host-Endpunkte unsichtbar bleiben.
*/
beforeEach(function () {
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
});
dataset('host endpoints', [
'register' => ['post', '/host/register'],
'progress' => ['post', '/host/progress'],
'routes' => ['get', '/host/routes'],
]);
it('answers 404 from outside the tunnel', function (string $method, string $path) {
// 404, nicht 403: eine Adresse, die es für Fremde nicht gibt, verrät auch
// nicht, dass es sie gibt. Dasselbe Muster wie RestrictAdminHost.
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
->call($method, $path)
->assertNotFound();
})->with('host endpoints');
it('is reachable from inside the tunnel', function (string $method, string $path) {
// Erreichbar heißt nicht erlaubt — ohne gültigen Ausweis folgt weiter unten
// eine Abweisung. Hier zählt nur, dass die Beschränkung nicht schon greift.
$this->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
->call($method, $path)
->assertStatus(fn (int $status) => $status !== 404);
})->with('host endpoints');
```
- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**
Expected: FAIL — die Routen gibt es noch nicht
- [ ] **Step 3: Middleware und Routen-Gerüst schreiben**
`RestrictTunnelOnly` prüft `$request->ip()` gegen `admin_access.trusted_ranges`
und wirft sonst `abort(404)`. Die drei Routen entstehen als Gerüst, das noch
nichts tut außer zu antworten — gefüllt werden sie in Task 3 bis 5.
- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**
Expected: PASS, 6 Tests
- [ ] **Step 5: Volle Suite, dann committen**
Nachricht: `Make the host endpoints invisible from outside the tunnel`
---
### Task 3: `POST /host/register` — Token in den Tresor, Schlüssel tauschen
**Files:**
- Create: `app/Http/Controllers/HostRegistrationController.php`
- Modify: `app/Services/Secrets/SecretVault.php` (Registry-Eintrag je Host)
- Test: `tests/Feature/Host/HostRegistrationTest.php`
**Interfaces:**
- Consumes: `HostEnrolment::claim()` (Task 1), Tunnel-Middleware (Task 2), `WireguardHub::addPeer/removePeer`.
- Produces: `hosts.api_token_ref` gefüllt, `hosts.wg_pubkey` auf den frischen Schlüssel, Antwort enthält das **dauerhafte Host-Token** für den Routen-Abruf.
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
```php
<?php
use App\Models\Host;
use App\Services\Wireguard\WireguardHub;
use App\Support\HostEnrolment;
beforeEach(function () {
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
$this->host = Host::factory()->create(['status' => 'onboarding']);
$this->code = HostEnrolment::issue($this->host);
});
function registerAs(string $code, array $payload = []): \Illuminate\Testing\TestResponse
{
return test()->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
->postJson('/host/register', array_merge([
'code' => $code,
'api_token' => 'root@pam!clupilot=abc-123',
'wg_pubkey' => 'FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=',
'node' => 'pve-fsn-1',
'pve_version' => '9.0-1',
'total_gb' => 1000,
'total_ram_mb' => 65536,
'cpu_cores' => 16,
'ssh_host_key' => 'SHA256:abc',
], $payload));
}
it('puts the proxmox token in the vault and never in the response', function () {
$response = registerAs($this->code);
$response->assertOk();
expect($response->json())->not->toContain('root@pam!clupilot=abc-123');
expect($this->host->fresh()->api_token_ref)->not->toBeNull();
});
/**
* Die Reihenfolge ist die ganze Aussage: erst aufnehmen, dann entfernen.
* Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem er
* sitzt, und die Antwort erreicht ihn nie.
*/
it('admits the new key before dropping the old one', function () {
$old = $this->host->fresh()->wg_pubkey;
registerAs($this->code)->assertOk();
$peers = app(WireguardHub::class)->peers();
expect($peers)->toContain('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=')
->and($peers)->not->toContain($old);
expect($this->host->fresh()->wg_pubkey)->toBe('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=');
});
it('refuses a code that belongs to another host', function () {
$other = Host::factory()->create();
$otherCode = HostEnrolment::issue($other);
registerAs($otherCode)->assertOk();
// Der fremde Host wurde eingerichtet, DIESER nicht.
expect($this->host->fresh()->api_token_ref)->toBeNull();
});
it('refuses a spent code', function () {
registerAs($this->code)->assertOk();
registerAs($this->code)->assertStatus(422);
});
it('hands back a durable host token for the route pull', function () {
expect(registerAs($this->code)->json('host_token'))->toBeString()->not->toBeEmpty();
});
```
- [ ] **Step 2 bis 6:** Fehlschlag bestätigen, schreiben, bestätigen, volle Suite, committen.
**Beim Schreiben beachten:** Der Proxmox-Token gehört in den Tresor (verschlüsselt
mit `SECRETS_KEY`), nicht in eine Spalte. `hosts.api_token_ref` hält nur den
Verweis — so war es schon vor diesem Plan, und der Handoff nennt die Verknüpfung
mit `SECRETS_KEY` ausdrücklich als das teuerste Einzelstück.
Nachricht: `Take the host's token through the tunnel and swap its key`
---
### Task 4: `POST /host/progress` — mit Nachreichen
**Files:**
- Create: `app/Http/Controllers/HostProgressController.php`
- Create: `database/migrations/2026_08_02_100000_record_what_a_host_reported.php`
- Test: `tests/Feature/Host/HostProgressTest.php`
**Interfaces:**
- Produces: Tabelle `host_progress_events` (`host_id`, `section`, `state`, `message`, `occurred_at`), die Abschnittsliste aus §7 der Spec.
Die Abschnitte, in dieser Reihenfolge und mit genau diesen Schlüsseln — sie sind
die **einzige Absprache** zwischen diesem Plan und dem Skript-Plan:
`rescue_checked`, `debian_installed`, `rebooted`, `proxmox_installed`,
`network_bridged`, `wireguard_joined`, `traefik_running`, `template_built`,
`registered`
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
Der wichtigste Fall ist das **Nachreichen**: alles vor `wireguard_joined` kann der
Host nicht melden, weil es bis dahin keinen Weg zu CluPilot gibt. Er schreibt es
lokal mit und reicht es beim ersten erreichbaren Aufruf nach.
```php
it('keeps the reported timestamps, not the arrival time', function () {
// Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine
// Sekunde, und man kann nicht erkennen, welcher Abschnitt lange gedauert hat.
$reported = now()->subMinutes(18);
postProgress($this->code, [
['section' => 'rescue_checked', 'state' => 'done', 'occurred_at' => $reported->toIso8601String()],
['section' => 'debian_installed', 'state' => 'done', 'occurred_at' => $reported->addMinutes(6)->toIso8601String()],
])->assertOk();
expect($this->host->progressEvents()->first()->occurred_at->toIso8601String())
->toBe($reported->copy()->subMinutes(6)->toIso8601String());
});
it('refuses a section it does not know', function () {
// Ein Abschnittsname aus dem Netz. Ohne diese Prüfung füllt jemand die
// Fortschrittsanzeige mit erfundenen Zeilen.
postProgress($this->code, [['section' => 'erfunden', 'state' => 'done']])
->assertStatus(422);
});
```
- [ ] **Steps 2 bis 6** wie gehabt. Nachricht: `Let the host tell the console how far it got`
---
### Task 5: `GET /host/routes` — Traefik holt sich seine Tabelle
**Files:**
- Create: `app/Http/Controllers/HostRoutesController.php`
- Create: `app/Services/Traefik/RouteTable.php`
- Test: `tests/Feature/Host/HostRoutesTest.php`
**Interfaces:**
- Consumes: das dauerhafte Host-Token aus Task 3, `Instance` mit `subdomain`, `custom_domain`, `routed_hostnames`.
- Produces: `RouteTable::forHost(Host $host): array` — Traefiks dynamische Konfiguration für genau diesen Host.
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
```php
it('serves only the instances of this host', function () {
// Ein entwendetes Token zeigt die Routen genau eines Hosts, nicht die aller.
$mine = Instance::factory()->create(['host_id' => $this->host->id, 'subdomain' => 'meine']);
$theirs = Instance::factory()->create(['subdomain' => 'fremde']);
$body = json_encode(fetchRoutes($this->hostToken)->json());
expect($body)->toContain('meine')->not->toContain('fremde');
});
it('serves both the subdomain and a proven custom domain', function () {
// Dazunehmen, nicht ersetzen — dieselbe Regel wie ConfigureDnsAndTls.
Instance::factory()->create([
'host_id' => $this->host->id,
'subdomain' => 'mueller',
'custom_domain' => 'cloud.mueller-gmbh.de',
'domain_verified_at' => now(),
]);
$body = json_encode(fetchRoutes($this->hostToken)->json());
expect($body)->toContain('mueller.')->and($body)->toContain('cloud.mueller-gmbh.de');
});
it('leaves out a custom domain that was never proven', function () {
Instance::factory()->create([
'host_id' => $this->host->id,
'custom_domain' => 'nicht-bewiesen.de',
'domain_verified_at' => null,
]);
expect(json_encode(fetchRoutes($this->hostToken)->json()))
->not->toContain('nicht-bewiesen.de');
});
```
**Beim Schreiben:** Die Entrypoint- und certResolver-Namen müssen zu dem passen,
was `SshTraefikWriter::render()` heute ausgibt. Der Handoff warnt in Block C
ausdrücklich: *„hier zuerst nachlesen, nicht raten."* Das gilt unverändert.
- [ ] **Steps 2 bis 6.** Nachricht: `Serve a host the routes it is supposed to answer for`
---
### Task 6: `SshTraefikWriter` ablösen
**Files:**
- Modify: `app/Services/Traefik/` (neuer `TraefikWriter`, der in die Datenbank schreibt)
- Delete: `app/Services/Traefik/SshTraefikWriter.php`
- Test: `tests/Feature/Host/NoShellForRoutesTest.php`
**Interfaces:**
- Die Aufrufer (`ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService`) ändern sich **nicht**. Sie rufen weiter `TraefikWriter::write(...)`; nur was dahinter passiert, ändert sich.
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
```php
/**
* Der Beweis, dass die Plattform keinen Shell-Zugang mehr auf Kundenhosts
* braucht. Solange irgendwo eine RemoteShell für Routen aufgemacht wird, ist der
* Entwurf nicht eingelöst — egal wie grün alles andere ist.
*/
it('opens no shell when an address is configured', function () {
$s = fakeServices();
$instance = Instance::factory()->create(['host_id' => $this->host->id]);
app(ConfigureDnsAndTls::class)->execute(runFor($instance));
expect($s['shell']->connectionsWith('key'))->toBeEmpty();
});
it('has no SshTraefikWriter left in the repository', function () {
expect(file_exists(base_path('app/Services/Traefik/SshTraefikWriter.php')))->toBeFalse();
});
```
- [ ] **Steps 2 bis 6.** Nachricht: `Stop reaching into customer hosts to write a route`
---
### Task 7: Adminbereich — Befehlszeile und Fortschritt
**Files:**
- Modify: `app/Livewire/Admin/HostCreate.php`, `HostDetail.php` und deren Blades
- Test: `tests/Feature/Admin/HostEnrolmentPageTest.php`
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
```php
it('shows the command exactly once, right after creating the host', function () {
// Der Code steht nur als Hash in der Datenbank. Wer die Seite neu lädt,
// bekommt ihn nicht wieder — er legt einen neuen an.
$page = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-fsn-2')
->set('datacenter', 'fsn')
->set('publicIp', '198.51.45.9')
->call('create');
$page->assertSee('curl');
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostDetail::class, ['uuid' => Host::latest('id')->first()->uuid])
->assertDontSee('curl');
});
it('shows which section the host is stuck in', function () {
// „Irgendwas ging schief" ist keine Auskunft. Welcher Abschnitt offen ist,
// ist eine.
$this->host->progressEvents()->create([
'section' => 'proxmox_installed', 'state' => 'done', 'occurred_at' => now()->subMinutes(30),
]);
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostDetail::class, ['uuid' => $this->host->uuid])
->assertSee(__('hosts.section.network_bridged'));
});
```
**R18, R19, R23, R24 gelten** — Blades werden angefasst. Die vier Wächter-Tests am
Ende ausdrücklich mitlaufen lassen. Zeiten der Fortschrittsmeldungen gehen durch
`->local()`.
- [ ] **Steps 2 bis 6.** Nachricht: `Give the operator one line to copy and a progress to watch`
---
### Task 8: Die Subdomain gehört dem Kunden
**Files:**
- Modify: `app/Livewire/Order.php` bzw. die Kasse, `app/Provisioning/Steps/Customer/ReserveResources.php`
- Create: `app/Rules/AvailableSubdomain.php`
- Test: `tests/Feature/SubdomainChoiceTest.php`
**Interfaces:**
- Heute: `ReserveResources::uniqueSubdomain()` bildet `Str::slug($customer->name).'-'.Str::random(5)`. Künftig kommt die Subdomain **aus dem Auftrag**; die Ableitung bleibt nur als Rückfall für einen leeren Wunsch.
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
```php
it('refuses a name the platform needs itself', function (string $name) {
expect(validator(['subdomain' => $name], ['subdomain' => new AvailableSubdomain])->passes())
->toBeFalse();
})->with(['www', 'mail', 'admin', 'api', 'app', 'ws', 'status', 'vpn']);
it('refuses a name another customer already has, whatever the case', function () {
Instance::factory()->create(['subdomain' => 'mueller']);
expect(validator(['subdomain' => 'MUELLER'], ['subdomain' => new AvailableSubdomain])->passes())
->toBeFalse();
});
it('falls back to a neutral name when the field is left empty', function () {
// Die Vorgabe darf NICHT der Nachname sein: jede Subdomain landet über das
// Zertifikat dauerhaft in den öffentlichen Certificate-Transparency-Logs.
$order = orderWithoutSubdomainWish(customerNamed('Anna Müller'));
$subdomain = app(ReserveResources::class)->execute(runFor($order))->context('subdomain');
expect($subdomain)->not->toContain('mueller');
});
it('keeps the name the customer chose', function () {
$order = orderWishing('meine-wolke');
expect(app(ReserveResources::class)->execute(runFor($order))->context('subdomain'))
->toBe('meine-wolke');
});
```
- [ ] **Steps 2 bis 6.** Der Hinweis neben dem Feld muss sagen, dass die Adresse
öffentlich und dauerhaft ist. Nachricht: `Let the customer pick the address they will keep`
---
### Task 9: Die Kette schrumpfen
**Files:**
- Modify: `config/provisioning.php`
- Delete: `app/Provisioning/Steps/Host/{EstablishSshTrust,PrepareBaseSystem,ConfigureWireguard,InstallProxmoxVe,RebootIntoPveKernel,ConfigureProxmox,CreateAutomationToken}.php` und ihre Tests
- Modify: `app/Provisioning/Steps/Host/SecureHostFirewall.php` — vom Schreiben zum Prüfen
- Test: `tests/Feature/Provisioning/HostPipelineShapeTest.php`
> **HALT — die einzige echte Kopplung zwischen den zwei Plänen.**
>
> Diese sieben Dateien sind die **Quelle** für den Skript-Plan. Sie enthalten
> Wissen, das aus echten Ausfällen entstanden ist: die Codename-Tabelle für
> Debian 13/PVE 9, die vollständige `vmbr0`-Diagnose, die nftables-Regeln samt
> der ICMP-Korrektur, die systemd-Aktivierung von `wg0`, und die Proxmox-Rolle
> **inklusive `Sys.Modify`**, ohne das jede Kundenbereitstellung am
> Backup-Schritt stirbt.
>
> **Führe diesen Task erst aus, wenn Task 4 bis 9 des Skript-Plans geschrieben
> sind** — oder vergewissere dich, dass der Skript-Strang sie gelesen hat. Aus
> `git log` sind sie zwar wiederherstellbar, aber niemand liest die Historie,
> wenn er nicht weiß, dass dort etwas fehlt. Genau so gehen Erkenntnisse
> verloren, die einmal einen Ausfall gekostet haben.
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
```php
it('has six steps that only look, never change', function () {
expect(config('provisioning.pipelines.host'))->toBe([
Host\ValidateHostInput::class,
Host\VerifyProxmoxApi::class,
Host\VerifyVmTemplate::class,
Host\RegisterHostDns::class,
Host\RegisterCapacity::class,
Host\CompleteHostOnboarding::class,
]);
});
it('opens no shell anywhere in the host pipeline', function () {
// Der Beweis für §1 der Spec: der einzige dauerhafte Zugang ist der
// Proxmox-Token.
$s = fakeServices();
foreach (config('provisioning.pipelines.host') as $step) {
app($step)->execute($this->run);
}
expect($s['shell']->connectionsWith('key'))->toBeEmpty()
->and($s['shell']->connectionsWith('password'))->toBeEmpty();
});
```
**Achtung:** `SecureHostFirewall` bleibt, wird aber zur Prüfung. Ob es dafür noch
in der Kette steht oder in die Bereitschaftsprüfungen wandert, entscheidet der
Implementierer — begründet im Bericht.
- [ ] **Steps 2 bis 6.** Nachricht: `Shrink the host pipeline to six steps that only look`
---
### Task 10: Bereitschaftsprüfungen nachziehen
**Files:**
- Modify: `app/Support/Readiness/OnboardingChecks.php`, `ProvisioningChecks.php`
- Modify: `lang/de/readiness.php`, `lang/en/readiness.php`
- Test: `tests/Feature/Readiness/OnboardingChecksTest.php`, `ProvisioningChecksTest.php`
Zu ändern, aus §10 der Spec:
- `onboarding.ssh_private_key`**nicht mehr blockierend.** Die Plattform braucht
keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Notfallzugang), sein
Fehlen hält nichts auf.
- `provisioning.traefik_path`**entfällt.** Es gibt kein Verzeichnis mehr, in das
CluPilot schreibt.
- **Neu:** mindestens ein Host holt seine Routen tatsächlich ab (letzter Abruf
jünger als das Intervall). Ein Host, der das nicht mehr tut, serviert
stillschweigend einen veralteten Stand.
**Der Wächter-Test aus dem Betriebsmodus-Vorhaben erzwingt, dass jeder
Tresor-Eintrag auf der Bereitschaftsseite auftaucht.** Wer hier einen Eintrag
entfernt oder umbenennt, zieht ihn nach.
- [ ] **Steps 1 bis 6.** Nachricht: `Teach the readiness page that nobody writes to a host any more`
---
## Abschluss
- [ ] Volle Suite grün.
- [ ] `VERSION` **nicht** anheben, **kein** Tag. Das passiert erst, wenn auch der Skript-Plan durch ist und beide auf echter Hardware zusammen liefen.
- [ ] Branch pushen:
```bash
git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" feature/host-bootstrap
```
## Was danach offen bleibt
- **Der Skript-Plan** (`2026-07-30-host-uebernahme-bootstrap-skript.md`) — läuft parallel, trifft sich mit diesem nur an den Abschnittsschlüsseln aus Task 4.
- **Die Abnahme auf echter Hardware.** Sie ist die einzige, die zählt.
- Die zwei Seed-Hosts aus `DatabaseSeeder` entfernen.
- Nextcloud zurücksetzen und die Störungsmeldung — beschlossen, eigene Entwürfe, §14 der Spec.

View File

@ -0,0 +1,482 @@
# Spec — Der Host installiert sich selbst, CluPilot übernimmt ihn nur
**Datum:** 2026-07-30
**Status:** entworfen, noch nicht umgesetzt
**Basis:** `feature/betriebsmodus` (Betriebsmodus + Bereitschaftsseite)
**Vorgänger-Kontext:** `docs/handoffs/2026-07-30-real-run-handoff.md` §3, Blöcke AC
---
## 1. Ziel
Die Verantwortungsgrenze verschiebt sich. **CluPilot installiert nichts mehr.** Ein
Bootstrap-Skript macht aus einer nackten Maschine einen fertigen Proxmox-Host mit
Traefik, WireGuard-Beitritt und goldener Nextcloud-Vorlage. CluPilot übernimmt ihn
danach und benutzt ihn.
Der einzige dauerhafte Zugang, den die Plattform zu einem Host behält, ist der
**Proxmox-API-Token**. Kein SSH, keine root-Shell, nichts, was eine Shell braucht.
### Warum
Der Betreiber will „so wenig Fehler wie möglich". Die drei teuersten Fehlerquellen
des heutigen Entwurfs sind alle Installationsarbeit über SSH:
- **`vmbr0` vollautomatisch** — im Handoff selbst als „riskantester Code des
Vorhabens" bezeichnet. Eine Brücke, die für einen Anbieter richtig ist, nimmt bei
einem anderen die Maschine vom Netz. `ConfigureProxmox` bricht heute bewusst mit
einer Anleitung ab, statt es zu versuchen.
- **Traefik als Systemdienst über SSH installieren**, samt Prüfung, dass er lauscht.
- **Die goldene Vorlage als Runbook.** Ein Dokument für einen Menschen, mit genau
den Fallen, die der Handoff aufzählt: `user: www-data` vergessen, LVM statt einer
letzten Partition, `qemu-guest-agent` nicht im Image.
Alle drei verschwinden als Plattformarbeit, wenn die Maschine fertig ankommt.
### Nicht Teil dieses Vorhabens
- **Teil C des Betriebsmodus-Vorhabens** (Testdaten markieren, eigene Belegserie,
Aufräumen inkl. VM und DNS). Weiterhin beschlossen, weiterhin eigener Entwurf.
- Das Bestellen des Servers und das Starten des Rettungssystems. Das passiert im
Kundenbereich von Hetzner oder netcup und ist von hier aus nicht erreichbar.
- Migration bestehender Hosts. Es gibt keine echten; die zwei in der Datenbank sind
Seed-Daten aus `DatabaseSeeder` und werden entfernt, nicht überführt.
---
## 2. Ausgangsbefund — gemessen, nicht gelesen
Am 2026-07-30 auf dem Stand von `feature/betriebsmodus` nachgemessen.
| Befund | Beleg |
|---|---|
| Die Host-Kette hat **14 Schritte** | `config/provisioning.php`, `pipelines.host` |
| Sie bleibt beim siebten stehen | `ConfigureProxmox` liefert `fail`, wenn `ip link show vmbr0` nichts findet |
| **Kein Traefik-Schritt** in der Kette | Block C nie gebaut |
| Routen gehen **per SSH als root** auf den Host | `SshTraefikWriter::write()``connectWithKey($trafficHost, 'root', …)` |
| Und zwar **nicht einmalig** | Aufrufer: `ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService` |
| Die Bereitschaftsseite meldet 6 blockierende Lücken | `Readiness::blocking()`, davon 2 echt |
Der vierte und fünfte Befund sind der Grund, warum „Zugang nur für die
VM-Erstellung" heute **nicht** stimmt: das Routing braucht eine dauerhafte
root-Shell auf jedem Kundenhost.
---
## 3. Entscheidungen aus dem Brainstorming (verbindlich)
| Frage | Entscheidung |
|---|---|
| Wer installiert? | **Ein Bootstrap-Skript auf der Maschine.** CluPilot nie. |
| Wie kommt eine Kundenroute auf den Host? | **Der Host holt sie sich.** Traefik-HTTP-Provider fragt einen CluPilot-Endpunkt im Tunnel ab. |
| Was hinterlässt das Skript? | **Alles, inklusive der goldenen Vorlage** auf `template_vmid 9000`. |
| Wie finden Host und Konsole zueinander? | **Einmal-Code aus dem Adminbereich**, alles Weitere im Tunnel. |
| Was sieht die Konsole währenddessen? | **Schritt für Schritt**, gemeldet vom Host. |
| Dauerhafter Zugang der Plattform | **Nur der Proxmox-Token.** |
---
## 4. Der Ablauf, wie ihn der Betreiber erlebt
1. Server beim Anbieter bestellen, **Rettungssystem starten** (Kundenbereich des
Anbieters).
2. Im Adminbereich **Host anlegen**: Name, Rechenzentrum, öffentliche IP. Die
Konsole zeigt **eine Zeile zum Kopieren** mit dem Einmal-Code darin.
3. Diese Zeile **einmal** im Rettungssystem einfügen.
4. Zusehen. Der Fortschritt läuft in der Konsole mit.
5. Der Host steht auf `active`, sobald er sich gemeldet und die Prüfungen bestanden
hat.
Zwei Stellen, an denen der Betreiber etwas tut. Kein Wert wird abgetippt.
---
## 5. Kein öffentlicher Endpunkt — die Befehlszeile trägt alles
### Das Henne-Ei-Problem
Alles soll im Tunnel passieren, aber um dem Tunnel beizutreten, braucht das Skript
die WireGuard-Angaben. Ein erster Entwurf löste das mit einem öffentlichen
`POST /enrol`. **Das war unnötig**, und der Betreiber hat es zu Recht bestritten.
Die drei Werte, die das Skript vor dem Tunnel braucht — Hub-Schlüssel,
Hub-Adresse, zugeteilte Tunnel-IP — kennt die Konsole beim Anlegen des Hosts alle.
Sie kann sie **in die kopierte Befehlszeile schreiben**. Es gibt nichts abzurufen.
### Der eine Haken, und wie er aufgelöst wird
Damit der Hub den Host hereinlässt, muss er dessen **öffentlichen** Schlüssel
kennen. Erzeugte der Host ihn selbst — wie `ConfigureWireguard` es heute tut —,
müsste er ihn melden, **bevor** der Tunnel steht. Genau dafür wäre ein öffentlicher
Endpunkt nötig gewesen.
Also erzeugt **CluPilot** das Schlüsselpaar beim Anlegen des Hosts und gibt es mit.
Der Hub kennt den Peer damit von Anfang an, und es gibt keinen Rückkanal vor dem
Tunnel.
### Und danach wird getauscht
Sobald der Tunnel steht, erzeugt der Host mit `wg genkey` einen **frischen**
Schlüssel, meldet den öffentlichen Teil **durch den Tunnel** (§6), und CluPilot
tauscht den Peer aus. Der Schlüssel, der je in einer Befehlszeile und einer
Zwischenablage stand, ist danach wertlos.
Das ist der Grund, warum diese Lösung der öffentlichen Tür vorzuziehen ist: der
Preis ist ein Schlüssel mit einer Lebensdauer von Minuten, der Gewinn ist, dass die
Netzgrenze **unverändert** bleibt. Es kommt kein einziger neuer Weg von außen
hinzu.
### Der Einmal-Code
Bleibt trotzdem, aber er ist jetzt nur noch ein **Ausweis für den Rückweg**, nicht
der Schlüssel zu einer Auskunft: 32 Zeichen aus `random_bytes`, in der Datenbank
**nur als Hash**, mit `expires_at` (**24 Stunden**, großzügig — er öffnet nichts
nach außen) und `used_at`. Das Skript legt ihn den Aufrufen aus §6 und §7 bei,
damit ein Gerät im Tunnel nicht für einen fremden Host sprechen kann.
---
## 6. Der Rückweg: Token durch den Tunnel
Nachdem das Skript dem Tunnel beigetreten ist, meldet es sich über die
**Tunnel-Adresse** von CluPilot — nicht über die öffentliche.
`POST /host/register` (nur aus dem WireGuard-Subnetz erreichbar, `TRUSTED_RANGES`)
mit dem Einmal-Code als Ausweis und:
- dem Proxmox-API-Token, den das Skript auf der Maschine erzeugt hat
- dem **frisch erzeugten WireGuard-Pubkey** (§5): CluPilot tauscht den Peer aus und
verwirft den, der in der Befehlszeile stand
- dem SSH-Host-Schlüssel-Fingerabdruck (nur zur Anzeige; die Plattform verbindet
sich nicht mehr)
- Knotenname, `pve_version`, Plattengröße, RAM, Kerne
**Reihenfolge beim Schlüsseltausch:** erst den neuen Peer aufnehmen, dann den alten
entfernen. Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem
er sitzt — und die Antwort erreicht ihn nie.
CluPilot legt den Token in den Tresor (`SecretVault`, verschlüsselt mit
`SECRETS_KEY`) und schreibt `hosts.api_token_ref`. **Der Betreiber sieht den Token
nie.**
Warum durch den Tunnel: ein Proxmox-Token, der über einen öffentlichen Endpunkt
gereicht wird, liegt für die Dauer der Anfrage in fremden Protokollen und
Zwischenspeichern. Der Tunnel ist ohnehin da, wenn dieser Aufruf passiert.
---
## 7. Fortschritt: der Host erzählt, die Konsole zeigt
`POST /host/progress` (ebenfalls nur aus dem Tunnel, mit dem Einmal-Code als
Ausweis) meldet je Abschnitt einen Schlüssel und einen Zustand.
Die Abschnitte **vor** `wireguard_joined` kann der Host naturgemäß noch nicht
melden — es gibt bis dahin keinen Weg zu CluPilot. Sie werden lokal mitgeschrieben
und beim ersten erreichbaren Aufruf **nachgereicht**, mit ihren Zeitstempeln. Die
Konsole zeigt bis dahin „wartet auf den Tunnel" und, nach einer Frist, dass sie
lange wartet.
Die Abschnitte des Skripts, in dieser Reihenfolge:
| Schlüssel | Was passiert |
|---|---|
| `rescue_checked` | Virtualisierung vorhanden (`vmx`/`svm`), Platte groß genug, Netz da |
| `debian_installed` | Grundsystem geschrieben, Partition **als letzte, ohne LVM** |
| `rebooted` | Maschine ist aus dem Rettungssystem heraus neu gestartet |
| `proxmox_installed` | PVE-Pakete drauf, Kernel aktiv |
| `network_bridged` | `vmbr0` steht, Maschine antwortet noch |
| `wireguard_joined` | Handshake mit dem Hub bewiesen |
| `traefik_running` | Dienst läuft, lauscht auf 80/443, holt seine Routen ab |
| `template_built` | Vorlage auf 9000, als Template konvertiert |
| `registered` | Token übergeben, Host meldet sich fertig |
Die Konsole zeigt dieselbe Fortschrittsdarstellung wie heute. Bleibt eine Meldung
länger als eine gesetzte Frist aus, steht dort **welcher Abschnitt** offen ist —
nicht „irgendetwas ging schief".
**`network_bridged` ist der gefährliche Abschnitt** und behält deshalb die
Selbstrücknahme aus Block B des Handoffs, nur im Skript statt in der Pipeline:
Netzkonfiguration sichern, Zeitgeber auf fünf Minuten der sie zurückspielt, Brücke
aktivieren, **von außen** prüfen ob die Maschine noch antwortet, erst dann den
Zeitgeber abbestellen. Eine Maschine, die dabei vom Netz geht, kommt von selbst
zurück.
---
## 8. Das Routing dreht die Richtung um
Heute schreibt CluPilot eine YAML-Datei per SSH in Traefiks dynamisches
Verzeichnis. Künftig liefert CluPilot sie aus und Traefik holt sie ab.
- Neuer Endpunkt `GET /host/routes` (nur aus dem Tunnel), der **alle** Routen dieses
Hosts als eine Traefik-Konfiguration ausliefert.
- Er braucht ein **dauerhaftes Host-Token**, nicht den Fortschritts-Token: der
Fortschritts-Token gilt für die Einrichtung und ist danach wertlos, während die
Routen für die Lebensdauer des Hosts abgerufen werden. CluPilot gibt es bei
`POST /host/register` (§6) zurück, das Skript legt es in Traefiks statische
Konfiguration. Es liegt wie jedes Zugangsdatum verschlüsselt im Tresor und ist
je Host verschieden — ein entwendetes Token zeigt die Routen genau eines Hosts,
nicht die aller.
- Traefiks statische Konfiguration bekommt einen `http`-Provider auf diese URL, mit
Abrufintervall.
- `SshTraefikWriter` und die `RemoteShell`-Abhängigkeit der Routen-Schreiber
entfallen. `TraefikWriter` bleibt als Schnittstelle; die Aufrufer
(`ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService`) ändern sich
**nicht** — sie schreiben weiter in die Datenbank, nur liest jetzt der Host.
**Der Nebengewinn ist größer als die Ersparnis an Zugang:** heute ist ein
fehlgeschlagener Schreibvorgang verloren und niemand merkt es. Künftig holt sich
ein Host, der eine Stunde nicht erreichbar war, seinen Stand beim nächsten Abruf
von selbst wieder. Der Zustand konvergiert, statt zu driften.
**Namen müssen passen:** Der Handoff warnt in Block C ausdrücklich, dass
Entrypoint- und certResolver-Namen zu dem passen müssen, was `SshTraefikWriter::render()`
schon ausgibt — „hier zuerst nachlesen, nicht raten". Das gilt unverändert für die
statische Konfiguration, die das Skript schreibt.
---
## 8a. Das Netzbild, ausdrücklich
Vom Betreiber festgelegt, und der Grund für mehrere Entscheidungen oben.
| Auf dem Host | |
|---|---|
| **80/443** | öffentlich. Die Kunden-Clouds müssen erreichbar sein. |
| **22 (SSH)** | **nur im Tunnel.** Aus dem Netz nicht sichtbar. |
| **8006 (Proxmox)** | **nur im Tunnel.** Aus dem Netz nicht sichtbar. |
| alles übrige | zu |
| Bei CluPilot | |
|---|---|
| öffentlich | **nur, was heute schon offen ist** — Website und Portal |
| `register`, `progress`, `routes` | ausschließlich aus dem WireGuard-Subnetz |
**Es entsteht kein einziger neuer Weg von außen.** Das ist die härteste Bedingung
dieser Spec, und §11 Test 1 hält sie fest.
Der Preis, bewusst getragen: fällt die Steuerungs-VM aus, holt kein Host seine
Routentabelle. Die Kunden merken davon nichts — Traefik beantwortet Anfragen aus
seiner lokalen Tabelle weiter. Verzögert wird nur eine **Änderung** (eine frisch
nachgewiesene Kundendomain, eine Kündigung), und die holt der Host beim nächsten
Abruf **von selbst** nach. Das ist besser als heute, wo ein fehlgeschlagener
Schreibvorgang verloren ist und niemand es bemerkt.
Zur Klarstellung, weil es im Gespräch zweimal durcheinanderging: WireGuard hat
keine Verbindung, die abreißen kann — es ist zustandsloses UDP und nimmt den
Verkehr von selbst wieder auf. Was ausfallen kann, ist ein **Ende**: die
Steuerungs-VM oder der Host. Das repariert kein Protokoll, und es gilt für jede
Variante gleichermaßen.
---
## 8b. Die Subdomain gehört dem Kunden
Heute leitet `ReserveResources::uniqueSubdomain()` sie aus dem Kundennamen ab
(`Str::slug($customer->name).'-'.Str::random(5)`). Der Kunde wird nie gefragt.
**Künftig wählt er sie an der Kasse**, mit drei Vorschlägen aus seinem eigenen
Namen und der Möglichkeit, etwas anderes einzugeben. Leeres Feld → ein
unauffälliger Zufallsname.
### Warum an der Kasse und nicht später
Die Subdomain ist die **Kennung** der Instanz: `instances.subdomain`, der A-Record
in unserer Zone, der interne Hostname, das erste Zertifikat, ein Eintrag in
Nextclouds `trusted_domains`. Sie nachträglich zu ändern heißt, alles davon
gleichzeitig umzuhängen — eine Migration mit Ausfall.
**Nicht zu verwechseln mit der eigenen Domain des Kunden.** Die kommt später
**dazu** und ersetzt nichts: `ConfigureDnsAndTls` bildet
`$hostnames = array_filter([$fqdn, $customDomain])` und serviert beide. Dazunehmen
ja, umbenennen nein.
### Warum ein Hinweis danebensteht
Eine Subdomain ist **dauerhaft öffentlich** — nicht nur im DNS. Jedes
Let's-Encrypt-Zertifikat wird in die Certificate-Transparency-Protokolle
eingetragen; die sind öffentlich, durchsuchbar und werden nie gelöscht.
`mueller.clupilot.cloud` verrät damit für immer, dass ein Müller hier Kunde ist.
Einer GmbH ist das gleich, einer Arztpraxis womöglich nicht. Der Kunde soll das
wissen, bevor er wählt — deshalb der Satz neben dem Feld, und deshalb ein
neutraler Zufallsname als Vorgabe für alle, die nichts eintragen.
### Regeln
- **Sperrliste**, mindestens `www`, `mail`, `admin`, `api`, `app`, `ws`, `status`,
`vpn` — sonst nimmt sich jemand einen Namen, den die Plattform selbst braucht.
- **Eindeutig über alle Kunden**, ohne Rücksicht auf Groß- und Kleinschreibung.
- **Reserviert beim Absenden**, nicht erst wenn die VM steht: zwei gleichzeitige
Bestellungen dürfen nicht nach demselben Namen greifen.
- Länge und erlaubte Zeichen nach DNS-Regeln, keine führenden oder abschließenden
Bindestriche.
---
## 9. Die Host-Kette schrumpft
| Heute | Künftig |
|---|---|
| `ValidateHostInput` | bleibt |
| `EstablishSshTrust` | **entfällt** |
| `PrepareBaseSystem` | **entfällt** |
| `ConfigureWireguard` | **entfällt** (das Skript tritt bei; die Kette prüft nur den Handshake) |
| `InstallProxmoxVe` | **entfällt** |
| `RebootIntoPveKernel` | **entfällt** |
| `ConfigureProxmox` | **entfällt** — Block B erledigt sich |
| `CreateAutomationToken` | **entfällt** (das Skript erzeugt ihn) |
| `VerifyProxmoxApi` | bleibt, wird zentral |
| `VerifyVmTemplate` | bleibt |
| `RegisterHostDns` | bleibt |
| `RegisterCapacity` | bleibt |
| `SecureHostFirewall` | **entfällt** als Schreiben, bleibt als **Prüfung** |
| `CompleteHostOnboarding` | bleibt |
Sechs Schritte statt vierzehn, und die verbleibenden **sehen nach, statt zu
verändern**. Ein Host, der die Kette nicht besteht, wird nicht repariert — er wird
neu aufgesetzt, was jetzt ein Befehl ist.
---
## 10. Was an der Bereitschaftsseite nachzuziehen ist
Das Betriebsmodus-Vorhaben hat Prüfungen gebaut, die von der alten Welt ausgehen:
- `onboarding.ssh_private_key`**entfällt als blockierend.** Die Plattform
braucht keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Altbestand,
Notfallzugang), aber sein Fehlen hält nichts mehr auf.
- `provisioning.traefik_path`**entfällt.** Es gibt kein Verzeichnis mehr, in das
CluPilot schreibt.
- Neu: **der Routen-Endpunkt ist erreichbar** und mindestens ein Host holt ihn
tatsächlich ab (letzter Abruf jünger als das Intervall). Ein Host, der seine
Routen nicht mehr holt, serviert stillschweigend einen veralteten Stand — genau
die Sorte stiller Fehler, gegen die die Seite gebaut ist.
Der Wächter-Test aus Aufgabe 12 erzwingt, dass jeder Tresor-Eintrag auf der Seite
auftaucht; Änderungen an der Registry ziehen ihn nach.
---
## 11. Tests
Ein Skript, das ein Betriebssystem installiert, lässt sich nicht in SQLite prüfen.
Die Beweislast teilt sich:
**Was die Suite prüfen kann und muss:**
1. **Es entsteht kein öffentlich erreichbarer Weg.** Ein Test hält fest, dass
`register`, `progress` und `routes` alle drei hinter der Tunnel-Beschränkung
liegen — der wichtigste Test dieser Spec, und der Grund, warum der Entwurf so
aussieht.
2. Ein Einmal-Code gilt **einmal**, verfällt nach 24 Stunden und liegt **nur als
Hash** in der Datenbank.
3. Ein Aufruf mit dem Code eines **fremden** Hosts wird abgewiesen — sonst spricht
ein Gerät im Tunnel für eine Maschine, die ihm nicht gehört.
4. Der Proxmox-Token landet im Tresor und **nie** in einer Antwort, einem Protokoll
oder einer Ansicht.
5. **Der Schlüsseltausch nimmt erst auf, dann weg.** Ein Test, der die Reihenfolge
umdreht, muss zeigen, dass der Host dabei aus dem Tunnel fällt — sonst ist die
Reihenfolge nur ein Kommentar.
6. Der Schlüssel aus der Befehlszeile ist nach dem Tausch **nicht mehr gültig**.
7. `GET /host/routes` liefert genau die Routen dieses Hosts und keine fremden.
8. Die geschrumpfte Kette hat die sechs Schritte in der festgelegten Reihenfolge.
9. Nachgereichte Fortschrittsmeldungen behalten **ihre** Zeitstempel, nicht den des
Eingangs — sonst sieht eine zwanzigminütige Installation aus wie eine Sekunde.
**Was nur auf echter Hardware zu beweisen ist:** alles im Skript. Der Handoff sagt
dazu das Richtige — *„Jeder Schritt, der scheitert, ist ein Fund — und der Server
darf dabei kaputtgehen, Debian ist neu eingespielt in Minuten."* Der dedizierte
Testserver ist der Prüfstand, und das Skript ist so gebaut, dass ein zweiter Lauf
auf einer frisch aufgesetzten Maschine immer gleich ausgeht.
---
## 12. Reihenfolge
1. Einmal-Code (Hash, Ablauf, Verbrauch) und die Schlüsselpaar-Erzeugung beim
Anlegen des Hosts — mit den Sicherheitstests aus §11.
2. Tunnel-Endpunkte `register` und `progress`, Token in den Tresor, Schlüsseltausch
in der richtigen Reihenfolge.
3. Adminbereich: Host anlegen zeigt die Befehlszeile, Fortschritt läuft mit.
4. Routen-Endpunkt und Umstellung von `SshTraefikWriter` auf den Abruf.
5. Die Kette schrumpfen, Bereitschaftsprüfungen nachziehen.
6. Das Bootstrap-Skript, Abschnitt für Abschnitt, mit Rückmeldung nach jedem.
7. Auf echter Hardware durchlaufen lassen.
Schritt 1 bis 5 ist prüfbare Plattformarbeit. Schritt 6 ist das Skript. Schritt 7
ist die Abnahme, und sie ist die einzige, die zählt.
---
## 13. Risiken
- **Schlüsselmaterial in der Zwischenablage.** Der WireGuard-Schlüssel, mit dem der
Host dem Tunnel beitritt, steht in der kopierten Befehlszeile und damit kurz in
der Zwischenablage und im Verlauf des Rettungssystems. Entschärft dadurch, dass
er Minuten lebt und nach dem Tausch (§5) wertlos ist — und dass das
Rettungssystem beim ersten Neustart ohnehin verworfen wird. Wer den Tausch
weglässt, hat diesen Punkt dauerhaft; deshalb ist er nicht optional.
- **Der Fortschritt hängt am Host.** Ein Skript, das stirbt, bevor es meldet,
hinterlässt eine Konsole, die auf etwas wartet, das nie kommt. Deshalb je
Abschnitt eine Frist und eine sichtbare Aussage, welcher offen ist.
- **Das Skript ist nicht wie die übrige Codebasis prüfbar.** Das ist ein echter
Rückschritt in der Beweisbarkeit und der Preis dafür, dass die riskanteste
Arbeit die Plattform verlässt. Er ist es wert, aber er ist nicht null.
- **Traefik holt sich Routen — auch veraltete.** Fällt CluPilot aus, servieren die
Hosts weiter, was sie zuletzt geholt haben. Das ist die richtige Richtung, muss
aber bewusst so sein und gehört auf die Bereitschaftsseite (§10).
---
## 14. Zwei beschlossene Vorhaben, die hier NICHT drin sind
Beide im selben Gespräch entschieden, beide bekommen einen eigenen Entwurf. Sie
stehen hier, damit die Entscheidungen nicht verlorengehen.
### Nextcloud zurücksetzen (Kundenfunktion)
Der Kunde kann seine Nextcloud auf Werkszustand zurücksetzen — wenn er etwas
zerschossen hat oder neu anfangen will. Technisch derselbe Ablauf wie eine
Erstbereitstellung, nur für einen Kunden, der schon bezahlt hat.
Beschlossen: **vorher ein Sicherungspunkt, 14 Tage aufbewahrt**, mit einer
Wiederherstellung, die der Kunde selbst auslösen kann. Nur der Kontoinhaber, nicht
jeder Platz. Subdomain zur Bestätigung abtippen statt nur klicken — „neu starten"
und „zurücksetzen" lesen sich zu ähnlich. Sperrfrist zwischen zwei Versuchen.
Bestätigung im Modal nach R23.
**Die Adresse bleibt:** dieselbe Subdomain, dasselbe Zertifikat, eine hinterlegte
eigene Domain bleibt hinterlegt. Der Kunde bekommt eine leere Nextcloud unter
seiner gewohnten Adresse, keine neue Adresse.
### Störungsmeldung an den Betreiber
**Gemessen, dass es das heute nicht gibt:** `SyncMonitoringStatus` läuft alle fünf
Minuten und schreibt den Zustand mit, legt aber **keinen Vorfall an** und schickt
**keine Mail**. In `app/Mail/` gibt es keine einzige Störungsmeldung — alle
vorhandenen Mails gehen an Kunden. Eine Kunden-VM kann also ausfallen, ohne dass
jemand es erfährt.
Beschlossen: **zwei Fehlmessungen in Folge** (der Zeitplan misst fünfminütig, das
sind die gewünschten zehn Minuten) lösen eine Störung aus. Ein einzelner Aussetzer
nicht.
**Fällt ein ganzer Host aus, kommt EINE Meldung** — „Host pve-fsn-1 antwortet
nicht, 40 Instanzen betroffen" — statt vierzig einzelner. Vierzig Mails sagen
nicht, dass es ein Host ist; eine sagt es sofort. Einzelne VMs melden sich
weiterhin einzeln.
Damit es Signal bleibt und nicht Lärm: keine Meldung während eines angekündigten
Wartungsfensters, keine für gekündigte oder abgeschaltete Instanzen, **eine Mail je
Störung** statt alle fünf Minuten eine neue, und eine Entwarnung, wenn es zurück
ist.
---
## 15. Folgepunkte
- Die zwei Seed-Hosts aus `DatabaseSeeder` entfernen. Sie stehen auf `active`,
haben keinen Token und tragen Adressen aus RFC 5737.
- Teil C des Betriebsmodus-Vorhabens (Testdaten und Aufräumen).
- Die geparkten Befunde aus `.superpowers/sdd/…/progress.md`, insbesondere der
zweite `source()`-Leser in `integrations.blade.php`.

View File

@ -32,6 +32,7 @@ return [
'revenue' => 'Umsatz',
'mail' => 'E-Mail',
'integrations' => 'Integrationen',
'readiness' => 'Bereitschaft',
'settings' => 'Einstellungen',
'two_factor_setup' => 'Zwei-Faktor-Anmeldung',
],
@ -98,6 +99,7 @@ return [
'host_error' => 'Host :host meldet einen Fehler.',
'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.',
'monitoring_down' => ':n überwachte Instanz(en) nicht erreichbar.',
'readiness_blocking' => '{1} :n Punkt auf der Bereitschaftsseite blockiert den Livebetrieb.|[2,*] :n Punkte auf der Bereitschaftsseite blockieren den Livebetrieb.',
],
'customers_sub' => 'Alle Kunden und ihre Pakete.',

View File

@ -131,6 +131,10 @@ return [
'pending_change_note' => 'Wechsel auf :plan zum :date vorgemerkt. Bis dahin ändert sich nichts an Ihrem Paket.',
'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.',
'mock_note' => 'Zahlung & Bereitstellung folgen nach Anbindung des Zahlungsanbieters.',
// Bewusst ohne Grund: weder "Stripe-Schlüssel fehlt" noch der aktive
// Betriebsmodus gehören dem Kunden mitgeteilt — das ist eine
// Betreiberangelegenheit und steht auf der Bereitschaftsseite.
'stripe_not_configured' => 'Die Bezahlung ist derzeit nicht eingerichtet. Bitte versuchen Sie es später erneut.',
'plan' => [
'start' => 'Start',

View File

@ -134,4 +134,46 @@ return [
'secure_host_firewall' => 'Host-Firewall absichern',
'complete_host_onboarding' => 'Onboarding abschließen',
],
'takeover' => [
'how_title' => 'So läuft eine Host-Übernahme ab',
'how_sub' => 'Sechs Schritte, etwa 20 bis 40 Minuten. Die ersten beiden passieren beim Anbieter — bevor hier etwas angelegt wird.',
'badge_before' => 'vorher',
'badge_now' => 'jetzt',
'badge_later' => 'danach',
's1_title' => 'Server bestellen',
's1_body' => 'Eine dedizierte Maschine. Keine Cloud-Instanz: ohne Hardware-Virtualisierung (/dev/kvm) startet später kein einziger Gast, und das Skript weist die Maschine gleich zu Beginn ab. Bei Hetzner CPX/CX ist das immer so, bei netcup je nach Produkt.',
's2_title' => 'Rettungssystem starten',
's2_body' => 'Im Kundenbereich des Anbieters das Rettungssystem einschalten UND den Server neu starten. Einschalten allein genügt nicht — er muss wirklich darin hochgefahren sein, sonst weigert sich das Skript. Es überschreibt Platten und prüft deshalb zuerst, ob es das darf.',
's3_title' => 'Host hier anlegen',
's3_body' => 'Name, Rechenzentrum, öffentliche IP und root-Kennwort eintragen und speichern. Erst danach entsteht der Einmal-Code, und ab dann läuft seine Frist von 24 Stunden.',
's4_title' => 'Die Befehlszeile kopieren',
's4_body' => 'Sie erscheint direkt nach dem Speichern und wird genau einmal gezeigt.',
's5_title' => 'Im Rettungssystem einfügen',
's5_body' => 'Per SSH als root auf den Server, Zeile einfügen, Eingabetaste. Es wird nichts abgetippt und nichts ausgefüllt: die Zeile trägt alles, was der Server vor dem Tunnel braucht.',
's5_hint' => 'Die Zeile holt das Installationsskript von :url, packt es nach /opt/clupilot aus und startet es. Mehr lädt der Server nicht nach.',
's6_title' => 'Zusehen',
's6_body' => 'Der Fortschritt läuft auf der Host-Seite mit, Abschnitt für Abschnitt. Bis der Tunnel steht, meldet der Server nichts — das ist so gewollt und dauert über den ersten Neustart hinweg. Danach kommt alles Vorherige auf einmal nach, mit den Zeiten von damals.',
'missing_title' => 'Der Tunnel ist noch nicht eingerichtet',
'missing_body' => 'Es fehlt: :settings. Ohne diese Werte entsteht eine Befehlszeile, die sauber aussieht, läuft — und in einem Tunnel endet, der nie einen Handshake hat. Das fällt erst auf der Maschine auf. Trage sie unter Einstellungen ein, bevor du einen Host anlegst.',
'title' => ':name ist angelegt',
'subtitle' => 'Noch drei Schritte, dann macht der Server den Rest allein.',
'once_title' => 'Diese Zeile gibt es nur jetzt',
'once_body' => 'In der Datenbank steht nur der Hash des Codes, der WireGuard-Schlüssel steht dort gar nicht. Wer die Seite verlässt oder neu lädt, bekommt sie nicht wieder, sondern legt einen neuen Code an — der alte ist damit wertlos.',
'watch' => 'Fortschritt ansehen',
'copy' => 'Kopieren',
'copied' => 'Kopiert',
'footnote' => 'Der Code gilt 24 Stunden und lässt sich genau einmal einlösen. Bleibt die Übernahme stehen, wird die Maschine neu aufgesetzt und nicht nachgebessert — was wo nachzusehen ist, steht im Runbook unter docs/runbooks/host-bootstrap.md.',
],
];

View File

@ -4,6 +4,20 @@ return [
'title' => 'Integrationen',
'subtitle' => 'Angebundene Dienste, gruppiert nach Zweck — Zugangsdaten und Einstellungen nebeneinander, nicht auf getrennten Seiten nach Speicherort.',
// Der Umschalter — über allen Zugangsdaten, weil er regiert, welcher
// Platz je Eintrag gilt.
'mode_title' => 'Betriebsmodus',
'mode_body' => 'Entscheidet, welcher Platz je Zugangsdatum unten gilt. Ist der Testplatz leer, gilt im Testbetrieb der Live-Platz — außer beim Stripe-Schlüssel, der in keine Richtung zurückfällt.',
'mode_switch_to_test' => 'Zu Testbetrieb wechseln',
'mode_switch_to_live' => 'Zu Livebetrieb wechseln',
'mode_switched' => 'Betriebsmodus umgelegt: :mode.',
'mode_switch_title_test' => 'Wirklich zu Testbetrieb wechseln?',
'mode_switch_body_test' => 'Ab sofort gilt für jedes Zugangsdatum der Testplatz — oder, wo dieser leer ist, der Live-Platz. Zahlungen laufen weiter über den aktiven Stripe-Schlüssel des jeweiligen Modus.',
'mode_switch_confirm_test' => 'Umlegen',
'mode_switch_title_live' => 'Wirklich zu Livebetrieb wechseln?',
'mode_switch_body_live' => 'Ab sofort fließt echtes Geld: jede Bestellung wird über den Live-Stripe-Schlüssel abgerechnet, nicht mehr über den Testschlüssel. Das ist keine Umschaltfläche zum Danebenklicken.',
'mode_switch_confirm_live' => 'Umlegen',
'payments_title' => 'Zahlungen (Stripe)',
'payments_body' => 'Der Secret Key, mit dem Zahlungen entgegengenommen und Webhooks geprüft werden.',
@ -107,4 +121,4 @@ return [
'platform' => 'Plattform',
'env' => 'Umgebung (.env)',
],
];
];

117
lang/de/readiness.php Normal file
View File

@ -0,0 +1,117 @@
<?php
/*
* Die Bereitschaftsprüfung (App\Support\Readiness). Jeder Satz unter
* `*_breaks` beantwortet nicht „was fehlt", sondern „was passiert, wenn es
* fehlt bleibt" — siehe den Kopfkommentar von App\Support\Readiness\Check.
*/
return [
'page_title' => 'Betriebsbereitschaft',
'page_sub' => 'Jedes Pflichtfeld dieser Installation an einer Stelle — bis oben „Bereit für Livebetrieb" steht, ist hier noch etwas offen.',
'ready_test' => 'Bereit für Testbetrieb',
'ready_live' => 'Bereit für Livebetrieb',
'not_ready' => '{1} Noch nicht bereit — :n blockierender Punkt offen|[2,*] Noch nicht bereit — :n blockierende Punkte offen',
'group' => [
'billing' => 'Abrechnung',
'onboarding' => 'Host-Onboarding',
'provisioning' => 'Bereitstellung',
'delivery' => 'Zustellung',
'operation' => 'Betrieb',
],
'status' => [
'satisfied' => 'Erfüllt',
'blocking' => 'Blockierend',
'warning' => 'Warnung',
],
'run_check' => 'Prüfen',
'check_ok' => 'Geprüft: in Ordnung',
'check_failed' => 'Geprüft: nicht in Ordnung',
'check_key_mode' => 'Der Schlüssel gehört zum Konto für :mode.',
'check_key_mode_conflict' => 'Diese Installation läuft aber im :mode — der Schlüssel liegt im falschen Platz.',
'fix_link' => 'Beheben',
'last_heartbeat' => 'Letzter Herzschlag: :when',
'mode' => [
'test' => 'Testbetrieb',
'live' => 'Livebetrieb',
],
'billing' => [
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
// Zwei getrennte Sätze, weil die Folgen entgegengesetzt sind: einmal
// fließt echtes Geld, das nicht fließen sollte, einmal fließt keines,
// das fließen sollte. Ein gemeinsamer Satz müsste beides andeuten und
// sagte am Ende keins von beidem.
'stripe_secret_test_slot_breaks' => 'Im Testplatz liegt ein Live-Schlüssel. Jede Bestellung bucht damit echtes Geld ab, während diese Seite und die Konsole „Testbetrieb" anzeigen — ein Probekauf ist dann ein echter Kauf, mit echter Karte und echter Abbuchung.',
'stripe_secret_live_slot_breaks' => 'Im Live-Platz liegt ein Testschlüssel. Der Kunde durchläuft die Kasse, es wird nie Geld eingezogen, und die Bestellung sieht trotzdem bezahlt aus — die Bereitstellung läuft, der Beleg wird ausgestellt, und auf dem Konto kommt nichts an.',
'webhook_secret' => 'Stripe-Signaturschlüssel',
'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.',
'company_details' => 'Firmendaten vollständig',
'company_details_breaks' => 'IssueInvoice verweigert die Ausstellung. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg.',
'invoice_series' => 'Rechnungsserie je Belegart',
'invoice_series_breaks' => 'IssueInvoice bricht beim Ausstellen ab, sobald eine Serie fehlt oder deaktiviert ist. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg, und der Fehler landet nur im Log.',
'catalogue_synced' => 'Stripe-Katalog abgeglichen',
'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
// Sagt, was passiert, und was dagegen hilft — inklusive der
// unbequemen Hälfte: `stripe:sync-catalogue` überspringt jede Zeile,
// die schon eine ID trägt, repariert das also NICHT von selbst. Ein
// Satz, der nur „bitte neu abgleichen" sagte, schickte den Betreiber
// auf einen Befehl, der ihm „already in step" antwortet.
// Der dritte Zustand, und ausdrücklich OHNE Leeranweisung: er entsteht
// ohne jeden Kontowechsel, und die Objekte gehören zum geltenden Konto.
// Wer hier zu löschen anfinge, löschte einen Katalog, an dem laufende
// Verträge abgerechnet werden.
'catalogue_origin_breaks' => 'Zu den gespeicherten Stripe-IDs ist nicht festgehalten, in welchem Konto sie entstanden sind — etwa weil ein Abgleich mitten im Anlegen abgebrochen ist oder weil eine Bestellung sich ihren fehlenden Preis selbst geholt hat. Solange das offen ist, kann diese Seite nicht sagen, ob die Kasse mit ihnen verkaufen kann. Ein Lauf von stripe:sync-catalogue setzt auf dem Vorhandenen auf, legt nur das Fehlende nach und hält die Herkunft fest — gelöscht werden muss dafür nichts.',
'catalogue_account_breaks' => 'Die gespeicherten Stripe-Produkt- und Preis-IDs wurden in einem anderen Konto angelegt als dem, das jetzt gilt (:mode) — Stripe-Objekte gehören dem Konto, das sie ausgestellt hat. Jede Bestellung scheitert damit an der Kasse mit „No such price": kein Auftrag, keine Zahlung. Ein neuer Lauf von stripe:sync-catalogue allein genügt nicht, er überspringt jede Zeile, die schon eine ID trägt; erst nach dem Leeren von plan_families.stripe_product_id und plan_prices.stripe_price_id sowie der Register stripe_plan_prices und stripe_addon_prices legt er den Katalog im aktiven Konto neu an.',
],
'onboarding' => [
'ssh_key' => 'SSH-Schlüssel für Hosts',
'ssh_key_breaks' => 'EstablishSshTrust legt nur den öffentlichen Teil auf dem Host ab; jeder Schritt danach — und jede Traefik-Änderung auf einem bereits laufenden Host — verbindet sich mit diesem privaten Schlüssel. Ohne ihn bricht das Onboarding im zweiten Schritt ab, und kein Routing-Update erreicht mehr einen Host, der längst live ist.',
'secrets_key' => 'Tresor-Schlüssel (SECRETS_KEY)',
'secrets_key_breaks' => 'Ohne ihn kann kein Geheimnis in dieser Installation gespeichert oder gelesen werden — nicht der Stripe-Schlüssel, nicht der DNS-Token, kein Host-Token. Die Konsole zeigt jedes dieser Felder als leer, egal was je eingetragen wurde.',
'vpn_config_key' => 'VPN-Konfigurationsschlüssel (VPN_CONFIG_KEY)',
'vpn_config_key_breaks' => 'Ohne ihn verweigert ConfigVault das Speichern jeder VPN-Konfiguration, statt sie im Klartext abzulegen — ein ausgestellter Zugang lässt sich dann nirgends mehr nachlesen.',
'wg_hub' => 'WireGuard-Hub vollständig konfiguriert',
'wg_hub_breaks' => 'Zwei von drei Werten sind kein halber Tunnel, sondern gar keiner: ConfigureWireguard schreibt die Peer-Konfiguration aus allen dreien auf einmal. Fehlt einer, bekommt der neue Host keinen Tunnel zur Verwaltung, und niemand kann ihn erreichen.',
'datacenter' => 'Mindestens ein Rechenzentrum',
'datacenter_breaks' => 'Ohne Rechenzentrum hat das Host-Anlegen-Formular nichts zur Auswahl, und ValidateHostInput bricht als allererster Schritt der Kette ab — noch bevor überhaupt eine SSH-Verbindung versucht wird.',
],
'provisioning' => [
'dns_token' => 'Hetzner-DNS-Token',
'dns_token_breaks' => 'Ohne ihn scheitert ConfigureDnsAndTls am A-Eintrag für die neue Instanz — mitten in der Bereitstellung, nachdem die Maschine schon läuft und der Kunde schon bezahlt hat.',
'dns_zone' => 'DNS-Zone',
'dns_zone_breaks' => 'Leer, und HttpHetznerDnsClient sucht bei Hetzner nach einer Zone ohne Namen: zoneId() findet keine, wirft „DNS zone not found", und ConfigureDnsAndTls scheitert damit an genau der Stelle, an der auch der fehlende DNS-Token zuschlägt — mitten in der Bereitstellung, nachdem der Kunde schon bezahlt hat.',
'traefik_path' => 'Traefik-Konfigurationspfad',
'traefik_path_breaks' => 'Leer, und SshTraefikWriter schreibt die Routendatei an die Wurzel des Dateisystems statt in Traefiks eigenes Verzeichnis. Der Schritt meldet trotzdem Erfolg — Traefik sieht die neue Route nur nie, und die Cloud des Kunden bleibt über ihre Adresse unerreichbar.',
'usable_host' => 'Mindestens ein einsetzbarer Host',
'usable_host_breaks' => 'Ein aktiver Host ohne lesbaren Token wird von ReserveResources trotzdem für eine neue Bestellung ausgewählt. Der Auftrag stirbt danach in CloneVirtualMachine — nachdem der Kunde schon bezahlt hat.',
'vm_template' => 'VM-Vorlage je veröffentlichter Version',
'vm_template_breaks' => 'Eine veröffentlichte Version ohne template_vmid lässt CloneVirtualMachine sofort mit „template_missing" scheitern — für jede Bestellung dieses Plans, nachdem der Kunde schon bezahlt hat.',
'monitoring_token' => 'Überwachungs-Token',
'monitoring_token_breaks' => 'Ohne ihn scheitert RegisterMonitoring. Ist `monitoring.required` nicht gesetzt (die Vorgabe), liefert die Bereitstellung trotzdem aus — nur ohne Überwachung, bis der Schlüssel nachgetragen wird. Ist es gesetzt, bricht der Lauf stattdessen ab, und die Bereitstellung hält an, bis der Schlüssel da ist.',
],
'delivery' => [
'mailer_not_log' => 'Mailtransport stellt tatsächlich zu',
'mailer_not_log_breaks' => 'Bei mail.default=log, =array oder ganz ohne gesetzten Standard läuft die Maschine, der Beleg wird ausgestellt, und die Mail, die dem Kunden seine fertige Cloud ankündigt, verlässt den Server nie — sie landet in einer Datei oder verschwindet spurlos. Der Kunde erfährt nie, dass er bezahlt hat und etwas läuft.',
'mailbox' => 'Mindestens eine Mailbox',
'mailbox_breaks' => 'Ohne Mailbox wirft der Versand aus CompleteProvisioning eine Ausnahme — der letzte Schritt der Bereitstellung schlägt fehl und wiederholt sich, obwohl die Maschine längst läuft und der Kunde schon bezahlt hat.',
'mail_templates' => 'Mindestens eine Antwortvorlage',
'mail_templates_breaks' => 'Auf der Kundenseite bleibt die Vorlagen-Auswahl leer, wo sie sonst anklickbare Antworten anbietet — der Support-Mitarbeiter merkt es sofort beim Öffnen, findet aber nichts zum Einfügen und tippt jede Antwort neu. Zwei Kunden mit derselben Frage bekommen dadurch leicht zwei unterschiedliche Auskünfte, ohne dass irgendwo ein Fehler erscheint.',
'inbound_password' => 'Passwort für eingehende Mail',
'inbound_password_breaks' => 'Ohne es liefert der Mail-Abruf jedes Mal eine leere Liste. Der Zeitplan läuft unauffällig weiter, aber keine Kundenantwort erreicht je die Inbox der Konsole, und nirgends erscheint ein Fehler.',
],
'operation' => [
'scheduler' => 'Zeitplaner läuft',
'scheduler_breaks' => 'Ohne ihn läuft kein einziger Zeitplan aus routes/console.php — nicht die Rechnungsarchivierung, nicht die Vertragsprüfung, nicht dieser Herzschlag selbst. Eine bezahlte Bestellung wartet auf TickProvisioning, das nie angestoßen wird, und die Konsole zeigt dieselbe Ruhe wie an einem Tag ohne Bestellungen.',
'queue_provisioning' => 'Bereitstellungs-Worker läuft',
'queue_provisioning_breaks' => 'Der Zeitplaner kann laufen und Aufträge auf die provisioning-Warteschlange stellen, ohne dass sie je jemand abholt. Eine bezahlte Bestellung bleibt dann liegen — kein Fehler, keine Meldung, einfach nichts, bis jemand von Hand nachsieht.',
],
];

View File

@ -22,7 +22,23 @@ return [
'unlocked_note' => 'Entsperrt. Änderungen wirken sofort auf den laufenden Betrieb.',
'lock_again' => 'Wieder sperren',
// Welcher der zwei Plätze dieses Eintrags jetzt gilt — Speichern UND
// Lesen, weil save()/render() ohne Modus-Argument auf
// OperatingMode::current() auflösen. Benennt den PLATZ, nicht den Modus:
// „Testbetrieb"/„Livebetrieb" steht schon in der Plakette oben auf der
// Seite, hier geht es darum, in welche der zwei Zeilen ein neuer Wert
// tatsächlich landet.
'slot' => [
'test' => 'Testplatz aktiv',
'live' => 'Live-Platz aktiv',
],
'source_stored' => 'Hier hinterlegt',
// Im Testbetrieb gilt der Live-Wert, wenn der Testplatz leer ist — genau
// das, was SecretVault::get() tut. Ohne eigene Beschriftung stand hier
// „Aus der Serverdatei" über einem Wert aus der Datenbank, und wer die
// .env daraufhin änderte, änderte nichts.
'source_stored_live' => 'Aus dem Live-Platz',
'source_environment' => 'Aus der Serverdatei',
'source_none' => 'Nicht gesetzt',
'stored_value' => 'Hinterlegt',

View File

@ -32,6 +32,7 @@ return [
'revenue' => 'Revenue',
'mail' => 'Email',
'integrations' => 'Integrations',
'readiness' => 'Readiness',
'settings' => 'Settings',
'two_factor_setup' => 'Two-factor login',
],
@ -98,6 +99,7 @@ return [
'host_error' => 'Host :host reports an error.',
'host_silent' => 'Host :host has not checked in for over :minutes minutes.',
'monitoring_down' => ':n monitored instance(s) unreachable.',
'readiness_blocking' => '{1} :n item on the readiness page blocks going live.|[2,*] :n items on the readiness page block going live.',
],
'customers_sub' => 'All customers and their plans.',

View File

@ -131,6 +131,10 @@ return [
'pending_change_note' => 'A move to :plan is booked for :date. Nothing about your package changes until then.',
'purchased' => 'Purchase noted — we activate it after payment.',
'mock_note' => 'Payment & fulfillment follow once the payment provider is connected.',
// Deliberately without a reason: neither "the Stripe key is missing" nor
// which operating mode is active is the customer's to know — that is an
// operator matter and belongs on the readiness page instead.
'stripe_not_configured' => 'Payment is not set up at the moment. Please try again later.',
'plan' => [
'start' => 'Start',

View File

@ -134,4 +134,46 @@ return [
'secure_host_firewall' => 'Secure host firewall',
'complete_host_onboarding' => 'Complete onboarding',
],
'takeover' => [
'how_title' => 'How a host takeover works',
'how_sub' => 'Six steps, roughly 20 to 40 minutes. The first two happen at the provider — before anything is created here.',
'badge_before' => 'first',
'badge_now' => 'now',
'badge_later' => 'then',
's1_title' => 'Order the server',
's1_body' => 'A dedicated machine. Not a cloud instance: without hardware virtualisation (/dev/kvm) no guest will ever start, and the script turns the machine away at the very beginning. Hetzner CPX/CX never has it; netcup depends on the product.',
's2_title' => 'Boot the rescue system',
's2_body' => 'Enable the rescue system in the provider\'s panel AND restart the server. Enabling alone is not enough — it has to actually be running in it, or the script refuses. It overwrites disks, so it checks first whether it may.',
's3_title' => 'Add the host here',
's3_body' => 'Name, datacenter, public IP and root password, then save. The one-time code is created at that point, and its 24-hour window starts running.',
's4_title' => 'Copy the command line',
's4_body' => 'It appears right after saving and is shown exactly once.',
's5_title' => 'Paste it into the rescue system',
's5_body' => 'SSH in as root, paste the line, press enter. Nothing is typed out and nothing is filled in: the line carries everything the server needs before the tunnel exists.',
's5_hint' => 'The line fetches the installer from :url, unpacks it to /opt/clupilot and starts it. The server downloads nothing else.',
's6_title' => 'Watch',
's6_body' => 'Progress appears on the host page, section by section. Until the tunnel is up the server reports nothing — that is intended, and it lasts across the first reboot. Everything before it then arrives at once, carrying the times it actually happened.',
'missing_title' => 'The tunnel is not set up yet',
'missing_body' => 'Missing: :settings. Without these, the command line looks clean, runs, and ends in a tunnel that never handshakes — which only shows up on the machine. Fill them in under Settings before adding a host.',
'title' => ':name is created',
'subtitle' => 'Three steps left, then the server does the rest on its own.',
'once_title' => 'This line exists only now',
'once_body' => 'The database holds only the hash of the code, and not the WireGuard key at all. Leaving or reloading this page does not bring it back — it mints a new code, and the old one becomes worthless.',
'watch' => 'Watch progress',
'copy' => 'Copy',
'copied' => 'Copied',
'footnote' => 'The code is valid for 24 hours and can be redeemed exactly once. If the takeover stops, the machine is reinstalled rather than repaired — where to look is in the runbook at docs/runbooks/host-bootstrap.md.',
],
];

View File

@ -4,6 +4,20 @@ return [
'title' => 'Integrations',
'subtitle' => 'Connected services, grouped by purpose — credentials and settings side by side, not on separate pages by storage mechanism.',
// The switch — above every credential, because it governs which slot of
// each entry below applies.
'mode_title' => 'Operating mode',
'mode_body' => 'Decides which slot of each credential below applies. In test mode, an empty test slot falls back to the live slot — except the Stripe key, which does not fall back either way.',
'mode_switch_to_test' => 'Switch to test mode',
'mode_switch_to_live' => 'Switch to live mode',
'mode_switched' => 'Operating mode switched: :mode.',
'mode_switch_title_test' => 'Really switch to test mode?',
'mode_switch_body_test' => 'From now on, every credential uses its test slot — or, where that one is empty, the live slot. Payments continue to go through whichever Stripe key belongs to the active mode.',
'mode_switch_confirm_test' => 'Switch',
'mode_switch_title_live' => 'Really switch to live mode?',
'mode_switch_body_live' => 'From now on, real money moves: every order is charged through the live Stripe key, not the test one. This is not a switch to click by accident.',
'mode_switch_confirm_live' => 'Switch',
'payments_title' => 'Payments (Stripe)',
'payments_body' => 'The secret key used to accept payments and verify webhooks.',
@ -107,4 +121,4 @@ return [
'platform' => 'Platform',
'env' => 'Environment (.env)',
],
];
];

112
lang/en/readiness.php Normal file
View File

@ -0,0 +1,112 @@
<?php
/*
* The readiness check (App\Support\Readiness). Every `*_breaks` sentence
* answers not "what is missing" but "what happens if it stays missing" see
* the header comment of App\Support\Readiness\Check.
*/
return [
'page_title' => 'Readiness',
'page_sub' => 'Every required field of this installation in one place — until it says "Ready for live mode" at the top, something here is still open.',
'ready_test' => 'Ready for test mode',
'ready_live' => 'Ready for live mode',
'not_ready' => '{1} Not ready yet — :n blocking item open|[2,*] Not ready yet — :n blocking items open',
'group' => [
'billing' => 'Billing',
'onboarding' => 'Host onboarding',
'provisioning' => 'Provisioning',
'delivery' => 'Delivery',
'operation' => 'Operation',
],
'status' => [
'satisfied' => 'Satisfied',
'blocking' => 'Blocking',
'warning' => 'Warning',
],
'run_check' => 'Check',
'check_ok' => 'Checked: OK',
'check_failed' => 'Checked: not OK',
'check_key_mode' => 'This key belongs to the :mode account.',
'check_key_mode_conflict' => 'This installation is running in :mode — the key is in the wrong slot.',
'fix_link' => 'Fix',
'last_heartbeat' => 'Last heartbeat: :when',
'mode' => [
'test' => 'Test mode',
'live' => 'Live mode',
],
'billing' => [
'stripe_secret' => 'Stripe key (:mode)',
'stripe_secret_breaks' => 'Without it the checkout accepts no order at all — no order is ever created.',
// Two separate sentences, because the consequences are opposite ones:
// real money moving that should not, and no money moving that should.
'stripe_secret_test_slot_breaks' => 'The test slot holds a live key. Every order charges real money while this page and the console say "test mode" — a trial purchase is a real purchase, on a real card.',
'stripe_secret_live_slot_breaks' => 'The live slot holds a test key. The customer completes checkout, no money is ever taken, and the order still looks paid — provisioning runs, the invoice is issued, and nothing arrives in the account.',
'webhook_secret' => 'Stripe signing secret',
'webhook_secret_breaks' => 'Without it a payment is never recorded: the customer pays, and nothing happens.',
'company_details' => 'Company details complete',
'company_details_breaks' => 'IssueInvoice refuses to issue the invoice. Provisioning still goes through — the customer gets a running cloud with no invoice.',
'invoice_series' => 'Invoice series for every document kind',
'invoice_series_breaks' => 'IssueInvoice aborts while issuing whenever a series is missing or switched off. Provisioning still goes through — the customer gets a running cloud with no invoice, and the failure is only written to the log.',
'catalogue_synced' => 'Stripe catalogue in sync',
'catalogue_synced_breaks' => 'A verified EU business customer cannot check out, because their net price is missing at Stripe.',
// Says what happens and what actually helps, including the awkward
// half: stripe:sync-catalogue skips every row that already carries an
// id, so it does not repair this on its own.
// The third state, deliberately WITHOUT a clear-it-out instruction: it
// arises without any account change, and the objects belong to the
// account in force.
'catalogue_origin_breaks' => 'Nothing records which account the stored Stripe ids were created in — a sync may have died part-way through, or an order may have minted its own missing price. Until that is settled this page cannot say whether the checkout can sell with them. Running stripe:sync-catalogue takes up what is already there, creates only what is missing, and records the account — nothing has to be deleted for it.',
'catalogue_account_breaks' => 'The stored Stripe product and price ids were created in a different account from the one now in force (:mode) — a Stripe object belongs to the account that issued it. Every order then fails at the checkout with "No such price": no order, no payment. Re-running stripe:sync-catalogue is not enough on its own, because it skips every row that already carries an id; only after clearing plan_families.stripe_product_id and plan_prices.stripe_price_id as well as the stripe_plan_prices and stripe_addon_prices registers does it recreate the catalogue in the active account.',
],
'onboarding' => [
'ssh_key' => 'SSH key for hosts',
'ssh_key_breaks' => 'EstablishSshTrust only deploys the public half onto the host; every step after it — and every Traefik change on a host already live — connects with this private key. Without it, onboarding stalls at the second step, and no routing update reaches a host that is already running.',
'secrets_key' => 'Vault key (SECRETS_KEY)',
'secrets_key_breaks' => 'Without it, nothing can be stored or read in this installation — not the Stripe key, not the DNS token, no host token. The console shows every one of these fields as empty, no matter what was ever entered.',
'vpn_config_key' => 'VPN config key (VPN_CONFIG_KEY)',
'vpn_config_key_breaks' => 'Without it, ConfigVault refuses to store any VPN config rather than write it in the clear — an issued access can then not be looked up anywhere again.',
'wg_hub' => 'WireGuard hub fully configured',
'wg_hub_breaks' => 'Two of three values are not half a tunnel, they are none: ConfigureWireguard writes the peer config from all three at once. Missing one leaves the new host with no management tunnel, and nobody can reach it.',
'datacenter' => 'At least one datacenter',
'datacenter_breaks' => 'Without a datacenter the host-create form has nothing to offer, and ValidateHostInput aborts as the very first step of the chain — before an SSH connection is even attempted.',
],
'provisioning' => [
'dns_token' => 'Hetzner DNS token',
'dns_token_breaks' => 'Without it, ConfigureDnsAndTls fails to create the A record for the new instance — in the middle of provisioning, after the machine is already running and the customer has already paid.',
'dns_zone' => 'DNS zone',
'dns_zone_breaks' => 'Empty, and HttpHetznerDnsClient asks Hetzner for a zone with no name: zoneId() finds none, throws "DNS zone not found", and ConfigureDnsAndTls fails at exactly the point the missing DNS token already breaks — in the middle of provisioning, after the customer has already paid.',
'traefik_path' => 'Traefik config path',
'traefik_path_breaks' => 'Empty, and SshTraefikWriter writes the route file at the filesystem root instead of Traefik\'s own directory. The step still reports success — Traefik simply never sees the new route, and the customer\'s cloud stays unreachable at its address.',
'usable_host' => 'At least one usable host',
'usable_host_breaks' => 'An active host with no readable token is still picked by ReserveResources for a new order. The order then dies in CloneVirtualMachine — after the customer has already paid.',
'vm_template' => 'VM template for every published version',
'vm_template_breaks' => 'A published version with no template_vmid makes CloneVirtualMachine fail instantly with "template_missing" — for every order of that plan, after the customer has already paid.',
'monitoring_token' => 'Monitoring token',
'monitoring_token_breaks' => 'Without it, RegisterMonitoring fails. With `monitoring.required` unset (the default), the cloud still gets delivered, just without monitoring, until the key is added. With it set, the run aborts instead, and delivery stalls until the key is there.',
],
'delivery' => [
'mailer_not_log' => 'Mail transport actually delivers',
'mailer_not_log_breaks' => 'With mail.default=log, =array, or no default configured at all, the machine runs, the invoice is issued, and the mail announcing the finished cloud never leaves the server — it lands in a file or vanishes without a trace. The customer never learns they paid for something that is running.',
'mailbox' => 'At least one mailbox',
'mailbox_breaks' => 'With no mailbox, sending from CompleteProvisioning throws — the last step of provisioning fails and keeps retrying, even though the machine is already running and the customer has already paid.',
'mail_templates' => 'At least one reply template',
'mail_templates_breaks' => 'On the customer page, the template picker sits empty where it would otherwise offer ready answers to click — the support agent notices immediately on opening it, finds nothing to insert, and types every reply from scratch. Two customers asking the same question can then easily get two different answers, with no error appearing anywhere.',
'inbound_password' => 'Inbound mail password',
'inbound_password_breaks' => 'Without it, fetching mail returns an empty list every time. The schedule keeps running quietly, but no customer reply ever reaches the console inbox, and no error appears anywhere.',
],
'operation' => [
'scheduler' => 'Scheduler is running',
'scheduler_breaks' => 'Without it, not a single schedule in routes/console.php runs — not invoice archiving, not the contract check, not this heartbeat itself. A paid order waits on TickProvisioning, which is never invoked, and the console shows the same calm as a day with no orders at all.',
'queue_provisioning' => 'Provisioning worker is running',
'queue_provisioning_breaks' => 'The scheduler can be running and putting jobs on the provisioning queue without anyone ever picking them up. A paid order then just sits there — no error, no notice, simply nothing, until someone happens to look.',
],
];

View File

@ -22,7 +22,22 @@ return [
'unlocked_note' => 'Unlocked. Changes take effect on the running system immediately.',
'lock_again' => 'Lock again',
// Which of the two slots this entry resolves to right now — for both
// saving and reading, since save()/render() resolve against
// OperatingMode::current() without a mode argument. Names the SLOT, not
// the mode: "Test mode"/"Live mode" already sits in the badge at the top
// of the page; this is about which of the two rows a new value actually
// lands in.
'slot' => [
'test' => 'Test slot active',
'live' => 'Live slot active',
],
'source_stored' => 'Stored here',
// In test mode the live value applies when the test slot is empty —
// exactly what SecretVault::get() does. Without a label of its own this
// read "From the server file" over a value coming from the database.
'source_stored_live' => 'From the live slot',
'source_environment' => 'From the server file',
'source_none' => 'Not set',
'stored_value' => 'Stored',

View File

@ -30,11 +30,32 @@
<!-- Fixed test key: storing a VPN config must be exercised, and a
random one would make a decryption failure irreproducible. -->
<env name="VPN_CONFIG_KEY" value="Y2x1cGlsb3QtdGVzdC1vbmx5LXZwbi1rZXktMzJieXQ=" force="true"/>
<!-- Blank so the suite never picks up the operator's real webhook secret
<!-- Blank so the suite never picks up the operator's real webhook secrets
from .env: with one set, every unsigned test payload is rejected as
an invalid signature. The two tests that DO exercise verification
set the secret themselves. -->
an invalid signature. The tests that DO exercise verification set
the secret(s) themselves. _TEST is the live secret's sibling
(StripeWebhookSecret picks between them by operating mode) — same
variable class, same risk, so it gets the same treatment even
though no current test happens to need it. -->
<env name="STRIPE_WEBHOOK_SECRET" value="" force="true"/>
<env name="STRIPE_WEBHOOK_SECRET_TEST" value="" force="true"/>
<!-- Blank so the suite never reads this operator's REAL Hetzner DNS token
from .env. This one is load-bearing in a way the two above are not:
DnsTokenCheck proves write access by actually WRITING a TXT record
to the configured zone and deleting it again — a suite that fell
through to the real token would write to and delete from the real
clupilot.cloud zone on every test run. Every test that exercises
DnsTokenCheck uses Http::fake() and supplies its own candidate
token instead. -->
<env name="HETZNER_DNS_TOKEN" value="" force="true"/>
<!-- Pinned so the suite stops depending on the operator's .env. It did:
HostStepsTest asserted `fsn-01.node.clupilot.com` and passed only
because this server's .env carried the wrong zone. Correcting the
.env turned a green test red without a line of production code
changing — a test that reads the developer's machine proves
nothing about the code. The value is the one the code itself
defaults to. -->
<env name="CLUPILOT_DNS_ZONE" value="clupilot.cloud" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="BROADCAST_CONNECTION" value="null" force="true"/>

View File

@ -1,4 +1,4 @@
@props(['entry', 'unlocked'])
@props(['entry', 'unlocked', 'mode'])
{{--
One vault entry outline, new-value field, test/save/forget reusable
across every integration section on App\Livewire\Admin\Integrations. Kept
@ -25,13 +25,33 @@
Serverdatei" it is the answer to "which line?". --}}
<p class="mt-0.5 font-mono text-xs text-muted">{{ $entry['envKey'] }}</p>
</div>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
: ($entry['source'] === 'environment' ? 'border-info-border bg-info-bg text-info'
: 'border-warning-border bg-warning-bg text-warning') }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('secrets.source_'.$entry['source']) }}
</span>
<div class="flex flex-wrap items-center gap-2">
{{-- Which of the two slots THIS card reads and writes right now
fix round, Important: save()/render() resolve without a mode
argument, so they always act on OperatingMode::current()'s
slot, but nothing on the card said so. The badge in the
header above (Integrations::mode_title) is not enough: it is
not visible at the card an operator is about to type into,
and repeating "Testbetrieb"/"Livebetrieb" there would only
restate the MODE, not name the PLATZ a value lands in. Same
colours as that header badge, for one visual language. --}}
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
{{ __('secrets.slot.'.$mode->value) }}
</span>
{{-- Aufgezählt nach den zwei Enden statt nach jedem Wert einzeln:
grün heißt „liegt in DIESEM Platz", gelb heißt „nirgends", und
alles dazwischen (aus der Serverdatei, aus dem Live-Platz) ist
blau in Kraft, aber woanders her. So bekommt ein künftiger
vierter Wert nicht still die Farbe von „Nicht gesetzt". --}}
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
: ($entry['source'] === 'none' ? 'border-warning-border bg-warning-bg text-warning'
: 'border-info-border bg-info-bg text-info') }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('secrets.source_'.$entry['source']) }}
</span>
</div>
</div>
@if (! $unlocked)

View File

@ -0,0 +1,109 @@
@props([
// Die fertige Befehlszeile, sobald der Host angelegt ist — sonst null.
'command' => null,
'archiveUrl' => '',
'hostUuid' => null,
])
{{-- Die Anleitung für eine Host-Übernahme, an EINER Stelle geschrieben.
Sie steht bewusst dort, wo jemand sie braucht, bevor er anfängt: die
Schritte 1 und 2 passieren beim Anbieter, und wer sie erst nach dem Anlegen
liest, hat den Einmal-Code schon in der Zwischenablage und dessen Frist
schon laufen. Die erste Fassung dieser Seite zeigte die Anleitung erst
hinterher das war genau dieser Fehler.
Deshalb sind IMMER alle sechs Schritte zu sehen, auch die erledigten und
die kommenden. Eine Anleitung, die nur den aktuellen Schritt zeigt,
beantwortet die Frage „wie lange dauert das noch" nicht. --}}
@php
$done = $command !== null;
// Schritt 3 ist das Formular auf dieser Seite; ist die Zeile da, sind 13
// vorbei und 4 ist dran.
$current = $done ? 4 : 3;
$steps = [
1 => ['title' => __('hosts.takeover.s1_title'), 'body' => __('hosts.takeover.s1_body')],
2 => ['title' => __('hosts.takeover.s2_title'), 'body' => __('hosts.takeover.s2_body')],
3 => ['title' => __('hosts.takeover.s3_title'), 'body' => __('hosts.takeover.s3_body')],
4 => ['title' => __('hosts.takeover.s4_title'), 'body' => __('hosts.takeover.s4_body')],
5 => ['title' => __('hosts.takeover.s5_title'), 'body' => __('hosts.takeover.s5_body')],
6 => ['title' => __('hosts.takeover.s6_title'), 'body' => __('hosts.takeover.s6_body')],
];
@endphp
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}>
<div class="border-b border-line px-6 py-4">
<h2 class="text-sm font-semibold text-ink">{{ __('hosts.takeover.how_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('hosts.takeover.how_sub') }}</p>
</div>
<ol class="divide-y divide-line">
@foreach ($steps as $number => $step)
@php
$isCurrent = $number === $current;
$isPast = $number < $current;
@endphp
<li class="flex gap-3 px-6 py-4 {{ $isCurrent ? 'bg-info-bg' : '' }}">
{{-- Die Nummer trägt den Zustand, nicht ein Icon daneben: eine
Ziffer, die man mit dem Blick abzählen kann, ist an dieser
Stelle mehr wert als ein Häkchen. --}}
<span class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold
{{ $isPast ? 'border-success-border bg-success-bg text-success' : ($isCurrent ? 'border-info-border bg-surface text-info' : 'border-line text-muted') }}">
{{ $number }}
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold {{ $isCurrent ? 'text-ink' : 'text-body' }}">{{ $step['title'] }}</h3>
@if ($number <= 2 && ! $done)
<x-ui.badge status="warning">{{ __('hosts.takeover.badge_before') }}</x-ui.badge>
@elseif ($isCurrent)
<x-ui.badge status="info">{{ __('hosts.takeover.badge_now') }}</x-ui.badge>
@endif
</div>
<p class="mt-1 text-sm text-muted">{{ $step['body'] }}</p>
@if ($number === 4 && $done)
<div x-data="{ copied: false }" class="mt-3">
<div class="relative">
{{-- Umbrechen statt waagerecht rollen: aus einem
Kasten mit Rollbalken markiert jemand die
Hälfte und merkt es erst auf der Maschine. --}}
<pre class="max-h-56 overflow-y-auto rounded border border-line bg-canvas p-3 pr-24 font-mono text-xs leading-relaxed text-ink break-all whitespace-pre-wrap"
x-ref="command">{{ $command }}</pre>
<button type="button"
class="absolute right-2 top-2 inline-flex items-center gap-1 rounded border border-line bg-surface px-2 py-1 text-xs font-medium text-body hover:text-ink"
x-on:click="navigator.clipboard.writeText($refs.command.textContent); copied = true; setTimeout(() => copied = false, 2000)">
<x-ui.icon name="copy" class="size-4" x-show="!copied" />
<x-ui.icon name="check" class="size-4" x-show="copied" x-cloak />
<span x-text="copied ? @js(__('hosts.takeover.copied')) : @js(__('hosts.takeover.copy'))">{{ __('hosts.takeover.copy') }}</span>
</button>
</div>
</div>
@endif
@if ($number === 5)
<p class="mt-2 text-xs text-muted">{{ __('hosts.takeover.s5_hint', ['url' => $archiveUrl]) }}</p>
@endif
@if ($number === 6 && $hostUuid)
<div class="mt-3">
<a href="{{ route('admin.hosts.show', ['host' => $hostUuid]) }}" wire:navigate>
<x-ui.button variant="primary" size="sm">
<x-slot:icon><x-ui.icon name="activity" class="size-4" /></x-slot:icon>
{{ __('hosts.takeover.watch') }}
</x-ui.button>
</a>
</div>
@endif
</div>
</li>
@endforeach
</ol>
<div class="border-t border-line px-6 py-3">
<p class="text-xs text-muted">{{ __('hosts.takeover.footnote') }}</p>
</div>
</div>

View File

@ -43,6 +43,7 @@
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'send' => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
'plug' => '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
'beaker' => '<path d="M4.5 3h15"/><path d="M6 3v16a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V3"/><path d="M6 14h12"/>',
];
$body = $icons[$name] ?? '';

View File

@ -42,6 +42,16 @@
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
<x-shell.topbar :crumbs="array_values(array_filter([__('admin.console'), $title ?? \App\Support\Navigation::currentLabel(console: true)]))">
{{-- "Bin ich im Test?" darf keine Frage sein, die man erst
durch Navigieren zur Integrationsseite beantwortet die
Plakette steht im Kopf jeder Konsolenseite, solange der
Modus test ist, und verschwindet von selbst im Livebetrieb. --}}
@if (\App\Support\OperatingMode::current()->isTest())
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-200">
<x-ui.icon name="beaker" class="size-4" />
{{ __('readiness.mode.test') }}
</span>
@endif
@auth
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">

View File

@ -0,0 +1,24 @@
{{--
Zweizeilige Rückfrage, kein Eingabefeld braucht nach R24 kein
<x-ui.modal>. Live bekommt eine eigene, wärmere Warnfarbe und einen
zweiten Satz: das ist der Moment, ab dem echtes Geld bewegt wird, und das
darf nicht dieselbe beiläufige Bestätigung sein wie der Wechsel zurück in
den Testbetrieb.
--}}
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg {{ $mode === 'live' ? 'bg-warning-bg text-warning' : 'bg-accent-subtle text-accent-text' }}">
<x-ui.icon name="{{ $mode === 'live' ? 'alert-triangle' : 'beaker' }}" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('integrations.mode_switch_title_'.$mode) }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('integrations.mode_switch_body_'.$mode) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
{{ __('integrations.mode_switch_confirm_'.$mode) }}
</x-ui.button>
</div>
</div>

View File

@ -1,43 +1,72 @@
<div class="mx-auto max-w-xl space-y-5">
<div class="animate-rise">
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a>
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p>
</div>
<form wire:submit="save" class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<x-ui.input name="name" wire:model="name" :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" autofocus />
<div class="space-y-1.5">
<label for="datacenter" class="block text-sm font-medium text-body">{{ __('hosts.field.datacenter') }}</label>
<select id="datacenter" wire:model="datacenter"
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
{{-- The building too, where it is known: two entries both
reading "Falkenstein (fsn)" are not a choice anybody can
make, and placing a second machine for redundancy is
exactly when it matters which hall it lands in. --}}
@foreach ($datacenters as $dc)
<option value="{{ $dc->code }}">{{ $dc->name }} ({{ $dc->code }})@if ($dc->facility) · {{ $dc->facility }}@endif</option>
@endforeach
</select>
@error('datacenter') <p class="text-xs text-danger">{{ $message }}</p> @enderror
</div>
<x-ui.input name="public_ip" wire:model="public_ip" :label="__('hosts.field.public_ip')" inputmode="numeric" placeholder="203.0.113.10" />
<x-ui.input name="root_password" wire:model="root_password" type="password"
:label="__('hosts.field.root_password')" :hint="__('hosts.field.root_password_hint')" autocomplete="off" />
<div class="flex items-center justify-end gap-3 pt-1">
<a href="{{ route('admin.hosts') }}" wire:navigate>
<x-ui.button variant="secondary" type="button">{{ __('hosts.cancel') }}</x-ui.button>
<div class="mx-auto max-w-2xl space-y-5">
@if ($command === null)
<div class="animate-rise">
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="save">{{ __('hosts.save') }}</span>
<span wire:loading wire:target="save">{{ __('hosts.save') }}</span>
</x-ui.button>
<h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p>
</div>
</form>
@if ($missingSettings)
<x-ui.alert variant="warning" class="animate-rise">
<p class="font-medium">{{ __('hosts.takeover.missing_title') }}</p>
<p class="mt-1">{{ __('hosts.takeover.missing_body', ['settings' => implode(', ', $missingSettings)]) }}</p>
</x-ui.alert>
@endif
{{-- Die Anleitung steht VOR dem Formular, weil ihre ersten beiden
Schritte beim Anbieter passieren und vor dem Anlegen erledigt sein
müssen. Wer sie erst danach liest, hat den Einmal-Code schon und
dessen Frist läuft. --}}
<x-admin.takeover-guide :archive-url="$archiveUrl" class="animate-rise" />
<form wire:submit="save" class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<x-ui.input name="name" wire:model="name" :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" autofocus />
<div class="space-y-1.5">
<label for="datacenter" class="block text-sm font-medium text-body">{{ __('hosts.field.datacenter') }}</label>
<select id="datacenter" wire:model="datacenter"
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
{{-- The building too, where it is known: two entries both
reading "Falkenstein (fsn)" are not a choice anybody can
make, and placing a second machine for redundancy is
exactly when it matters which hall it lands in. --}}
@foreach ($datacenters as $dc)
<option value="{{ $dc->code }}">{{ $dc->name }} ({{ $dc->code }})@if ($dc->facility) · {{ $dc->facility }}@endif</option>
@endforeach
</select>
@error('datacenter') <p class="text-xs text-danger">{{ $message }}</p> @enderror
</div>
<x-ui.input name="public_ip" wire:model="public_ip" :label="__('hosts.field.public_ip')" inputmode="numeric" placeholder="203.0.113.10" />
<x-ui.input name="root_password" wire:model="root_password" type="password"
:label="__('hosts.field.root_password')" :hint="__('hosts.field.root_password_hint')" autocomplete="off" />
<div class="flex items-center justify-end gap-3 pt-1">
<a href="{{ route('admin.hosts') }}" wire:navigate>
<x-ui.button variant="secondary" type="button">{{ __('hosts.cancel') }}</x-ui.button>
</a>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="save">{{ __('hosts.save') }}</span>
<span wire:loading wire:target="save">{{ __('hosts.save') }}</span>
</x-ui.button>
</div>
</form>
@else
{{-- Der Host steht. Dieselbe Anleitung, jetzt mit der Befehlszeile an
Schritt 4 und ohne Formular, weil es nichts mehr auszufüllen gibt. --}}
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('hosts.takeover.title', ['name' => $createdName]) }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.takeover.subtitle') }}</p>
</div>
<x-ui.alert variant="warning" class="animate-rise">
<p class="font-medium">{{ __('hosts.takeover.once_title') }}</p>
<p class="mt-1">{{ __('hosts.takeover.once_body') }}</p>
</x-ui.alert>
<x-admin.takeover-guide :command="$command" :archive-url="$archiveUrl" :host-uuid="$createdUuid" class="animate-rise" />
@endif
</div>

View File

@ -9,6 +9,21 @@
</a>
</div>
{{-- Die Anleitung gehört hierher, nicht nur auf die Anlegen-Seite: ihre
ersten zwei Schritte passieren beim Anbieter, und wer erst auf „Host
hinzufügen" klickt, hat den Server womöglich noch gar nicht bestellt.
Zusammengeklappt, damit sie die Liste nicht verdrängt aber auffindbar,
ohne dass man vorher etwas anlegen muss. --}}
<details class="group rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:20ms]">
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-6 py-3">
<span class="flex items-center gap-2 text-sm font-medium text-body">
<x-ui.icon name="life-buoy" class="size-4" />{{ __('hosts.takeover.how_title') }}
</span>
<x-ui.icon name="chevron-down" class="size-4 shrink-0 text-muted transition group-open:rotate-180" />
</summary>
<x-admin.takeover-guide :archive-url="$archiveUrl" class="rounded-none border-0 border-t border-line shadow-none" />
</details>
{{-- Filter bar scales to many hosts (search + datacenter + status). --}}
<div class="flex flex-wrap items-center gap-2 animate-rise [animation-delay:40ms]">
<div class="relative min-w-48 flex-1">

View File

@ -62,6 +62,36 @@
@endif
@if ($tab === 'services')
{{-- The switch above every vault entry, because it governs them: which
slot of each credential below is in force. Same lock as the vault
itself (secrets.manage + confirmed password): the mode a stranger
could type into a form field is only ever applied through
switchMode()'s own guard, but the button that offers it in the first
place stays behind the same unlock as everything else on this page. --}}
@if ($canSecrets)
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.mode_title') }}</h2>
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('integrations.mode_body') }}</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('readiness.mode.'.$mode->value) }}
</span>
@if ($unlocked)
<x-ui.button variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-switch-mode', arguments: { mode: '{{ $mode->isTest() ? 'live' : 'test' }}' } })">
{{ $mode->isTest() ? __('integrations.mode_switch_to_live') : __('integrations.mode_switch_to_test') }}
</x-ui.button>
@endif
</div>
</div>
@endif
{{-- Two columns from lg up. Each card is a form of two or three fields
stacked, they left half the window empty and pushed monitoring below
the fold. --}}
@ -73,7 +103,7 @@
<h2 class="font-semibold text-ink">{{ __('integrations.payments_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.payments_body') }}</p>
</div>
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" :mode="$mode" />
@if ($unlocked)
<x-ui.alert variant="info">{{ __('secrets.webhook_secret_note') }}</x-ui.alert>
@endif
@ -97,7 +127,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif
@ -123,7 +153,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" :mode="$mode" />
@endif
{{-- The one thing an operator wants after typing all of it: did any
@ -171,7 +201,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif
@ -217,7 +247,7 @@
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" />
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" :mode="$mode" />
@endif
</div>
@endif

View File

@ -0,0 +1,110 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('readiness.page_title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('readiness.page_sub') }}</p>
</div>
{{-- The one line that matters. Everything below is detail. --}}
<div class="flex items-center gap-3 rounded-lg border p-5 shadow-xs animate-rise
{{ $ready ? 'border-success-border bg-success-bg' : 'border-warning-border bg-warning-bg' }}">
<x-ui.icon :name="$ready ? 'shield-check' : 'alert-triangle'"
class="{{ $ready ? 'text-success' : 'text-warning' }}" />
<p class="text-lg font-semibold {{ $ready ? 'text-success' : 'text-warning' }}">
@if ($ready)
{{ $mode->isTest() ? __('readiness.ready_test') : __('readiness.ready_live') }}
@else
{{ trans_choice('readiness.not_ready', $blockingCount, ['n' => $blockingCount]) }}
@endif
</p>
</div>
@foreach ($groups as $groupKey => $checks)
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('readiness.group.'.$groupKey) }}</h2>
<ul class="mt-3 divide-y divide-line">
@foreach ($checks as $check)
@php
$badgeStatus = $check->satisfied ? 'active' : ($check->isBlocking() ? 'failed' : 'warning');
$statusLabel = $check->satisfied
? __('readiness.status.satisfied')
: ($check->isBlocking() ? __('readiness.status.blocking') : __('readiness.status.warning'));
$iconName = $check->satisfied ? 'check' : ($check->isBlocking() ? 'x' : 'alert-triangle');
$iconTone = $check->satisfied ? 'text-success' : ($check->isBlocking() ? 'text-danger' : 'text-warning');
$result = $results[$check->key] ?? null;
$heartbeat = $heartbeats[$check->key] ?? null;
@endphp
<li class="flex flex-wrap items-start gap-3 py-3">
<x-ui.icon :name="$iconName" class="mt-0.5 size-4 shrink-0 {{ $iconTone }}" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-ink">{{ $check->label }}</span>
<x-ui.badge :status="$badgeStatus">{{ $statusLabel }}</x-ui.badge>
</div>
@unless ($check->satisfied)
<p class="mt-1 text-sm text-muted">{{ $check->breaks }}</p>
@endunless
@if ($heartbeat !== null)
<p class="mt-1 font-mono text-xs text-faint">
{{ __('readiness.last_heartbeat', ['when' => $heartbeat]) }}
</p>
@endif
@if ($result !== null)
<p class="mt-1 text-xs {{ $result['ok'] ? 'text-success' : 'text-danger' }}">
{{ $result['ok'] ? __('readiness.check_ok') : __('readiness.check_failed') }}
@if (($result['reason'] ?? null) !== null)
<span class="font-mono">{{ $result['reason'] }}</span>
@endif
</p>
{{-- StripeCheck rechnet die Kontoart des Schlüssels längst aus,
und diese Seite hat sie bislang weggeworfen: wer „Prüfen"
drückte, las „Geprüft: in Ordnung" über einen Live-Schlüssel
im Testplatz. Die Prüfung selbst blockiert dafür jetzt
(BillingChecks); hier steht, was der Knopf gerade gesehen
hat, damit er ihr nicht widerspricht. --}}
@if (array_key_exists('live', $result))
@php $keyMode = $result['live'] ? 'live' : 'test'; @endphp
<p class="mt-1 text-xs {{ $keyMode === $mode->value ? 'text-muted' : 'text-danger' }}">
{{ __('readiness.check_key_mode', ['mode' => __('readiness.mode.'.$keyMode)]) }}
@unless ($keyMode === $mode->value)
{{ __('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.'.$mode->value)]) }}
@endunless
</p>
@endif
@endif
</div>
<div class="flex shrink-0 items-center gap-3">
{{-- mount() only requires hosts.manage OR secrets.manage, so an
Admin (hosts.manage alone) can reach this page but runCheck()
itself demands secrets.manage and 403s otherwise. A button that
is visible and throws when pressed is worse than no button. --}}
@if (in_array($check->key, $runnable, true))
@can('secrets.manage')
<x-ui.button variant="secondary" size="sm"
wire:click="runCheck('{{ $check->key }}')"
wire:loading.attr="disabled"
wire:target="runCheck('{{ $check->key }}')">
<x-ui.icon name="refresh" class="size-4" />
{{ __('readiness.run_check') }}
</x-ui.button>
@endcan
@endif
<a href="{{ $this->checkUrl($check) }}" wire:navigate
class="inline-flex items-center gap-1 text-xs font-semibold text-accent-text hover:underline">
{{ __('readiness.fix_link') }}
<x-ui.icon name="external-link" class="size-4" />
</a>
</div>
</li>
@endforeach
</ul>
</div>
@endforeach
</div>

View File

@ -1,4 +1,4 @@
<?php use Illuminate\Support\Number; ?>
<?php ?>
<div class="space-y-6" x-data="{ msgs: @js(['purchased' => __('billing.purchased')]) }">
@php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
@ -7,6 +7,17 @@
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
</div>
{{-- purchase() refuses before touching anything when the active mode has
no Stripe key a sentence here, not a 500 page, and not a cart entry
nobody can ever pay for.
OUTSIDE the contract branch below on purpose: the customer most likely
to meet this is the one buying for the FIRST time, who has no contract
yet. Inside the @else it would be invisible to exactly them. --}}
@error('purchase')
<x-ui.alert variant="danger">{{ $message }}</x-ui.alert>
@enderror
{{-- Nothing booked. Said plainly instead of falling back to the first plan
and rendering it as "AKTUELLES PAKET — Status Aktiv": that is a contract
nobody entered into, on the page a customer opens to find out what they

View File

@ -2,8 +2,11 @@
use App\Http\Controllers\ImpersonationController;
use App\Livewire\Admin;
use App\Models\Invoice;
use App\Services\Billing\InvoiceRenderer;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
/*
@ -97,12 +100,12 @@ Route::get('/invoices/new', Admin\NewInvoice::class)->name('invoices.new');
// than a Livewire action: a download is a download, and routing one through a
// component round-trip only adds a way for it to fail.
Route::get('/invoices/{uuid}/pdf', function (string $uuid) {
Illuminate\Support\Facades\Gate::authorize('site.manage');
Gate::authorize('site.manage');
$invoice = App\Models\Invoice::query()->where('uuid', $uuid)->firstOrFail();
$invoice = Invoice::query()->where('uuid', $uuid)->firstOrFail();
return response()->streamDownload(
fn () => print (app(App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)),
fn () => print (app(InvoiceRenderer::class)->forInvoice($invoice)),
$invoice->number.'.pdf',
['Content-Type' => 'application/pdf'],
);
@ -117,6 +120,11 @@ Route::get('/integrations', Admin\Integrations::class)->name('integrations');
// uses in routes/web.php.
Route::get('/secrets', fn () => redirect()->route('admin.integrations', status: 301))->name('secrets');
Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', status: 301))->name('infrastructure');
// Everything App\Support\Readiness knows how to check, in one place — gated
// the same way as Integrations just above, since it is the page where both
// halves that page configures (Settings and the vault) come back together as
// one list of what is still missing.
Route::get('/readiness', Admin\Readiness::class)->name('readiness');
Route::get('/settings', Admin\Settings::class)->name('settings');
// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without
// exempting the rest of admin.settings — see that middleware for why.

View File

@ -4,8 +4,10 @@ use App\Console\TickProvisioning;
use App\Models\StripePendingEvent;
use App\Provisioning\Jobs\CollectInstanceTraffic;
use App\Provisioning\Jobs\PingHosts;
use App\Provisioning\Jobs\RecordProvisioningHeartbeat;
use App\Provisioning\Jobs\SyncMonitoringStatus;
use App\Provisioning\Jobs\SyncVpnPeers;
use App\Support\Settings;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@ -20,6 +22,22 @@ Schedule::call(fn () => app(TickProvisioning::class)())
->name('provisioning-tick')
->withoutOverlapping();
// Two signs of life for the readiness page (App\Support\Readiness\
// OperationChecks). Written through Settings, not the cache: a heartbeat that
// vanishes on a Redis restart would report an outage that never happened.
//
// Two of them, because the scheduler and the provisioning queue worker fail
// independently. The dangerous case is the second one: the scheduler runs and
// enqueues jobs (this one included) that nobody ever picks up. A heartbeat
// only the scheduler writes would call that state healthy.
Schedule::call(fn () => Settings::set('heartbeat.scheduler', now()->toIso8601String()))
->everyMinute()
->name('heartbeat-scheduler');
Schedule::job(new RecordProvisioningHeartbeat)
->everyMinute()
->name('heartbeat-provisioning');
// Refresh the VPN peer state (handshakes, traffic) so the console does not go
// stale while nobody has it open. The job itself runs on the provisioning
// queue — that worker owns wg0.

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\BootstrapArchiveController;
use App\Http\Controllers\ImpersonationController;
use App\Http\Controllers\LandingController;
use App\Http\Controllers\StatusController;
@ -190,6 +191,13 @@ $publicSite = function () {
// because it has already gone out in mail footers.
Route::get('/sicherheit', fn () => redirect()->route('security', status: 301));
// Das Bootstrap-Skript für die Host-Übernahme. Muss öffentlich sein: es
// wird aus dem Rettungssystem eines nackten Servers geholt, der noch keinen
// Tunnel hat und nichts kennt als curl. Eine statische Datei ohne
// Geheimnisse — die reisen in der Befehlszeile mit, die der Adminbereich
// zeigt.
Route::get('/bootstrap.tar.gz', BootstrapArchiveController::class)->name('bootstrap.archive');
Route::get('/robots.txt', function () {
$body = App\Support\Settings::bool('site.public', true)
? "User-agent: *\nAllow: /\n"

View File

@ -1,13 +1,13 @@
<?php
use App\Livewire\Admin\Instances as AdminInstances;
use App\Livewire\Admin\Overview;
use App\Livewire\Admin\Revenue;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Livewire\Admin\Instances as AdminInstances;
use App\Livewire\Admin\Overview;
use App\Livewire\Admin\Revenue;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
@ -19,7 +19,6 @@ use Livewire\Livewire;
* twelve-month growth curve. It read like a running business and measured
* nothing. These tests exist so that cannot come back quietly.
*/
it('reports an empty estate as empty, not as a going concern', function () {
// The strongest statement of the old bug: with nothing in the database the
// console still claimed 42 customers, 39 instances, 4 hosts and €7,842 a
@ -43,9 +42,17 @@ it('reports an empty estate as empty, not as a going concern', function () {
})
->assertViewHas('hostLoad', [])
->assertViewHas('runs', [])
->assertViewHas('notices', [])
->assertSee(__('admin.no_open_runs'))
->assertSee(__('admin.no_notices'));
// Not notices === [] any more (Task 12): an installation with nothing
// configured is not the same as an installation with nothing WRONG.
// Readiness::blocking() reports a real, non-invented gap here (nine
// unmet checks on a bare database — no Stripe key, no DNS token, no
// scheduler heartbeat yet, and so on), and hiding that behind an
// empty notices list would be exactly the kind of fiction this file
// exists to catch. What is still true of a truly empty estate: no
// INCIDENT-shaped notice invents itself out of nothing — the one
// notice present is the readiness gap, and nothing else.
->assertViewHas('notices', fn (array $notices) => collect($notices)->pluck('route')->all() === ['admin.readiness'])
->assertSee(__('admin.no_open_runs'));
});
it('counts what is actually there', function () {
@ -112,20 +119,29 @@ it('states monthly revenue off the contracts, with a yearly term divided', funct
->assertDontSee('999,00');
});
it('raises a notice only when something is actually wrong', function () {
// See above: the test suite deliberately delivers no mail, and the console
// says so. Pinned here so "clean" means an estate with nothing wrong in it.
/**
* `admin.systems_ok` can no longer be asserted on a bare install (Task 12): a
* brand new console always starts with at least one readiness gap, which is
* exactly the point of Readiness::blocking() existing at all a badge
* claiming nothing is wrong would itself be the invented-data problem this
* file is about. What this test is actually for still holds: an INCIDENT
* notice (a failed run, a silent host…) appears only once one is real, on
* top of whatever readiness gaps a fresh install already carries.
*/
it('raises an incident notice only when something is actually wrong', function () {
// The suite deliberately delivers no mail, and the console now says so on
// the overview. Pinned so this test's "clean" estate is clean of the MAIL
// notice too — the readiness gaps above it stay, by design.
config()->set('mail.default', 'smtp');
$clean = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class);
$clean->assertSee(__('admin.systems_ok'));
$clean->assertDontSee(__('admin.notice.failed_runs', ['n' => 1]));
ProvisioningRun::factory()->create(['status' => ProvisioningRun::STATUS_FAILED]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertSee(__('admin.notice.failed_runs', ['n' => 1]))
->assertDontSee(__('admin.systems_ok'));
->assertSee(__('admin.notice.failed_runs', ['n' => 1]));
});
it('gets the same committed storage whether or not the sum was preloaded', function () {

View File

@ -35,6 +35,11 @@ it('lists real hosts for admins', function () {
it('creates a host and starts onboarding with an encrypted password', function () {
Queue::fake();
// Das Anlegen erzeugt jetzt auch das WireGuard-Schlüsselpaar und nimmt den
// Peer beim Hub auf, damit der Host schon im Tunnel bekannt ist, bevor er
// das erste Mal bootet (Spec §5). Ohne den gefälschten Hub griffe das nach
// `wg` auf einer Maschine, die keine wg0 hat.
fakeServices();
\App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
Livewire::actingAs(admin(), 'operator')
@ -44,7 +49,11 @@ it('creates a host and starts onboarding with an encrypted password', function (
->set('public_ip', '203.0.113.7')
->set('root_password', 'supersecret')
->call('save')
->assertRedirect();
// KEINE Weiterleitung mehr, und das ist der Punkt: die Befehlszeile für
// das Rettungssystem gibt es nur hier und nur jetzt. Wer den Betreiber
// weiterschickt, schickt ihn von dem einzigen Wert weg, den er braucht.
->assertNoRedirect()
->assertSee('clupilot-bootstrap.sh');
$host = Host::query()->where('name', 'pve-fsn-7')->first();
expect($host)->not->toBeNull()->and($host->status)->toBe('pending');

View File

@ -0,0 +1,74 @@
<?php
use App\Livewire\Admin\HostCreate;
use App\Livewire\Admin\Hosts;
use App\Models\Operator;
use Livewire\Livewire;
beforeEach(function () {
fakeServices();
config()->set('admin_access.app_host', 'app.clupilot.com');
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
});
function guidePage(string $component): \Livewire\Features\SupportTesting\Testable
{
return Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')->test($component);
}
/**
* Der eigentliche Fund, der diese Datei nötig gemacht hat.
*
* Die erste Fassung zeigte die Anleitung ERST NACH dem Anlegen. Ihre Schritte 1
* und 2 passieren aber beim Anbieter Server bestellen, Rettungssystem starten
* , und wer sie dort zum ersten Mal liest, hat den Einmal-Code schon in der
* Zwischenablage und dessen 24-Stunden-Frist läuft bereits.
*/
it('explains the whole procedure before anything is created', function () {
guidePage(HostCreate::class)
->assertSee(__('hosts.takeover.s1_title'))
->assertSee(__('hosts.takeover.s2_title'))
->assertSee(__('hosts.takeover.s3_title'))
->assertSee(__('hosts.takeover.s4_title'))
->assertSee(__('hosts.takeover.s5_title'))
->assertSee(__('hosts.takeover.s6_title'));
});
/**
* Und schon auf der Liste, nicht erst hinter „Host hinzufügen": wer dort steht,
* hat den Server womöglich noch gar nicht bestellt und genau das ist Schritt 1.
*/
it('offers the same procedure on the hosts list', function () {
guidePage(Hosts::class)
->assertSee(__('hosts.takeover.how_title'))
->assertSee(__('hosts.takeover.s1_title'))
->assertSee(__('hosts.takeover.s2_title'));
});
/**
* Die zwei Schritte beim Anbieter sind als solche gekennzeichnet. Ohne die
* Markierung liest sich die Liste, als könne man oben anfangen und sich
* durcharbeiten und Schritt 2 ist dann eine halbe Stunde Wartezeit mitten
* in einem laufenden Code.
*/
it('marks the two provider steps as things to do first', function () {
guidePage(HostCreate::class)->assertSee(__('hosts.takeover.badge_before'));
});
/**
* Die Anleitung nennt die Adresse, von der geladen wird, und es muss dieselbe
* sein, die später in der Zeile steht. Zwei Fassungen davon liefen auseinander,
* und der Unterschied fiele auf einem Server auf, der schon bestellt ist.
*/
it('names the same archive address the command will use', function () {
guidePage(HostCreate::class)->assertSee('app.clupilot.com/bootstrap.tar.gz');
guidePage(Hosts::class)->assertSee('app.clupilot.com/bootstrap.tar.gz');
});
/**
* Vor dem Anlegen gibt es keine Befehlszeile sonst stünde dort eine, die zu
* keinem Host gehört.
*/
it('shows no command until a host exists', function () {
guidePage(HostCreate::class)->assertDontSee('--wg-private');
});

View File

@ -0,0 +1,176 @@
<?php
use App\Livewire\Admin\HostCreate;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Operator;
use App\Support\HostEnrolment;
use Livewire\Livewire;
beforeEach(function () {
fakeServices();
config()->set('provisioning.wireguard.hub_public_key', 'HUBKEYHUBKEYHUBKEYHUBKEYHUBKEYHUBKEYHUBKEY0=');
config()->set('provisioning.wireguard.endpoint', 'hub.clupilot.cloud:51820');
config()->set('provisioning.wireguard.hub_address', '10.66.0.1');
config()->set('provisioning.wireguard.subnet', '10.66.0.0/24');
config()->set('admin_access.app_host', 'app.clupilot.com');
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
// firstOrCreate: die Migrationen legen Rechenzentren schon an, und ein
// zweites `fsn` verletzt den eindeutigen Index.
Datacenter::query()->firstOrCreate(
['code' => 'fsn'],
['name' => 'Falkenstein', 'location' => 'DE', 'active' => true],
);
});
function createHostAs(): \Livewire\Features\SupportTesting\Testable
{
return Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-fsn-2')
->set('datacenter', 'fsn')
->set('public_ip', '198.51.45.9')
->set('root_password', 'ein-langes-kennwort')
->call('save');
}
/**
* Die eine Frage, die diese Seite beantworten muss: was gebe ich auf dem Server
* ein? Steht die Zeile nicht da, ist die ganze Übernahme nicht benutzbar.
*/
it('shows the command exactly once, right after creating the host', function () {
$page = createHostAs();
$page->assertSee('curl')
->assertSee('clupilot-bootstrap.sh')
->assertSee('--code');
// Ein frisch geladenes Bauteil — also jemand, der die Seite neu lädt — hat
// nichts mehr. Der Code steht nur als Hash in der Datenbank.
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostCreate::class)
->assertDontSee('curl');
});
/**
* Die Zeile muss ALLES tragen, was das Skript vor dem Tunnel braucht (Spec §5).
* Fehlt ein Wert, läuft sie an und endet in einem Tunnel ohne Handshake und
* das merkt man erst auf einer Maschine, die schon bestellt ist.
*/
it('carries every value the script needs before the tunnel', function () {
$command = createHostAs()->get('command');
foreach (['--code', '--wg-private', '--wg-ip', '--hub-pubkey', '--hub-endpoint', '--api', '--fqdn'] as $argument) {
expect($command)->toContain($argument);
}
expect($command)
->toContain('hub.clupilot.cloud:51820')
->toContain('--api http://10.66.0.1')
->toContain('/opt/clupilot');
// Der Name des Hosts liegt auf der PLATTFORM-Zone. `.cloud` gehört den
// Kundeninstanzen und ist laut OfficialDomains bewusst eine andere
// registrierbare Domain — geprüft wird deshalb der --fqdn-Wert selbst und
// nicht die ganze Zeile: der Hub-Endpunkt ist eine eigene Einstellung und
// darf heißen, wie er will.
preg_match('/--fqdn (\S+)/', $command, $fqdn);
expect($fqdn[1] ?? '')->toEndWith('.node.clupilot.com');
});
/**
* Dieselbe Trennung an der Stelle, an der sie schon einmal gebrochen war.
*
* `RegisterHostDns` nennt im eigenen Kopfkommentar `fsn-01.node.clupilot.com`,
* die Prüfregel in `Datacenters` nennt denselben Namen, und `ServicesTest`
* schreibt ihn wörtlich der Schritt baute den Namen aber aus
* `provisioning.dns.zone`, und das ist die KUNDENZONE. Auf dieser Installation
* hieß ein Host damit `fsn-01.node.clupilot.cloud`.
*/
it('keeps host names out of the customer zone', function () {
config()->set('provisioning.dns.zone', 'clupilot.cloud');
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
$host = Host::factory()->create(['dns_name' => 'fsn-01']);
expect(App\Support\HostTakeoverCommand::fqdnFor($host))
->toBe('fsn-01.node.clupilot.com');
});
/**
* Über den ÖFFENTLICHEN Hostnamen. Die Konsole läuft unter admin., aber die
* Zeile wird auf einer Maschine ausgeführt, die den Adminbereich nicht erreichen
* darf sie ist genau dafür abgeriegelt. Ein Link auf den Konsolen-Hostnamen
* liefe in eine 404 auf einem Server, an den man dann nur noch über die
* Anbieterkonsole kommt.
*/
it('fetches the installer over the public hostname', function () {
expect(createHostAs()->get('command'))
->toContain('https://app.clupilot.com/bootstrap.tar.gz')
->not->toContain('admin.');
});
/**
* Der Schlüssel ist base64 und enthält +, / und =. Ohne Anführungszeichen
* zerlegt die Shell die Zeile an einer Stelle, die niemand sieht.
*/
it('quotes the keys so a shell cannot split them', function () {
$command = createHostAs()->get('command');
expect($command)->toMatch("/--wg-private '[^']+'/")
->and($command)->toMatch("/--hub-pubkey '[^']+'/");
});
/**
* Der Code aus der Zeile muss der sein, mit dem der Host sich meldet. Sonst ist
* die Anleitung richtig und der Ablauf trotzdem kaputt.
*/
it('shows a code that actually resolves to the new host', function () {
$command = createHostAs()->get('command');
preg_match('/--code ([A-Za-z0-9]+)/', $command, $matches);
expect(HostEnrolment::claim($matches[1])?->name)->toBe('pve-fsn-2');
});
/**
* Ein leerer Hub-Schlüssel ergibt eine Zeile, die läuft und nie handshaked.
* Das gehört gesagt, BEVOR jemand einen Server bestellt.
*/
it('warns before creating a host when the tunnel settings are missing', function () {
config()->set('provisioning.wireguard.hub_public_key', '');
config()->set('provisioning.wireguard.endpoint', '');
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostCreate::class)
->assertSee('CLUPILOT_WG_HUB_PUBKEY')
->assertSee('CLUPILOT_WG_ENDPOINT');
});
/**
* Nach dem Anlegen bleibt dieselbe Anleitung stehen, nur mit der Zeile darin.
* Ein Betreiber, der bei Schritt 5 nicht weiterweiß, soll nicht auf einer Seite
* landen, die nur noch aus einem Kasten besteht.
*/
it('keeps the whole procedure visible after the host exists', function () {
createHostAs()
->assertSee(__('hosts.takeover.s1_title'))
->assertSee(__('hosts.takeover.s5_title'))
->assertSee(__('hosts.takeover.s6_title'))
->assertSee('--wg-private', escape: false);
});
it('refuses to create a host without the permission', function () {
Livewire::actingAs(Operator::factory()->create(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-fsn-3')
->set('datacenter', 'fsn')
->set('public_ip', '198.51.45.10')
->set('root_password', 'ein-langes-kennwort')
->call('save')
->assertForbidden();
expect(Host::query()->where('name', 'pve-fsn-3')->exists())->toBeFalse();
});

View File

@ -5,6 +5,7 @@ use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Secrets\SecretVault;
use App\Services\Stripe\HttpStripeClient;
use App\Support\OperatingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
@ -17,28 +18,56 @@ use Illuminate\Support\Facades\Http;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
config()->set('services.stripe.secret', 'sk_env_fallback');
// None of these tests are about the test/live split (that is
// SecretVaultModeTest); they call put()/get() without a $mode and must
// therefore land on a known slot regardless of what an earlier test file
// left in the array cache that Settings and OperatingMode share.
OperatingMode::set(OperatingMode::Live);
});
it('prefers a stored key over the environment, and says which is in force', function () {
$vault = app(SecretVault::class);
expect($vault->get('stripe.secret'))->toBe('sk_env_fallback')
->and($vault->source('stripe.secret'))->toBe('environment');
// This test uses dns.token (not stripe.secret) to preserve the original
// assertion: a non-strict entry falls back to the environment and reports
// it as its source. Stripe is exempt from fallback (it is strict),
// tested separately in StripeStrictModeTest.
config()->set('provisioning.dns.token', 'env_token_fallback');
$vault->put('stripe.secret', 'sk_live_stored_value', Operator::factory()->create());
expect($vault->get('dns.token'))->toBe('env_token_fallback')
->and($vault->source('dns.token'))->toBe('environment');
expect($vault->get('stripe.secret'))->toBe('sk_live_stored_value')
->and($vault->source('stripe.secret'))->toBe('stored');
$vault->put('dns.token', 'stored_value', Operator::factory()->create());
expect($vault->get('dns.token'))->toBe('stored_value')
->and($vault->source('dns.token'))->toBe('stored');
// Removing it falls back rather than leaving nothing.
$vault->forget('stripe.secret');
expect($vault->get('stripe.secret'))->toBe('sk_env_fallback');
$vault->forget('dns.token');
expect($vault->get('dns.token'))->toBe('env_token_fallback');
});
it('strips the environment from stripe when nothing is stored', function () {
// Stripe is a strict entry: it never falls back to the environment, and
// source() never reports it as in force when it is not stored. This
// prevents the silent contradiction: get() returns null while source()
// claims 'environment', and the console would tell the operator a key is
// in force while the billing system refuses to use it.
$vault = app(SecretVault::class);
config()->set('services.stripe.secret', 'sk_env_from_config');
expect($vault->get('stripe.secret'))->toBeNull()
->and($vault->source('stripe.secret'))->toBe('none');
});
it('stores the value encrypted, and never in the clear', function () {
app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', Operator::factory()->create());
$stored = DB::table('app_secrets')->where('key', 'stripe.secret')->value('value');
// The row key carries a mode suffix (SecretVaultModeTest) — the active
// mode here is pinned to live above.
$stored = DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('value');
expect($stored)->not->toContain('sk_live_verysecret');
});
@ -161,10 +190,10 @@ it('keeps the original creation date when a key is rotated', function () {
$operator = Operator::factory()->create();
app(SecretVault::class)->put('stripe.secret', 'sk_first', $operator);
$created = DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at');
$created = DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('created_at');
$this->travel(2)->days();
app(SecretVault::class)->put('stripe.secret', 'sk_second', $operator);
expect(DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'))->toBe($created);
expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->value('created_at'))->toBe($created);
});

View File

@ -51,6 +51,10 @@ function entitlementShopper(string $plan = 'team'): array
$order->subscription->update(['instance_id' => $instance->id]);
// The tests that buy through the shop call Billing::purchase(), which
// since Task 5 refuses outright without a Stripe key for the active mode.
withStripeSecret();
return [$customer, $user, $order->subscription->fresh()];
}

View File

@ -46,6 +46,12 @@ function domainCustomer(string $plan, array $instanceAttributes = []): array
'instance_id' => $instance->id,
]);
// One test below calls Billing::purchase(), which since Task 5 refuses
// outright without a Stripe key for the active mode. Without this, that
// test would see the same "no order" result for the wrong reason — the
// Stripe guard, not the plan-restriction it names — and prove nothing.
withStripeSecret();
return [$customer, $user, $instance, $subscription];
}

View File

@ -33,6 +33,13 @@ use Livewire\Livewire;
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
// Every test here calls Billing::purchase() to place the order it then
// marks paid by hand. Since Task 5, that call refuses outright without a
// Stripe key for the active mode — binding the fake StripeClient above
// does not help, because the guard reads the vault directly rather than
// going through the StripeClient interface.
withStripeSecret();
});
/** A customer on a live contract, with a machine. */

View File

@ -45,6 +45,11 @@ function pendingChangeCustomer(string $plan = 'business'): array
// due date arrives against this id, so there has to be one.
$subscription->update(['instance_id' => $instance->id, 'stripe_subscription_id' => 'sub_'.$customer->id]);
// These tests call Billing::purchase() to book the downgrade/upgrade,
// which since Task 5 refuses outright without a Stripe key for the
// active mode.
withStripeSecret();
return [$customer, $user, $subscription->fresh(), $instance];
}

View File

@ -65,6 +65,11 @@ function storageFixture(string $plan = 'team', array $instanceAttributes = []):
$subscription->update(['instance_id' => $instance->id]);
// Some of these tests book storage packs through Billing::purchase(),
// which since Task 5 refuses outright without a Stripe key for the
// active mode.
withStripeSecret();
return [
'host' => $host,
'order' => $order,

View File

@ -0,0 +1,130 @@
<?php
use App\Models\PlanPrice;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\OperatingMode;
use App\Support\Settings;
use App\Support\StripeCatalogueMode;
use Illuminate\Contracts\Console\Kernel;
/**
* Der Abgleich sagt, in wessen Konto er gearbeitet hat und weigert sich, in
* einen Katalog hineinzuarbeiten, der einem anderen gehört.
*
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der Befehl
* überspringt jede Zeile, die schon eine ID trägt (PlanPrices::inStep()), also
* repariert ein zweiter Lauf nach dem Umschalten gar nichts er meldete
* „already in step" und ließe eine Zeile zurück, die behauptet, der Katalog
* gehöre jetzt zum Livekonto. Genau diese Lüge macht die Weigerung unnötig.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
});
it('records the mode its objects were created in', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test)
->and(StripeCatalogueMode::hasStoredObjects())->toBeTrue();
});
it('records nothing on a dry run, which creates nothing', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue', ['--dry-run' => true])->assertSuccessful();
expect(StripeCatalogueMode::recorded())->toBeNull();
});
it('refuses to work into a catalogue that belongs to the other account', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
OperatingMode::set(OperatingMode::Live);
$this->artisan('stripe:sync-catalogue')->assertFailed();
// Und die Zeile lügt danach nicht: der Katalog gehört weiterhin dem
// Testkonto, weil dieser Lauf nichts angelegt hat.
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
});
/**
* „Herkunft nie festgehalten" ist NICHT „fremdes Konto".
*
* Der Zustand entsteht ohne jeden Fehler: `CheckoutController` `PlanPrices::
* ensure()` und `BookAddon` → `SyncStripeAddonItems` → `AddonPrices::ensure()`
* legen fehlende Preise selbst an, und keiner der beiden ruft `record()`. Er
* entsteht auch nach einem Lauf, der mittendrin abgebrochen ist.
*
* Die Objekte stammen dann aus genau dem geltenden Konto. Diesen Zustand in die
* Konto-Weigerung zu schicken, hieße einem Betreiber das Leeren eines Katalogs
* aufzutragen, an dem laufende Verträge abgerechnet werden für ein
* Wiederaufsetzen, für das die Idempotenzschlüssel dieses Befehls ausdrücklich
* gebaut sind.
*/
it('takes up a catalogue whose origin was never written down', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
Settings::forget(StripeCatalogueMode::SETTING);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
});
it('leaves no half-built catalogue that contradicts itself when a run dies', function () {
OperatingMode::set(OperatingMode::Test);
// Stripe geht mitten im Lauf weg, nachdem das erste Produkt schon angelegt
// und seine ID gespeichert ist. `createProduct()` und `ensure()` werfen
// ungefangen — stünde record() am ENDE von handle(), bliebe genau der
// Zustand zurück, den der Test darüber beschreibt: IDs da, Herkunft leer.
app()->instance(StripeClient::class, new class extends FakeStripeClient
{
public function createPrice(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata = [],
?string $idempotencyKey = null,
): string {
throw new RuntimeException('Stripe went away mid-run');
}
});
try {
app(Kernel::class)->call('stripe:sync-catalogue');
} catch (RuntimeException) {
// Erwartet — der Abbruch IST der Testfall.
}
expect(StripeCatalogueMode::hasStoredObjects())->toBeTrue()
->and(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test);
});
it('syncs into the new account once the stale ids are cleared', function () {
OperatingMode::set(OperatingMode::Test);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
OperatingMode::set(OperatingMode::Live);
// Was der `breaks`-Satz und die Fehlermeldung dem Betreiber auftragen —
// Zeiger UND Register, weil PlanPrices::inStep() für den Netto-Preis eines
// Reverse-Charge-Kunden allein das Register fragt.
DB::table('plan_families')->update(['stripe_product_id' => null]);
DB::table('plan_prices')->update(['stripe_price_id' => null]);
DB::table('stripe_plan_prices')->delete();
DB::table('stripe_addon_prices')->delete();
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Live)
->and(PlanPrice::query()->whereNotNull('stripe_price_id')->exists())->toBeTrue();
});

View File

@ -18,9 +18,12 @@ use Illuminate\Support\Facades\Http;
* the thing standing between a customer and being charged twice.
*/
beforeEach(function () {
// The environment fallback is enough — HttpStripeClient reads the vault,
// and the vault falls back to config until a secret has been stored.
config()->set('services.stripe.secret', 'sk_test_plan_task_one');
// A stored vault row, not config(): `stripe.secret` is a STRICT vault entry
// as of the operating-mode work, so it never falls back to the environment
// in either direction — otherwise the .env would be the back door through
// which a live key reaches a test-mode checkout. Without a real row
// HttpStripeClient::secret() throws StripeNotConfigured, which is the point.
withStripeSecret();
});
it('sends a different key once the metadata changes', function () {

View File

@ -14,6 +14,10 @@ function billingSetup(string $plan = 'start'): array
$order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => $plan]);
Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => $plan, 'status' => 'active']);
// Several of these tests call Billing::purchase(), which since Task 5
// refuses outright without a Stripe key for the active mode.
withStripeSecret();
return compact('user', 'customer');
}

View File

@ -3,14 +3,21 @@
use App\Livewire\Billing;
use App\Livewire\ConfirmRemoveOrder;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
function cartCustomer(): array
{
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
// Some of these tests call Billing::purchase(), which since Task 5
// refuses outright without a Stripe key for the active mode.
withStripeSecret();
return [$customer, $user];
}
@ -73,7 +80,7 @@ it('refuses to remove an order that is already paid', function () {
it('keeps at most one plan change in the cart', function () {
[$customer, $user] = cartCustomer();
App\Models\Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']);
Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']);
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'upgrade', 'business');
@ -88,7 +95,7 @@ it('keeps at most one plan change in the cart', function () {
it('still lets add-ons stack', function () {
[$customer, $user] = cartCustomer();
App\Models\Instance::factory()->for($customer)->create(['plan' => 'team', 'status' => 'active']);
Instance::factory()->for($customer)->create(['plan' => 'team', 'status' => 'active']);
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'storage');
@ -127,7 +134,7 @@ it('separates what recurs from a one-off top-up', function () {
// Formatted through the same helper the view uses, or the non-breaking
// space in the currency makes this a test about whitespace.
$expected = __('billing.cart.recurring_note', [
'amount' => Illuminate\Support\Number::currency(12.00, in: 'EUR', locale: 'de'),
'amount' => Number::currency(12.00, in: 'EUR', locale: 'de'),
]);
Livewire\Livewire::actingAs($user)->test(Billing::class)->assertSee($expected);
@ -143,7 +150,7 @@ it('keeps the recurring figure consistent with the total to the cent', function
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 3, 'status' => 'pending']);
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 3, 'status' => 'pending']);
$expected = Illuminate\Support\Number::currency(0.08, in: 'EUR', locale: 'de');
$expected = Number::currency(0.08, in: 'EUR', locale: 'de');
Livewire\Livewire::actingAs($user)->test(Billing::class)
->assertSee($expected) // total gross
@ -188,7 +195,7 @@ it('accepts a verified value stored in display form', function () {
config()->set('provisioning.tax.rate_percent', 20);
config()->set('provisioning.tax.seller_country', 'AT');
[$customer, ] = cartCustomer();
[$customer] = cartCustomer();
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
// A verifier may hand back the number the way it prints it; spaces are
@ -200,7 +207,7 @@ it('accepts a verified value stored in display form', function () {
]);
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue()
->and($customer->fresh()->vat_id_verified_at)->toBeInstanceOf(Illuminate\Support\Carbon::class)
->and($customer->fresh()->vat_id_verified_at)->toBeInstanceOf(Carbon::class)
->and(Order::query()->first()->fresh()->grossCents())->toBe(1000);
});
@ -208,7 +215,7 @@ it('will not let a typed-in VAT ID zero the tax', function () {
config()->set('provisioning.tax.rate_percent', 20);
config()->set('provisioning.tax.seller_country', 'AT');
[$customer, ] = cartCustomer();
[$customer] = cartCustomer();
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
// Plausible-looking but unverified: still domestic VAT.

View File

@ -0,0 +1,102 @@
<?php
use App\Exceptions\StripeNotConfigured;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\Order;
use App\Models\User;
use App\Services\Secrets\SecretVault;
use App\Services\Stripe\HttpStripeClient;
use App\Support\OperatingMode;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
/**
* Fehlt der Schlüssel des aktiven Modus, entsteht kein Auftrag und der Kunde
* liest einen Satz statt einer 500er-Seite.
*
* Die Ausnahme ist eine KONKRETE Klasse. `toThrow(Throwable::class)` würde hier
* jeden beliebigen Fehler durchgehen lassen, auch einen Tippfehler im Test.
*/
it('refuses to reach stripe without a key for the active mode', function () {
OperatingMode::set(OperatingMode::Test);
expect(fn () => app(HttpStripeClient::class)->createPrice('prod_x', 100, 'EUR', 'month'))
->toThrow(StripeNotConfigured::class);
});
/**
* Die andere Hälfte derselben Entscheidung, und der Riegel vor dem Merge:
* `secret()` WIRFT, `isConfigured()` WIRFT NICHT.
*
* Sechs Aufrufer stellen genau diese Frage, um KEINE Ausnahme zu bekommen
* CheckoutController:87 (der Kunde liest sonst eine 500 statt eines Satzes),
* SyncStripeCatalogue, RepriceStripeSubscriptions, SyncStripeAddonItems,
* MoveStripeSubscriptionPrice. `main` trägt an dieser Stelle noch
* `return filled($this->secret());`, und der Trockenlauf des Merges führt die
* Datei OHNE Konfliktmarkierung zusammen: niemand wird gezwungen hinzusehen.
* Mit `main`s Rumpf und dem werfenden `secret()` dieses Zweigs wird aus der
* Frage eine Ausnahme und ohne diesen Test bleiben dabei 1795 Tests grün
* (nachgewiesen im Schluss-Review durch genau diese Mutation).
*/
it('answers the configured question instead of throwing it', function () {
Http::fake();
OperatingMode::set(OperatingMode::Test);
// Nicht toBeFalse() allein: eine geworfene Ausnahme wäre kein „false",
// sondern gar keine Antwort — und genau das ist der Rückschritt.
expect(app(HttpStripeClient::class)->isConfigured())->toBeFalse();
// Die Frage darf auch nichts kosten: sie wird auf jedem Kassenaufruf
// gestellt und liest nur den Tresor.
Http::assertNothingSent();
});
it('says configured once the slot of the active mode is filled', function () {
Http::fake();
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);
expect(app(HttpStripeClient::class)->isConfigured())->toBeTrue();
Http::assertNothingSent();
});
it('does not create an order when the key is missing', function () {
OperatingMode::set(OperatingMode::Test);
$customer = Customer::factory()->create();
// A portal user is matched to its customer by email, not by a foreign
// key — see the existing Billing feature tests (e.g. StorageAllowanceTest).
$user = User::factory()->create(['email' => $customer->email]);
// 'team' is a real, sellable plan (seeded by the plan-catalogue migration)
// that ranks above the customer's implicit 'start' plan — a genuine
// upgrade the purchase() guards would otherwise accept. A made-up key
// would be refused by the existing plan-validity check regardless of
// whether the Stripe guard works at all, and would prove nothing.
//
// assertHasErrors(), not assertHasNoErrors(): the guard reports through
// addError('purchase', …) — the same page-blocking pattern order.blade.php
// already uses for "cannot proceed" — and Livewire's test harness folds
// any non-empty error bag into the call's result. Asserting no errors
// here would contradict the very behaviour this test exists to prove.
Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'upgrade', 'team')
->assertHasErrors('purchase');
// Der Punkt: nach der Zahlung wäre es zu spät. Es darf gar nichts entstehen.
expect(Order::count())->toBe(0);
});
it('says why instead of failing silently', function () {
OperatingMode::set(OperatingMode::Test);
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'upgrade', 'team')
->assertSee(__('billing.stripe_not_configured'));
});

View File

@ -19,7 +19,6 @@ use Livewire\Livewire;
* disabled button that does not name the obstacle is the thing people ring
* about.
*/
function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
{
$user = User::factory()->create();
@ -44,6 +43,12 @@ function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
]);
}
// One test below calls Billing::purchase(), which since Task 5 refuses
// outright without a Stripe key for the active mode. Without this, that
// test would see the same "no order" result for the wrong reason — the
// Stripe guard, not the seat block it names — and prove nothing.
withStripeSecret();
return [$user, $customer, $instance];
}

View File

@ -0,0 +1,78 @@
<?php
/**
* Das Archiv ist der einzige Weg, auf dem das Skript auf eine nackte Maschine
* kommt. Was hier schiefgeht, merkt man erst im Rettungssystem eines Servers,
* der schon bestellt ist.
*/
it('serves the bootstrap script as an archive', function () {
$response = $this->get('/bootstrap.tar.gz');
$response->assertOk();
expect($response->headers->get('Content-Type'))->toBe('application/gzip');
});
/**
* Der oberste Eintrag MUSS `bootstrap/` heißen. Die kopierte Zeile packt mit
* `tar xz -C /opt/clupilot` aus, und das Skript sucht sich selbst unter
* `/opt/clupilot/bootstrap` ein anderer Name landet daneben, und der Fehler
* zeigt sich als „lib/report.sh nicht gefunden" auf einer Maschine, an die man
* nur über die Anbieterkonsole kommt.
*/
it('unpacks to bootstrap/ with the script and its library', function () {
$this->get('/bootstrap.tar.gz')->assertOk();
$listing = shell_exec('tar tzf '.escapeshellarg(storage_path('app/bootstrap.tar.gz')));
expect($listing)
->toContain('bootstrap/clupilot-bootstrap.sh')
->toContain('bootstrap/lib/report.sh')
->toContain('bootstrap/lib/proxmox.sh')
->toContain('bootstrap/lib/network.sh')
->toContain('bootstrap/lib/traefik.sh')
->toContain('bootstrap/lib/template.sh')
->toContain('bootstrap/lib/register.sh')
->toContain('bootstrap/assets/docker-compose.yml');
});
/**
* Es darf nichts Vertrauliches darin stehen. Alle Geheimnisse reisen in der
* Befehlszeile mit, die der Adminbereich genau einmal zeigt.
*/
it('carries no secrets', function () {
$this->get('/bootstrap.tar.gz')->assertOk();
$dir = sys_get_temp_dir().'/bootstrap-pruefung-'.bin2hex(random_bytes(4));
mkdir($dir);
shell_exec('tar xzf '.escapeshellarg(storage_path('app/bootstrap.tar.gz')).' -C '.escapeshellarg($dir));
$content = '';
foreach (glob($dir.'/bootstrap/{,lib/,assets/}*', GLOB_BRACE) as $file) {
if (is_file($file)) {
$content .= file_get_contents($file);
}
}
expect($content)->not->toBeEmpty()
->and($content)->not->toContain(config('app.key'))
->and($content)->not->toContain((string) env('SECRETS_KEY'));
shell_exec('rm -rf '.escapeshellarg($dir));
});
/**
* Ein Archiv, das einmal gebaut wurde und danach liegen bleibt, ist die Fassung
* von dem Tag, an dem jemand zuletzt daran gedacht hat.
*/
it('rebuilds itself when the script changes', function () {
$this->get('/bootstrap.tar.gz')->assertOk();
$first = filemtime(storage_path('app/bootstrap.tar.gz'));
// Eine Sekunde zurück, damit die Änderung sichtbar später liegt.
touch(storage_path('app/bootstrap.tar.gz'), $first - 10);
touch(base_path('deploy/bootstrap/clupilot-bootstrap.sh'));
$this->get('/bootstrap.tar.gz')->assertOk();
expect(filemtime(storage_path('app/bootstrap.tar.gz')))->toBeGreaterThan($first - 10);
});

View File

@ -0,0 +1,119 @@
<?php
use App\Models\Host;
use App\Services\Wireguard\WireguardHub;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
fakeServices();
});
/**
* Der Code ist ein Ausweis für den Rückweg, kein Schlüssel zu einer Auskunft:
* er öffnet nichts nach außen (§5 der Spec), sondern verhindert nur, dass ein
* Gerät im Tunnel für einen fremden Host spricht.
*/
it('hands out the code exactly once and stores only its hash', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
expect($code)->toHaveLength(32);
$stored = DB::table('hosts')->where('id', $host->id)->value('enrolment_code_hash');
expect($stored)->not->toBe($code)
->and($stored)->not->toContain($code);
});
it('resolves a valid code to its host and consumes it', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
expect(HostEnrolment::claim($code)?->id)->toBe($host->id);
// Verbraucht. Ein zweiter Lauf braucht einen neuen Code aus der Konsole —
// eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert.
expect(HostEnrolment::claim($code))->toBeNull();
});
/**
* Der Fortschritt kommt VOR der Registrierung und darf den Code deshalb nicht
* verbrauchen sonst könnte der Host sich nach seiner ersten Meldung nie mehr
* registrieren.
*/
it('resolves a code without consuming it, for the progress reports', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
expect(HostEnrolment::resolve($code)?->id)->toBe($host->id)
->and(HostEnrolment::resolve($code)?->id)->toBe($host->id);
// Und danach ist er immer noch einlösbar.
expect(HostEnrolment::claim($code)?->id)->toBe($host->id);
});
it('refuses a code past its expiry', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
$this->travel(25)->hours();
expect(HostEnrolment::claim($code))->toBeNull();
});
it('refuses a code that was never issued', function () {
expect(HostEnrolment::claim(str_repeat('a', 32)))->toBeNull();
});
/**
* Der Hub muss den Peer kennen, BEVOR der Host im Tunnel ist sonst käme er
* nie hinein und müsste seinen Schlüssel über einen öffentlichen Weg melden.
* Genau das vermeidet dieser Entwurf.
*/
it('allocates a tunnel address and admits the peer up front', function () {
$host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]);
HostEnrolment::issue($host);
$host->refresh();
expect($host->wg_ip)->not->toBeNull()
->and($host->wg_pubkey)->not->toBeNull();
// `peers()` ist nach dem öffentlichen Schlüssel indiziert, die Werte sind
// Momentaufnahmen — geprüft wird deshalb gegen die Schlüssel.
expect(array_keys(app(WireguardHub::class)->peers()))->toContain($host->wg_pubkey);
});
/**
* Der private Schlüssel steht in der kopierten Befehlszeile und sonst nirgends.
* Ihn zu speichern hieße, ein Geheimnis aufzubewahren, das nach Task 9 des
* Skripts ohnehin wertlos ist und bis dahin eine Stelle mehr, an der es liegt.
*/
it('returns the private key with the code and never stores it', function () {
$host = Host::factory()->create();
$enrolment = HostEnrolment::issueWithKeys($host);
expect($enrolment['private_key'])->not->toBeEmpty();
$row = (array) DB::table('hosts')->where('id', $host->id)->first();
foreach ($row as $value) {
expect((string) $value)->not->toContain($enrolment['private_key']);
}
});
/**
* Ein zweiter Code macht den ersten wertlos. Sonst hätte eine Maschine, die im
* Rettungssystem hängengeblieben ist, weiter einen gültigen Ausweis für einen
* Host, den der Betreiber gerade neu übernimmt.
*/
it('invalidates the previous code when a new one is issued', function () {
$host = Host::factory()->create();
$first = HostEnrolment::issue($host);
$second = HostEnrolment::issue($host);
expect(HostEnrolment::claim($first))->toBeNull()
->and(HostEnrolment::claim($second)?->id)->toBe($host->id);
});

View File

@ -0,0 +1,34 @@
<?php
use App\Support\OperatingMode;
use App\Support\Settings;
/**
* Der Modus entscheidet, welche Zugangsdaten gelten. Er wird an genau einer
* Stelle gelesen, damit nirgends sonst ein String verglichen wird ein
* Tippfehler in einem Vergleich wäre sonst ein stiller Wechsel auf Live.
*/
it('is live when nothing has been stored', function () {
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
it('reads the stored mode', function () {
Settings::set('platform.mode', 'test');
expect(OperatingMode::current())->toBe(OperatingMode::Test);
});
it('falls back to live when the stored value is not a mode', function () {
// Eine Zeile, die von Hand oder von einer alten Fassung geschrieben wurde.
// Live ist die sichere Richtung: eine echte Zahlung wird korrekt behandelt,
// ein Testereignis scheitert laut.
Settings::set('platform.mode', 'staging');
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
it('writes the mode and takes effect immediately', function () {
OperatingMode::set(OperatingMode::Test);
expect(OperatingMode::current())->toBe(OperatingMode::Test);
});

View File

@ -0,0 +1,388 @@
<?php
use App\Models\Host;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Services\Dns\DnsTokenCheck;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Proxmox\VmTemplateCheck;
use App\Services\Vpn\WireguardEndpointCheck;
use App\Support\Settings;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* Ein UDP-Port lässt sich von außen nicht sauber anklopfen es gibt keinen
* Handshake zu beobachten. Was ENTSCHEIDBAR ist: ob dort überhaupt eine
* Adresse steht, die ein Host im Internet erreichen kann.
*
* Am 2026-07-30 stand `10.10.90.185:51820` in der Konfiguration. Das sieht
* gesetzt aus und ist für jeden Host außerhalb dieses Netzes unerreichbar.
*
* Jeder Test ruft trotzdem Http::fake() ohne Ausnahme, wie jeder Test dieser
* drei Prüfungen. WireguardEndpointCheck macht heute keinen Netzverkehr, aber
* die Zusicherung gilt der Prüfung, nicht der heutigen Implementierung.
*/
it('rejects a private address', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['ok'])->toBeFalse();
});
it('rejects a documentation address', function () {
// RFC 5737 — Seed- und Beispieldaten landen erfahrungsgemäß in echten
// Installationen.
Http::fake();
expect((new WireguardEndpointCheck)->run('203.0.113.11:51820')['ok'])->toBeFalse();
});
it('rejects loopback', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('127.0.0.1:51820')['ok'])->toBeFalse();
});
it('accepts a public address', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('198.51.45.9:51820')['ok'])->toBeTrue();
});
it('accepts a hostname, which it cannot judge', function () {
// Ein Name kann öffentlich auflösen; hier zu raten wäre schlechter als
// durchzulassen — die Prüfung sagt, was sie weiß, nicht was sie vermutet.
Http::fake();
expect((new WireguardEndpointCheck)->run('vpn.clupilot.cloud:51820')['ok'])->toBeTrue();
});
it('rejects an endpoint without a port', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('198.51.45.9')['ok'])->toBeFalse();
});
it('names the offending address, so the page can show it', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['reason'])->toBe('not_public');
});
it('rejects a blank endpoint', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('')['reason'])->toBe('missing');
});
/**
* Fix-Runde (Befund 1): FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE
* does not reject RFC 6598 Carrier-Grade NAT verified live against
* `100.64.0.5:51820` before this fix, which came back `ok: true`. A host
* behind CGNAT is exactly as unreachable from outside as one behind RFC 1918,
* it is just an ISP handing the address out instead of an operator.
*/
it('rejects a carrier-grade NAT address', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('100.64.0.5:51820')['ok'])->toBeFalse();
});
/**
* Fix-Runde (Befund 1, vollständiger Durchgang): kein Mehrfach-Empfänger ist
* je der eigene Endpunkt eines einzelnen WireGuard-Peers.
*/
it('rejects a multicast address', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('224.1.1.1:51820')['ok'])->toBeFalse();
});
// --- DnsTokenCheck --------------------------------------------------------
//
// SICHERHEITSAUFLAGE: jeder dieser Tests benutzt Http::fake() und darf unter
// keinen Umständen die echte Hetzner-API oder die echte Zone berühren. Ein
// Test, der clupilot.cloud tatsächlich anfasst, ist ein Fehlschlag der
// Aufgabe, auch wenn er grün ist.
beforeEach(function () {
// Eine feste, erfundene Zone statt des .env-Werts dieser Installation —
// ein Test, der den echten Zonennamen des Entwicklerrechners nachrechnet,
// beweist nichts (siehe phpunit.xml zu CLUPILOT_DNS_ZONE).
Settings::set('provisioning.dns_zone', 'probe.example');
});
it('confirms write access by writing a probe record and removing it again', function () {
// Genau der Punkt der ganzen Prüfung: Zone LISTEN allein beweist nichts,
// weil ein Leserecht-Token das ebenfalls anstandslos tut. Erst Schreiben
// und Löschen beweisen ein Schreibrecht-Token.
Http::fake([
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
'dns.hetzner.com/api/v1/records' => Http::response(['record' => ['id' => 'rec-probe-1']]),
'dns.hetzner.com/api/v1/records/*' => Http::response([], 200),
]);
$result = (new DnsTokenCheck)->run('rw-token');
expect($result['ok'])->toBeTrue()
->and($result['reason'])->toBe('writable')
->and($result['probe_removed'])->toBeTrue();
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& str_contains((string) $request->url(), '/records')
&& $request->hasHeader('Auth-API-Token', 'rw-token'));
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& str_contains((string) $request->url(), '/records/rec-probe-1'));
});
it('reports the zone as not found when the token cannot see it', function () {
// Ein Token für eine andere Zone (oder gar keine) listet erfolgreich,
// findet die konfigurierte Zone darin aber nicht.
Http::fake([
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-9', 'name' => 'someone-elses-zone.example']]]),
]);
$result = (new DnsTokenCheck)->run('some-token');
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('zone_not_found')
->and($result['zone'])->toBe('probe.example');
// Kein Schreibversuch, wenn die Zone gar nicht gefunden wurde.
Http::assertNotSent(fn ($request) => $request->method() === 'POST');
});
it('tells a read-only token apart from one that can write', function () {
// Genau der Leserecht-Token aus dem Handoff: er listet die Zone
// anstandslos (200) und sieht in der Konsole aus wie ein funktionierender
// — bis zum Schreibversuch, den Hetzner mit 403 quittiert.
Http::fake([
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
'dns.hetzner.com/api/v1/records' => Http::response(['error' => 'forbidden'], 403),
]);
$result = (new DnsTokenCheck)->run('read-only-token');
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('read_only')
->and($result['status'])->toBe(403);
// Kein Löschversuch für einen Eintrag, der nie geschrieben wurde.
Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
});
/**
* Fix-Runde (Befund 2): Das GET auf /zones ist gegen Throwable abgesichert
* (Reason "unreachable"), der Schreibversuch danach war es nicht ein
* Netzwerkfehler genau dort hätte die Ausnahme aus run() herausgeworfen, auf
* einer Seite, deren einziger Zweck ist, ruhig zu berichten. "Netz kaputt"
* bleibt dabei von "Token darf nicht schreiben" (read_only, 403 s.o.) und von
* "Zone falsch benannt" getrennt dieselbe Reason wie beim GET, weil es
* dieselbe Fehlerklasse ist, nur an einer anderen Stelle der Kette.
*/
it('reports unreachable when the write attempt itself cannot reach the network', function () {
Http::fake([
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
'dns.hetzner.com/api/v1/records' => function () {
throw new ConnectionException('Could not resolve host');
},
]);
$result = (new DnsTokenCheck)->run('rw-token');
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('unreachable');
});
/**
* Fix-Runde (Befund 2, Sonderfall Aufräumen): der Schreibversuch selbst hat
* bereits bewiesen, dass der Token schreiben darf das ist der Zweck der
* ganzen Prüfung, und ein kaputtes Netz beim Löschen ändert daran nichts.
* `probe_removed` muss trotzdem false sein, und der liegengebliebene Eintrag
* muss benennbar bleiben, sonst weiß niemand, dass er in der Zone liegt.
*/
it('still proves write access when the cleanup delete cannot reach the network', function () {
Http::fake([
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
'dns.hetzner.com/api/v1/records' => Http::response(['record' => ['id' => 'rec-orphaned']]),
'dns.hetzner.com/api/v1/records/*' => function () {
throw new ConnectionException('Could not resolve host');
},
]);
$result = (new DnsTokenCheck)->run('rw-token');
expect($result['ok'])->toBeTrue()
->and($result['reason'])->toBe('writable')
->and($result['probe_removed'])->toBeFalse()
->and($result['leftover_record_id'])->toBe('rec-orphaned');
});
it('does not attempt anything without a token or a zone', function () {
// Zurückgesetzt statt sich auf den .env-Wert dieser Installation zu
// verlassen — genau die Falle, die phpunit.xml zu CLUPILOT_DNS_ZONE
// dokumentiert, und der Grund, warum HETZNER_DNS_TOKEN dort jetzt
// ebenfalls auf leer gepinnt ist.
config()->set('provisioning.dns.token', '');
Http::fake();
expect((new DnsTokenCheck)->run('')['reason'])->toBe('missing');
Http::assertNothingSent();
});
// --- VmTemplateCheck -------------------------------------------------------
beforeEach(function () {
// 2026_07_26_040000_create_plan_catalogue_tables.php seeds a real baseline
// catalogue (start/team/business/enterprise, all template_vmid 9000) as
// part of the migration itself — not a Seeder, so it is present in every
// test database and does not roll away with RefreshDatabase. Left alone,
// it is unconditionally "required" by VmTemplateCheck alongside whatever
// a test creates. Closed here instead of deleted: a published version
// cannot be deleted (see PlanVersion::booted()), but its availability
// WINDOW is deliberately not frozen — closing it changes nothing about
// what the baseline promises, it only takes it out of "currently sold"
// so these tests can speak about their own versions in isolation.
PlanVersion::query()->whereNotNull('published_at')->update(['available_until' => now()->subDay()]);
});
function makePublishedVersion(int $templateVmid, ?string $familyKey = null): void
{
$family = PlanFamily::query()->create([
'key' => $familyKey ?? 'active-check-'.Str::random(8),
'name' => 'Active Check Plan',
'tier' => 9,
]);
$version = $family->versions()->create([
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
'available_from' => now(),
]);
$version->update(['published_at' => now(), 'template_vmid' => $templateVmid]);
}
it('is satisfied once the template actually exists on an active host', function () {
// FakeProxmoxClient talks to nothing real; Http::fake() rides along anyway
// — every test of these three checks does, without exception.
Http::fake();
$pve = new FakeProxmoxClient;
$pve->clonedVmids = [4001];
app()->instance(ProxmoxClient::class, $pve);
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
makePublishedVersion(4001);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeTrue()
->and($result['reason'])->toBe('present');
});
it('names the missing vmid when the template is not on any active host', function () {
Http::fake();
$pve = new FakeProxmoxClient; // clonedVmids/runningVmids stay empty: nothing exists anywhere
app()->instance(ProxmoxClient::class, $pve);
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
makePublishedVersion(4002);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('template_missing')
->and($result['vmids'])->toContain(4002);
});
/**
* Fix-Runde (Befund 3): ein Host, der gerade nicht antwortet, ist kein
* Beweis dafür, dass die Vorlage fehlt sie könnte genau auf diesem Host
* liegen. Der bestehende FakeProxmoxClient kann das nicht abbilden (jede
* Antwort ist entweder true oder false, nie eine Ausnahme), deshalb eine
* anonyme Unterklasse nach dem Muster aus TrafficTest.php
* ($flaky/$blind), die vmExists() gezielt scheitern lässt.
*/
it('does not confuse a silent host with a confirmed-missing template', function () {
Http::fake();
$silent = new class extends FakeProxmoxClient
{
public function vmExists(string $node, int $vmid): bool
{
throw new RuntimeException('proxmox unreachable');
}
};
app()->instance(ProxmoxClient::class, $silent);
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
makePublishedVersion(4004);
$result = app(VmTemplateCheck::class)->run();
// "build the template" and "your hosts are not answering" are two
// different instructions — a reason of 'template_missing' here would
// send an operator to build a template that might already exist.
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('hosts_unreachable')
->and($result['reason'])->not->toBe('template_missing')
->and($result['vmids'])->toContain(4004);
});
/**
* Fix-Runde (Befund 3, Gegenprobe): ein Host, der antwortet und die Vorlage
* bestätigt, darf das Ergebnis nicht kippen lassen, nur weil ein ANDERER
* Host in derselben Runde schweigt "gefunden" sticht "still", und die
* Schleife bricht bei einem Treffer ab, statt den stillen Host danach noch
* als Beweis für irgendetwas zu werten.
*
* Eine einzige ProxmoxClient-Instanz bedient in VmTemplateCheck ALLE Hosts
* (`forHost($host)` wird pro Host auf demselben `$this->pve` aufgerufen), also
* unterscheidet dieser Fake anhand dessen, welcher Host zuletzt über
* `forHost()` hereinkam genau das Feld, das `FakeProxmoxClient::forHost()`
* ohnehin schon merkt.
*/
it('still confirms a template that a working host has, despite a silent one', function () {
Http::fake();
$mixed = new class extends FakeProxmoxClient
{
public function vmExists(string $node, int $vmid): bool
{
if ($this->host?->node === 'silent-node') {
throw new RuntimeException('proxmox unreachable');
}
return parent::vmExists($node, $vmid);
}
};
$mixed->clonedVmids = [4005]; // what the WORKING host actually has
app()->instance(ProxmoxClient::class, $mixed);
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'silent-node']);
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-2', 'node' => 'working-node']);
makePublishedVersion(4005);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeTrue()
->and($result['reason'])->toBe('present');
});
it('reports no usable host when nothing is left to ask', function () {
Http::fake();
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
// Kein aktiver Host mit lesbarem Token — derselbe Zustand, den
// ProvisioningChecks::usable_host als Sperre meldet.
Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
makePublishedVersion(4003);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('no_usable_host');
});
it('is satisfied when no plan version is published yet', function () {
Http::fake();
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeTrue()
->and($result['reason'])->toBe('nothing_published');
});

View File

@ -0,0 +1,219 @@
<?php
use App\Models\InvoiceSeries;
use App\Models\Operator;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
use App\Support\Settings;
use App\Support\StripeCatalogueMode;
/**
* Die Bereitschaftsprüfung berichtet, was fehlt und sagt dazu, was
* kaputtgeht. Ein Feldname allein ist keine Auskunft: „vat_id fehlt" sagt
* nichts, „der Kunde bekommt keinen Beleg" sagt alles.
*/
function checkFor(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
it('reports the stripe key of the active mode as missing', function () {
OperatingMode::set(OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
});
it('reports it as satisfied once the key of that mode is stored', function () {
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
});
it('does not accept a live key as proof while the test mode is active', function () {
// Sonst meldete die Seite Bereitschaft für einen Modus, in dem die Kasse
// sperrt — die Seite widerspräche der Sperre aus Task 5.
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_live_x', Operator::factory()->create(), OperatingMode::Live);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
});
/**
* Ein Schlüssel im falschen Platz ist ein blockierender Befund.
*
* Gemessen im Schluss-Review: `put('stripe.secret', 'sk_live_REALMONEY')` im
* Testbetrieb wird angenommen, `get()` liefert ihn, die Bereitschaftsseite
* meldete „erfüllt" — und oben auf derselben Seite steht die Plakette
* „Testbetrieb". Das ist derselbe Ausgang, gegen den die strikte Ausnahme im
* Tresor gebaut wurde („bucht echtes Geld ab, während die Konsole Testbetrieb
* anzeigt"), nur über den eingetippten statt den automatischen Weg.
*
* Am Präfix ohne Netzverkehr entscheidbar OperatingMode::ofStripeKey(),
* dieselbe Stelle, die auch die Migration und StripeCheck fragen.
*/
it('refuses a live key sitting in the test slot', function () {
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
});
it('refuses a test key sitting in the live slot', function () {
// Die andere Richtung kostet kein Geld, sondern nimmt keins ein: der Kunde
// zahlt in Stripes Testkonto, und die Bestellung sieht bezahlt aus.
OperatingMode::set(OperatingMode::Live);
app(SecretVault::class)->put('stripe.secret', 'sk_test_notreal', Operator::factory()->create(), OperatingMode::Live);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
});
it('accepts the restricted key of the active mode', function () {
// rk_ ist ein echter Schlüssel, kein Sonderfall — die Erkennung darf ihn
// nicht für einen Widerspruch halten, nur weil er nicht sk_ heißt.
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'rk_test_readonly', Operator::factory()->create(), OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
});
it('says what the wrong key does, not that a field is empty', function () {
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
// Der Satz für „gar kein Schlüssel" wäre hier eine Lüge: einer liegt da,
// er ist der falsche, und die Folge ist eine ganz andere.
expect(checkFor('billing.stripe_secret')->breaks)
->not->toBe(__('readiness.billing.stripe_secret_breaks'));
});
it('reports incomplete company details without repeating the list', function () {
Settings::forget('company.name');
expect(checkFor('billing.company_details')->satisfied)->toBeFalse();
});
it('names what breaks, not just what is missing', function () {
expect(checkFor('billing.company_details')->breaks)->not->toBe('');
});
it('says the installation is not ready while something blocking is open', function () {
expect(Readiness::isReady())->toBeFalse();
expect(Readiness::blocking())->not->toBeEmpty();
});
it('does not report the invoice series ready when the required series is switched off', function () {
// IssueInvoice draws its series with `where('kind', ...)->where('active', true)`
// (IssueInvoice.php:373 and :444) — a deactivated 'invoice' row is as
// invisible to it as a missing one. Counting distinct kinds without the
// same `active` filter would report readiness for an installation where
// not a single invoice can draw a number.
InvoiceSeries::query()->where('kind', 'invoice')->update(['active' => false]);
expect(checkFor('billing.invoice_series')->satisfied)->toBeFalse();
});
it('does not raise a permanent alarm for a draft version stripe never touches', function () {
// stripe:sync-catalogue only mirrors PUBLISHED versions (its own header
// comment: "Only PUBLISHED versions are synced. A draft has promised
// nothing..."). A draft is an entirely ordinary state — there is a
// dedicated modal to create one — so a query that does not also filter on
// `published_at` would keep this check permanently unsatisfied on any
// installation that has ever drafted a next version: an alarm that is
// always on and therefore never read.
PlanPrice::query()->update(['stripe_price_id' => 'price_already_synced']);
$family = PlanFamily::query()->create(['key' => 'draftonly', 'name' => 'Draft Only', 'tier' => 9]);
$draft = $family->versions()->create([
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
'available_from' => now(),
// published_at left null: a draft, exactly what stripe:sync-catalogue skips.
]);
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 1000, 'currency' => 'EUR']);
// Der Abgleich hält seit dieser Runde fest, in welchem Konto er die IDs
// angelegt hat; ohne diese Zeile wäre der Katalog oben von unbekannter
// Herkunft und die Prüfung meldete zu Recht einen Widerspruch.
StripeCatalogueMode::record();
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
});
/**
* Der Modus schaltet die Zugangsdaten um nicht die Objekte, die aus ihnen
* entstanden sind.
*
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der geplante
* Ablauf dieser Installation war: im Testbetrieb abgleichen, Live-Schlüssel
* hinterlegen, umschalten und diese Prüfung meldete weiter „erfüllt", weil
* sie nur nachsah, ob die Spalte gefüllt ist. Die Seite sagte „Bereit für
* Livebetrieb", und die erste echte Bestellung bekam von Stripe *No such
* price*. Kein Auftrag, und die Kernzusage dieser Seite gebrochen in genau dem
* Moment, für den sie existiert.
*/
it('does not call the catalogue synced when its ids belong to the other account', function () {
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']);
StripeCatalogueMode::record(OperatingMode::Test);
OperatingMode::set(OperatingMode::Live);
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse();
expect(checkFor('billing.catalogue_synced')->severity)->toBe(Check::SEVERITY_BLOCKING);
});
it('says the sale is refused and a fresh sync is needed, not that a column is empty', function () {
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']);
StripeCatalogueMode::record(OperatingMode::Test);
OperatingMode::set(OperatingMode::Live);
// Der Satz für „nie abgeglichen" wäre hier falsch: abgeglichen wurde, nur
// in einem anderen Konto — und ein blosser neuer Lauf repariert das NICHT,
// weil stripe:sync-catalogue jede Zeile überspringt, die schon eine ID
// trägt. Das muss dastehen, sonst schickt die Seite den Betreiber auf einen
// Befehl, der ihm „already in step" antwortet.
expect(checkFor('billing.catalogue_synced')->breaks)
->not->toBe(__('readiness.billing.catalogue_synced_breaks'))
// Nur DIESER Fall trägt die Leeranweisung.
->and(checkFor('billing.catalogue_synced')->breaks)->toContain('stripe_plan_prices');
});
/**
* Der dritte Zustand, und er darf nicht mit dem zweiten zusammenfallen.
*
* IDs liegen da, aber nichts sagt, aus welchem Konto weil ein Abgleich
* mittendrin abgebrochen ist, oder weil eine Bestellung sich ihren fehlenden
* Preis selbst angelegt hat (CheckoutController PlanPrices::ensure(),
* BookAddon AddonPrices::ensure(); keiner der beiden ruft record()). Sie
* stammen aus genau dem geltenden Konto. Wer hier die Leeranweisung liest,
* löscht einen Katalog, an dem laufende Verträge abgerechnet werden.
*/
it('tells an unrecorded origin apart from a foreign account', function () {
PlanPrice::query()->update(['stripe_price_id' => 'price_minted_by_a_checkout']);
Settings::forget(StripeCatalogueMode::SETTING);
// Blockierend bleibt es: beweisen lässt sich die Herkunft nicht, und die
// Seite behauptet nichts, was sie nicht weiß.
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse();
// Aber die Handlungsanweisung ist „lauf den Abgleich noch einmal", nicht
// „lösche deinen Katalog" — kein Registername in diesem Satz.
expect(checkFor('billing.catalogue_synced')->breaks)
->not->toContain('stripe_plan_prices')
->and(checkFor('billing.catalogue_synced')->breaks)->toContain('stripe:sync-catalogue');
});
it('is satisfied again once the catalogue was synced in the mode that is running', function () {
PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_live_account']);
OperatingMode::set(OperatingMode::Live);
StripeCatalogueMode::record();
expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue();
});

View File

@ -0,0 +1,112 @@
<?php
use App\Models\Mailbox;
use App\Models\MailTemplate;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
function deliveryCheck(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
/**
* `mail.default = log` heißt: die VM läuft, der Beleg ist geschrieben, und die
* Mail, die dem Kunden seine fertige Cloud ankündigt, liegt in einer Datei auf
* dem Server. Der Kunde erfährt nie, dass er eine Cloud hat. Kein Fehler
* taucht irgendwo auf.
*/
it('refuses to call an installation ready while mail goes to the log file', function () {
config()->set('mail.default', 'log');
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeFalse();
});
/**
* Fix-Runde (Befund 1): `!== 'log'` allein ist zu eng. MailboxTransport::
* NON_DELIVERING kennt bereits die vollständige Liste 'array' (der
* MAIL_MAILER, den phpunit.xml der GANZEN Suite aufzwingt) und der
* unkonfigurierte Fall (null) liefern genauso wenig zu wie 'log'. Eine
* Prüfung, die nur 'log' kennt, war unter dem eigenen Testlauf grün, obwohl
* hier nie eine Mail hinausginge genau das Grün-aus-falschem-Grund, gegen
* das diese Seite gebaut ist. Jeder Wert der Liste wird hier durchgegangen,
* nicht nur der eine, den der ursprüngliche Vorfall zeigte.
*/
it('does not accept any value MailboxTransport itself treats as non-delivering', function (?string $default) {
config()->set('mail.default', $default);
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeFalse();
})->with([
'log' => ['log'],
'array (the suite\'s own MAIL_MAILER)' => ['array'],
'unset' => [null],
]);
it('accepts a real mailer', function () {
config()->set('mail.default', 'smtp');
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeTrue();
});
it('needs at least one mailbox to send from', function () {
// Eine Migration seedet fünf Mailboxen (no-reply, support, billing,
// office, info) für jede Installation — auch für diese Suite. Auf den
// leeren Zustand zurückgesetzt, den dieser Test eigentlich prüft (siehe
// clearMailboxSeed() in Pest.php).
clearMailboxSeed();
// CompleteProvisioning verschickt CloudReady SYNCHRON und wirft, wenn
// MailboxResolver keine Mailbox findet — der letzte Schritt der
// Bereitstellung scheitert dann, nachdem die Maschine schon läuft und der
// Kunde schon bezahlt hat.
expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeFalse();
Mailbox::factory()->create();
expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeTrue();
});
it('treats missing reply templates as a warning, never as a blocker', function () {
// Eine Migration seedet einen Grundstock an Antwortvorlagen für jede
// Installation — auch für diese Suite. Geleert, um den leeren Zustand zu
// prüfen, den ein frisches Setup ohne diese Migration tatsächlich hätte.
MailTemplate::query()->delete();
expect(deliveryCheck('delivery.mail_templates')->satisfied)->toBeFalse();
expect(deliveryCheck('delivery.mail_templates')->severity)->toBe(Check::SEVERITY_WARNING);
MailTemplate::query()->create([
'name' => 'Test Template',
'subject' => 'Re: your question',
'body' => 'Thanks for reaching out.',
'sort' => 1,
'active' => true,
]);
expect(deliveryCheck('delivery.mail_templates')->satisfied)->toBeTrue();
});
it('treats a missing inbound mail password as a warning, never as a blocker', function () {
OperatingMode::set(OperatingMode::Live);
expect(deliveryCheck('delivery.inbound_mail_password')->satisfied)->toBeFalse();
expect(deliveryCheck('delivery.inbound_mail_password')->severity)->toBe(Check::SEVERITY_WARNING);
app(SecretVault::class)->put('inbound_mail.password', 'secret', Operator::factory()->create(), OperatingMode::Live);
expect(deliveryCheck('delivery.inbound_mail_password')->satisfied)->toBeTrue();
});
it('names what breaks for every delivery check, not just what is missing', function () {
foreach ([
'delivery.mailer_not_log',
'delivery.mailbox',
'delivery.mail_templates',
'delivery.inbound_mail_password',
] as $key) {
expect(deliveryCheck($key)->breaks)->not->toBe('');
}
});

View File

@ -0,0 +1,96 @@
<?php
use App\Provisioning\Jobs\RecordProvisioningHeartbeat;
use App\Support\Readiness;
use App\Support\Readiness\Check;
use App\Support\Settings;
use Illuminate\Support\Facades\Queue;
function operationCheck(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
/**
* „Ohne Worker passiert schlicht nichts, ohne Fehlermeldung." Genau dieser
* Zustand war in der Konsole bisher nicht von „alles ruhig" zu unterscheiden.
*
* Zwei getrennte Herzschläge, weil Zeitplaner und Warteschlangen-Worker
* getrennt ausfallen: der Zeitplaner kann laufen und Aufträge einstellen, die
* niemand abholt.
*/
it('reports a missing heartbeat as not satisfied', function () {
expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
});
it('accepts a fresh heartbeat', function () {
Settings::set('heartbeat.scheduler', now()->toIso8601String());
expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue();
});
it('rejects a heartbeat older than five minutes', function () {
Settings::set('heartbeat.scheduler', now()->subMinutes(6)->toIso8601String());
expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
});
it('proves the worker, not just the scheduler', function () {
// Der Zeitplaner läuft, der Bereitstellungs-Worker nicht: der eine
// Herzschlag ist frisch, der andere nicht.
Settings::set('heartbeat.scheduler', now()->toIso8601String());
expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue();
expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeFalse();
});
it('records the worker heartbeat when the job runs', function () {
(new RecordProvisioningHeartbeat)->handle();
expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeTrue();
});
/**
* Fix-Runde (Befund 1): calling ->handle() directly, as every test above
* does, proves only that the job writes its value never that it is the
* PROVISIONING worker doing it. That one property is the entire reason a
* second heartbeat exists at all: if this job ever migrated onto the default
* queue, this check would keep reporting a live provisioning worker while the
* queue that actually matters sat unattended the exact silent failure this
* task was built to catch.
*/
it('is pushed onto the provisioning queue, not the default one', function () {
Queue::fake();
RecordProvisioningHeartbeat::dispatch();
Queue::assertPushedOn('provisioning', RecordProvisioningHeartbeat::class);
});
/**
* Fix-Runde (Befund 2): reproduced live by the reviewer a garbled value in
* the heartbeat row made Carbon::parse() throw, which propagated straight out
* of Readiness::all(). Not just this one check failing: the whole readiness
* page, whose only purpose is to calmly report what is missing, going down
* with it. Unreachable through any real write path today, which is the
* argument FOR guarding it cheaply, not against.
*/
it('treats an unreadable heartbeat as not satisfied, without taking the whole page down', function () {
Settings::set('heartbeat.scheduler', 'not-a-timestamp');
$checks = Readiness::all();
expect(collect($checks)->firstWhere('key', 'operation.scheduler')->satisfied)->toBeFalse();
});
/**
* Fix-Runde (Befund 3): isFresh() had no upper bound a heartbeat with a
* timestamp in the future (a clock skewed between containers, or a corrupted
* value that still happens to parse) would report permanent health for as
* long as nobody noticed the clock was wrong.
*/
it('does not treat a heartbeat from the future as healthy', function () {
Settings::set('heartbeat.scheduler', now()->addHour()->toIso8601String());
expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
});

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