Add the SSH identity to the vault, and give deployment config a console page
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
parent
5f7b8ff81b
commit
864126ec7f
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ return [
|
|||
'revenue' => 'Umsatz',
|
||||
'mail' => 'E-Mail',
|
||||
'secrets' => 'Zugangsdaten',
|
||||
'infrastructure' => 'Infrastruktur',
|
||||
'settings' => 'Einstellungen',
|
||||
'two_factor_setup' => 'Zwei-Faktor-Anmeldung',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ return [
|
|||
'revenue' => 'Revenue',
|
||||
'mail' => 'Email',
|
||||
'secrets' => 'Credentials',
|
||||
'infrastructure' => 'Infrastructure',
|
||||
'settings' => 'Settings',
|
||||
'two_factor_setup' => 'Two-factor login',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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] ?? '';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -65,9 +65,25 @@
|
|||
@endif
|
||||
|
||||
<div>
|
||||
@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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue