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>
feature/host-bootstrap
parent
2194ab8079
commit
ba4996f316
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ final class HostTakeoverCommand
|
|||
"--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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -55,13 +56,44 @@ final class HostTakeoverCommand
|
|||
*/
|
||||
public static function archiveUrl(): string
|
||||
{
|
||||
$appHost = (string) config('admin_access.app_host');
|
||||
$host = (string) config('admin_access.app_host');
|
||||
|
||||
if ($appHost === '') {
|
||||
return url('/bootstrap.tar.gz');
|
||||
// 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 'https://'.$appHost.'/bootstrap.tar.gz';
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ CLUPILOT_ROOT_SSH_KEY=''
|
|||
# 1, wenn der `[first-boot]`-Hook uns nach dem Neustart wieder aufgenommen hat.
|
||||
CLUPILOT_RESUMING=0
|
||||
|
||||
# Wird in `derive_fqdn` gesetzt; der Installer verlangt einen.
|
||||
# Kommt aus `--fqdn`. CluPilot vergibt den Namen; diese Maschine rät ihn nicht.
|
||||
CLUPILOT_FQDN=''
|
||||
|
||||
# Wird von `run_section` gefüllt, wenn ein Abschnitt der Meldung etwas
|
||||
|
|
@ -88,7 +88,11 @@ CLUPILOT_INSTALL_TIMEOUT="${CLUPILOT_INSTALL_TIMEOUT:-3600}"
|
|||
# Kontaktadresse für Let's Encrypt. Sie taucht in keinem Zertifikat auf, aber
|
||||
# an sie gehen die Ablaufwarnungen — eine Adresse, die niemand liest, ist die
|
||||
# Vorstufe zu abgelaufenen Kundenzertifikaten.
|
||||
CLUPILOT_ACME_EMAIL="${CLUPILOT_ACME_EMAIL:-ssl@clupilot.cloud}"
|
||||
#
|
||||
# Auf der PLATTFORM-Domain, nicht auf der Kundenzone: `.cloud` gehört den
|
||||
# Instanzen der Kunden, der Betreiber sitzt auf `.com`. Hier stand zuerst
|
||||
# `ssl@clupilot.cloud` — dieselbe Verwechslung, die auch RegisterHostDns hatte.
|
||||
CLUPILOT_ACME_EMAIL="${CLUPILOT_ACME_EMAIL:-ssl@clupilot.com}"
|
||||
|
||||
usage() {
|
||||
cat <<'TEXT'
|
||||
|
|
@ -102,6 +106,8 @@ fertigen Proxmox-Host.
|
|||
--hub-endpoint <host:port> Adresse des Hubs
|
||||
--api <url> CluPilot über die TUNNEL-Adresse, ohne Schrägstrich
|
||||
am Ende, z. B. http://10.66.0.1
|
||||
--fqdn <name> der Name, den CluPilot für diesen Host führt,
|
||||
z. B. fsn-01.node.clupilot.com
|
||||
|
||||
Freiwillig:
|
||||
|
||||
|
|
@ -139,6 +145,7 @@ parse_arguments() {
|
|||
--hub-pubkey) CLUPILOT_HUB_PUBKEY="${2:-}"; shift 2 ;;
|
||||
--hub-endpoint) CLUPILOT_HUB_ENDPOINT="${2:-}"; shift 2 ;;
|
||||
--api) CLUPILOT_API="${2:-}"; shift 2 ;;
|
||||
--fqdn) CLUPILOT_FQDN="${2:-}"; shift 2 ;;
|
||||
--root-ssh-key) CLUPILOT_ROOT_SSH_KEY="${2:-}"; shift 2 ;;
|
||||
--resume) CLUPILOT_RESUMING=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
|
|
@ -165,6 +172,7 @@ parse_arguments() {
|
|||
[ -n "$CLUPILOT_HUB_PUBKEY" ] || die '--hub-pubkey fehlt'
|
||||
[ -n "$CLUPILOT_HUB_ENDPOINT" ] || die '--hub-endpoint fehlt'
|
||||
[ -n "$CLUPILOT_API" ] || die '--api fehlt'
|
||||
[ -n "$CLUPILOT_FQDN" ] || die '--fqdn fehlt'
|
||||
|
||||
# Ein Schrägstrich am Ende ergäbe `…//host/progress`. Manche Router
|
||||
# beantworten das, manche nicht — und der Unterschied fiele erst beim ersten
|
||||
|
|
@ -214,27 +222,28 @@ running_in_rescue_system() {
|
|||
return 1
|
||||
}
|
||||
|
||||
# Der Installer verlangt einen FQDN, und die Befehlszeile bringt keinen mit.
|
||||
# Der Installer verlangt einen FQDN. Er kommt aus der Befehlszeile.
|
||||
#
|
||||
# Erst die Rückwärtsauflösung der öffentlichen Adresse — das ist der Name, den
|
||||
# der Anbieter für diese Maschine führt. Sonst ein Name aus der Tunneladresse,
|
||||
# der eindeutig ist und niemanden in die Irre führt. In Task 9 ersetzt CluPilot
|
||||
# ihn ohnehin durch den, den es selbst führt.
|
||||
derive_fqdn() {
|
||||
_ip="$(ip -4 -o addr show scope global 2>/dev/null | awk '{ sub(/\/.*/, "", $4); print $4; exit }')"
|
||||
|
||||
if [ -n "$_ip" ] && command -v getent >/dev/null 2>&1; then
|
||||
_name="$(getent hosts "$_ip" 2>/dev/null | awk '{ print $2; exit }')"
|
||||
case "$_name" in
|
||||
*.*.*)
|
||||
CLUPILOT_FQDN="$_name"
|
||||
# CluPilot kennt den Namen dieses Hosts — es hat ihn gerade vergeben —, also
|
||||
# gibt CluPilot ihn mit. Die erste Fassung leitete ihn hier ab: erst aus der
|
||||
# Rückwärtsauflösung, sonst aus der Tunneladresse, notfalls gegen eine fest
|
||||
# verdrahtete Domain. Alle drei waren falsch.
|
||||
#
|
||||
# Ein geratener Name bleibt nämlich stehen. Er landet in `/etc/hostname`, in
|
||||
# `/etc/hosts`, in jeder Protokollzeile dieser Maschine und in jedem
|
||||
# Zertifikatsantrag, den sie später stellt. Und die fest verdrahtete Domain war
|
||||
# `clupilot.net` — eine, die in diesem Projekt nirgends sonst vorkommt: die
|
||||
# Plattform liegt auf `.com`, die Kundeninstanzen auf `.cloud`, und `.net` war
|
||||
# schlicht erfunden. `PrepareBaseSystem.php` macht denselben Fehler.
|
||||
#
|
||||
# Der Rückfall bleibt, aber er rät nicht mehr über die Domain: ohne `--fqdn`
|
||||
# wird abgebrochen.
|
||||
require_fqdn() {
|
||||
if [ -n "$CLUPILOT_FQDN" ]; then
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
_suffix="$(printf '%s' "$CLUPILOT_WG_IP" | sed -e 's#/.*##' -e 's/\./-/g')"
|
||||
CLUPILOT_FQDN="host-${_suffix}.clupilot.net"
|
||||
die '--fqdn fehlt. Der Name dieses Hosts wird von CluPilot vergeben und nicht auf der Maschine erraten — ein geratener Name bleibt in /etc/hosts und in jedem späteren Zertifikatsantrag stehen.'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -461,7 +470,7 @@ section_debian_installed() {
|
|||
_raid="$(zfs_raid_for "$_count")"
|
||||
_boot_mode="$(detect_boot_mode)"
|
||||
|
||||
derive_fqdn
|
||||
require_fqdn
|
||||
log "Ziel: ${CLUPILOT_FQDN}, ${_boot_mode}, ZFS ${_raid} über ${_count} Platte(n): ${_disks}"
|
||||
|
||||
if ! ensure_autoinstall_assistant; then
|
||||
|
|
|
|||
|
|
@ -359,6 +359,8 @@ 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"
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ aber entschieden): als **Archiv**. Die kopierte Zeile aus dem Adminbereich holt
|
|||
dort — ungefähr so:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://clupilot.cloud/bootstrap.tar.gz | tar xz -C /opt/clupilot \
|
||||
curl -fsSL https://app.clupilot.com/bootstrap.tar.gz | tar xz -C /opt/clupilot \
|
||||
&& /opt/clupilot/bootstrap/clupilot-bootstrap.sh --code … --wg-private … …
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ use Livewire\Livewire;
|
|||
|
||||
beforeEach(function () {
|
||||
fakeServices();
|
||||
config()->set('admin_access.app_host', 'clupilot.cloud');
|
||||
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
|
||||
|
|
@ -60,8 +61,8 @@ it('marks the two provider steps as things to do first', function () {
|
|||
* 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('clupilot.cloud/bootstrap.tar.gz');
|
||||
guidePage(Hosts::class)->assertSee('clupilot.cloud/bootstrap.tar.gz');
|
||||
guidePage(HostCreate::class)->assertSee('app.clupilot.com/bootstrap.tar.gz');
|
||||
guidePage(Hosts::class)->assertSee('app.clupilot.com/bootstrap.tar.gz');
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ beforeEach(function () {
|
|||
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', 'clupilot.cloud');
|
||||
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.
|
||||
|
|
@ -61,7 +62,7 @@ it('shows the command exactly once, right after creating the host', function ()
|
|||
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'] as $argument) {
|
||||
foreach (['--code', '--wg-private', '--wg-ip', '--hub-pubkey', '--hub-endpoint', '--api', '--fqdn'] as $argument) {
|
||||
expect($command)->toContain($argument);
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +70,33 @@ it('carries every value the script needs before the tunnel', function () {
|
|||
->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');
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -80,7 +108,7 @@ it('carries every value the script needs before the tunnel', function () {
|
|||
*/
|
||||
it('fetches the installer over the public hostname', function () {
|
||||
expect(createHostAs()->get('command'))
|
||||
->toContain('https://clupilot.cloud/bootstrap.tar.gz')
|
||||
->toContain('https://app.clupilot.com/bootstrap.tar.gz')
|
||||
->not->toContain('admin.');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue