From 42953978ea9ebbb396bf96040f0b1c9194528919 Mon Sep 17 00:00:00 2001 From: Jacob Callahan Date: Mon, 29 Jan 2024 16:26:40 -0500 Subject: [PATCH] Add IPv6 support This change is mostly focused around the addition of IPv6 connection functionality to Broker's Host Sessions. Included are options for an ipv6 connection preference with an opt-out ipv6 connection fallback. These have also been added to the settings file. --- broker/hosts.py | 35 +++++++++++++++----- broker/session.py | 64 +++++++++++++++++++++++++++++++++--- broker/settings.py | 2 ++ broker_settings.yaml.example | 4 +++ 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/broker/hosts.py b/broker/hosts.py index d45fcb94..f8b39f03 100644 --- a/broker/hosts.py +++ b/broker/hosts.py @@ -35,13 +35,15 @@ def __init__(self, **kwargs): """Create a Host instance. Expected kwargs: - hostname: str - Hostname or IP address of the host, required - name: str - Name of the host - username: str - Username to use for SSH connection - password: str - Password to use for SSH connection - connection_timeout: int - Timeout for SSH connection - port: int - Port to use for SSH connection - key_filename: str - Path to SSH key file to use for SSH connection + hostname: (str) - Hostname or IP address of the host, required + name: (str) - Name of the host + username: (str) - Username to use for SSH connection + password: (str) - Password to use for SSH connection + connection_timeout: (int) - Timeout for SSH connection + port: (int) - Port to use for SSH connection + key_filename: (str) - Path to SSH key file to use for SSH connection + ipv6 (bool): Whether or not to use IPv6. Defaults to False. + ipv4_fallback (bool): Whether or not to fallback to IPv4 if IPv6 fails. Defaults to True. """ logger.debug(f"Constructing host using {kwargs=}") self.hostname = kwargs.get("hostname") or kwargs.get("ip") @@ -59,6 +61,8 @@ def __init__(self, **kwargs): self.timeout = kwargs.pop("connection_timeout", settings.HOST_CONNECTION_TIMEOUT) self.port = kwargs.pop("port", settings.HOST_SSH_PORT) self.key_filename = kwargs.pop("key_filename", settings.HOST_SSH_KEY_FILENAME) + self.ipv6 = kwargs.pop("ipv6", settings.HOST_IPV6) + self.ipv4_fallback = kwargs.pop("ipv4_fallback", settings.HOST_IPV4_FALLBACK) self.__dict__.update(kwargs) # Make every other kwarg an attribute self._session = None @@ -84,7 +88,16 @@ def session(self): self.connect() return self._session - def connect(self, username=None, password=None, timeout=None, port=22, key_filename=None): + def connect( + self, + username=None, + password=None, + timeout=None, + port=22, + key_filename=None, + ipv6=False, + ipv4_fallback=True, + ): """Connect to the host using SSH. Args: @@ -93,6 +106,8 @@ def connect(self, username=None, password=None, timeout=None, port=22, key_filen timeout (int): The timeout for the SSH connection in seconds. port (int): The port to use for the SSH connection. Defaults to 22. key_filename (str): The path to the private key file to use for the SSH connection. + ipv6 (bool): Whether or not to use IPv6. Defaults to False. + ipv4_fallback (bool): Whether or not to fallback to IPv4 if IPv6 fails. Defaults to True. """ username = username or self.username password = password or self.password @@ -103,6 +118,8 @@ def connect(self, username=None, password=None, timeout=None, port=22, key_filen if ":" in self.hostname: _hostname, port = self.hostname.split(":") _port = int(port) + ipv6 = ipv6 or self.ipv6 + ipv4_fallback = ipv4_fallback or self.ipv4_fallback self.close() self._session = Session( hostname=_hostname, @@ -111,6 +128,8 @@ def connect(self, username=None, password=None, timeout=None, port=22, key_filen port=_port, key_filename=key_filename, timeout=timeout, + ipv6=ipv6, + ipv4_fallback=ipv4_fallback, ) def close(self): diff --git a/broker/session.py b/broker/session.py index 87c6f743..b35d6c77 100644 --- a/broker/session.py +++ b/broker/session.py @@ -30,6 +30,54 @@ FILE_FLAGS = ssh2_sftp.LIBSSH2_FXF_CREAT | ssh2_sftp.LIBSSH2_FXF_WRITE +def _create_connect_socket(host, port, timeout, ipv6=False, ipv4_fallback=True, sock=None): + """Create a socket and establish a connection to the specified host and port. + + Args: + host (str): The hostname or IP address of the remote server. + port (int): The port number to connect to. + timeout (float): The timeout value in seconds for the socket connection. + ipv6 (bool, optional): Whether to use IPv6. Defaults to False. + ipv4_fallback (bool, optional): Whether to fallback to IPv4 if IPv6 fails. Defaults to True. + sock (socket.socket, optional): An existing socket object to use. Defaults to None. + + Returns: + socket.socket: The connected socket object. + bool: True if IPv6 was used, False otherwise. + + Raises: + exceptions.ConnectionError: If unable to establish a connection to the host. + """ + if ipv6 and not sock: + try: + sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + except OSError as err: + if ipv4_fallback: + logger.warning(f"IPv6 failed with {err}. Falling back to IPv4.") + return _create_connect_socket(host, port, timeout, ipv6=False) + else: + raise exceptions.ConnectionError( + f"Unable to establish IPv6 connection to {host}." + ) from err + elif not sock: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + if ipv6: + try: + sock.connect((host, port)) + except socket.gaierror as err: + if ipv4_fallback: + logger.warning(f"IPv6 connection failed to {host}. Falling back to IPv4.") + return _create_connect_socket(host, port, timeout, ipv6=False, sock=sock) + else: + raise exceptions.ConnectionError( + f"Unable to establish IPv6 connection to {host}." + ) from err + else: + sock.connect((host, port)) + return sock, ipv6 + + class Session: """Wrapper around ssh2-python's auth/connection system.""" @@ -43,22 +91,30 @@ def __init__(self, **kwargs): port (int): The port number to connect to. Defaults to 22. key_filename (str): The path to the private key file to use for authentication. password (str): The password to use for authentication. + ipv6 (bool): Whether or not to use IPv6. Defaults to False. + ipv4_fallback (bool): Whether or not to fallback to IPv4 if IPv6 fails. Defaults to True. Raises: AuthException: If no password or key file is provided. + ConnectionError: If the connection fails. FileNotFoundError: If the key file is not found. """ host = kwargs.get("hostname", "localhost") user = kwargs.get("username", "root") - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(kwargs.get("timeout")) port = kwargs.get("port", 22) key_filename = kwargs.get("key_filename") password = kwargs.get("password") timeout = kwargs.get("timeout", 60) - helpers.simple_retry(sock.connect, [(host, port)], max_timeout=timeout) + # create the socket + self.sock, self.is_ipv6 = _create_connect_socket( + host, + port, + timeout, + ipv6=kwargs.get("ipv6", False), + ipv4_fallback=kwargs.get("ipv4_fallback", True), + ) self.session = ssh2_Session() - self.session.handshake(sock) + self.session.handshake(self.sock) try: if key_filename: auth_type = "Key" diff --git a/broker/settings.py b/broker/settings.py index 3372fdf6..853a5f4c 100644 --- a/broker/settings.py +++ b/broker/settings.py @@ -92,6 +92,8 @@ def init_settings(settings_path, interactive=False): Validator("HOST_CONNECTION_TIMEOUT", default=60), Validator("HOST_SSH_PORT", default=22), Validator("HOST_SSH_KEY_FILENAME", default=None), + Validator("HOST_IPV6", default=False), + Validator("HOST_IPV4_FALLBACK", default=True), Validator("LOGGING", is_type_of=dict), Validator( "LOGGING.CONSOLE_LEVEL", diff --git a/broker_settings.yaml.example b/broker_settings.yaml.example index dcae286a..4a9b38ab 100644 --- a/broker_settings.yaml.example +++ b/broker_settings.yaml.example @@ -9,6 +9,10 @@ host_username: root host_password: "" host_ssh_port: 22 host_ssh_key_filename: "" +# Default all host ssh connections to IPv6 +host_ipv6: False +# If IPv6 connection attempts fail, fallback to IPv4 +host_ipv4_fallback: True # Provider settings AnsibleTower: base_url: "https:///"