159 lines
7.6 KiB
PHP
159 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Provisioning\Steps\Host;
|
|
|
|
use App\Models\ProvisioningRun;
|
|
use App\Provisioning\StepResult;
|
|
use App\Services\Ssh\RemoteShell;
|
|
|
|
/**
|
|
* Baseline Proxmox configuration after the kernel switch: confirm the default
|
|
* bridge exists, turn the datacenter firewall on so per-VM rules mean something,
|
|
* and drop the enterprise repo the proxmox-ve package brings back with it.
|
|
*
|
|
* This step used to do none of those things. It ran `ip link show vmbr0` and
|
|
* threw the result away — its comment claimed it "records the absence", and it
|
|
* recorded nothing — then repeated a `rm -f` and always advanced. Two real
|
|
* consequences followed from that:
|
|
*
|
|
* 1. Proxmox installed ON TOP OF DEBIAN does not create vmbr0; only the ISO
|
|
* installer writes that bridge into /etc/network/interfaces. So onboarding
|
|
* reported `active` on a host that had no bridge to attach a customer VM to,
|
|
* and the first paid order died in ConfigureNetwork.
|
|
* 2. Proxmox applies a guest's firewall rules only while the firewall is enabled
|
|
* at DATACENTER level (cluster.fw `[OPTIONS] enable`). It ships disabled, so
|
|
* the "80/443 only" rules HttpProxmoxClient::applyFirewall() writes for every
|
|
* customer VM were inert on every host this pipeline ever onboarded.
|
|
*/
|
|
class ConfigureProxmox extends HostStep
|
|
{
|
|
public function __construct(private RemoteShell $shell) {}
|
|
|
|
public function key(): string
|
|
{
|
|
return 'configure_proxmox';
|
|
}
|
|
|
|
public function execute(ProvisioningRun $run): StepResult
|
|
{
|
|
$host = $this->host($run);
|
|
$this->keyLogin($this->shell, $host);
|
|
|
|
if (($refusal = $this->refuseWithoutBridge()) !== null) {
|
|
return $refusal;
|
|
}
|
|
|
|
if (($failure = $this->enableDatacenterFirewall()) !== null) {
|
|
return $failure;
|
|
}
|
|
|
|
// AFTER the install, not before: the proxmox-ve package ships the
|
|
// enterprise repository itself, so InstallProxmoxVe's removal (which
|
|
// happens before apt runs) cannot be the one that sticks. Both spellings,
|
|
// because PVE 9 ships the deb822 file and PVE 8 the one-line one.
|
|
$this->shell->run('rm -f /etc/apt/sources.list.d/pve-enterprise.list /etc/apt/sources.list.d/pve-enterprise.sources');
|
|
|
|
return StepResult::advance();
|
|
}
|
|
|
|
/**
|
|
* vmbr0 must exist — and this step will NOT create it.
|
|
*
|
|
* Creating a bridge over the primary NIC from a remote shell means editing
|
|
* /etc/network/interfaces and reloading the network on the same link the
|
|
* shell is running over. Getting it even slightly wrong (a provider that
|
|
* needs a pointopoint route, a MAC the upstream switch will not accept on a
|
|
* bridge, an IPv6 gateway on the wrong device) leaves the machine off the
|
|
* network with no way back except the provider's console. The failure below
|
|
* costs an operator ten minutes; the alternative costs a dead server.
|
|
*
|
|
* The message carries the default route, because that is the one fact the
|
|
* operator needs and the one they would otherwise go and look up.
|
|
*/
|
|
private function refuseWithoutBridge(): ?StepResult
|
|
{
|
|
if ($this->shell->run('ip link show vmbr0')->ok()) {
|
|
return null;
|
|
}
|
|
|
|
$route = trim($this->shell->run('ip -4 route show default')->stdout);
|
|
|
|
return StepResult::fail(
|
|
'No vmbr0 bridge on this host. Proxmox installed on top of Debian does not create one — only the '.
|
|
'ISO installer does — and CluPilot deliberately will not build it over the primary NIC from a remote '.
|
|
'shell, because a mistake there takes the machine off the network for good. Create it by hand in '.
|
|
'/etc/network/interfaces (bridge_ports = the NIC carrying the default route, the host address and '.
|
|
'gateway moved onto vmbr0), apply it with `ifreload -a`, confirm you still have SSH, then retry this '.
|
|
'run. Current default route: '.($route === '' ? '(none reported)' : $route).'.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Enable the datacenter firewall WITHOUT locking the host out.
|
|
*
|
|
* The plain `enable 1` is a trap. Proxmox's datacenter input policy defaults
|
|
* to DROP, the node's own host firewall defaults to ENABLED, and the only
|
|
* sources it lets reach 8006 and 22 are the members of the auto-generated
|
|
* `management` ipset — which is seeded from `local_network`, the subnet of
|
|
* the node's own management address, i.e. the PUBLIC subnet. The WireGuard
|
|
* management address every later step and every maintenance run comes from is
|
|
* not in it. Enabling the firewall blindly therefore drops CluPilot's own SSH
|
|
* and API access the moment pve-firewall next compiles, on a host whose
|
|
* public port 22 SecureHostFirewall is about to close as well.
|
|
*
|
|
* So three settings, in this order, each doing one job:
|
|
*
|
|
* - `policy_in`/`policy_out` ACCEPT at datacenter level. This is the HOST
|
|
* input policy only: a guest reads its own vm.fw `policy_in`, which
|
|
* applyFirewall() sets to DROP explicitly for every customer VM, so ACCEPT
|
|
* here cannot loosen a single customer rule. What it does do is make sure
|
|
* that if anyone ever re-enables the node firewall from the GUI, the host
|
|
* does not become unreachable over the tunnel in the same instant.
|
|
* - The node's host firewall off. The host policy lives in nftables, written
|
|
* by SecureHostFirewall — see that step's docblock for why that was chosen
|
|
* over pve-firewall. Two filters both claiming to own the input chain is
|
|
* how a host ends up unreachable for a reason nobody can find: with both
|
|
* active a packet must be accepted by BOTH, so the WireGuard rules in
|
|
* nftables would be silently overruled by an ipset compiled from the public
|
|
* subnet. One owner, named in one place.
|
|
* - `enable 1` last, once neither of the above can bite.
|
|
*
|
|
* `policy_forward` is deliberately left alone: it only applies on a node
|
|
* running the new nftables-based pve-firewall backend, which is opt-in and
|
|
* off here, and setting a knob whose scope depends on which backend is
|
|
* active would be guessing.
|
|
*
|
|
* The node is addressed by `$(hostname)` rather than by hosts.name because
|
|
* that is exactly how Proxmox names the node; if the two ever diverge, the
|
|
* machine is right and the database row is not.
|
|
*/
|
|
private function enableDatacenterFirewall(): ?StepResult
|
|
{
|
|
$commands = [
|
|
'pvesh set /cluster/firewall/options --policy_in ACCEPT --policy_out ACCEPT',
|
|
'pvesh set /nodes/"$(hostname)"/firewall/options --enable 0',
|
|
'pvesh set /cluster/firewall/options --enable 1',
|
|
];
|
|
|
|
foreach ($commands as $command) {
|
|
if (! $this->shell->run($command)->ok()) {
|
|
return StepResult::retry(20, 'could not configure the Proxmox datacenter firewall: '.$command);
|
|
}
|
|
}
|
|
|
|
// Read it back. `pvesh set` exiting 0 is not the same as the setting
|
|
// being in cluster.fw: /etc/pve is a replicated filesystem and a write
|
|
// that lost quorum is precisely the case where every customer VM's rules
|
|
// would go on being inert while onboarding reported success.
|
|
$options = $this->shell->run('pvesh get /cluster/firewall/options --output-format json')->stdout;
|
|
if (! str_contains(preg_replace('/\s+/', '', $options) ?? '', '"enable":1')) {
|
|
return StepResult::retry(
|
|
20,
|
|
'the Proxmox datacenter firewall does not read back as enabled; per-VM rules would have no effect'
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|