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
` 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; } }