diff --git a/app/Livewire/Admin/Infrastructure.php b/app/Livewire/Admin/Infrastructure.php new file mode 100644 index 0000000..e8ec0f1 --- /dev/null +++ b/app/Livewire/Admin/Infrastructure.php @@ -0,0 +1,81 @@ +authorize('hosts.manage'); + + $this->dnsZone = ProvisioningSettings::dnsZone(); + $this->wgEndpoint = ProvisioningSettings::wgEndpoint(); + $this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey(); + $this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath(); + $this->sshPublicKey = ProvisioningSettings::sshPublicKey(); + $this->monitoringUrl = ProvisioningSettings::monitoringUrl(); + } + + public function save(): void + { + $this->authorize('hosts.manage'); + + $data = $this->validate([ + 'dnsZone' => ['nullable', 'string', 'max:255'], + 'wgEndpoint' => ['nullable', 'string', 'max:255'], + 'wgHubPubkey' => ['nullable', 'string', 'max:255'], + 'traefikDynamicPath' => ['nullable', 'string', 'max:255'], + 'sshPublicKey' => ['nullable', 'string', 'max:1000'], + 'monitoringUrl' => ['nullable', 'url', 'max:255'], + ]); + + Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone'])); + Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint'])); + Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey'])); + Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath'])); + Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey'])); + Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl'])); + + $this->dispatch('notify', message: __('infrastructure.saved')); + } + + public function render() + { + return view('livewire.admin.infrastructure'); + } +} diff --git a/app/Livewire/Admin/Secrets.php b/app/Livewire/Admin/Secrets.php index d3f435f..eeddca0 100644 --- a/app/Livewire/Admin/Secrets.php +++ b/app/Livewire/Admin/Secrets.php @@ -163,6 +163,10 @@ class Secrets extends Component // Only ever an outline, and only once unlocked. 'outline' => $unlocked ? $vault->outline($key) : null, 'updated_at' => $unlocked ? $vault->updatedAt($key) : null, + // The SSH identity is a multi-line PEM key: a single-line + // password field would mangle it on paste. Everything else + // here is a one-line token. + 'multiline' => $key === 'ssh.private_key', ]) ->values() ->all(), diff --git a/app/Notifications/CloudReady.php b/app/Notifications/CloudReady.php index cddf63f..534471e 100644 --- a/app/Notifications/CloudReady.php +++ b/app/Notifications/CloudReady.php @@ -3,8 +3,10 @@ namespace App\Notifications; use App\Mail\Concerns\SendsFromMailbox; +use App\Models\Datacenter; use App\Models\Instance; use App\Services\Mail\MailPurpose; +use App\Support\ProvisioningSettings; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -42,7 +44,7 @@ class CloudReady extends Notification public function toMail(object $notifiable): MailMessage { - $url = 'https://'.$this->instance->subdomain.'.'.config('provisioning.dns.zone'); + $url = 'https://'.$this->instance->subdomain.'.'.ProvisioningSettings::dnsZone(); [$from, $replyTo] = $this->mailboxAddresses(MailPurpose::PROVISIONING); @@ -90,7 +92,7 @@ class CloudReady extends Notification return '—'; } - $datacenter = \App\Models\Datacenter::query()->where('code', $code)->first(); + $datacenter = Datacenter::query()->where('code', $code)->first(); if ($datacenter === null) { return $code; diff --git a/app/Provisioning/Jobs/IssueInstanceAdminAccess.php b/app/Provisioning/Jobs/IssueInstanceAdminAccess.php index f1edd87..edb91f5 100644 --- a/app/Provisioning/Jobs/IssueInstanceAdminAccess.php +++ b/app/Provisioning/Jobs/IssueInstanceAdminAccess.php @@ -5,6 +5,7 @@ namespace App\Provisioning\Jobs; use App\Models\Instance; use App\Services\Proxmox\ProxmoxClient; use App\Services\Wireguard\ConfigHandoff; +use App\Support\ProvisioningSettings; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -100,7 +101,7 @@ class IssueInstanceAdminAccess implements ShouldQueue ]); ConfigHandoff::put(json_encode([ - 'url' => 'https://'.($instance->custom_domain ?: $instance->subdomain.'.'.config('provisioning.dns.zone')), + 'url' => 'https://'.($instance->custom_domain ?: $instance->subdomain.'.'.ProvisioningSettings::dnsZone()), 'username' => $username, 'password' => $password, ]), $this->handoffToken); diff --git a/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php b/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php index f60d699..b3d8095 100644 --- a/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php +++ b/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php @@ -6,6 +6,7 @@ use App\Models\ProvisioningRun; use App\Provisioning\StepResult; use App\Services\Dns\HetznerDnsClient; use App\Services\Traefik\TraefikWriter; +use App\Support\ProvisioningSettings; class ConfigureDnsAndTls extends CustomerStep { @@ -29,7 +30,7 @@ class ConfigureDnsAndTls extends CustomerStep { $instance = $this->instance($run); $host = $instance->host; - $fqdn = $instance->subdomain.'.'.config('provisioning.dns.zone'); + $fqdn = $instance->subdomain.'.'.ProvisioningSettings::dnsZone(); // DNS — provider upsert is idempotent; the local row uses firstOrCreate on // the record id and is written BEFORE the breadcrumb (the short-circuit guard). diff --git a/app/Provisioning/Steps/Customer/ConfigureNextcloud.php b/app/Provisioning/Steps/Customer/ConfigureNextcloud.php index cb13c90..65edae7 100644 --- a/app/Provisioning/Steps/Customer/ConfigureNextcloud.php +++ b/app/Provisioning/Steps/Customer/ConfigureNextcloud.php @@ -5,6 +5,7 @@ namespace App\Provisioning\Steps\Customer; use App\Models\ProvisioningRun; use App\Provisioning\StepResult; use App\Services\Proxmox\ProxmoxClient; +use App\Support\ProvisioningSettings; class ConfigureNextcloud extends CustomerStep { @@ -25,7 +26,7 @@ class ConfigureNextcloud extends CustomerStep $instance = $this->instance($run); $pve = $this->pve->forHost($instance->host); - $fqdn = $instance->subdomain.'.'.config('provisioning.dns.zone'); + $fqdn = $instance->subdomain.'.'.ProvisioningSettings::dnsZone(); $occ = 'cd /opt/nextcloud && docker compose exec -T app php occ '; // Idempotent occ calls (setting the same values is a no-op). diff --git a/app/Provisioning/Steps/Customer/RegisterMonitoring.php b/app/Provisioning/Steps/Customer/RegisterMonitoring.php index bf579a2..c9593a7 100644 --- a/app/Provisioning/Steps/Customer/RegisterMonitoring.php +++ b/app/Provisioning/Steps/Customer/RegisterMonitoring.php @@ -5,6 +5,7 @@ namespace App\Provisioning\Steps\Customer; use App\Models\ProvisioningRun; use App\Provisioning\StepResult; use App\Services\Monitoring\MonitoringClient; +use App\Support\ProvisioningSettings; class RegisterMonitoring extends CustomerStep { @@ -27,7 +28,7 @@ class RegisterMonitoring extends CustomerStep } $instance = $this->instance($run); - $url = 'https://'.$instance->subdomain.'.'.config('provisioning.dns.zone').'/status.php'; + $url = 'https://'.$instance->subdomain.'.'.ProvisioningSettings::dnsZone().'/status.php'; // Register with the monitoring service [E] before recording it. // Monitoring is observability, not the product: a paid customer's cloud diff --git a/app/Provisioning/Steps/Customer/RunAcceptanceChecks.php b/app/Provisioning/Steps/Customer/RunAcceptanceChecks.php index e352821..53f2085 100644 --- a/app/Provisioning/Steps/Customer/RunAcceptanceChecks.php +++ b/app/Provisioning/Steps/Customer/RunAcceptanceChecks.php @@ -7,6 +7,7 @@ use App\Provisioning\StepResult; use App\Services\Monitoring\MonitoringClient; use App\Services\Proxmox\ProxmoxClient; use App\Services\Traefik\TraefikWriter; +use App\Support\ProvisioningSettings; /** * Gate: nothing grants the customer access until every check here actually @@ -36,7 +37,7 @@ class RunAcceptanceChecks extends CustomerStep $instance = $this->instance($run); $node = (string) $run->context('node'); $vmid = (int) $run->context('vmid'); - $fqdn = $instance->subdomain.'.'.config('provisioning.dns.zone'); + $fqdn = $instance->subdomain.'.'.ProvisioningSettings::dnsZone(); $pve = $this->pve->forHost($instance->host); $occ = 'cd /opt/nextcloud && docker compose exec -T app php occ '; diff --git a/app/Provisioning/Steps/Host/EstablishSshTrust.php b/app/Provisioning/Steps/Host/EstablishSshTrust.php index 450993e..f56b69d 100644 --- a/app/Provisioning/Steps/Host/EstablishSshTrust.php +++ b/app/Provisioning/Steps/Host/EstablishSshTrust.php @@ -5,6 +5,7 @@ namespace App\Provisioning\Steps\Host; use App\Models\ProvisioningRun; use App\Provisioning\StepResult; use App\Services\Ssh\RemoteShell; +use App\Support\ProvisioningSettings; use Illuminate\Support\Facades\Crypt; /** @@ -29,7 +30,7 @@ class EstablishSshTrust extends HostStep return StepResult::advance(); } - $publicKey = trim((string) config('provisioning.ssh.public_key')); + $publicKey = trim(ProvisioningSettings::sshPublicKey()); $password = Crypt::decryptString((string) $run->context('root_password')); $this->shell->connectWithPassword($host->public_ip, 'root', $password); diff --git a/app/Provisioning/Steps/Host/HostStep.php b/app/Provisioning/Steps/Host/HostStep.php index 0f33d14..88b5860 100644 --- a/app/Provisioning/Steps/Host/HostStep.php +++ b/app/Provisioning/Steps/Host/HostStep.php @@ -6,6 +6,7 @@ use App\Models\Host; use App\Models\ProvisioningRun; use App\Provisioning\Contracts\ProvisioningStep; use App\Provisioning\Steps\ManagesRunResources; +use App\Services\Secrets\SecretVault; use App\Services\Ssh\RemoteShell; /** @@ -43,10 +44,13 @@ abstract class HostStep implements ProvisioningStep */ protected function keyLogin(RemoteShell $shell, Host $host): void { + // The identity in force: the one stored in the console if there is + // one, else CLUPILOT_SSH_PRIVATE_KEY(_PATH) — read here, at the + // point of use, per SecretVault's docblock (rule 3). $shell->connectWithKey( filled($host->wg_ip) ? $host->wg_ip : $host->public_ip, 'root', - (string) config('provisioning.ssh.private_key'), + (string) app(SecretVault::class)->get('ssh.private_key'), $host->ssh_host_key, // pinned during EstablishSshTrust ); } diff --git a/app/Services/Dns/HttpHetznerDnsClient.php b/app/Services/Dns/HttpHetznerDnsClient.php index a7c2267..4bbbfe7 100644 --- a/app/Services/Dns/HttpHetznerDnsClient.php +++ b/app/Services/Dns/HttpHetznerDnsClient.php @@ -3,13 +3,15 @@ namespace App\Services\Dns; use App\Services\Secrets\SecretVault; +use App\Support\ProvisioningSettings; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; use RuntimeException; /** - * Hetzner DNS API client. Zone from config/provisioning.php; the token comes - * from the vault (console-stored, falling back to HETZNER_DNS_TOKEN). + * Hetzner DNS API client. Zone from ProvisioningSettings (console-stored, + * falling back to CLUPILOT_DNS_ZONE); the token comes from the vault + * (console-stored, falling back to HETZNER_DNS_TOKEN). * Not unit-tested against the real service (live I/O). */ class HttpHetznerDnsClient implements HetznerDnsClient @@ -28,7 +30,7 @@ class HttpHetznerDnsClient implements HetznerDnsClient private function zoneId(): string { - $zone = (string) config('provisioning.dns.zone'); + $zone = ProvisioningSettings::dnsZone(); $zones = $this->http()->get('/zones', ['name' => $zone])->throw()->json('zones', []); return $zones[0]['id'] ?? throw new RuntimeException("DNS zone {$zone} not found"); @@ -37,7 +39,7 @@ class HttpHetznerDnsClient implements HetznerDnsClient public function upsertRecord(string $fqdn, string $type, string $value): string { $zoneId = $this->zoneId(); - $name = rtrim(str_replace('.'.config('provisioning.dns.zone'), '', $fqdn), '.'); + $name = rtrim(str_replace('.'.ProvisioningSettings::dnsZone(), '', $fqdn), '.'); $existing = collect($this->http()->get('/records', ['zone_id' => $zoneId])->throw()->json('records', [])) ->first(fn ($r) => $r['name'] === $name && $r['type'] === $type); diff --git a/app/Services/Monitoring/HttpMonitoringClient.php b/app/Services/Monitoring/HttpMonitoringClient.php index 7cdb47c..83a8e7d 100644 --- a/app/Services/Monitoring/HttpMonitoringClient.php +++ b/app/Services/Monitoring/HttpMonitoringClient.php @@ -3,6 +3,7 @@ namespace App\Services\Monitoring; use App\Services\Secrets\SecretVault; +use App\Support\ProvisioningSettings; use Illuminate\Support\Facades\Http; /** @@ -10,15 +11,17 @@ use Illuminate\Support\Facades\Http; * configured, returns a deterministic id (no-op) so provisioning still records a * stable breadcrumb. Not unit-tested (live I/O). * - * The bridge token comes from the vault (console-stored, falling back to - * MONITORING_API_TOKEN) — read here, at the point of use, never overlaid onto - * config at boot. See SecretVault's docblock (rule 3). + * The bridge URL comes from ProvisioningSettings (console-stored, falling + * back to MONITORING_API_URL); the bridge token comes from the vault + * (console-stored, falling back to MONITORING_API_TOKEN) — both read here, at + * the point of use, never overlaid onto config at boot. See SecretVault's + * docblock (rule 3). */ class HttpMonitoringClient implements MonitoringClient { public function registerTarget(string $name, string $url): string { - $endpoint = (string) config('services.monitoring.url'); + $endpoint = ProvisioningSettings::monitoringUrl(); if (blank($endpoint)) { return 'mon-'.substr(sha1($url), 0, 12); } @@ -49,7 +52,7 @@ class HttpMonitoringClient implements MonitoringClient public function health(string $externalId): ?bool { - $endpoint = (string) config('services.monitoring.url'); + $endpoint = ProvisioningSettings::monitoringUrl(); if (blank($endpoint)) { return null; // nothing is watching, which is not the same as healthy @@ -67,7 +70,7 @@ class HttpMonitoringClient implements MonitoringClient public function deregisterTarget(string $externalId): void { - $endpoint = (string) config('services.monitoring.url'); + $endpoint = ProvisioningSettings::monitoringUrl(); if (filled($endpoint)) { Http::withToken((string) app(SecretVault::class)->get('monitoring.token')) ->delete(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw(); diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index cdba487..ad6a625 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -3,6 +3,7 @@ namespace App\Services\Secrets; use App\Models\Operator; +use App\Services\Stripe\StripeCheck; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -40,9 +41,10 @@ final class SecretVault * * The list is the whole point of the area. With one entry it was a page * that existed to hold a single Stripe key, which is not worth a password - * gate; these three are the credentials that actually stop the business when - * they expire, and none of them can otherwise be rotated without a shell on - * the server. + * gate; these are the credentials that actually stop the business when + * they expire or leak, and none of them can otherwise be rotated without a + * shell on the server. ssh.private_key is the most sensitive value here — + * it opens every server this installation owns. * * mail.password lived here too, until the mailboxes table (see its own * migration) took over sending credentials — a single SMTP password could @@ -56,12 +58,18 @@ final class SecretVault * Deliberately NOT here: STRIPE_WEBHOOK_SECRET. It is read on every * incoming payment event, and a database problem would turn signature * verification into a silent failure. It stays in the server file. + * + * Also deliberately NOT here: KUMA_PASSWORD / KUMA_TOTP. They authenticate + * docker/kuma-bridge/app.py — a separate Python container — to Uptime Kuma + * at process boot; nothing in this Laravel app ever reads them, so storing + * them here would recreate exactly the decorative-field problem this + * registry exists to avoid. They stay in the server file. */ public const REGISTRY = [ 'stripe.secret' => [ 'config' => 'services.stripe.secret', 'label' => 'secrets.item.stripe_secret', - 'check' => \App\Services\Stripe\StripeCheck::class, + 'check' => StripeCheck::class, ], 'dns.token' => [ 'config' => 'provisioning.dns.token', @@ -71,6 +79,10 @@ final class SecretVault 'config' => 'services.monitoring.token', 'label' => 'secrets.item.monitoring_token', ], + 'ssh.private_key' => [ + 'config' => 'provisioning.ssh.private_key', + 'label' => 'secrets.item.ssh_private_key', + ], ]; public function has(string $key): bool diff --git a/app/Services/Ssh/FakeRemoteShell.php b/app/Services/Ssh/FakeRemoteShell.php index 1611c81..88f932c 100644 --- a/app/Services/Ssh/FakeRemoteShell.php +++ b/app/Services/Ssh/FakeRemoteShell.php @@ -16,7 +16,7 @@ class FakeRemoteShell implements RemoteShell /** @var array */ private array $files = []; - /** @var array */ + /** @var array 3 is the private key, only for 'key' connections. */ public array $connections = []; public string $fingerprint = 'SHA256:fakehostkeyfingerprint'; @@ -58,7 +58,7 @@ class FakeRemoteShell implements RemoteShell if ($this->failConnect) { throw new \RuntimeException("connection refused: {$host}"); } - $this->connections[] = ['key', $host, $user]; + $this->connections[] = ['key', $host, $user, $privateKey]; } public function run(string $command): CommandResult diff --git a/app/Services/Traefik/SshTraefikWriter.php b/app/Services/Traefik/SshTraefikWriter.php index 6a525a4..950f5e2 100644 --- a/app/Services/Traefik/SshTraefikWriter.php +++ b/app/Services/Traefik/SshTraefikWriter.php @@ -2,7 +2,9 @@ namespace App\Services\Traefik; +use App\Services\Secrets\SecretVault; use App\Services\Ssh\RemoteShell; +use App\Support\ProvisioningSettings; use Illuminate\Support\Facades\Http; /** @@ -17,19 +19,29 @@ class SshTraefikWriter implements TraefikWriter public function write(string $trafficHost, string $subdomain, string $backend): void { - $zone = (string) config('provisioning.dns.zone'); + $zone = ProvisioningSettings::dnsZone(); $yaml = $this->render($subdomain, "{$subdomain}.{$zone}", $backend); - $this->shell->connectWithKey($trafficHost, 'root', (string) config('provisioning.ssh.private_key')); + $this->shell->connectWithKey($trafficHost, 'root', $this->privateKey()); $this->shell->putFile($this->path($subdomain), $yaml); // putFile throws on failure } public function remove(string $trafficHost, string $subdomain): void { - $this->shell->connectWithKey($trafficHost, 'root', (string) config('provisioning.ssh.private_key')); + $this->shell->connectWithKey($trafficHost, 'root', $this->privateKey()); $this->shell->run('rm -f '.escapeshellarg($this->path($subdomain))); } + /** + * The identity CluPilot deploys to every host: the one stored in the + * console if there is one, else CLUPILOT_SSH_PRIVATE_KEY(_PATH). Read + * HERE, at the point of use — see SecretVault's docblock (rule 3). + */ + private function privateKey(): string + { + return (string) app(SecretVault::class)->get('ssh.private_key'); + } + public function certReachable(string $fqdn): bool { // While DNS propagates / Traefik warms up, the client throws @@ -44,7 +56,7 @@ class SshTraefikWriter implements TraefikWriter private function path(string $subdomain): string { - return rtrim((string) config('provisioning.traefik.dynamic_path'), '/')."/{$subdomain}.yml"; + return rtrim(ProvisioningSettings::traefikDynamicPath(), '/')."/{$subdomain}.yml"; } private function render(string $subdomain, string $fqdn, string $backend): string diff --git a/app/Services/Wireguard/LocalWireguardHub.php b/app/Services/Wireguard/LocalWireguardHub.php index 27d04a6..568821c 100644 --- a/app/Services/Wireguard/LocalWireguardHub.php +++ b/app/Services/Wireguard/LocalWireguardHub.php @@ -4,6 +4,7 @@ namespace App\Services\Wireguard; use App\Models\Host; use App\Models\VpnPeer; +use App\Support\ProvisioningSettings; use Illuminate\Support\Facades\Process; use RuntimeException; @@ -79,11 +80,11 @@ class LocalWireguardHub implements WireguardHub public function endpoint(): string { - return (string) config('provisioning.wireguard.endpoint'); + return ProvisioningSettings::wgEndpoint(); } public function publicKey(): string { - return (string) config('provisioning.wireguard.hub_public_key'); + return ProvisioningSettings::wgHubPublicKey(); } } diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 244664b..0f3df14 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -54,6 +54,7 @@ final class Navigation ['label' => __('admin.nav_group.system'), 'items' => [ ['admin.mail', 'mail', 'mail', 'mail.manage'], ['admin.secrets', 'lock', 'secrets', 'secrets.manage'], + ['admin.infrastructure', 'globe', 'infrastructure', 'hosts.manage'], ['admin.settings', 'settings', 'settings', null], // null capability, deliberately: the compulsory two-factor // switch (Admin\Settings::saveTwoFactorPolicy()) applies to diff --git a/app/Support/ProvisioningSettings.php b/app/Support/ProvisioningSettings.php new file mode 100644 index 0000000..e5a0724 --- /dev/null +++ b/app/Support/ProvisioningSettings.php @@ -0,0 +1,63 @@ + 'Umsatz', 'mail' => 'E-Mail', 'secrets' => 'Zugangsdaten', + 'infrastructure' => 'Infrastruktur', 'settings' => 'Einstellungen', 'two_factor_setup' => 'Zwei-Faktor-Anmeldung', ], diff --git a/lang/de/infrastructure.php b/lang/de/infrastructure.php new file mode 100644 index 0000000..6548194 --- /dev/null +++ b/lang/de/infrastructure.php @@ -0,0 +1,33 @@ + 'Infrastruktur', + 'subtitle' => 'Bereitstellungs-Einstellungen — sichtbar und hier änderbar, ohne Zugriff auf den Server. Die zugehörigen Zugangsdaten liegen unter Zugangsdaten.', + + 'dns_title' => 'DNS & Traefik', + 'dns_body' => 'Die Zone, unter der Kundeninstanzen und Hosts erreichbar sind, und wo Traefik seine dynamische Konfiguration erwartet.', + 'dns_zone' => 'DNS-Zone', + 'dns_zone_hint' => 'Zum Beispiel clupilot.com — Kundeninstanzen erhalten ..', + 'traefik_path' => 'Traefik: Pfad für dynamische Konfiguration', + 'traefik_path_hint' => 'Verzeichnis auf dem Traffic-Host, in das Routen geschrieben werden.', + + 'wg_title' => 'WireGuard-Hub', + 'wg_body' => 'Wie ein neuer Host CluPilot als VPN-Hub erreicht. Der private Hub-Schlüssel bleibt in der Serverdatei — nur der öffentliche Teil und die Adresse stehen hier.', + 'wg_endpoint' => 'Hub-Endpunkt', + 'wg_endpoint_hint' => 'Öffentlich erreichbare Adresse und Port, z. B. vpn.clupilot.com:51820.', + 'wg_hub_pubkey' => 'Hub-Public-Key', + 'wg_hub_pubkey_hint' => 'Ausgabe von wg pubkey < /etc/wireguard/privatekey auf diesem Server.', + + 'ssh_title' => 'SSH-Identität', + 'ssh_body' => 'Der öffentliche Teil des Schlüssels, der auf jedem neuen Host hinterlegt wird. Der private Teil ist geheim und liegt unter Zugangsdaten.', + 'ssh_public_key' => 'SSH-Schlüssel (öffentlich)', + 'ssh_public_key_hint' => 'Wird bei der Host-Aufnahme in authorized_keys eingetragen.', + + 'monitoring_title' => 'Monitoring', + 'monitoring_body' => 'Wo die Kuma-Bridge erreichbar ist. Kuma selbst (KUMA_URL/KUMA_USERNAME/KUMA_PASSWORD/KUMA_TOTP) bleibt in der Serverdatei — das ist die Bridge, ein eigener Container, der sie beim Start liest, nicht diese Anwendung.', + 'monitoring_url' => 'Monitoring-Bridge-URL', + 'monitoring_url_hint' => 'Zum Beispiel http://kuma-bridge:8080. Leer lässt Monitoring aus.', + + 'save' => 'Speichern', + 'saved' => 'Gespeichert. Der neue Wert gilt ab sofort.', +]; diff --git a/lang/de/secrets.php b/lang/de/secrets.php index 096ea69..a6e32f4 100644 --- a/lang/de/secrets.php +++ b/lang/de/secrets.php @@ -6,8 +6,11 @@ return [ 'stripe_secret' => 'Stripe Secret Key', 'dns_token' => 'Hetzner-DNS-API-Token', 'monitoring_token' => 'Uptime-Kuma-API-Token', + 'ssh_private_key' => 'SSH-Schlüssel (privat)', ], + 'ssh_private_key_hint' => 'Diese Identität wird auf jedem neuen Host hinterlegt — der empfindlichste Wert im System, er öffnet jeden Server, den CluPilot verwaltet.', + 'title' => 'Zugangsdaten', 'subtitle' => 'Schlüssel für angebundene Dienste — hier änderbar, ohne Zugriff auf den Server.', 'no_key' => 'SECRETS_KEY ist auf diesem Server nicht gesetzt. Ohne eigenen Schlüssel werden hier keine Zugangsdaten gespeichert — bewusst, denn APP_KEY wird routinemäßig gewechselt.', diff --git a/lang/en/admin.php b/lang/en/admin.php index e5af694..b058726 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -23,6 +23,7 @@ return [ 'revenue' => 'Revenue', 'mail' => 'Email', 'secrets' => 'Credentials', + 'infrastructure' => 'Infrastructure', 'settings' => 'Settings', 'two_factor_setup' => 'Two-factor login', ], diff --git a/lang/en/infrastructure.php b/lang/en/infrastructure.php new file mode 100644 index 0000000..e5eb5c2 --- /dev/null +++ b/lang/en/infrastructure.php @@ -0,0 +1,33 @@ + 'Infrastructure', + 'subtitle' => 'Deployment settings — visible and changeable here, without server access. The matching credentials live under Credentials.', + + 'dns_title' => 'DNS & Traefik', + 'dns_body' => 'The zone customer instances and hosts live under, and where Traefik expects its dynamic configuration.', + 'dns_zone' => 'DNS zone', + 'dns_zone_hint' => 'For example clupilot.com — customer instances get ..', + 'traefik_path' => 'Traefik dynamic config path', + 'traefik_path_hint' => 'Directory on the traffic host that routes get written into.', + + 'wg_title' => 'WireGuard hub', + 'wg_body' => 'How a new host reaches CluPilot as the VPN hub. The private hub key stays in the server file — only the public half and the address live here.', + 'wg_endpoint' => 'Hub endpoint', + 'wg_endpoint_hint' => 'Publicly reachable address and port, e.g. vpn.clupilot.com:51820.', + 'wg_hub_pubkey' => 'Hub public key', + 'wg_hub_pubkey_hint' => 'Output of wg pubkey < /etc/wireguard/privatekey on this server.', + + 'ssh_title' => 'SSH identity', + 'ssh_body' => 'The public half of the key deployed to every fresh host. The private half is secret and lives under Credentials.', + 'ssh_public_key' => 'SSH key (public)', + 'ssh_public_key_hint' => 'Written into authorized_keys during host onboarding.', + + 'monitoring_title' => 'Monitoring', + 'monitoring_body' => 'Where the Kuma bridge is reachable. Kuma itself (KUMA_URL/KUMA_USERNAME/KUMA_PASSWORD/KUMA_TOTP) stays in the server file — that is the bridge, a separate container that reads them at startup, not this application.', + 'monitoring_url' => 'Monitoring bridge URL', + 'monitoring_url_hint' => 'For example http://kuma-bridge:8080. Leave blank to leave monitoring off.', + + 'save' => 'Save', + 'saved' => 'Saved. The new value applies immediately.', +]; diff --git a/lang/en/secrets.php b/lang/en/secrets.php index f03264c..ddc3e4b 100644 --- a/lang/en/secrets.php +++ b/lang/en/secrets.php @@ -6,8 +6,11 @@ return [ 'stripe_secret' => 'Stripe secret key', 'dns_token' => 'Hetzner DNS API token', 'monitoring_token' => 'Uptime Kuma API token', + 'ssh_private_key' => 'SSH key (private)', ], + 'ssh_private_key_hint' => 'This identity is deployed to every fresh host — the most sensitive value in the system, it opens every server CluPilot manages.', + 'title' => 'Credentials', 'subtitle' => 'Keys for connected services — changeable here, without server access.', 'no_key' => 'SECRETS_KEY is not set on this server. Without a key of its own nothing is stored here — deliberately, because APP_KEY is rotated as routine maintenance.', diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 3a578e9..62d8473 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -39,6 +39,7 @@ 'trash-2' => '', 'settings' => '', 'mail' => '', + 'globe' => '', 'send' => '', ]; $body = $icons[$name] ?? ''; diff --git a/resources/views/livewire/admin/infrastructure.blade.php b/resources/views/livewire/admin/infrastructure.blade.php new file mode 100644 index 0000000..b8277b8 --- /dev/null +++ b/resources/views/livewire/admin/infrastructure.blade.php @@ -0,0 +1,63 @@ +
+
+

