435 lines
18 KiB
PHP
435 lines
18 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Server;
|
||
use App\Support\Os\FirewallTool;
|
||
use App\Support\Os\OsDetector;
|
||
|
||
/**
|
||
* Host firewall control over SSH (as root), abstracted across ufw and firewalld
|
||
* (resolved per OS via OsDetector/FirewallTool).
|
||
*
|
||
* 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 can never sever the operator's own SSH session.
|
||
*
|
||
* Both mutating methods return array{ok: bool, output: string}.
|
||
*/
|
||
class FirewallService
|
||
{
|
||
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
|
||
|
||
/**
|
||
* GUARD: allow the real sshd Port + 80/443 first, THEN enable the firewall.
|
||
* Never enable a firewall that would drop the current SSH session. Installs the
|
||
* firewall package first when supported and missing.
|
||
*
|
||
* @return array{ok: bool, output: string}
|
||
*/
|
||
public function enable(Server $server): array
|
||
{
|
||
$os = $this->detector->detect($server);
|
||
if ($reason = $os->supports('firewall')) {
|
||
return ['ok' => false, 'output' => $reason];
|
||
}
|
||
|
||
// long timeout: may install the firewall package first.
|
||
$script = FirewallTool::for($os)->enableScript($this->sshPort($server));
|
||
$res = $this->fleet->runPrivileged($server, $script, 600);
|
||
|
||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||
}
|
||
|
||
/** Turn the firewall off (clean stop, never a panic/drop-all). @return array{ok: bool, output: string} */
|
||
public function disable(Server $server): array
|
||
{
|
||
$os = $this->detector->detect($server);
|
||
$res = $this->fleet->runPrivileged($server, FirewallTool::for($os)->disableScript());
|
||
|
||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||
}
|
||
|
||
/**
|
||
* Read the full firewall state in one privileged round-trip: installed/active,
|
||
* default policies (ufw) or zone+target (firewalld), and the rule list. Returns
|
||
* `supported=false` + a German reason on an OS without a supported firewall.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function status(Server $server): array
|
||
{
|
||
$os = $this->detector->detect($server);
|
||
$reason = $os->supports('firewall');
|
||
$tool = FirewallTool::for($os);
|
||
|
||
$base = [
|
||
'supported' => $reason === null,
|
||
'reason' => $reason,
|
||
'tool' => $tool->isFirewalld() ? 'firewalld' : 'ufw',
|
||
'installed' => false,
|
||
'active' => false,
|
||
'defaults' => [],
|
||
'rules' => [],
|
||
];
|
||
|
||
if ($reason !== null) {
|
||
return $base;
|
||
}
|
||
|
||
$res = $this->fleet->runPrivileged($server, $tool->statusScript());
|
||
// The sentinel proves the privileged read actually RAN (sudo/SSH ok). A sudo
|
||
// failure emits error text (non-empty) but no sentinel — so we must not parse
|
||
// that as an empty firewall (which would make a delete falsely "succeed").
|
||
if (! str_contains($res['output'], 'CLUSEV_FWREAD_OK')) {
|
||
return $base + ['readError' => true];
|
||
}
|
||
|
||
$s = $this->sections($res['output']);
|
||
|
||
// ufw: a successful read ALWAYS prints a "Status:" line. If the tool is installed
|
||
// but that line is missing, the privileged ufw call itself failed (malformed
|
||
// config, etc.) — treat as a read error rather than an empty (no-rules) firewall,
|
||
// so a later delete can't falsely report "already gone".
|
||
if (! $tool->isFirewalld()
|
||
&& trim($s['inst'] ?? '') === 'yes'
|
||
&& ! preg_match('/Status:/i', $s['verbose'] ?? '')) {
|
||
return $base + ['readError' => true];
|
||
}
|
||
|
||
return $tool->isFirewalld() ? $this->parseFirewalld($s, $base) : $this->parseUfw($s, $base);
|
||
}
|
||
|
||
/**
|
||
* Add a firewall rule from validated parts. ufw: action/proto/port/from;
|
||
* firewalld: opens a port in the default zone.
|
||
*
|
||
* @return array{ok: bool, output: string}
|
||
*/
|
||
public function addRule(Server $server, string $action, string $proto, ?int $port, ?string $from): array
|
||
{
|
||
$os = $this->detector->detect($server);
|
||
if ($reason = $os->supports('firewall')) {
|
||
return ['ok' => false, 'output' => $reason];
|
||
}
|
||
|
||
// Rule mutation is ufw-only this release; firewalld is read-only.
|
||
if ($os->firewallTool === 'firewalld') {
|
||
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
|
||
}
|
||
|
||
$action = in_array($action, ['allow', 'deny', 'reject', 'limit'], true) ? $action : 'allow';
|
||
$proto = in_array($proto, ['tcp', 'udp', 'any'], true) ? $proto : 'tcp';
|
||
$from = ($from !== null && trim($from) !== '') ? trim($from) : null;
|
||
|
||
if ($port !== null && ($port < 1 || $port > 65535)) {
|
||
return ['ok' => false, 'output' => 'Ungültiger Port (1–65535).'];
|
||
}
|
||
if ($from !== null && ! $this->validIpOrCidr($from)) {
|
||
return ['ok' => false, 'output' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.'];
|
||
}
|
||
if ($port === null && $from === null) {
|
||
return ['ok' => false, 'output' => 'Port oder Quelle ist erforderlich.'];
|
||
}
|
||
|
||
// LOCKOUT GUARD: a deny/reject that can cover the SSH connection would sever
|
||
// every future management session. Over TCP/any, refuse it when it targets the
|
||
// SSH port OR has NO port (a portless deny blocks ALL inbound, incl. SSH) —
|
||
// regardless of source, since a source may include Clusev's own address. Use
|
||
// fail2ban to block individual IPs from SSH instead.
|
||
$sshPort = $this->sshPort($server);
|
||
if (in_array($action, ['deny', 'reject'], true) && in_array($proto, ['tcp', 'any'], true)
|
||
&& ($port === null || $port === $sshPort)) {
|
||
return ['ok' => false, 'output' => ($port === null
|
||
? 'Eine '.$action.'-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. '
|
||
: 'Eine '.$action.'-Regel auf den SSH-Port ('.$sshPort.') ist nicht erlaubt (Aussperrgefahr). ')
|
||
.'Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.'];
|
||
}
|
||
|
||
$tool = FirewallTool::for($os);
|
||
if ($tool->isFirewalld() && $port === null) {
|
||
return ['ok' => false, 'output' => 'firewalld: Bitte einen Port angeben.'];
|
||
}
|
||
|
||
$res = $this->fleet->runPrivileged($server, $tool->addRuleScript($action, $proto, $port, $from), 60);
|
||
|
||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||
}
|
||
|
||
/**
|
||
* Delete a rule. ufw: $spec is the rule's raw line content — we re-read NOW and
|
||
* delete by the CURRENT number that matches, side-stepping the renumber race.
|
||
* firewalld: $spec is "port:80/tcp" or "service:ssh".
|
||
*
|
||
* @return array{ok: bool, output: string}
|
||
*/
|
||
public function deleteRule(Server $server, string $spec): array
|
||
{
|
||
$os = $this->detector->detect($server);
|
||
if ($reason = $os->supports('firewall')) {
|
||
return ['ok' => false, 'output' => $reason];
|
||
}
|
||
|
||
// Rule mutation is ufw-only this release; firewalld is read-only.
|
||
if ($os->firewallTool === 'firewalld') {
|
||
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
|
||
}
|
||
|
||
$tool = FirewallTool::for($os);
|
||
$sshPort = $this->sshPort($server);
|
||
|
||
// ufw: $spec is a public-method argument, so it is UNTRUSTED. Whitelist it
|
||
// against the specs ufw itself reported (which contain no shell metacharacters)
|
||
// — a forged spec like "allow 80/tcp; rm -rf /" can never match, so the raw
|
||
// concatenation into `ufw delete <spec>` can never inject. Delete-by-spec is
|
||
// also race-free (no rule number to shift).
|
||
$status = $this->status($server);
|
||
if (! empty($status['readError'])) {
|
||
return ['ok' => false, 'output' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.'];
|
||
}
|
||
$known = array_column($status['rules'], 'spec');
|
||
if (! in_array($spec, $known, true)) {
|
||
// The rule vanished between confirm and apply — report not_found so the
|
||
// caller does NOT audit a deletion that never happened.
|
||
return ['ok' => true, 'notFound' => true, 'output' => 'Regel existiert nicht mehr.'];
|
||
}
|
||
// LOCKOUT GUARD: refuse to remove an allow/limit rule that keeps the SSH port open.
|
||
if ($this->ufwSpecOpensSsh($spec, $sshPort)) {
|
||
return ['ok' => false, 'output' => $this->sshGuardMessage($sshPort)];
|
||
}
|
||
|
||
$res = $this->fleet->runPrivileged($server, $tool->deleteUfwScript($spec));
|
||
|
||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||
}
|
||
|
||
/**
|
||
* True when a ufw allow/limit spec keeps the SSH port (or an SSH app profile)
|
||
* open. The port is matched ONLY in the destination position — the simple form
|
||
* ("allow 22/tcp") or after "to any port" — never inside a source IP like
|
||
* 192.0.2.22, which must stay deletable.
|
||
*/
|
||
private function ufwSpecOpensSsh(string $spec, int $sshPort): bool
|
||
{
|
||
$spec = trim($spec);
|
||
if (! preg_match('/^(allow|limit)\b/i', $spec)) {
|
||
return false;
|
||
}
|
||
if (stripos($spec, 'OpenSSH') !== false || preg_match('/(^|\s)SSH(\s|$)/i', $spec)) {
|
||
return true;
|
||
}
|
||
// SSH is TCP — ignore explicitly UDP-only rules (simple "<port>/udp" or "proto udp").
|
||
if (preg_match('/^(allow|limit)\s+\d+(?::\d+)?\/udp\b/i', $spec) || preg_match('/proto\s+udp/i', $spec)) {
|
||
return false;
|
||
}
|
||
// simple form: "allow 22", "allow 22/tcp", or a range "allow 20:30/tcp".
|
||
if (preg_match('/^(allow|limit)\s+(\d+)(?::(\d+))?(\/tcp)?(\s|$)/i', $spec, $m)
|
||
&& $this->portInRange($sshPort, (int) $m[2], ($m[3] ?? '') !== '' ? (int) $m[3] : null)) {
|
||
return true;
|
||
}
|
||
// destination-port form: "... port 22 ..." or a range "... port 20:30 ...".
|
||
if (preg_match('/\bport\s+(\d+)(?::(\d+))?/i', $spec, $m)
|
||
&& $this->portInRange($sshPort, (int) $m[1], ($m[2] ?? '') !== '' ? (int) $m[2] : null)) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/** True when $needle equals the single port, or falls inside the [low, high] range. */
|
||
private function portInRange(int $needle, int $low, ?int $high): bool
|
||
{
|
||
return $high === null ? $needle === $low : ($needle >= $low && $needle <= $high);
|
||
}
|
||
|
||
/** German refusal shown when a delete would sever Clusev's own SSH access. */
|
||
private function sshGuardMessage(int $port): string
|
||
{
|
||
return "Diese Regel hält den SSH-Zugang (Port {$port}) offen und kann nicht entfernt werden — "
|
||
.'sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.';
|
||
}
|
||
|
||
// NOTE: editing default policies (e.g. "deny incoming") was intentionally NOT
|
||
// exposed — it is the highest-lockout-risk firewall operation (a restrictive
|
||
// default with a missing/source-mismatched SSH rule severs Clusev). Defaults are
|
||
// shown READ-ONLY in the panel; activating the firewall via the hardening toggle
|
||
// applies safe defaults while guaranteeing ssh/80/443 stay open.
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, string> $s
|
||
* @param array<string, mixed> $base
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function parseUfw(array $s, array $base): array
|
||
{
|
||
// Defaults from /etc/default/ufw — correct whether ufw is active or inactive
|
||
// (verbose only prints the Default: line while active).
|
||
$map = ['DROP' => 'deny', 'REJECT' => 'reject', 'ACCEPT' => 'allow'];
|
||
$defaults = ['incoming' => null, 'outgoing' => null, 'routed' => null];
|
||
if (preg_match('/DEFAULT_INPUT_POLICY="?(\w+)"?/', $s['defaults'] ?? '', $mi)) {
|
||
$defaults['incoming'] = $map[strtoupper($mi[1])] ?? strtolower($mi[1]);
|
||
}
|
||
if (preg_match('/DEFAULT_OUTPUT_POLICY="?(\w+)"?/', $s['defaults'] ?? '', $mo)) {
|
||
$defaults['outgoing'] = $map[strtoupper($mo[1])] ?? strtolower($mo[1]);
|
||
}
|
||
|
||
// Rules from `ufw show added` (add-syntax). The spec IS the delete argument.
|
||
$rules = [];
|
||
foreach (preg_split('/\R/', $s['added'] ?? '') ?: [] as $line) {
|
||
if (! preg_match('/^ufw\s+(.+\S)\s*$/', trim($line), $m)) {
|
||
continue;
|
||
}
|
||
$spec = trim(preg_replace('/\s+/', ' ', $m[1]) ?? '');
|
||
// Routed/forwarding rules look like "route allow in on eth0 ..." — the action
|
||
// is the SECOND token; keep the whole spec (delete needs "route allow …").
|
||
$first = (string) strtok($spec, ' ');
|
||
$routed = strcasecmp($first, 'route') === 0;
|
||
$afterFirst = trim((string) substr($spec, strlen($first)));
|
||
$action = strtoupper($routed ? (string) strtok($afterFirst, ' ') : $first);
|
||
if (! in_array($action, ['ALLOW', 'DENY', 'REJECT', 'LIMIT'], true)) {
|
||
continue;
|
||
}
|
||
$label = $routed ? $spec : (trim((string) substr($spec, strlen($first))) ?: $spec);
|
||
$rules[] = ['raw' => $spec, 'label' => $label, 'action' => $action, 'from' => '', 'v6' => false, 'spec' => $spec];
|
||
}
|
||
|
||
$installed = trim($s['inst'] ?? '') === 'yes';
|
||
|
||
return array_merge($base, [
|
||
'installed' => $installed,
|
||
'active' => (bool) preg_match('/Status:\s*active/i', $s['verbose'] ?? ''),
|
||
// ufw stores rules in files, so they are manageable even while inactive.
|
||
'manageable' => $installed,
|
||
'readOnly' => false,
|
||
'defaults' => $defaults,
|
||
'rules' => $rules,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, string> $s
|
||
* @param array<string, mixed> $base
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function parseFirewalld(array $s, array $base): array
|
||
{
|
||
// Each line is "zone|value"; the zone is shown when it is not the default zone,
|
||
// so a port enforced in a non-default zone is attributed correctly and identical
|
||
// rules in different zones stay distinct. firewalld is read-only, so `spec` is
|
||
// cosmetic here (never used for deletion).
|
||
$default = trim($s['zone'] ?? '');
|
||
$rules = [];
|
||
$seen = [];
|
||
|
||
$add = function (string $line, string $kind) use (&$rules, &$seen, $default): void {
|
||
$line = trim($line);
|
||
if ($line === '') {
|
||
return;
|
||
}
|
||
[$zone, $value] = array_pad(explode('|', $line, 2), 2, '');
|
||
if ($value === '') {
|
||
[$zone, $value] = [$default, $zone];
|
||
}
|
||
$key = $kind.'|'.$zone.'|'.$value;
|
||
if (isset($seen[$key])) {
|
||
return;
|
||
}
|
||
$seen[$key] = true;
|
||
|
||
$suffix = ($zone !== '' && $zone !== $default) ? ' · '.$zone : '';
|
||
$action = 'ALLOW';
|
||
$label = $value.$suffix;
|
||
if ($kind === 'service') {
|
||
$label = $value.' (Dienst)'.$suffix;
|
||
} elseif ($kind === 'rich') {
|
||
$action = stripos($value, 'accept') !== false ? 'ALLOW'
|
||
: (stripos($value, 'reject') !== false ? 'REJECT' : (stripos($value, 'drop') !== false ? 'DENY' : 'ALLOW'));
|
||
}
|
||
$rules[] = ['label' => $label, 'action' => $action, 'from' => '', 'v6' => false, 'spec' => $kind.':'.$value];
|
||
};
|
||
|
||
foreach (preg_split('/\R/', $s['ports'] ?? '') ?: [] as $l) {
|
||
$add($l, 'port');
|
||
}
|
||
foreach (preg_split('/\R/', $s['services'] ?? '') ?: [] as $l) {
|
||
$add($l, 'service');
|
||
}
|
||
foreach (preg_split('/\R/', $s['rich'] ?? '') ?: [] as $l) {
|
||
$add($l, 'rich');
|
||
}
|
||
|
||
$installed = trim($s['inst'] ?? '') === 'yes';
|
||
$active = trim($s['active'] ?? '') === 'active';
|
||
|
||
return array_merge($base, [
|
||
'installed' => $installed,
|
||
'active' => $active,
|
||
// firewalld is READ-ONLY this release: rules are displayed (runtime state)
|
||
// but not mutated from Clusev, so it is never "manageable" for add/delete.
|
||
'manageable' => false,
|
||
'readOnly' => true,
|
||
'defaults' => ['zone' => trim($s['zone'] ?? '')],
|
||
'rules' => $rules,
|
||
]);
|
||
}
|
||
|
||
/** Split a marker-sectioned (===CLUSEV:name===) output into [name => body]. */
|
||
private function sections(string $out): array
|
||
{
|
||
$sections = [];
|
||
$current = null;
|
||
$buffer = [];
|
||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
||
if (str_starts_with($line, '===CLUSEV:')) {
|
||
if ($current !== null) {
|
||
$sections[$current] = implode("\n", $buffer);
|
||
}
|
||
$current = rtrim(substr($line, strlen('===CLUSEV:')), '=');
|
||
$buffer = [];
|
||
} elseif ($current !== null) {
|
||
$buffer[] = $line;
|
||
}
|
||
}
|
||
if ($current !== null) {
|
||
$sections[$current] = implode("\n", $buffer);
|
||
}
|
||
|
||
return $sections;
|
||
}
|
||
|
||
/** Accept a bare IP (v4/v6) or an IP/CIDR with a valid prefix length. */
|
||
private function validIpOrCidr(string $v): bool
|
||
{
|
||
if (filter_var($v, FILTER_VALIDATE_IP)) {
|
||
return true;
|
||
}
|
||
if (! str_contains($v, '/')) {
|
||
return false;
|
||
}
|
||
[$ip, $mask] = explode('/', $v, 2);
|
||
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! ctype_digit($mask)) {
|
||
return false;
|
||
}
|
||
$max = str_contains($ip, ':') ? 128 : 32;
|
||
|
||
return (int) $mask >= 0 && (int) $mask <= $max;
|
||
}
|
||
}
|