host($run); $this->keyLogin($this->shell, $host); // Idempotent replay: the tunnel has already handshaked once (`wg_peer`) // — just re-verify it. Asking tunnelProven() rather than checking // wg_ip + the run's own breadcrumb keeps ONE definition of "the tunnel // works" in the pipeline, the same one keyLogin() dials on. if ($this->tunnelProven($host)) { return $this->verifyHandshake(); } // The hub identity, checked BEFORE anything is installed, allocated or // written. renderConfig() would otherwise happily emit `PublicKey = ` / // `Endpoint = `, which is the likeliest way to reach a never-handshaking // tunnel at all. Checked here rather than in ValidateHostInput because // this is where the values are actually consumed: they come from the // WireguardHub (settings table, .env fallback) and can be changed between // step 1 and step 4, so a check further upstream would be reading // something other than what gets written into wg0.conf. Nothing on the // host and nothing in the database has been touched at this point, so the // run fails clean and an operator can fill the setting in and retry. $hubPublicKey = trim($this->hub->publicKey()); $hubEndpoint = trim($this->hub->endpoint()); if (blank($hubPublicKey) || blank($hubEndpoint)) { return StepResult::fail($this->missingHubSettingsMessage($hubPublicKey, $hubEndpoint)); } 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); $desiredConfig = $this->renderConfig($wgIp, $privateKey, $hubPublicKey, $hubEndpoint); // Compare before writing. The file used to be rewritten unconditionally // with nothing reloading it, so an operator who corrected a wrong hub key // and pressed Retry saw the new file on disk and no change in behaviour — // the running interface still held the old peer. Knowing whether the file // CHANGED is what lets the interface handling below decide between // leaving a working tunnel alone and actually applying a correction. $currentConfig = $this->shell->run('cat '.escapeshellarg(self::CONFIG_PATH).' 2>/dev/null')->stdout; $configChanged = trim($currentConfig) !== trim($desiredConfig); if ($configChanged) { $this->shell->putFile(self::CONFIG_PATH, $desiredConfig); } if (($failure = $this->bringUpInterface($configChanged)) !== null) { return $failure; } // See RemoveWireguardPeer: hub mutations are serialized against the // reconciliation that feeds the VPN console. Cache::lock('wireguard:hub', 30)->block(10, fn () => $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, // and keyLogin() must not dial an unproven tunnel. $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(); } /** * Get wg0 into the state onboarding needs: up now AND up after the step-6 * reboot, running the configuration we just wrote. * * Three states, handled separately, because the single line this replaces * (`systemctl enable --now wg-quick@wg0 || wg-quick up wg0`) got two of them * wrong. Its fallback brought the interface up WITHOUT the systemd * enablement, so the tunnel did not come back after the reboot; and on the * next attempt both halves failed with "wg0 already exists", so the step * retried forever against a tunnel that was in fact working. * * The order — enable before start — matters for the reboot: `enable` is what * survives it, and it is checked and repaired even when the interface is * already up by other means (an earlier `wg-quick up`, or an operator). */ private function bringUpInterface(bool $configChanged): ?StepResult { $present = $this->shell->run('ip link show wg0')->ok(); // `is-enabled` is asked for its exit status, not its wording, so a // template unit that has never been enabled ("disabled", exit 1) and one // that does not exist yet are treated the same: enable it. if (! $this->shell->run('systemctl is-enabled --quiet '.self::UNIT)->ok()) { if (! $this->shell->run('systemctl enable '.self::UNIT)->ok()) { return StepResult::retry(20, 'could not enable '.self::UNIT.' (wg0 would not survive a reboot)'); } } if (! $present) { if (! $this->shell->run('systemctl start '.self::UNIT)->ok()) { return StepResult::retry(20, 'bringing up wg0 failed'); } return null; } // Present and unchanged: the running interface already matches the file. // Restarting here is what used to make a working tunnel look broken. if (! $configChanged) { return null; } // Present with a changed configuration: a restart, not `wg syncconf`. // syncconf applies peers only, and a corrected Address or AllowedIPs // would silently not take effect — the exact class of "the file changed // and nothing happened" bug this method exists to remove. The brief // interruption costs nothing: this step's own SSH session runs over the // public IP (see the class docblock), and the short-circuit above means // a proven tunnel never reaches this line. if (! $this->shell->run('systemctl restart '.self::UNIT)->ok()) { return StepResult::retry(20, 'restarting wg0 with the corrected configuration failed'); } return null; } 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'); } /** Names the setting that is missing, so the console shows what to fill in. */ private function missingHubSettingsMessage(string $publicKey, string $endpoint): string { $missing = []; if (blank($publicKey)) { $missing[] = 'the hub public key (CLUPILOT_WG_HUB_PUBKEY)'; } if (blank($endpoint)) { $missing[] = 'the hub endpoint (CLUPILOT_WG_ENDPOINT, host:port)'; } return 'Refusing to write wg0.conf: this installation has not been told '. implode(' or ', $missing).'. Set it under Integrations and retry — '. 'a peer section without those two values can never handshake.'; } private function renderConfig(string $wgIp, string $privateKey, string $hubPublicKey, string $hubEndpoint): 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 = '.$hubPublicKey, 'Endpoint = '.$hubEndpoint, "AllowedIPs = {$subnet}", 'PersistentKeepalive = 25', '', ]); } }