223 lines
9.7 KiB
PHP
223 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace App\Provisioning\Steps\Host;
|
|
|
|
use App\Models\ProvisioningRun;
|
|
use App\Provisioning\StepResult;
|
|
use App\Services\Ssh\RemoteShell;
|
|
use App\Services\Wireguard\WireguardHub;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Installs WireGuard on the host, allocates a management IP, registers the host
|
|
* as a peer on the CluPilot hub, and verifies the tunnel is up.
|
|
*
|
|
* SSH for this step runs over the PUBLIC IP, not the tunnel — see
|
|
* HostStep::keyLogin(), which only prefers the tunnel once a handshake has
|
|
* actually succeeded. That is what makes the interface handling below safe to
|
|
* restart: the session it would otherwise cut does not go through wg0.
|
|
*/
|
|
class ConfigureWireguard extends HostStep
|
|
{
|
|
private const CONFIG_PATH = '/etc/wireguard/wg0.conf';
|
|
|
|
private const UNIT = 'wg-quick@wg0';
|
|
|
|
public function __construct(private RemoteShell $shell, private WireguardHub $hub) {}
|
|
|
|
public function key(): string
|
|
{
|
|
return 'configure_wireguard';
|
|
}
|
|
|
|
public function execute(ProvisioningRun $run): StepResult
|
|
{
|
|
$host = $this->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',
|
|
'',
|
|
]);
|
|
}
|
|
}
|