{{ __('infrastructure.title') }}

+

{{ __('infrastructure.subtitle') }}

+
+ + {{-- One page, one save: every field here is a plain deployment setting — + no password re-confirmation, no per-field confirm modal (R23 governs + destructive actions; saving a URL is not one), unlike Zugangsdaten. --}} +
+
+
+

{{ __('infrastructure.dns_title') }}

+

{{ __('infrastructure.dns_body') }}

+
+
+ + +
+
+ +
+
+

{{ __('infrastructure.wg_title') }}

+

{{ __('infrastructure.wg_body') }}

+
+
+ + +
+
+ +
+
+

{{ __('infrastructure.ssh_title') }}

+

{{ __('infrastructure.ssh_body') }}

+
+
+ + +

{{ __('infrastructure.ssh_public_key_hint') }}

+ @error('sshPublicKey')

{{ $message }}

@enderror +
+
+ +
+
+

{{ __('infrastructure.monitoring_title') }}

+

{{ __('infrastructure.monitoring_body') }}

+
+ +
+ +
+ + {{ __('infrastructure.save') }} + +
+
+
diff --git a/resources/views/livewire/admin/secrets.blade.php b/resources/views/livewire/admin/secrets.blade.php index 54f0a59..e413059 100644 --- a/resources/views/livewire/admin/secrets.blade.php +++ b/resources/views/livewire/admin/secrets.blade.php @@ -65,9 +65,25 @@ @endif
- + @if ($entry['multiline']) + {{-- A multi-line PEM key: a single-line password field + would mangle it on paste. Not masked — a textarea + cannot mask its content in any browser, and every + other tool that accepts an SSH key (GitHub + included) shows it in the clear while typing for + the same reason. --}} + + +

