Commit Graph

3 Commits (feature/host-bootstrap)

Author SHA1 Message Date
nexxo 156de12c8c Give the downloads a hostname of their own, with two halves
A domain that exists only to serve one installer is not worth a certificate.
One that also carries the AGB, the AV and the TOM is — those addresses go into
contracts and onto invoices and have to still resolve in three years, which an
address that moves with the next rebuild of the portal cannot promise. That
reason is what changed the answer.

files.… over cdn./storage./archiv.: a CDN is an edge network and this is not
one, so the name would be a lie the day a real CDN goes in front of it.
"storage" reads like object storage or customer data, and a customer seeing it
will wonder whether their files live there. "archiv" says superseded, which the
terms currently in force are not — and R13 keeps paths and names English
anyway.

Two halves on that host, with opposite rules, and that is the whole point of
giving it its own name:

Public — storage/app/files/public/, served to anyone, indexable, because
somebody looking for the terms should find them. Versioned filenames:
agb-2026-01.pdf, never agb.pdf, so a contract signed in January cannot come to
point at conditions written in July. The rule is written where somebody will
look for it rather than enforced, because a upload that rejects a filename
helps nobody.

Private — the installer, and it is not a file in that directory at all: it is
built from deploy/bootstrap on demand. The gate is the one-time enrolment code
that is ALREADY in the pasted line, resolved without being consumed, because
the code is still needed for every progress report and for the registration at
the end. No second secret: a dedicated download token would never expire, would
sit in shell histories forever, and would travel in the same line as the
WireGuard private key — protecting the least sensitive thing with the exposure
of the most sensitive one. 404 rather than 403 on a bad code, and noindex on
the response.

Path traversal is answered before it starts: basename() only, no directory tree
under public/ by design, and dotfiles refused. The test walks ../../.env three
different ways.

Empty FILES_HOST keeps the archive on the portal host exactly as before, so
nothing breaks in the window between setting the variable and the DNS record
existing.

The hostname had to move into phpunit.xml rather than a config()->set(): routes
are bound at boot, so a test that sets it afterwards is setting it too late.

2025 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:37:47 +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 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