163 lines
7.2 KiB
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;
|
|
}
|
|
}
|