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