diff options
| -rw-r--r-- | python/vyos/utils/network.py | 32 | ||||
| -rwxr-xr-x | smoketest/scripts/cli/test_load-balancing_haproxy.py | 49 | ||||
| -rw-r--r-- | src/conf_mode/load-balancing_haproxy.py | 39 |
3 files changed, 111 insertions, 9 deletions
diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 366433146..f20450a5f 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -356,27 +356,53 @@ def check_port_availability(address: str=None, port: int=0, protocol: str='tcp') return False -def is_listen_port_bind_service(port: int, service: str) -> bool: +def is_listen_port_bind_service(port: int, service: str, address: str = None) -> bool: """Check if listen port bound to expected program name :param port: Bind port :param service: Program name + :param address: IP address - if None, not consider this IP for filtering :return: bool Example: % is_listen_port_bind_service(443, 'nginx') True + % is_listen_port_bind_service(443, 'nginx', address='10.10.0.1') + False % is_listen_port_bind_service(443, 'ocserv-main') False """ from psutil import net_connections as connections from psutil import Process as process + from ipaddress import ip_address + + has_address = bool(address) + + if has_address: + try: + # Normalize address before comparison to handle IPv6 format variations: + # 0:0:0:0:0:0:0:1 vs ::1 + address = ip_address(address).compressed + except ValueError: + raise ValueError(f'{address} is not a valid IPv4 or IPv6 address') + for connection in connections(): addr = connection.laddr pid = connection.pid + + # The PID of the process that opened the socket may not be retrievable + if pid is None: + continue + pid_name = process(pid).name() pid_port = addr.port - if service == pid_name and port == pid_port: - return True + + if has_address: + if address == addr.ip and port == pid_port and service == pid_name: + return True + else: + if service == pid_name and port == pid_port: + return True + return False def is_ipv6_link_local(addr): diff --git a/smoketest/scripts/cli/test_load-balancing_haproxy.py b/smoketest/scripts/cli/test_load-balancing_haproxy.py index 8223c6f60..9cb031276 100755 --- a/smoketest/scripts/cli/test_load-balancing_haproxy.py +++ b/smoketest/scripts/cli/test_load-balancing_haproxy.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import re +import time import textwrap import unittest @@ -22,6 +23,7 @@ from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError from vyos.template import get_default_port +from vyos.utils.process import call from vyos.utils.process import process_named_running from vyos.utils.file import read_file @@ -680,5 +682,52 @@ class TestLoadBalancingReverseProxy(VyOSUnitTestSHIM.TestCase): port = get_default_port('certbot_haproxy') self.assertIn(f'server localhost 127.0.0.1:{port}', config[backend_name]) + def test_reverse_proxy_listen_address_no_port_conflict(self): + # HAProxy port conflict check must consider listen-address (T7928) + + frontend = 'svc-1' + backend = 'bk-1' + shared_port = '993' + addr_listen = '::1' # HAProxy listen-address + addr_busy = '127.0.0.1' # IP address kept busy by nc (different from the first) + + # Run the netcat command to bind to the specified address and port + call(f'sudo nc -lk -s {addr_busy} -p {shared_port} &') + + # Give nc a moment to bind before we commit + time.sleep(0.5) + + try: + backend_path = base_path + ['backend', backend] + self.cli_set(backend_path + ['mode', 'tcp']) + self.cli_set(backend_path + ['server', 'srv-m', 'address', '192.0.2.14']) + self.cli_set(backend_path + ['server', 'srv-m', 'port', shared_port]) + + # Configure HAProxy frontend with listen-address + service_path = base_path + ['service', frontend] + self.cli_set(service_path + ['mode', 'tcp']) + self.cli_set(service_path + ['port', shared_port]) + self.cli_set(service_path + ['listen-address', addr_listen]) + self.cli_set(service_path + ['backend', backend]) + + # Must commit without raising "TCP port N is used by another service" + self.cli_commit() + + # Commit with raising "TCP port N is used by another service" + self.cli_set(service_path + ['listen-address', addr_busy]) + with self.assertRaises(ConfigSessionError) as e: + self.cli_commit() + finally: + # Always clean up nc regardless of test outcome + call('sudo pkill nc') + + self.assertTrue(process_named_running(PROCESS_NAME)) + config = read_file(HAPROXY_CONF) + + # The busy address must NOT appear as a HAProxy bind + self.assertNotIn(f'bind {addr_busy}:{shared_port}', config) + self.assertIn(f'bind [{addr_listen}]:{shared_port}', config) + + if __name__ == '__main__': unittest.main(verbosity=2, failfast=VyOSUnitTestSHIM.TestCase.debug_on()) diff --git a/src/conf_mode/load-balancing_haproxy.py b/src/conf_mode/load-balancing_haproxy.py index b5509f526..2a4f206f5 100644 --- a/src/conf_mode/load-balancing_haproxy.py +++ b/src/conf_mode/load-balancing_haproxy.py @@ -27,6 +27,7 @@ from vyos.utils.dict import dict_search from vyos.utils.process import call from vyos.utils.network import check_port_availability from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.network import is_addr_assigned from vyos.pki import find_chain from vyos.pki import load_certificate from vyos.pki import load_private_key @@ -71,12 +72,38 @@ def verify(lb): if 'port' not in front_config: raise ConfigError(f'"{front} service port" must be configured!') - # Check if bind address:port are used by another service - tmp_address = front_config.get('address', None) - tmp_port = front_config['port'] - if check_port_availability(tmp_address, int(tmp_port), 'tcp') is not True and \ - not is_listen_port_bind_service(int(tmp_port), 'haproxy'): - raise ConfigError(f'TCP port "{tmp_port}" is used by another service') + # Check if bind 'listen-address:port' are used by another service + listen_addresses = front_config.get('listen_address') or {} + listen_port = int(front_config['port']) + if listen_addresses: + for listen_address in listen_addresses: + # Remove the interface name if present in the listen address + if '%' in listen_address: + listen_address, *_ = listen_address.split('%', maxsplit=1) + + if not is_addr_assigned(listen_address): + raise ConfigError( + f'listen-address "{listen_address}" not assigned on any interface!' + ) + + port_availability = check_port_availability( + listen_address, listen_port, 'tcp' + ) + port_bind_service = is_listen_port_bind_service( + listen_port, 'haproxy', address=listen_address + ) + if not port_availability and not port_bind_service: + raise ConfigError( + f'TCP port "{listen_port}" on address "{listen_address}" is used by another service' + ) + else: + # Verify listen port for all IP addresses + port_availability = check_port_availability(None, listen_port, 'tcp') + port_bind_service = is_listen_port_bind_service(listen_port, 'haproxy') + if not port_availability and not port_bind_service: + raise ConfigError( + f'TCP port "{listen_port}" is used by another service' + ) if 'http_compression' in front_config: if front_config['mode'] != 'http': |
