"""Regel-Engine für White-/Blacklists auf Host- und Port-Ebene. Reihenfolge der Auswertung: 1. Loopback-Schutz (Einstellung block_loopback) 2. Sperr-Regeln des Benutzers -> blockiert 3. Erlaubt-Regeln des Benutzers -> erlaubt 4. Globale Sperr-Regeln -> blockiert 5. Globale Erlaubt-Regeln -> erlaubt 6. Standardrichtlinie (des Benutzers, sonst global) Host-Muster: "" oder "*" -> alle Ziele example.com -> example.com und alle Subdomains *.example.com -> Platzhalter (fnmatch), nur Subdomains 10.0.0.0/8, 1.2.3.4 -> IP/Netz (gegen aufgelöste Ziel-IP geprüft) re:^ads?\\. -> regulärer Ausdruck Port-Muster: "" = alle, "25", "8000-9000", "25,465,587" """ import fnmatch import ipaddress import re from dataclasses import dataclass from .db import db LOOPBACK = [ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128"), ipaddress.ip_network("0.0.0.0/32")] def parse_ports(spec: str) -> list[tuple[int, int]] | None: """Gibt Liste von (von, bis) zurück; None = alle Ports. Wirft ValueError bei Fehlern.""" spec = (spec or "").strip() if not spec or spec == "*": return None ranges = [] for part in spec.split(","): part = part.strip() if not part: continue if "-" in part: a, b = (int(x) for x in part.split("-", 1)) else: a = b = int(part) if not (0 < a <= 65535 and 0 < b <= 65535 and a <= b): raise ValueError(f"Ungültiger Port-Bereich: {part}") ranges.append((a, b)) return ranges class HostMatcher: def __init__(self, pattern: str): self.raw = (pattern or "").strip() p = self.raw.lower() self.any = p in ("", "*") self.net = None self.regex = None self.glob = None self.domain = None if self.any: return if p.startswith("re:"): self.regex = re.compile(self.raw[3:], re.IGNORECASE) return try: self.net = ipaddress.ip_network(p, strict=False) return except ValueError: pass if "*" in p or "?" in p: self.glob = p else: self.domain = p.lstrip(".").rstrip(".") def match(self, host: str, ip) -> bool: if self.any: return True if self.net is not None: return ip is not None and ip.version == self.net.version and ip in self.net h = (host or "").lower().rstrip(".") if self.regex is not None: return bool(self.regex.search(h)) if self.glob is not None: return fnmatch.fnmatchcase(h, self.glob) return h == self.domain or h.endswith("." + self.domain) def validate_rule(host: str, ports: str) -> None: HostMatcher(host) # wirft re.error bei kaputtem Regex parse_ports(ports) @dataclass class Rule: id: int user_id: int | None action: str host: HostMatcher ports: list | None text: str def match(self, host: str, port: int, ip) -> bool: if self.ports is not None and not any(a <= port <= b for a, b in self.ports): return False return self.host.match(host, ip) @dataclass class Decision: allowed: bool reason: str rule_id: int | None = None class Policy: def __init__(self): self.global_rules: list[Rule] = [] self.user_rules: dict[int, list[Rule]] = {} self.settings: dict = {} self.anon_nets: list = [] self.reload() def reload(self) -> None: self.settings = db.settings() g, u = [], {} for r in db.query("SELECT * FROM rules WHERE enabled=1 ORDER BY id"): try: rule = Rule(r["id"], r["user_id"], r["action"], HostMatcher(r["host"]), parse_ports(r["ports"]), f'{r["action"]} {r["host"] or "*"}:{r["ports"] or "*"}') except (ValueError, re.error): continue (u.setdefault(r["user_id"], []) if r["user_id"] else g).append(rule) self.global_rules, self.user_rules = g, u self.anon_nets = parse_networks(self.settings.get("anonymous_networks", "")) def is_anonymous_allowed(self, client_ip: str) -> bool: return ip_in(client_ip, self.anon_nets) def decide(self, user: dict | None, host: str, port: int, ip=None) -> Decision: if ip is None: try: ip = ipaddress.ip_address(host.strip("[]")) except ValueError: ip = None if ip is not None and self.settings.get("block_loopback", "1") == "1": if getattr(ip, "ipv4_mapped", None): ip = ip.ipv4_mapped if any(ip.version == n.version and ip in n for n in LOOPBACK): return Decision(False, "Loopback-Ziel gesperrt") layers = [] if user: layers.append(("Benutzer", self.user_rules.get(user["id"], []))) layers.append(("Global", self.global_rules)) for label, rules in layers: for action in ("deny", "allow"): for r in rules: if r.action == action and r.match(host, port, ip): return Decision(action == "allow", f"{label}-Regel #{r.id}: {r.text}", r.id) policy = self.settings.get("default_policy", "allow") source = "Standardrichtlinie" if user and user.get("policy") in ("allow", "deny"): policy = user["policy"] source = "Benutzer-Standardrichtlinie" return Decision(policy == "allow", f"{source}: {'erlauben' if policy == 'allow' else 'sperren'}") def parse_networks(text: str) -> list: nets = [] for part in re.split(r"[\s,;]+", text or ""): if part: try: nets.append(ipaddress.ip_network(part, strict=False)) except ValueError: pass return nets def ip_in(ip: str, nets: list) -> bool: try: addr = ipaddress.ip_address(ip) except ValueError: return False if getattr(addr, "ipv4_mapped", None): addr = addr.ipv4_mapped return any(addr.version == n.version and addr in n for n in nets) policy = Policy()