summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--python/vyos/utils/network.py32
-rwxr-xr-xsmoketest/scripts/cli/test_load-balancing_haproxy.py49
-rw-r--r--src/conf_mode/load-balancing_haproxy.py39
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':