101 lines
3.8 KiB
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();
|
|
}
|
|
}
|