host($run); $this->keyLogin($this->shell, $host); // Idempotent replay: tunnel already fully provisioned — just re-verify it. if (filled($host->wg_ip) && $this->hasResource($run, 'wg_peer')) { return $this->verifyHandshake(); } if (! $this->shell->run('export DEBIAN_FRONTEND=noninteractive; apt-get install -y wireguard')->ok()) { return StepResult::retry(30, 'installing wireguard failed'); } $this->shell->run('test -f /etc/wireguard/privatekey || (umask 077; wg genkey > /etc/wireguard/privatekey)'); $publicKey = trim($this->shell->run('wg pubkey < /etc/wireguard/privatekey')->stdout); if (blank($publicKey)) { return StepResult::retry(20, 'could not read host WireGuard public key'); } // Allocate + reserve the management IP atomically so concurrent onboarding // runs can never receive the same address. $wgIp = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($host) { $ip = $host->wg_ip ?: $this->hub->allocateIp(); if (blank($host->wg_ip)) { $host->update(['wg_ip' => $ip]); } return $ip; }); $privateKey = trim($this->shell->run('cat /etc/wireguard/privatekey')->stdout); $this->shell->putFile('/etc/wireguard/wg0.conf', $this->renderConfig($wgIp, $privateKey)); if (! $this->shell->run('systemctl enable --now wg-quick@wg0 || wg-quick up wg0')->ok()) { return StepResult::retry(20, 'bringing up wg0 failed'); } $this->hub->addPeer($publicKey, $wgIp); // Store the pubkey now (so removal can always clean up the hub peer), but // record the wg_peer resource only AFTER the handshake verifies — the // idempotent short-circuit must not skip an un-verified configuration. $host->update(['wg_pubkey' => $publicKey]); if ($this->verifyHandshake()->type !== StepResult::ADVANCE) { return StepResult::retry(15, 'WireGuard handshake not up yet'); } $this->recordResource($run, $host, 'wg_peer', $publicKey); return StepResult::advance(); } private function verifyHandshake(): StepResult { $hubIp = (string) config('provisioning.wireguard.hub_ip'); return $this->shell->run('ping -c1 -W2 '.escapeshellarg($hubIp))->ok() ? StepResult::advance() : StepResult::retry(15, 'WireGuard handshake not up yet'); } private function renderConfig(string $wgIp, string $privateKey): string { $subnet = (string) config('provisioning.wireguard.subnet', '10.66.0.0/24'); $prefix = Str::afterLast($subnet, '/'); // honour the configured prefix length return implode("\n", [ '[Interface]', "Address = {$wgIp}/{$prefix}", "PrivateKey = {$privateKey}", '', '[Peer]', 'PublicKey = '.$this->hub->publicKey(), 'Endpoint = '.$this->hub->endpoint(), "AllowedIPs = {$subnet}", 'PersistentKeepalive = 25', '', ]); } }