Add the SSH identity to the vault, and give deployment config a console page
tests / pest (push) Failing after 7m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details

The SSH private key CluPilot deploys to every host is now stored through
SecretVault (ssh.private_key), preferred over CLUPILOT_SSH_PRIVATE_KEY(_PATH)
at all three real consumers — SshTraefikWriter and HostStep::keyLogin.
kuma.password/kuma.totp stay in .env: they authenticate a separate Python
container at boot, and nothing in this app reads them, so the vault would
have nothing to wire them to.

A new /admin/infrastructure page, backed by App\Support\ProvisioningSettings,
makes the non-secret deployment settings that used to force a shell —
DNS zone, WireGuard hub endpoint and public key, Traefik's dynamic-config
path, the SSH public key, and the monitoring bridge URL — visible and
editable from the console, each wired to every real consumer. WG subnet/hub
IP and Kuma's own connection details stay in .env: the compose file and a
separate container read those before this application ever does.
feat/granted-plans
nexxo 2026-07-29 00:52:44 +02:00
parent 5f7b8ff81b
commit 864126ec7f
31 changed files with 477 additions and 34 deletions

View File

@ -0,0 +1,81 @@
<?php
namespace App\Livewire\Admin;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Deployment configuration that is not secret but still has to be settable
* which DNS zone customer instances live under, where the WireGuard hub is
* reachable, where Traefik's dynamic config lives, the public half of the SSH
* identity, and the monitoring bridge's URL.
*
* Split from Admin\Secrets on purpose: these values are visible and editable
* without a password re-confirmation, because none of them opens anything by
* itself the credentials that pair with some of them (the SSH private key,
* the monitoring bridge token) live in the vault instead, gated the way a
* live payment key is.
*
* Every field here is genuinely read from Settings by its consumer
* (App\Support\ProvisioningSettings) see that class for the full list and
* for the two values (CLUPILOT_WG_SUBNET, CLUPILOT_WG_HUB_IP) deliberately
* left out because the compose file reads them too.
*/
#[Layout('layouts.admin')]
class Infrastructure extends Component
{
public string $dnsZone = '';
public string $wgEndpoint = '';
public string $wgHubPubkey = '';
public string $traefikDynamicPath = '';
public string $sshPublicKey = '';
public string $monitoringUrl = '';
public function mount(): void
{
$this->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');
}
}

View File

@ -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(),

View File

@ -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;

View File

@ -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);

View File

@ -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).

View File

@ -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).

View File

@ -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

View File

@ -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 ';

View File

@ -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);

View File

@ -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
);
}

View File

@ -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);

View File

@ -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();

View File

@ -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

View File

@ -16,7 +16,7 @@ class FakeRemoteShell implements RemoteShell
/** @var array<string, string> */
private array $files = [];
/** @var array<int, array{0:string,1:string,2:string}> */
/** @var array<int, array{0:string,1:string,2:string,3?:string}> 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

View File

@ -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

View File

@ -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();
}
}

View File

@ -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

View File

@ -0,0 +1,63 @@
<?php
namespace App\Support;
/**
* Deployment configuration an operator can change from the console instead of
* editing .env and restarting: which DNS zone customer instances live under,
* where the WireGuard hub is reachable, where Traefik's dynamic config lives,
* the public half of the SSH identity, and where the monitoring bridge is.
*
* None of these are secret the counterpart credentials live in SecretVault,
* not here but several are read from more than one class, so the Settings
* key name and its .env-derived default are centralised here rather than
* repeated at every call site, where a copy-pasted default could drift from
* the others.
*
* Falls back to the existing config() value (itself env()-derived) when
* nothing has been saved from the console yet, so an installation that
* already has these in .env keeps working unchanged until an operator
* chooses to move a value onto this page.
*
* Deliberately NOT here: CLUPILOT_WG_SUBNET and CLUPILOT_WG_HUB_IP. The
* compose file reads them too (vpn-dns, vpn-gateway), at container boot
* moving them here would let the console show one value while the containers
* that already started keep another, which is worse than the single .env
* source of truth it would replace. KUMA_URL/KUMA_USERNAME are the same
* problem one layer further out: docker/kuma-bridge/app.py reads them from
* its own process environment at boot, and nothing in this application reads
* them at all, so there is no consumer here to wire them to.
*/
final class ProvisioningSettings
{
public static function dnsZone(): string
{
return (string) Settings::get('provisioning.dns_zone', (string) config('provisioning.dns.zone'));
}
public static function wgEndpoint(): string
{
return (string) Settings::get('provisioning.wg_endpoint', (string) config('provisioning.wireguard.endpoint'));
}
public static function wgHubPublicKey(): string
{
return (string) Settings::get('provisioning.wg_hub_pubkey', (string) config('provisioning.wireguard.hub_public_key'));
}
public static function traefikDynamicPath(): string
{
return (string) Settings::get('provisioning.traefik_dynamic_path', (string) config('provisioning.traefik.dynamic_path'));
}
/** The PUBLIC half only. The private key is a secret — see SecretVault's ssh.private_key. */
public static function sshPublicKey(): string
{
return (string) Settings::get('provisioning.ssh_public_key', (string) config('provisioning.ssh.public_key'));
}
public static function monitoringUrl(): string
{
return (string) Settings::get('monitoring.api_url', (string) config('services.monitoring.url'));
}
}

