Skip to content

Commit fb2296b

Browse files
committed
feat(core): add iptables helpers
Signed-off-by: Ronan Abhamon <ronan.abhamon@vates.tech>
1 parent 8713304 commit fb2296b

5 files changed

Lines changed: 331 additions & 0 deletions

File tree

‎src/xcp_storage/config/__init__.py‎

Whitespace-only changes.

‎src/xcp_storage/config/platform.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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 xcp_storage.typing import Final
16+
17+
# ==============================================================================
18+
# Attributes that depend on the execution environment.
19+
# Its primary purpose is to decouple the storage logic from the underlying
20+
# host's specificities (OS, firewall rules, ...).
21+
# ==============================================================================
22+
23+
DEFAULT_FIREWALL_INPUT_CHAIN: Final = "xapi-INPUT"

‎src/xcp_storage/network/__init__.py‎

Whitespace-only changes.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 xcp_storage.config.platform import DEFAULT_FIREWALL_INPUT_CHAIN
16+
from xcp_storage.utils.process import CommandError, run_command
17+
18+
from xcp_storage.typing import (
19+
Final,
20+
List,
21+
Optional,
22+
Tuple,
23+
)
24+
25+
# ==============================================================================
26+
27+
_MAIN_FIREWALL_INPUT_CHAIN: Final = "INPUT"
28+
29+
_EXEC_PATH_IPTABLES: Final = "/usr/sbin/iptables"
30+
_EXEC_PATH_SERVICE: Final = "/usr/sbin/service"
31+
32+
_PROTOCOL_TCP: Final = "tcp"
33+
34+
# ------------------------------------------------------------------------------
35+
36+
class IptablesError(Exception):
37+
def __init__(self, message: str, code: Optional[int] = None) -> None:
38+
super().__init__(message)
39+
self.code = code
40+
41+
# ------------------------------------------------------------------------------
42+
43+
def _save_iptables_changes() -> None:
44+
try:
45+
run_command([_EXEC_PATH_SERVICE, "iptables", "save"], expected_ret_code=0)
46+
except CommandError as e:
47+
raise IptablesError(f"Failed to save iptables changes: `{e.reason}`.", e.code) from None
48+
49+
# ------------------------------------------------------------------------------
50+
51+
def has_iptables_rule(rule: List[str]) -> bool:
52+
try:
53+
_stdout, _stderr, ret_code = run_command([_EXEC_PATH_IPTABLES, "-C"] + rule, simple=False)
54+
return not ret_code
55+
except CommandError as e:
56+
raise IptablesError(e.reason, e.code) from None
57+
58+
# ------------------------------------------------------------------------------
59+
60+
def _update_iptables_ports(protocol: str, str_ports: str, *, open_ports: bool, stateful: bool, chain: str) -> None:
61+
# 1. Check if the rule is present.
62+
rule = [chain, "-p", protocol]
63+
if stateful:
64+
rule += ["-m", "conntrack", "--ctstate", "NEW", "-m", protocol]
65+
rule += ["--dport", str_ports, "-j", "ACCEPT"]
66+
67+
if open_ports == has_iptables_rule(rule):
68+
return
69+
70+
# 2. Open or close.
71+
if open_ports:
72+
try:
73+
_stdout, _stderr, ret_code = run_command([_EXEC_PATH_IPTABLES, "-N", chain], simple=False)
74+
if not ret_code:
75+
# Create chain if necessary.
76+
run_command([_EXEC_PATH_IPTABLES, "-A", chain, "-j", "RETURN"], expected_ret_code=0)
77+
run_command([_EXEC_PATH_IPTABLES, "-I", _MAIN_FIREWALL_INPUT_CHAIN, "-j", chain], expected_ret_code=0)
78+
# Create rule.
79+
run_command([_EXEC_PATH_IPTABLES, "-I"] + rule, expected_ret_code=0)
80+
except CommandError as e:
81+
raise IptablesError(
82+
f"Failed to open {protocol.upper()} port(s): `{e.reason}`.", e.code
83+
) from None
84+
else:
85+
try:
86+
run_command([_EXEC_PATH_IPTABLES, "-D"] + rule, expected_ret_code=0)
87+
except CommandError as e:
88+
raise IptablesError(
89+
f"Failed to close {protocol.upper()} port(s): `{e.reason}`.", e.code
90+
) from None
91+
92+
# 3. Save.
93+
_save_iptables_changes()
94+
95+
# ------------------------------------------------------------------------------
96+
97+
# Open/close TCP port using stateless or stateful rule.
98+
#
99+
# /!\ When opening or closing TCP ports using stateful rules, be aware of the Linux `conntrack` mechanism:
100+
# it maintains a state database of all network connections (TCP, UDP, ...) passing through a machine.
101+
# This is important for packet filtering, but there is a limitation:
102+
# The kernel sets a maximum limit `nf_conntrack_max` based on available RAM.
103+
# If this limit is reached, the system will log:
104+
# `nf_conntrack: table full, dropping packets`
105+
# and and start rejecting incoming/outgoing traffic...
106+
#
107+
# Even after a connection is closed, the kernel retains the entry in the table for a long period,
108+
# 120 seconds for TCP.
109+
#
110+
# So it's important to avoid `conntrack` for specific case like DRBD protocol.
111+
# For it, that's important to allow packets to pass even when the connection tracking table
112+
# ran full temporarily because short-lived connections are the primary cause of table exhaustion
113+
# AND because we can have this scenario with DRBD.
114+
115+
def update_iptables_tcp_port(
116+
port: int,
117+
*,
118+
open_port: bool,
119+
stateful: bool = True,
120+
chain: str = DEFAULT_FIREWALL_INPUT_CHAIN
121+
) -> None:
122+
_update_iptables_ports(
123+
_PROTOCOL_TCP,
124+
str(port),
125+
open_ports=open_port,
126+
stateful=stateful,
127+
chain=chain
128+
)
129+
130+
def update_iptables_tcp_port_range(
131+
ports: Tuple[int, int],
132+
*,
133+
open_ports: bool,
134+
stateful: bool = True,
135+
chain: str = DEFAULT_FIREWALL_INPUT_CHAIN
136+
) -> None:
137+
_update_iptables_ports(
138+
_PROTOCOL_TCP,
139+
f"{ports[0]}:{ports[1]}",
140+
open_ports=open_ports,
141+
stateful=stateful,
142+
chain=chain
143+
)

‎tests/network/test_iptables.py‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

Comments
 (0)