113 lines
3.9 KiB
PHP
113 lines
3.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Server;
|
|
|
|
/**
|
|
* UFW firewall control over SSH (as root).
|
|
*
|
|
* Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd
|
|
* Port (detected from sshd_config, default 22) plus 80/443 BEFORE turning the
|
|
* firewall on — so enabling UFW can never sever the operator's own SSH session.
|
|
*
|
|
* status()/preview() are read-only command builders; the mutating methods all
|
|
* return array{ok: bool, output: string, preview: string}.
|
|
*/
|
|
class FirewallService
|
|
{
|
|
public function __construct(private FleetService $fleet) {}
|
|
|
|
/**
|
|
* Parse `ufw status verbose` into a structured snapshot.
|
|
*
|
|
* @return array{active: bool, raw: string, rules: array<int, string>}
|
|
*/
|
|
public function status(Server $server): array
|
|
{
|
|
$res = $this->fleet->runPrivileged($server, 'ufw status verbose 2>&1');
|
|
$raw = $res['output'];
|
|
|
|
$active = (bool) preg_match('/Status:\s*active/i', $raw);
|
|
|
|
$rules = [];
|
|
foreach (preg_split('/\R/', $raw) as $line) {
|
|
$line = trim($line);
|
|
// rule lines carry an action token (ALLOW/DENY/REJECT/LIMIT)
|
|
if ($line !== '' && preg_match('/\b(ALLOW|DENY|REJECT|LIMIT)\b/i', $line)) {
|
|
$rules[] = $line;
|
|
}
|
|
}
|
|
|
|
return ['active' => $active, 'raw' => $raw, 'rules' => $rules];
|
|
}
|
|
|
|
/**
|
|
* GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never
|
|
* enable a firewall that would drop the current SSH session. Installs ufw if missing.
|
|
*
|
|
* @return array{ok: bool, output: string}
|
|
*/
|
|
public function enable(Server $server): array
|
|
{
|
|
// long timeout: may apt-install ufw first.
|
|
$res = $this->fleet->runPrivileged($server, $this->enableScript($this->sshPort($server)), 600);
|
|
|
|
return ['ok' => $res['ok'], 'output' => $res['output']];
|
|
}
|
|
|
|
/** Turn the firewall off (ufw disable). @return array{ok: bool, output: string} */
|
|
public function disable(Server $server): array
|
|
{
|
|
$res = $this->fleet->runPrivileged($server, 'ufw disable');
|
|
|
|
return ['ok' => $res['ok'], 'output' => $res['output']];
|
|
}
|
|
|
|
/** Allow a TCP port through the firewall. @return array{ok: bool, output: string} */
|
|
public function allow(Server $server, int $port): array
|
|
{
|
|
$res = $this->fleet->runPrivileged($server, 'ufw allow '.$this->clampPort($port).'/tcp');
|
|
|
|
return ['ok' => $res['ok'], 'output' => $res['output']];
|
|
}
|
|
|
|
/** Deny a TCP port through the firewall. @return array{ok: bool, output: string} */
|
|
public function deny(Server $server, int $port): array
|
|
{
|
|
$res = $this->fleet->runPrivileged($server, 'ufw deny '.$this->clampPort($port).'/tcp');
|
|
|
|
return ['ok' => $res['ok'], 'output' => $res['output']];
|
|
}
|
|
|
|
/** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */
|
|
private function enableScript(int $port): string
|
|
{
|
|
return '(command -v ufw >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y ufw) && '
|
|
."ufw allow {$port}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
|
|
}
|
|
|
|
/**
|
|
* Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls
|
|
* back to 22 when nothing is set explicitly.
|
|
*/
|
|
private function sshPort(Server $server): int
|
|
{
|
|
$res = $this->fleet->runPrivileged(
|
|
$server,
|
|
'grep -rhiE "^[[:space:]]*Port[[:space:]]+[0-9]+" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null '
|
|
.'| awk \'{print $2}\' | head -1'
|
|
);
|
|
|
|
$port = (int) trim($res['output']);
|
|
|
|
return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22;
|
|
}
|
|
|
|
/** Keep a port in the valid TCP range. */
|
|
private function clampPort(int $port): int
|
|
{
|
|
return max(1, min(65535, $port));
|
|
}
|
|
}
|