{{ __('secrets.ssh_private_key_hint') }}

+ @error('entered.'.$entry['field'])

{{ $message }}

@enderror + @else + + @endif
diff --git a/routes/admin.php b/routes/admin.php index 1f28a2f..598a933 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -40,6 +40,7 @@ Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/mail', Admin\Mail::class)->name('mail'); Route::get('/secrets', Admin\Secrets::class)->name('secrets'); +Route::get('/infrastructure', Admin\Infrastructure::class)->name('infrastructure'); 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. diff --git a/tests/Feature/Admin/InfrastructureSettingsTest.php b/tests/Feature/Admin/InfrastructureSettingsTest.php new file mode 100644 index 0000000..2950ae5 --- /dev/null +++ b/tests/Feature/Admin/InfrastructureSettingsTest.php @@ -0,0 +1,55 @@ +test(Infrastructure::class) + ->assertForbidden(); +}); + +it('loads the .env-derived value when nothing has been saved from the console yet', function () { + config()->set('provisioning.dns.zone', 'env-zone.example'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Infrastructure::class) + ->assertSet('dnsZone', 'env-zone.example'); +}); + +it('saves every field, and the consumer reads the saved value back', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Infrastructure::class) + ->set('dnsZone', 'console-zone.example') + ->set('wgEndpoint', 'vpn.example:51820') + ->set('wgHubPubkey', 'hubpubkey==') + ->set('traefikDynamicPath', '/etc/traefik/dynamic') + ->set('sshPublicKey', 'ssh-ed25519 AAAAC3 clupilot') + ->set('monitoringUrl', 'http://kuma-bridge:8080') + ->call('save') + ->assertHasNoErrors(); + + // Not just that Settings holds the value — that the class every real + // consumer (HttpHetznerDnsClient, LocalWireguardHub, SshTraefikWriter, + // EstablishSshTrust, HttpMonitoringClient) actually calls returns it. + expect(ProvisioningSettings::dnsZone())->toBe('console-zone.example') + ->and(ProvisioningSettings::wgEndpoint())->toBe('vpn.example:51820') + ->and(ProvisioningSettings::wgHubPublicKey())->toBe('hubpubkey==') + ->and(ProvisioningSettings::traefikDynamicPath())->toBe('/etc/traefik/dynamic') + ->and(ProvisioningSettings::sshPublicKey())->toBe('ssh-ed25519 AAAAC3 clupilot') + ->and(ProvisioningSettings::monitoringUrl())->toBe('http://kuma-bridge:8080'); +}); + +it('refuses a monitoring URL that is not a URL', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Infrastructure::class) + ->set('monitoringUrl', 'not a url') + ->call('save') + ->assertHasErrors('monitoringUrl'); +}); diff --git a/tests/Feature/Admin/SecretsPageTest.php b/tests/Feature/Admin/SecretsPageTest.php index 95c9120..913908f 100644 --- a/tests/Feature/Admin/SecretsPageTest.php +++ b/tests/Feature/Admin/SecretsPageTest.php @@ -171,6 +171,24 @@ it('forgets the stored value once the page receives the confirmed event', functi expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse(); }); +it('stores the SSH identity with its internal newlines intact', function () { + // The one multi-line entry on the page (a PEM key) — save() only trims + // the ENDS, so an internal newline must survive the round trip exactly. + config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + $owner = operator('Owner'); + $pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nabcdef\nghijkl\n-----END OPENSSH PRIVATE KEY-----"; + + Livewire::actingAs($owner, 'operator') + ->test(SecretsPage::class) + ->set('confirmablePassword', 'password') + ->call('confirmPassword') + ->set('entered.ssh_private_key', $pem) + ->call('save', 'ssh.private_key') + ->assertHasNoErrors(); + + expect(app(SecretVault::class)->get('ssh.private_key'))->toBe($pem); +}); + it('binds the form to a key Livewire can actually write to', function () { // A dot in a Livewire property path means nesting, so a registry key with a // dot in it would be written to entered['stripe']['secret'] and the value diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index d35f490..0c6265d 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -2,6 +2,7 @@ use App\Models\Datacenter; use App\Models\Host; +use App\Models\Operator; use App\Models\ProvisioningRun; use App\Models\RunResource; use App\Provisioning\Jobs\PurgeHost; @@ -18,6 +19,7 @@ use App\Provisioning\Steps\Host\RegisterHostDns; use App\Provisioning\Steps\Host\SecureHostFirewall; use App\Provisioning\Steps\Host\ValidateHostInput; use App\Provisioning\Steps\Host\VerifyProxmoxApi; +use App\Services\Secrets\SecretVault; use App\Services\Ssh\CommandResult; use Illuminate\Support\Facades\Crypt; @@ -109,6 +111,28 @@ it('falls back to the public IP before the tunnel exists', function () { } }); +it('prefers the vault-stored SSH key over CLUPILOT_SSH_PRIVATE_KEY', function () { + // Mutation boundary (Part B): the SSH identity is the most sensitive + // value in the system — it opens every host CluPilot manages. Before + // this, HostStep::keyLogin() read config('provisioning.ssh.private_key') + // directly, so a key stored in the console was encrypted and kept, but + // never once reached an actual connection. + config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + config()->set('provisioning.ssh.private_key', 'env-key-should-not-be-used'); + app(SecretVault::class)->put('ssh.private_key', 'vault-key-should-be-used', Operator::factory()->create()); + + $s = fakeServices(); + $run = hostRun(Host::factory()->create()); + + app(ConfigureProxmox::class)->execute($run); + + $connections = $s['shell']->connectionsWith('key'); + expect($connections)->not->toBeEmpty(); + foreach ($connections as $connection) { + expect($connection[3])->toBe('vault-key-should-be-used'); + } +}); + // --- PrepareBaseSystem --- it('prepares the base system', function () {