clusev/app/Support/Os/FirewallTool.php

163 lines
7.2 KiB
PHP

<?php
namespace App\Support\Os;
/**
* Shell command strings for the host firewall (ufw or firewalld), built per OS.
*
* Safety model is preserved across both tools: enableScript() ALWAYS opens the
* real sshd port + 80/443 BEFORE turning the firewall on, so Clusev can never sever
* its own SSH session. Disable is the clean stop — never `firewall-cmd --panic-on`
* (which would drop ALL traffic, the exact lockout the guard avoids).
*/
final class FirewallTool
{
private const MARK = '===CLUSEV:';
/** @param 'ufw'|'firewalld' $tool */
public function __construct(
private string $tool,
private PackageManager $pm,
private bool $canInstall,
) {}
public static function for(OsProfile $os): self
{
return new self(
$os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw',
$os->managesPackages() ? PackageManager::for($os) : new PackageManager('none'),
$os->managesPackages(),
);
}
public function isFirewalld(): bool
{
return $this->tool === 'firewalld';
}
private function packageName(): string
{
return $this->tool === 'firewalld' ? 'firewalld' : 'ufw';
}
/** Guarded enable: install if needed + missing, open ssh/80/443, then turn on. */
public function enableScript(int $sshPort): string
{
$ensure = $this->canInstall ? $this->pm->ensureInstalledScript($this->packageName()).' && ' : '';
if ($this->isFirewalld()) {
$ports = "--add-port={$sshPort}/tcp --add-port=80/tcp --add-port=443/tcp";
// GUARD: open ssh/80/443 in the PERMANENT config BEFORE firewalld starts
// filtering, so starting it can never drop the operator's SSH session (a
// custom SSH port is not in the default zone). Handle both states: if the
// daemon is already running, add to permanent + reload; if it is stopped,
// write the permanent rules offline first, THEN start it.
return $ensure
.'if systemctl is-active --quiet firewalld; then '
."firewall-cmd --permanent {$ports} && systemctl enable --now firewalld && firewall-cmd --reload; "
.'else '
."firewall-offline-cmd {$ports} && systemctl enable --now firewalld; "
.'fi';
}
return $ensure
."ufw allow {$sshPort}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
}
/** Clean disable (never a panic/drop-all). */
public function disableScript(): string
{
return $this->isFirewalld() ? 'systemctl disable --now firewalld' : 'ufw disable';
}
/** Shell CONDITION exiting 0 when the firewall binary is present. */
public function installedTest(): string
{
return $this->isFirewalld()
? 'command -v firewall-cmd >/dev/null 2>&1'
: 'command -v ufw >/dev/null 2>&1';
}
/** Shell CONDITION exiting 0 when the firewall is currently active. */
public function activeTest(): string
{
return $this->isFirewalld()
? 'systemctl is-active --quiet firewalld'
: 'ufw status 2>/dev/null | grep -qi "Status: active"';
}
/** One privileged read of the firewall state, marker-sectioned for parsing. */
public function statusScript(): string
{
if ($this->isFirewalld()) {
// firewalld is READ-ONLY in this release (rule mutation is ufw-only), so we
// read the RUNTIME state of the active default zone — exactly what is being
// enforced right now — which also sidesteps any runtime/permanent zone drift.
// Enumerate EVERY active zone (an interface/source may be bound to a
// non-default zone), so the read-only view reflects all enforced rules.
$zones = 'firewall-cmd --get-active-zones 2>/dev/null | grep -v "^[[:space:]]"';
return 'echo CLUSEV_FWREAD_OK; '
.'echo '.self::MARK.'inst===; command -v firewall-cmd >/dev/null 2>&1 && echo yes || echo no; '
.'echo '.self::MARK.'active===; systemctl is-active --quiet firewalld && echo active || echo inactive; '
.'echo '.self::MARK.'zone===; firewall-cmd --get-default-zone 2>/dev/null; '
// emit "zone|value" per line so each rule keeps its zone attribution.
.'echo '.self::MARK.'ports===; for z in $('.$zones.'); do for p in $(firewall-cmd --zone="$z" --list-ports 2>/dev/null); do echo "$z|$p"; done; done; '
.'echo '.self::MARK.'services===; for z in $('.$zones.'); do for s in $(firewall-cmd --zone="$z" --list-services 2>/dev/null); do echo "$z|$s"; done; done; '
.'echo '.self::MARK.'rich===; for z in $('.$zones.'); do firewall-cmd --zone="$z" --list-rich-rules 2>/dev/null | while IFS= read -r r; do [ -n "$r" ] && echo "$z|$r"; done; done';
}
return 'echo CLUSEV_FWREAD_OK; '
.'echo '.self::MARK.'inst===; command -v ufw >/dev/null 2>&1 && echo yes || echo no; '
.'echo '.self::MARK.'verbose===; ufw status verbose 2>/dev/null; '
// `ufw show added` lists user rules in add-syntax and works whether ufw is
// active OR inactive (status only shows rules while active); the spec
// doubles as the delete argument, so deletes need no rule NUMBER (race-free).
.'echo '.self::MARK.'added===; ufw show added 2>/dev/null; '
// defaults from the config file so they are correct even when inactive.
.'echo '.self::MARK.'defaults===; grep -E "^DEFAULT_(INPUT|OUTPUT)_POLICY" /etc/default/ufw 2>/dev/null';
}
/**
* Build the ufw add command from already-validated parts (action/proto/port/from).
* Rule mutation is ufw-only in this release; firewalld is read-only.
*/
public function addRuleScript(string $action, string $proto, ?int $port, ?string $from): string
{
$spec = $action;
if ($from !== null && $from !== '') {
// ufw extended syntax wants `proto <p>` BEFORE the `from` clause; the
// trailing-proto form is rejected. proto is kept even without a port so a
// source-only rule constrains the protocol instead of allowing all.
if ($proto !== 'any') {
$spec .= ' proto '.$proto;
}
$spec .= ' from '.$from;
if ($port !== null) {
$spec .= ' to any port '.$port;
}
} elseif ($port !== null) {
$spec .= ' '.$port.($proto !== 'any' ? '/'.$proto : '');
}
return 'ufw '.$spec;
}
/**
* Delete a ufw rule by its add-syntax spec (e.g. "allow 80/tcp"). Race-free:
* no rule number is involved, so a concurrent change cannot shift the target.
* Deleting by rule does not prompt (unlike delete-by-number).
*/
public function deleteUfwScript(string $spec): string
{
// Routed rules use the route grammar: "ufw route delete allow …" (NOT "ufw
// delete route …", which ufw rejects).
if (preg_match('/^route\s+(.+)$/i', $spec, $m)) {
return 'ufw route delete '.$m[1];
}
return 'ufw delete '.$spec;
}
}