CluPilotCloud/app/Provisioning/Steps/Host/HostStep.php

101 lines
3.8 KiB
PHP

<?php
namespace App\Provisioning\Steps\Host;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Models\RunResource;
use App\Provisioning\Contracts\ProvisioningStep;
use App\Provisioning\Steps\ManagesRunResources;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\RemoteShell;
/**
* Shared helpers for host-onboarding steps: subject resolution, key login, and
* idempotency breadcrumbs (run_resources).
*/
abstract class HostStep implements ProvisioningStep
{
use ManagesRunResources;
public function label(): string
{
return 'hosts.step.'.$this->key();
}
public function maxDuration(): int
{
return 300;
}
protected function host(ProvisioningRun $run): Host
{
/** @var Host $host */
$host = $run->subject;
return $host;
}
/**
* Reach the host over the WireGuard tunnel once the tunnel is PROVEN, and
* over its public IP until then.
*
* The distinction is the whole point. `wg_ip` is written inside the
* allocation lock in ConfigureWireguard, before the handshake has been
* verified even once — so "wg_ip is filled" means "an address has been
* reserved", not "SSH works over the tunnel". Dialling the tunnel on the
* strength of the reservation alone produced an unrecoverable dead end:
* attempt 1 reserved the address and then failed at the handshake, and every
* later attempt — including an operator pressing Retry in the console — died
* connecting to a tunnel that did not work, before reaching the only code
* that could have repaired wg0.conf. Recovery meant nulling hosts.wg_ip by
* hand. The same shape sat under RebootIntoPveKernel, ConfigureProxmox,
* CreateAutomationToken and SecureHostFirewall, which all call this method.
*
* tunnelProven() reads the `wg_peer` breadcrumb, which ConfigureWireguard
* records ONLY after a successful handshake — the one fact in the database
* that means "this tunnel has carried traffic".
*
* Falling back to the public IP is not a way around the firewall: port 22 on
* the public IP is only open until SecureHostFirewall runs, that step runs
* last, and by the time it has run the tunnel is necessarily proven (it is
* the same `wg_peer` breadcrumb, recorded several steps earlier, and
* SecureHostFirewall additionally re-checks the handshake from the host's own
* side before writing a single rule). So the fallback only ever applies while
* 22 is still open anyway, and stops applying exactly when it closes.
*/
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(
$this->tunnelProven($host) ? $host->wg_ip : $host->public_ip,
'root',
(string) app(SecretVault::class)->get('ssh.private_key'),
$host->ssh_host_key, // pinned during EstablishSshTrust
);
}
/**
* Has a WireGuard handshake to this host ever succeeded?
*
* Scoped to the HOST, not to one run: the tunnel is a property of the
* machine, and every maintenance run made after onboarding must keep using
* it — a run-scoped lookup would send a later run to a public IP where 22 is
* closed. Nothing prunes provisioning runs or their resources, so the
* breadcrumb outlives the run that wrote it.
*/
protected function tunnelProven(Host $host): bool
{
if (blank($host->wg_ip)) {
return false;
}
return RunResource::query()
->where('host_id', $host->id)
->where('kind', 'wg_peer')
->exists();
}
}