From de93d8f0f7a1cba2fb0762a0a0db7eb1ebb7c76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Pi=C3=A9dallu?= Date: Mon, 9 Dec 2024 11:49:08 +0100 Subject: [PATCH] firewall.py complete revamp --- conf/yunohost/firewall.yml | 61 ++- share/actionsmap.yml | 86 +-- src/firewall.py | 708 ++++++++++--------------- src/migrations/0032_firewall_config.py | 62 +++ src/tests/test_app_resources.py | 6 +- src/utils/resources.py | 6 +- 6 files changed, 433 insertions(+), 496 deletions(-) create mode 100644 src/migrations/0032_firewall_config.py diff --git a/conf/yunohost/firewall.yml b/conf/yunohost/firewall.yml index 64c6b93264..a222298a85 100644 --- a/conf/yunohost/firewall.yml +++ b/conf/yunohost/firewall.yml @@ -1,12 +1,49 @@ -uPnP: - enabled: false - TCP: [22, 25, 80, 443, 587, 993, 5222, 5269] - UDP: [] - TCP_TO_CLOSE: [] - UDP_TO_CLOSE: [] -ipv4: - TCP: [22, 25, 53, 80, 443, 587, 993, 5222, 5269] - UDP: [53, 5353] -ipv6: - TCP: [22, 25, 53, 80, 443, 587, 993, 5222, 5269] - UDP: [53, 5353] +router_forwarding_upnp: false + +tcp: + 22: + open: true + upnp: true + comment: Default SSH port + 25: + open: true + upnp: true + comment: SMTP email server + 80: + open: true + upnp: true + comment: HTTP server + 443: + open: true + upnp: true + comment: HTTPS server + 587: + open: true + upnp: true + comment: SMTP MSA email server + 993: + open: true + upnp: true + comment: IMAP email server + 5222: + open: true + upnp: true + comment: XMPP server + 5269: + open: true + upnp: true + comment: XMPP server-to-server + +udp: + 53: + open: true + upnp: false + comment: DNS server + 1900: + open: true + upnp: false + comment: UPnP services + 5353: + open: true + upnp: false + comment: mDNS diff --git a/share/actionsmap.yml b/share/actionsmap.yml index 73528e4e6b..841e3175df 100755 --- a/share/actionsmap.yml +++ b/share/actionsmap.yml @@ -1396,72 +1396,58 @@ firewall: full: --raw help: Return the complete YAML dict action: store_true - -i: - full: --by-ip-version - help: List rules by IP version - action: store_true + protocol: + help: "If not raw, protocol type to list (tcp/udp)" + choices: + - tcp + - udp -f: - full: --list-forwarded - help: List forwarded ports with UPnP + full: --forwarded + help: If not raw, list UPnP forwarded ports instead of open ports action: store_true - ### firewall_allow() - allow: + ### firewall_open() + open: action_help: Allow connections on a port - api: PUT /firewall//allow/ + api: PUT /firewall//open/ arguments: protocol: - help: "Protocol type to allow (TCP/UDP/Both)" + help: "Protocol type (tcp/udp)" choices: - - TCP - - UDP - - Both - default: TCP + - tcp + - udp + default: tcp port: help: Port or range of ports to open extra: pattern: &pattern_port_or_range - !!str ((^|(?!\A):)([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])){1,2}?$ - "pattern_port_or_range" - -4: - full: --ipv4-only - help: Only add a rule for IPv4 connections - action: store_true - -6: - full: --ipv6-only - help: Only add a rule for IPv6 connections - action: store_true - --no-upnp: - help: Do not add forwarding of this port with UPnP + comment: + help: A reason for the port to be open (like the app's name) + default: "" + --upnp: + help: Add forwarding of this port with UPnP action: store_true --no-reload: help: Do not reload firewall rules action: store_true - ### firewall_disallow() - disallow: + ### firewall_close() + close: action_help: Disallow connections on a port - api: PUT /firewall//disallow/ + api: PUT /firewall//close/ arguments: protocol: - help: "Protocol type to allow (TCP/UDP/Both)" + help: "Protocol type (tcp/udp)" choices: - - TCP - - UDP - - Both - default: TCP + - tcp + - udp + default: tcp port: help: Port or range of ports to close extra: pattern: *pattern_port_or_range - -4: - full: --ipv4-only - help: Only remove the rule for IPv4 connections - action: store_true - -6: - full: --ipv6-only - help: Only remove the rule for IPv6 connections - action: store_true --upnp-only: help: Only remove forwarding of this port with UPnP action: store_true @@ -1469,6 +1455,25 @@ firewall: help: Do not reload firewall rules action: store_true + ### firewall_delete() + delete: + action_help: Unregister a port from YunoHost + api: PUT /firewall//delete/ + arguments: + protocol: + help: "Protocol type (tcp/udp)" + choices: + - tcp + - udp + default: tcp + port: + help: Port or range of ports to delete + extra: + pattern: *pattern_port_or_range + --no-reload: + help: Do not reload firewall rules + action: store_true + ### firewall_upnp() upnp: action_help: Manage port forwarding using UPnP @@ -1479,7 +1484,6 @@ firewall: - enable - disable - status - - reload nargs: "?" default: status --no-refresh: diff --git a/src/firewall.py b/src/firewall.py index 9e751399ae..d5f5de23df 100644 --- a/src/firewall.py +++ b/src/firewall.py @@ -19,510 +19,363 @@ # import os +import shutil +from typing import Any +from pathlib import Path from logging import getLogger import miniupnpc import yaml from moulinette import m18n -from moulinette.utils import process from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.regenconf import regen_conf -FIREWALL_FILE = "/etc/yunohost/firewall.yml" -UPNP_CRON_JOB = "/etc/cron.d/yunohost-firewall-upnp" logger = getLogger("yunohost.firewall") -def firewall_allow( - protocol, - port, - ipv4_only=False, - ipv6_only=False, - no_upnp=False, - no_reload=False, - reload_only_if_change=False, -): +class YunoUPnP(): + UPNP_CRON_JOB = Path("/etc/cron.d/yunohost-firewall-upnp") + + def __init__(self, enabled: bool) -> None: + self.enabled = enabled + self.upnpc: miniupnpc.UPnP | None = None + + def find_gid(self) -> bool: + upnpc = miniupnpc.UPnP() + upnpc.discoverdelay = 3000 + # Discover UPnP device(s) + logger.debug("discovering UPnP devices...") + try: + nb_dev = upnpc.discover() + except Exception: + logger.warning("Failed to find any UPnP device on the network") + nb_dev = -1 + if nb_dev < 1: + logger.error(m18n.n("upnp_dev_not_found")) + return False + logger.debug("found %d UPnP device(s)", int(nb_dev)) + try: + # Select UPnP device + upnpc.selectigd() + except Exception: + logger.debug("unable to select UPnP device", exc_info=1) + return False + return True + + def open_port(self, protocol: str, port: int | str, comment: str) -> bool: + if self.upnpc is None: + self.find_gid() + assert self.upnpc is not None + + # FIXME : how should we handle port ranges ? + if not isinstance(port, int): + logger.warning("Can't use UPnP to open '%s'" % port) + return False + + # Clean the mapping of this port + if self.upnpc.getspecificportmapping(port, protocol): + try: + self.upnpc.deleteportmapping(port, protocol) + except Exception: + return False + + # Add new port mapping + desc = f"yunohost firewall: port {port} {comment}" + try: + self.upnpc.addportmapping( port, protocol, self.upnpc.lanaddr, port, desc, "") + except Exception: + logger.debug("unable to add port %d using UPnP", port, exc_info=1) + return False + return True + + def close_port(self, protocol: str, port: int | str) -> bool: + if self.upnpc is None: + self.find_gid() + assert self.upnpc is not None + + if self.upnpc.getspecificportmapping(port, protocol): + try: + self.upnpc.deleteportmapping(port, protocol) + except Exception: + return False + return True + + def refresh(self, firewall: "YunoFirewall") -> bool: + if not self.find_gid(): + return False + + status = True + for protocol, port in firewall.upnp_to_close: + status = status or self.close_port(protocol, port) + + for protocol, ports in firewall.config.items(): + for port, info in ports.items(): + if self.enabled: + status = status or self.open_port(protocol, port, info["comment"]) + else: + status = status or self.close_port(protocol, port) + + return status + + def enable(self) -> None: + if not self.enabled: + # Add cron job + cron = "*/50 * * * * root /usr/bin/yunohost firewall upnp status >>/dev/null\n" + self.UPNP_CRON_JOB.write_text(cron) + self.enabled = True + + def disable(self) -> None: + if self.enabled: + # Remove cron job + self.UPNP_CRON_JOB.unlink(missing_ok=True) + self.enabled = False + + + +class YunoFirewall(): + FIREWALL_FILE = Path("/etc/yunohost/firewall.yml") + + def __init__(self) -> None: + self.need_reload = False + + # This is a workaround for when we need to actively close UPnP ports + self.upnp_to_close: list[tuple[str, int | str]] + + self.read() + + def read(self) -> None: + self.config = yaml.safe_load(self.FIREWALL_FILE.read_text()) + + def write(self) -> None: + old_file = self.FIREWALL_FILE.parent / (self.FIREWALL_FILE.name + ".old") + shutil.copyfile(self.FIREWALL_FILE, old_file) + self.FIREWALL_FILE.write_text(yaml.dump(self.config)) + + def list(self, protocol: str, forwarded: bool = False) -> list[int]: + protocol, _ = self._validate_port(protocol, 0) + return [ + port + for port, status in self.config[protocol] + if (status["forwarded"] if forwarded else status["open"]) + ] + + @staticmethod + def _validate_port(protocol: str, port: int | str) -> tuple[str, int | str]: + if isinstance(port, str) and ":" not in port: + port = int(port) + if protocol not in ["tcp", "udp"]: + raise ValueError(f"protocol should be tcp or udp, not {protocol}") + return protocol, port + + def open_port(self, protocol: str, port: int | str, comment: str, upnp: bool = False) -> None: + protocol, port = self._validate_port(protocol, port) + + if port not in self.config[protocol]: + self.config[protocol][port] = {"open": False, "upnp": False, "comment": comment} + + if not self.config[protocol][port]["open"]: + self.config[protocol][port]["open"] = True + self.need_reload = True + + if self.config[protocol][port]["upnp"] != upnp: + self.config[protocol][port]["upnp"] = upnp + self.need_reload = True + + def close_port(self, protocol: str, port: int | str, upnp_only: bool = False) -> None: + protocol, port = self._validate_port(protocol, port) + + if port not in self.config[protocol]: + return + + if self.config[protocol][port]["upnp"]: + self.config[protocol][port]["upnp"] = False + # not need_reload, it's only upnp + self.upnp_to_close.append((protocol, port)) + + if upnp_only: + return + + if self.config[protocol][port]["open"]: + self.config[protocol][port]["upnp"] = True + self.need_reload = True + + def delete_port(self, protocol: str, port: int | str) -> None: + protocol, port = self._validate_port(protocol, port) + + if port not in self.config[protocol]: + return + + if self.config[protocol][port]["upnp"]: + self.upnp_to_close.append((protocol, port)) + + del self.config[protocol][port] + self.need_reload = True + + def apply(self, upnp: bool = True) -> None: + # Just leverage regen_conf that will regen the nftables files, reload nftables + regen_conf(["nftables"], force=True) + self.need_reload = False + + # Refresh port forwarding with UPnP + if self.config["router_forwarding_upnp"] and upnp: + YunoUPnP(self.config["router_forwarding_upnp"]).refresh(self) + + def clear(self) -> None: + os.system("systemctl stop nftables") + + +def firewall_open( + protocol: str, + port: int | str, + comment: str, + upnp: bool = False, + no_reload: bool = False, + reload_if_changed: bool = False, +) -> None: """ Allow connections on a port Keyword arguments: protocol -- Protocol type to allow (TCP/UDP/Both) port -- Port or range of ports to open - ipv4_only -- Only add a rule for IPv4 connections - ipv6_only -- Only add a rule for IPv6 connections + comment -- A reason for the port to be open no_upnp -- Do not add forwarding of this port with UPnP no_reload -- Do not reload firewall rules - """ - firewall = firewall_list(raw=True) + firewall = YunoFirewall() - # Validate port - if not isinstance(port, int) and ":" not in port: - port = int(port) + firewall.open_port(protocol, port, comment, upnp) + if not reload_if_changed and not firewall.need_reload: + logger.warning(m18n.n("port_already_opened", port=port)) - # Validate protocols - protocols = ["TCP", "UDP"] - if protocol != "Both" and protocol in protocols: - protocols = [ - protocol, - ] + firewall.write() + if (firewall.need_reload and reload_if_changed) or (not no_reload and not reload_if_changed): + firewall.apply() - # Validate IP versions - ipvs = ["ipv4", "ipv6"] - if ipv4_only and not ipv6_only: - ipvs = [ - "ipv4", - ] - elif ipv6_only and not ipv4_only: - ipvs = [ - "ipv6", - ] - changed = False - - for p in protocols: - # Iterate over IP versions to add port - for i in ipvs: - if port not in firewall[i][p]: - firewall[i][p].append(port) - changed = True - else: - ipv = "IPv%s" % i[3] - if not reload_only_if_change: - logger.warning( - m18n.n("port_already_opened", port=port, ip_version=ipv) - ) - # Add port forwarding with UPnP - if not no_upnp and port not in firewall["uPnP"][p]: - firewall["uPnP"][p].append(port) - if ( - p + "_TO_CLOSE" in firewall["uPnP"] - and port in firewall["uPnP"][p + "_TO_CLOSE"] - ): - firewall["uPnP"][p + "_TO_CLOSE"].remove(port) - - # Update and reload firewall - _update_firewall_file(firewall) - if (not reload_only_if_change and not no_reload) or ( - reload_only_if_change and changed - ): - return firewall_reload() - - -def firewall_disallow( - protocol, - port, - ipv4_only=False, - ipv6_only=False, - upnp_only=False, - no_reload=False, - reload_only_if_change=False, -): +def firewall_close( + protocol: str, + port: int | str, + upnp_only: bool = False, + no_reload: bool = False, + reload_if_changed: bool = False, +) -> None: """ Disallow connections on a port Keyword arguments: - protocol -- Protocol type to disallow (TCP/UDP/Both) + protocol -- Protocol type to disallow (tcp/udp) port -- Port or range of ports to close - ipv4_only -- Only remove the rule for IPv4 connections - ipv6_only -- Only remove the rule for IPv6 connections upnp_only -- Only remove forwarding of this port with UPnP no_reload -- Do not reload firewall rules - """ - firewall = firewall_list(raw=True) + firewall = YunoFirewall() - # Validate port - if not isinstance(port, int) and ":" not in port: - port = int(port) + firewall.close_port(protocol, port, upnp_only=upnp_only) + if not firewall.need_reload and not reload_if_changed: + logger.warning(m18n.n("port_already_closed", port=port)) - # Validate protocols - protocols = ["TCP", "UDP"] - if protocol != "Both" and protocol in protocols: - protocols = [ - protocol, - ] + firewall.write() + if (firewall.need_reload and reload_if_changed) or (not no_reload and not reload_if_changed): + firewall.apply() - # Validate IP versions and UPnP - ipvs = ["ipv4", "ipv6"] - upnp = True - if ipv4_only and ipv6_only: - upnp = True # automatically disallow UPnP - elif ipv4_only: - ipvs = [ - "ipv4", - ] - upnp = upnp_only - elif ipv6_only: - ipvs = [ - "ipv6", - ] - upnp = upnp_only - elif upnp_only: - ipvs = [] - - changed = False - - for p in protocols: - # Iterate over IP versions to remove port - for i in ipvs: - if port in firewall[i][p]: - firewall[i][p].remove(port) - changed = True - else: - ipv = "IPv%s" % i[3] - if not reload_only_if_change: - logger.warning( - m18n.n("port_already_closed", port=port, ip_version=ipv) - ) - # Remove port forwarding with UPnP - if upnp and port in firewall["uPnP"][p]: - firewall["uPnP"][p].remove(port) - if p + "_TO_CLOSE" not in firewall["uPnP"]: - firewall["uPnP"][p + "_TO_CLOSE"] = [] - firewall["uPnP"][p + "_TO_CLOSE"].append(port) - - # Update and reload firewall - _update_firewall_file(firewall) - if (not reload_only_if_change and not no_reload) or ( - reload_only_if_change and changed - ): - return firewall_reload() - - -def firewall_list(raw=False, by_ip_version=False, list_forwarded=False): + +def firewall_list(raw: bool = False, protocol: str = "tcp", forwarded: bool = False) -> dict[str, Any] | list[int]: """ List all firewall rules Keyword arguments: raw -- Return the complete YAML dict - by_ip_version -- List rules by IP version - list_forwarded -- List forwarded ports with UPnP - + tcp -- If not raw, list TCP ports + udp -- If not raw, list UDP ports + forwarded -- If not raw, list UPnP forwarded ports instead of open ports """ - with open(FIREWALL_FILE) as f: - firewall = yaml.safe_load(f) - if raw: - return firewall - - # Retrieve all ports for IPv4 and IPv6 - ports = {} - for i in ["ipv4", "ipv6"]: - f = firewall[i] - # Combine TCP and UDP ports - ports[i] = sorted( - set(f["TCP"]) | set(f["UDP"]), - key=lambda p: int(p.split(":")[0]) if isinstance(p, str) else p, - ) - - if not by_ip_version: - # Combine IPv4 and IPv6 ports - ports = sorted( - set(ports["ipv4"]) | set(ports["ipv6"]), - key=lambda p: int(p.split(":")[0]) if isinstance(p, str) else p, - ) - - # Format returned dict - ret = {"opened_ports": ports} - if list_forwarded: - # Combine TCP and UDP forwarded ports - ret["forwarded_ports"] = sorted( - set(firewall["uPnP"]["TCP"]) | set(firewall["uPnP"]["UDP"]), - key=lambda p: int(p.split(":")[0]) if isinstance(p, str) else p, - ) - return ret - - -def firewall_reload(skip_upnp=False): + firewall = YunoFirewall() + return firewall.config if raw else firewall.list(protocol, forwarded) + + +def firewall_reload(skip_upnp: bool = False) -> None: """ Reload all firewall rules Keyword arguments: skip_upnp -- Do not refresh port forwarding using UPnP - """ from yunohost.hook import hook_callback from yunohost.service import _run_service_command - reloaded = False - errors = False + firewall = YunoFirewall() # Check if SSH port is allowed - ssh_port = _get_ssh_port() - if ssh_port not in firewall_list()["opened_ports"]: - firewall_allow("TCP", ssh_port, no_reload=True) - - # Retrieve firewall rules and UPnP status - firewall = firewall_list(raw=True) - upnp = firewall_upnp()["enabled"] if not skip_upnp else False - - # IPv4 - try: - process.check_output("iptables -n -w -L") - except process.CalledProcessError as e: - logger.debug( - "iptables seems to be not available, it outputs:\n%s", - e.output.decode().strip(), - ) - logger.warning(m18n.n("iptables_unavailable")) - else: - rules = [ - "iptables -w -F", - "iptables -w -X", - "iptables -w -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT", - ] - # Iterate over ports and add rule - for protocol in ["TCP", "UDP"]: - for port in firewall["ipv4"][protocol]: - rules.append( - "iptables -w -A INPUT -p %s --dport %s -j ACCEPT" - % (protocol, process.quote(str(port))) - ) - rules += [ - "iptables -w -A INPUT -i lo -j ACCEPT", - "iptables -w -A INPUT -p icmp -j ACCEPT", - "iptables -w -P INPUT DROP", - ] + firewall.open_port("tcp", _get_ssh_port(), "SSH port", upnp=True) + firewall.write() - # Execute each rule - if process.run_commands(rules, callback=_on_rule_command_error): - errors = True - reloaded = True - - # IPv6 - try: - process.check_output("ip6tables -n -L") - except process.CalledProcessError as e: - logger.debug( - "ip6tables seems to be not available, it outputs:\n%s", - e.output.decode().strip(), - ) - logger.warning(m18n.n("ip6tables_unavailable")) - else: - rules = [ - "ip6tables -w -F", - "ip6tables -w -X", - "ip6tables -w -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT", - ] - # Iterate over ports and add rule - for protocol in ["TCP", "UDP"]: - for port in firewall["ipv6"][protocol]: - rules.append( - "ip6tables -w -A INPUT -p %s --dport %s -j ACCEPT" - % (protocol, process.quote(str(port))) - ) - rules += [ - "ip6tables -w -A INPUT -i lo -j ACCEPT", - "ip6tables -w -A INPUT -p icmpv6 -j ACCEPT", - "ip6tables -w -P INPUT DROP", - ] - - # Execute each rule - if process.run_commands(rules, callback=_on_rule_command_error): - errors = True - reloaded = True - - if not reloaded: - raise YunohostError("firewall_reload_failed") - - hook_callback( - "post_iptable_rules", args=[upnp, os.path.exists("/proc/net/if_inet6")] - ) - - if upnp: - # Refresh port forwarding with UPnP - firewall_upnp(no_refresh=False) + firewall.apply(upnp=not skip_upnp) _run_service_command("restart", "fail2ban") + # FIXME: how to get errors from regen_conf? if errors: logger.warning(m18n.n("firewall_rules_cmd_failed")) else: logger.success(m18n.n("firewall_reloaded")) - return firewall_list() -def firewall_upnp(action="status", no_refresh=False): +def firewall_upnp(action: str = "status", no_refresh: bool = False) -> dict[str, bool]: """ Manage port forwarding using UPnP - Note: 'reload' action is deprecated and will be removed in the near - future. You should use 'status' instead - which retrieve UPnP status - and automatically refresh port forwarding if 'no_refresh' is False. + Available actions are status, enable, disable. + All actions will refresh port forwarding unless 'no_refresh' is False. Keyword argument: action -- Action to perform no_refresh -- Do not refresh port forwarding - """ - firewall = firewall_list(raw=True) - enabled = firewall["uPnP"]["enabled"] + if action not in ["status", "enable", "disable"]: + raise YunohostValidationError("action_invalid", action=action) - # Compatibility with previous version - if action == "reload": - logger.debug("'reload' action is deprecated and will be removed") - try: - # Remove old cron job - os.remove("/etc/cron.d/yunohost-firewall") - except Exception: - pass - action = "status" - no_refresh = False + firewall = YunoFirewall() + enabled = firewall.config["router_forwarding_upnp"] + upnp = YunoUPnP(enabled) - if action == "status" and no_refresh: - # Only return current state - return {"enabled": enabled} - elif action == "enable" or (enabled and action == "status"): - # Add cron job - with open(UPNP_CRON_JOB, "w+") as f: - f.write( - "*/50 * * * * root " - "/usr/bin/yunohost firewall upnp status >>/dev/null\n" - ) - # Open port 1900 to receive discovery message - if 1900 not in firewall["ipv4"]["UDP"]: - firewall_allow("UDP", 1900, no_upnp=True, no_reload=True) - if not enabled: - firewall_reload(skip_upnp=True) - enabled = True - elif action == "disable" or (not enabled and action == "status"): - try: - # Remove cron job - os.remove(UPNP_CRON_JOB) - except Exception: - pass - enabled = False - if action == "status": - no_refresh = True - else: - raise YunohostValidationError("action_invalid", action=action) + if action == "enable": + upnp.enable() + firewall.config["router_forwarding_upnp"] = True + firewall.write() - # Refresh port mapping using UPnP - if not no_refresh: - upnpc = miniupnpc.UPnP(localport=1) - upnpc.discoverdelay = 3000 + if action == "disable": + upnp.disable() + firewall.config["router_forwarding_upnp"] = False + firewall.write() - # Discover UPnP device(s) - logger.debug("discovering UPnP devices...") - try: - nb_dev = upnpc.discover() - except Exception: - logger.warning("Failed to find any UPnP device on the network") - nb_dev = -1 - enabled = False + if no_refresh: + # Only return current state + return {"enabled": enabled} - logger.debug("found %d UPnP device(s)", int(nb_dev)) - if nb_dev < 1: - logger.error(m18n.n("upnp_dev_not_found")) - enabled = False - else: - try: - # Select UPnP device - upnpc.selectigd() - except Exception: - logger.debug("unable to select UPnP device", exc_info=1) - enabled = False - else: - # Iterate over ports - for protocol in ["TCP", "UDP"]: - if protocol + "_TO_CLOSE" in firewall["uPnP"]: - for port in firewall["uPnP"][protocol + "_TO_CLOSE"]: - if not isinstance(port, int): - # FIXME : how should we handle port ranges ? - logger.warning("Can't use UPnP to close '%s'" % port) - continue - - # Clean the mapping of this port - if upnpc.getspecificportmapping(port, protocol): - try: - upnpc.deleteportmapping(port, protocol) - except Exception: - pass - firewall["uPnP"][protocol + "_TO_CLOSE"] = [] - - for port in firewall["uPnP"][protocol]: - if not isinstance(port, int): - # FIXME : how should we handle port ranges ? - logger.warning("Can't use UPnP to open '%s'" % port) - continue - - # Clean the mapping of this port - if upnpc.getspecificportmapping(port, protocol): - try: - upnpc.deleteportmapping(port, protocol) - except Exception: - pass - if not enabled: - continue - try: - # Add new port mapping - upnpc.addportmapping( - port, - protocol, - upnpc.lanaddr, - port, - "yunohost firewall: port %d" % port, - "", - ) - except Exception: - logger.debug( - "unable to add port %d using UPnP", port, exc_info=1 - ) - enabled = False - - _update_firewall_file(firewall) - - if enabled != firewall["uPnP"]["enabled"]: - firewall = firewall_list(raw=True) - firewall["uPnP"]["enabled"] = enabled - - _update_firewall_file(firewall) - - if not no_refresh: - # Display success message if needed - if action == "enable" and enabled: - logger.success(m18n.n("upnp_enabled")) - elif action == "disable" and not enabled: - logger.success(m18n.n("upnp_disabled")) - # Make sure to disable UPnP - elif action != "disable" and not enabled: - firewall_upnp("disable", no_refresh=True) - - if not enabled and (action == "enable" or 1900 in firewall["ipv4"]["UDP"]): - # Close unused port 1900 - firewall_disallow("UDP", 1900, no_reload=True) - if not no_refresh: - firewall_reload(skip_upnp=True) - - if action == "enable" and not enabled: + if upnp.refresh(firewall): + # Display success message if needed + logger.success(m18n.n("upnp_enabled" if enabled else "upnp_disabled")) + else: + enabled = False + # FIXME: Do not update the config file to let a refresh handle the failure? raise YunohostError("upnp_port_open_failed") + return {"enabled": enabled} -def firewall_stop(): +def firewall_stop() -> None: """ - Stop iptables and ip6tables - - + Stop nftables """ - if os.system("iptables -w -P INPUT ACCEPT") != 0: raise YunohostError("iptables_unavailable") + YunoFirewall().clear() - os.system("iptables -w -F") - os.system("iptables -w -X") - if os.path.exists("/proc/net/if_inet6"): - os.system("ip6tables -P INPUT ACCEPT") - os.system("ip6tables -F") - os.system("ip6tables -X") - - if os.path.exists(UPNP_CRON_JOB): - firewall_upnp("disable") - - -def _get_ssh_port(default=22): +def _get_ssh_port(default: int = 22) -> int: """Return the SSH port to use Retrieve the SSH port from the sshd_config file or used the default @@ -537,22 +390,3 @@ def _get_ssh_port(default=22): except Exception: pass return default - - -def _update_firewall_file(rules): - """Make a backup and write new rules to firewall file""" - os.system("cp {0} {0}.old".format(FIREWALL_FILE)) - with open(FIREWALL_FILE, "w") as f: - yaml.safe_dump(rules, f, default_flow_style=False) - - -def _on_rule_command_error(returncode, cmd, output): - """Callback for rules commands error""" - # Log error and continue commands execution - logger.debug( - '"%s" returned non-zero exit status %d:\n%s', - cmd, - returncode, - output.decode().strip(), - ) - return True diff --git a/src/migrations/0032_firewall_config.py b/src/migrations/0032_firewall_config.py new file mode 100644 index 0000000000..c455983c54 --- /dev/null +++ b/src/migrations/0032_firewall_config.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 YunoHost Contributors +# +# This file is part of YunoHost (see https://yunohost.org) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +import logging +from typing import Any + +import yaml + +from moulinette import m18n + +from yunohost.tools import Migration + +from yunohost.firewall import FIREWALL_FILE + +logger = logging.getLogger("yunohost.migration") + + +class MyMigration(Migration): + "Rework the firewall configuration" + + mode = "auto" + + + def firewall_file_migrate(self) -> None: + old_data = yaml.safe_load(FIREWALL_FILE.open("r", encoding="utf-8")) + + new_data: dict[str, Any] = { + "router_forwarding_upnp": old_data["uPnP"]["enabled"], + "tcp": {}, + "udp": {} + } + for proto in ["TCP", "UDP"]: + new_data[proto.lower()] = { + port: { + "open": True, + "upnp": port in old_data["uPnP"][proto], + } + for port in set(old_data["ipv4"][proto] + old_data["ipv6"][proto]) + } + yaml.dump(new_data, FIREWALL_FILE.open("w", encoding="utf-8")) + + + def run(self): + self.firewall_file_migrate() + pass diff --git a/src/tests/test_app_resources.py b/src/tests/test_app_resources.py index 011172fc97..a31537c0a3 100644 --- a/src/tests/test_app_resources.py +++ b/src/tests/test_app_resources.py @@ -275,17 +275,17 @@ def test_resource_ports_firewall(): r(conf, "testapp").provision_or_update() - assert 12345 not in firewall_list()["opened_ports"] + assert 12345 not in firewall_list(protocol="tcp") conf = {"main": {"default": 12345, "exposed": "TCP"}} r(conf, "testapp").provision_or_update() - assert 12345 in firewall_list()["opened_ports"] + assert 12345 in firewall_list(protocol="tcp") r(conf, "testapp").deprovision() - assert 12345 not in firewall_list()["opened_ports"] + assert 12345 not in firewall_list(protocol="tcp") def test_resource_database(): diff --git a/src/utils/resources.py b/src/utils/resources.py index ef96f2e795..5f1ffc1128 100644 --- a/src/utils/resources.py +++ b/src/utils/resources.py @@ -1407,10 +1407,10 @@ def provision_or_update(self, context: Dict = {}): self.set_setting(setting_name, port_value) if infos["exposed"]: - firewall_allow(infos["exposed"], port_value, reload_only_if_change=True) + firewall_allow(infos["exposed"], port_value, reload_if_change=True) else: firewall_disallow( - infos["exposed"], port_value, reload_only_if_change=True + infos["exposed"], port_value, reload_if_change=True ) def deprovision(self, context: Dict = {}): @@ -1422,7 +1422,7 @@ def deprovision(self, context: Dict = {}): self.delete_setting(setting_name) if value and str(value).strip(): firewall_disallow( - infos["exposed"], int(value), reload_only_if_change=True + infos["exposed"], int(value), reload_if_change=True )