|
| 1 | +# Copyright (C) 2026 Vates SAS |
| 2 | +# |
| 3 | +# This program is free software: you can redistribute it and/or modify |
| 4 | +# it under the terms of the GNU General Public License as published by |
| 5 | +# the Free Software Foundation, either version 3 of the License, or |
| 6 | +# (at your option) any later version. |
| 7 | +# This program is distributed in the hope that it will be useful, |
| 8 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 9 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 10 | +# GNU General Public License for more details. |
| 11 | +# |
| 12 | +# You should have received a copy of the GNU General Public License |
| 13 | +# along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 14 | + |
| 15 | +from unittest.mock import ( |
| 16 | + _Call, |
| 17 | + call, |
| 18 | + MagicMock, |
| 19 | + patch, |
| 20 | +) |
| 21 | + |
| 22 | +import pytest |
| 23 | + |
| 24 | +from xcp_storage.network.iptables import ( |
| 25 | + _EXEC_PATH_IPTABLES, |
| 26 | + _EXEC_PATH_SERVICE, |
| 27 | + _PROTOCOL_TCP, |
| 28 | + DEFAULT_FIREWALL_INPUT_CHAIN, |
| 29 | + update_iptables_tcp_port, |
| 30 | +) |
| 31 | +from xcp_storage.utils.process import CommandResultType |
| 32 | + |
| 33 | +from xcp_storage.typing import ( |
| 34 | + Callable, |
| 35 | + cast, |
| 36 | + Final, |
| 37 | + List, |
| 38 | + Optional, |
| 39 | + ParamSpec, |
| 40 | +) |
| 41 | + |
| 42 | +P = ParamSpec("P") |
| 43 | + |
| 44 | +# ============================================================================== |
| 45 | + |
| 46 | +def get_rule( |
| 47 | + protocol: str, |
| 48 | + str_ports: str, |
| 49 | + *, |
| 50 | + stateful: bool = True, |
| 51 | + chain: str = DEFAULT_FIREWALL_INPUT_CHAIN |
| 52 | +) -> List[str]: |
| 53 | + rule = [chain, "-p", protocol] |
| 54 | + if stateful: |
| 55 | + rule += ["-m", "conntrack", "--ctstate", "NEW", "-m", protocol] |
| 56 | + rule += ["--dport", str_ports, "-j", "ACCEPT"] |
| 57 | + return rule |
| 58 | + |
| 59 | +def get_tcp_rule(str_ports: str, *, stateful: bool = True, chain: str = DEFAULT_FIREWALL_INPUT_CHAIN) -> List[str]: |
| 60 | + return get_rule(_PROTOCOL_TCP, str_ports, stateful=stateful, chain=chain) |
| 61 | + |
| 62 | +# ------------------------------------------------------------------------------ |
| 63 | + |
| 64 | +def get_has_chain_call(chain: Optional[str] = DEFAULT_FIREWALL_INPUT_CHAIN) -> _Call: |
| 65 | + return call([_EXEC_PATH_IPTABLES, "-N", chain], simple=False) |
| 66 | + |
| 67 | +def get_add_chain_call(chain: Optional[str] = DEFAULT_FIREWALL_INPUT_CHAIN) -> _Call: |
| 68 | + return call([_EXEC_PATH_IPTABLES, "-A", chain, "-j", "RETURN"], expected_ret_code=0) |
| 69 | + |
| 70 | +def get_insert_chain_call(chain: Optional[str] = DEFAULT_FIREWALL_INPUT_CHAIN) -> _Call: |
| 71 | + return call([_EXEC_PATH_IPTABLES, "-I", "INPUT", "-j", chain], expected_ret_code=0) |
| 72 | + |
| 73 | +def get_has_rule_call(rule: List[str]) -> _Call: |
| 74 | + return call([_EXEC_PATH_IPTABLES, "-C"] + rule, simple=False) |
| 75 | + |
| 76 | +def get_insert_rule_call(rule: List[str]) -> _Call: |
| 77 | + return call([_EXEC_PATH_IPTABLES, "-I"] + rule, expected_ret_code=0) |
| 78 | + |
| 79 | +def get_destroy_rule_call(rule: List[str]) -> _Call: |
| 80 | + return call([_EXEC_PATH_IPTABLES, "-D"] + rule, expected_ret_code=0) |
| 81 | + |
| 82 | +def get_save_iptables_call() -> _Call: |
| 83 | + return call([_EXEC_PATH_SERVICE, "iptables", "save"], expected_ret_code=0) |
| 84 | + |
| 85 | +# ------------------------------------------------------------------------------ |
| 86 | + |
| 87 | +def run_command_side_effect_factory(*, has_chain: bool, has_rule: bool) -> Callable[P, CommandResultType]: |
| 88 | + def impl(*args: P.args, **kwargs: P.kwargs) -> CommandResultType: |
| 89 | + cmd_args = cast(List[str], args[0]) |
| 90 | + |
| 91 | + program_name = cmd_args[0] |
| 92 | + assert program_name in (_EXEC_PATH_IPTABLES, _EXEC_PATH_SERVICE) |
| 93 | + |
| 94 | + simple = kwargs.get("simple", True) |
| 95 | + |
| 96 | + # Has chain? |
| 97 | + if "-N" in cmd_args: |
| 98 | + assert not simple |
| 99 | + return ("", "", int(has_chain)) |
| 100 | + |
| 101 | + # Has rule? |
| 102 | + if "-C" in cmd_args: |
| 103 | + assert not simple |
| 104 | + return ("", "", int(not has_rule)) |
| 105 | + |
| 106 | + assert simple |
| 107 | + return "" |
| 108 | + return impl |
| 109 | + |
| 110 | +# ------------------------------------------------------------------------------ |
| 111 | + |
| 112 | +@patch("xcp_storage.network.iptables.run_command") |
| 113 | +class TestIptablesTcpPort: |
| 114 | + TEST_PORT: Final = 80 |
| 115 | + TEST_RULE: Final = get_tcp_rule(str(TEST_PORT)) |
| 116 | + |
| 117 | + @pytest.mark.parametrize("open_port", (True, False)) |
| 118 | + def test_update_port_no_change(self, mock_run_command: MagicMock, *, open_port: bool) -> None: |
| 119 | + mock_run_command.side_effect = run_command_side_effect_factory(has_chain=True, has_rule=open_port) |
| 120 | + update_iptables_tcp_port(self.TEST_PORT, open_port=open_port) |
| 121 | + has_rule_call = get_has_rule_call(self.TEST_RULE) |
| 122 | + mock_run_command.assert_called_once_with(*has_rule_call.args, **has_rule_call.kwargs) |
| 123 | + |
| 124 | + def test_open_tcp_port(self, mock_run_command: MagicMock) -> None: |
| 125 | + mock_run_command.side_effect = run_command_side_effect_factory(has_chain=True, has_rule=False) |
| 126 | + expected_calls = [ |
| 127 | + get_has_rule_call(self.TEST_RULE), |
| 128 | + get_has_chain_call(), |
| 129 | + get_insert_rule_call(self.TEST_RULE), |
| 130 | + get_save_iptables_call() |
| 131 | + ] |
| 132 | + |
| 133 | + update_iptables_tcp_port(self.TEST_PORT, open_port=True) |
| 134 | + |
| 135 | + mock_run_command.assert_has_calls(expected_calls) |
| 136 | + assert mock_run_command.call_count == len(expected_calls) |
| 137 | + |
| 138 | + def test_open_port_and_create_chain(self, mock_run_command: MagicMock) -> None: |
| 139 | + mock_run_command.side_effect = run_command_side_effect_factory(has_chain=False, has_rule=False) |
| 140 | + expected_calls = [ |
| 141 | + get_has_rule_call(self.TEST_RULE), |
| 142 | + get_has_chain_call(), |
| 143 | + get_add_chain_call(), |
| 144 | + get_insert_chain_call(), |
| 145 | + get_insert_rule_call(self.TEST_RULE), |
| 146 | + get_save_iptables_call() |
| 147 | + ] |
| 148 | + |
| 149 | + update_iptables_tcp_port(self.TEST_PORT, open_port=True) |
| 150 | + |
| 151 | + mock_run_command.assert_has_calls(expected_calls) |
| 152 | + assert mock_run_command.call_count == len(expected_calls) |
| 153 | + |
| 154 | + def test_close_port(self, mock_run_command: MagicMock) -> None: |
| 155 | + mock_run_command.side_effect = run_command_side_effect_factory(has_chain=False, has_rule=True) |
| 156 | + expected_calls = [ |
| 157 | + get_has_rule_call(self.TEST_RULE), |
| 158 | + get_destroy_rule_call(self.TEST_RULE), |
| 159 | + get_save_iptables_call() |
| 160 | + ] |
| 161 | + |
| 162 | + update_iptables_tcp_port(self.TEST_PORT, open_port=False) |
| 163 | + |
| 164 | + mock_run_command.assert_has_calls(expected_calls) |
| 165 | + assert mock_run_command.call_count == len(expected_calls) |
0 commit comments