View File

@ -23,6 +23,7 @@ return [
'revenue' => 'Umsatz',
'mail' => 'E-Mail',
'secrets' => 'Zugangsdaten',
'infrastructure' => 'Infrastruktur',
'settings' => 'Einstellungen',
'two_factor_setup' => 'Zwei-Faktor-Anmeldung',
],

View File

@ -0,0 +1,33 @@
<?php
return [
'title' => '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 <subdomain>.<Zone>.',
'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.',
];

View File

@ -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.',

View File

@ -23,6 +23,7 @@ return [
'revenue' => 'Revenue',
'mail' => 'Email',
'secrets' => 'Credentials',
'infrastructure' => 'Infrastructure',
'settings' => 'Settings',
'two_factor_setup' => 'Two-factor login',
],

View File

@ -0,0 +1,33 @@
<?php
return [
'title' => '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 <subdomain>.<zone>.',
'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.',
];

View File

@ -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.',

View File

@ -39,6 +39,7 @@
'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'mail' => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 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"/>',
];
$body = $icons[$name] ?? '';

View File

@ -0,0 +1,63 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('infrastructure.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.subtitle') }}</p>
</div>
{{-- 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. --}}
<form wire:submit="save" class="space-y-6">
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div>
<h2 class="font-semibold text-ink">{{ __('infrastructure.dns_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.dns_body') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="dnsZone" wire:model="dnsZone" :label="__('infrastructure.dns_zone')" :hint="__('infrastructure.dns_zone_hint')" />
<x-ui.input name="traefikDynamicPath" wire:model="traefikDynamicPath" :label="__('infrastructure.traefik_path')" :hint="__('infrastructure.traefik_path_hint')" />
</div>
</div>
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('infrastructure.wg_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.wg_body') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="wgEndpoint" wire:model="wgEndpoint" :label="__('infrastructure.wg_endpoint')" :hint="__('infrastructure.wg_endpoint_hint')" placeholder="vpn.clupilot.com:51820" />
<x-ui.input name="wgHubPubkey" wire:model="wgHubPubkey" :label="__('infrastructure.wg_hub_pubkey')" :hint="__('infrastructure.wg_hub_pubkey_hint')" />
</div>
</div>
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('infrastructure.ssh_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.ssh_body') }}</p>
</div>
<div>
<label for="sshPublicKey" class="block text-sm font-medium text-body">{{ __('infrastructure.ssh_public_key') }}</label>
<textarea id="sshPublicKey" name="sshPublicKey" rows="3" autocomplete="off" spellcheck="false"
wire:model="sshPublicKey"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"
placeholder="ssh-ed25519 AAAA... clupilot"></textarea>
<p class="mt-1.5 text-xs text-muted">{{ __('infrastructure.ssh_public_key_hint') }}</p>
@error('sshPublicKey')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('infrastructure.monitoring_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.monitoring_body') }}</p>
</div>
<x-ui.input name="monitoringUrl" type="url" wire:model="monitoringUrl" :label="__('infrastructure.monitoring_url')" :hint="__('infrastructure.monitoring_url_hint')" placeholder="http://kuma-bridge:8080" />
</div>
<div class="flex justify-end">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save">
{{ __('infrastructure.save') }}
</x-ui.button>
</div>
</form>
</div>

View File

@ -65,9 +65,25 @@
@endif
<div>
<x-ui.input name="entered.{{ $entry['field'] }}" type="password" autocomplete="off"
:label="__('secrets.new_value')" :hint="__('secrets.new_value_hint')"
wire:model="entered.{{ $entry['field'] }}" />
@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. --}}
<label for="entered-{{ $entry['field'] }}" class="block text-sm font-medium text-body">{{ __('secrets.new_value') }}</label>
<textarea id="entered-{{ $entry['field'] }}" name="entered.{{ $entry['field'] }}" rows="6" autocomplete="off" spellcheck="false"
wire:model="entered.{{ $entry['field'] }}"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
<p class="mt-1.5 text-xs text-muted">{{ __('secrets.ssh_private_key_hint') }}</p>
@error('entered.'.$entry['field'])<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
@else
<x-ui.input name="entered.{{ $entry['field'] }}" type="password" autocomplete="off"
:label="__('secrets.new_value')" :hint="__('secrets.new_value_hint')"
wire:model="entered.{{ $entry['field'] }}" />
@endif
</div>
<div class="flex flex-wrap gap-2">

View File

@ -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.

View File

@ -0,0 +1,55 @@
<?php
use App\Livewire\Admin\Infrastructure;
use App\Support\ProvisioningSettings;
use Livewire\Livewire;
/**
* Deployment configuration that is not secret but still has to be settable
* see App\Support\ProvisioningSettings for the full list of what is wired
* here and what is deliberately left in .env instead.
*/
it('is not reachable without hosts.manage', function () {
Livewire::actingAs(operator('Support'), 'operator')
->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');
});

View File

@ -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

View File

@ -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 () {