Skip to content

Commit dbf6573

Browse files
authored
Ipv6 (#322)
* Added IPV6 support to native client. Added IPV6 support to embedded server for tests, single client test connecting to IPV6 and IPV6 only test. Added flag for only connecting to IPV6 hosts and exception. * Renamed exception. Added IPv6 flags to parallel client and ssh clients. * Updated setup, changelog, documentation. * Updated documentation. * Updated requirements Resolves #291
1 parent ec75aa9 commit dbf6573

File tree

17 files changed

+267
-69
lines changed

17 files changed

+267
-69
lines changed

Changelog.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
Change Log
22
============
33

4+
2.7.0
5+
+++++
6+
7+
Changes
8+
-------
9+
10+
* All clients now support IPv6 addresses for both DNS and IP entries in host list.
11+
* Added ``ipv6_only`` flag to ``ParallelSSHClient`` and ``SSHClient`` for choosing only IPv6 addresses when both v4 and
12+
v6 are available.
13+
* Removed Python 2 from binary wheel compatibility as it is no longer supported and not guaranteed to work.
14+
415
2.6.0
516
+++++
617

doc/advanced.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,3 +882,40 @@ Clients for hosts that would be removed by a reassignment can be calculated with
882882
883883
set(enumerate(client.hosts)).difference(
884884
set(enumerate(new_hosts)))
885+
886+
887+
IPv6 Addresses
888+
***************
889+
890+
All clients support IPv6 addresses in both host list, and via DNS. Typically IPv4 addresses are preferred as they are
891+
the first entries in DNS resolution depending on DNS server configuration and entries.
892+
893+
The ``ipv6_only`` flag may be used to override this behaviour and force the client(s) to only choose IPv6 addresses, or
894+
raise an error if none are available.
895+
896+
Connecting to localhost via an IPv6 address.
897+
898+
.. code-block:: python
899+
900+
client = ParallelSSHClient(['::1'])
901+
<..>
902+
903+
Asking client to only use IPv6 for DNS resolution.
904+
:py:class:`NoIPv6AddressFoundError <pssh.exceptions.NoIPv6AddressFoundError>` is raised if no IPv6 address is available
905+
for hosts.
906+
907+
.. code-block:: python
908+
909+
client = ParallelSSHClient(['myhost.com'], ipv6_only=True)
910+
output = client.run_command('echo me')
911+
912+
Similarly for single clients.
913+
914+
.. code-block:: python
915+
916+
client = SSHClient(['myhost.com'], ipv6_only=True)
917+
918+
For choosing a mix of IPv4/IPv6 depending on the host name, developers can use `socket.getaddrinfo` directly and pick
919+
from available addresses.
920+
921+
*New in 2.7.0*

pssh/clients/base/parallel.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ def __init__(self, hosts, user=None, password=None, port=None, pkey=None,
4141
num_retries=DEFAULT_RETRIES,
4242
timeout=120, pool_size=10,
4343
host_config=None, retry_delay=RETRY_DELAY,
44-
identity_auth=True):
44+
identity_auth=True,
45+
ipv6_only=False,
46+
):
4547
self.allow_agent = allow_agent
4648
self.pool_size = pool_size
4749
self.pool = gevent.pool.Pool(size=self.pool_size)
@@ -57,6 +59,7 @@ def __init__(self, hosts, user=None, password=None, port=None, pkey=None,
5759
self.retry_delay = retry_delay
5860
self.cmds = None
5961
self.identity_auth = identity_auth
62+
self.ipv6_only = ipv6_only
6063
self._check_host_config()
6164

6265
def _validate_hosts(self, _hosts):

pssh/clients/base/single.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from ...constants import DEFAULT_RETRIES, RETRY_DELAY
3333
from ..reader import ConcurrentRWBuffer
3434
from ...exceptions import UnknownHostError, AuthenticationError, \
35-
ConnectionError, Timeout
35+
ConnectionError, Timeout, NoIPv6AddressFoundError
3636
from ...output import HostOutput, HostOutputBuffers, BufferData
3737

3838

@@ -166,7 +166,9 @@ def __init__(self, host,
166166
proxy_host=None,
167167
proxy_port=None,
168168
_auth_thread_pool=True,
169-
identity_auth=True):
169+
identity_auth=True,
170+
ipv6_only=False,
171+
):
170172
self._auth_thread_pool = _auth_thread_pool
171173
self.host = host
172174
self.user = user if user else getuser()
@@ -183,6 +185,7 @@ def __init__(self, host,
183185
self.pkey = _validate_pkey_path(pkey, self.host)
184186
self.identity_auth = identity_auth
185187
self._keepalive_greenlet = None
188+
self.ipv6_only = ipv6_only
186189
self._init()
187190

188191
def _init(self):
@@ -254,25 +257,37 @@ def _connect_init_session_retry(self, retries):
254257
self._connect(self._host, self._port, retries=retries)
255258
return self._init_session(retries=retries)
256259

260+
def _get_addr_info(self, host, port):
261+
addr_info = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
262+
if self.ipv6_only:
263+
filtered = [addr for addr in addr_info if addr[0] is socket.AF_INET6]
264+
if not filtered:
265+
raise NoIPv6AddressFoundError(
266+
"Requested IPv6 only and no IPv6 addresses found for host %s from "
267+
"address list %s", host, [addr for _, _, _, _, addr in addr_info])
268+
addr_info = filtered
269+
return addr_info
270+
257271
def _connect(self, host, port, retries=1):
258-
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
259-
if self.timeout:
260-
self.sock.settimeout(self.timeout)
261-
logger.debug("Connecting to %s:%s", host, port)
262272
try:
263-
self.sock.connect((host, port))
273+
addr_info = self._get_addr_info(host, port)
264274
except sock_gaierror as ex:
265275
logger.error("Could not resolve host '%s' - retry %s/%s",
266276
host, retries, self.num_retries)
267277
if retries < self.num_retries:
268278
sleep(self.retry_delay)
269279
return self._connect(host, port, retries=retries+1)
270-
ex = UnknownHostError("Unknown host %s - %s - retry %s/%s",
271-
host, str(ex.args[1]), retries,
272-
self.num_retries)
273-
ex.host = host
274-
ex.port = port
275-
raise ex
280+
unknown_ex = UnknownHostError("Unknown host %s - %s - retry %s/%s",
281+
host, str(ex.args[1]), retries,
282+
self.num_retries)
283+
raise unknown_ex from ex
284+
family, _type, proto, _, sock_addr = addr_info[0]
285+
self.sock = socket.socket(family, _type)
286+
if self.timeout:
287+
self.sock.settimeout(self.timeout)
288+
logger.debug("Connecting to %s:%s", host, port)
289+
try:
290+
self.sock.connect(sock_addr)
276291
except sock_error as ex:
277292
logger.error("Error connecting to host '%s:%s' - retry %s/%s",
278293
host, port, retries, self.num_retries)
@@ -387,8 +402,8 @@ def read_stderr(self, stderr_buffer, timeout=None):
387402
"""Read standard error buffer.
388403
Returns a generator of line by line output.
389404
390-
:param stdout_buffer: Buffer to read from.
391-
:type stdout_buffer: :py:class:`pssh.clients.reader.ConcurrentRWBuffer`
405+
:param stderr_buffer: Buffer to read from.
406+
:type stderr_buffer: :py:class:`pssh.clients.reader.ConcurrentRWBuffer`
392407
:rtype: generator
393408
"""
394409
logger.debug("Reading from stderr buffer, timeout=%s", timeout)

pssh/clients/native/parallel.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
3636
proxy_host=None, proxy_port=None,
3737
proxy_user=None, proxy_password=None, proxy_pkey=None,
3838
forward_ssh_agent=False,
39-
keepalive_seconds=60, identity_auth=True):
39+
keepalive_seconds=60, identity_auth=True,
40+
ipv6_only=False,
41+
):
4042
"""
4143
:param hosts: Hosts to connect to
4244
:type hosts: list(str)
@@ -109,6 +111,10 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
109111
Defaults to False if not set.
110112
Requires agent forwarding implementation in libssh2 version used.
111113
:type forward_ssh_agent: bool
114+
:param ipv6_only: Choose IPv6 addresses only if multiple are available
115+
for the host(s) or raise NoIPv6AddressFoundError otherwise. Note this will
116+
disable connecting to an IPv4 address if an IP address is provided instead.
117+
:type ipv6_only: bool
112118
113119
:raises: :py:class:`pssh.exceptions.PKeyFileError` on errors finding
114120
provided private key.
@@ -118,7 +124,9 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
118124
allow_agent=allow_agent, num_retries=num_retries,
119125
timeout=timeout, pool_size=pool_size,
120126
host_config=host_config, retry_delay=retry_delay,
121-
identity_auth=identity_auth)
127+
identity_auth=identity_auth,
128+
ipv6_only=ipv6_only,
129+
)
122130
self.pkey = _validate_pkey_path(pkey)
123131
self.proxy_host = proxy_host
124132
self.proxy_port = proxy_port
@@ -241,6 +249,7 @@ def _make_ssh_client(self, host_i, host):
241249
forward_ssh_agent=self.forward_ssh_agent,
242250
keepalive_seconds=self.keepalive_seconds,
243251
identity_auth=self.identity_auth,
252+
ipv6_only=self.ipv6_only,
244253
)
245254
self._host_clients[(host_i, host)] = _client
246255
return _client

pssh/clients/native/single.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ def __init__(self, host,
5858
proxy_user=None,
5959
proxy_password=None,
6060
_auth_thread_pool=True, keepalive_seconds=60,
61-
identity_auth=True,):
61+
identity_auth=True,
62+
ipv6_only=False,
63+
):
6264
""":param host: Host name or IP to connect to.
6365
:type host: str
6466
:param user: User to connect as. Defaults to logged in user.
@@ -95,6 +97,11 @@ def __init__(self, host,
9597
:type proxy_port: int
9698
:param keepalive_seconds: Interval of keep alive messages being sent to
9799
server. Set to ``0`` or ``False`` to disable.
100+
:type keepalive_seconds: int
101+
:param ipv6_only: Choose IPv6 addresses only if multiple are available
102+
for the host or raise NoIPv6AddressFoundError otherwise. Note this will
103+
disable connecting to an IPv4 address if an IP address is provided instead.
104+
:type ipv6_only: bool
98105
99106
:raises: :py:class:`pssh.exceptions.PKeyFileError` on errors finding
100107
provided private key.
@@ -126,7 +133,9 @@ def __init__(self, host,
126133
allow_agent=allow_agent, _auth_thread_pool=_auth_thread_pool,
127134
timeout=timeout,
128135
proxy_host=proxy_host, proxy_port=proxy_port,
129-
identity_auth=identity_auth)
136+
identity_auth=identity_auth,
137+
ipv6_only=ipv6_only,
138+
)
130139

131140
def _shell(self, channel):
132141
return self._eagain(channel.shell)

pssh/clients/ssh/parallel.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
3838
gssapi_server_identity=None,
3939
gssapi_client_identity=None,
4040
gssapi_delegate_credentials=False,
41-
identity_auth=True):
41+
identity_auth=True,
42+
ipv6_only=False,
43+
):
4244
"""
4345
:param hosts: Hosts to connect to
4446
:type hosts: list(str)
@@ -123,6 +125,10 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
123125
:param gssapi_delegate_credentials: Enable/disable server credentials
124126
delegation.
125127
:type gssapi_delegate_credentials: bool
128+
:param ipv6_only: Choose IPv6 addresses only if multiple are available
129+
for the host or raise NoIPv6AddressFoundError otherwise. Note this will
130+
disable connecting to an IPv4 address if an IP address is provided instead.
131+
:type ipv6_only: bool
126132
127133
:raises: :py:class:`pssh.exceptions.PKeyFileError` on errors finding
128134
provided private key.
@@ -132,7 +138,9 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
132138
allow_agent=allow_agent, num_retries=num_retries,
133139
timeout=timeout, pool_size=pool_size,
134140
host_config=host_config, retry_delay=retry_delay,
135-
identity_auth=identity_auth)
141+
identity_auth=identity_auth,
142+
ipv6_only=ipv6_only,
143+
)
136144
self.pkey = _validate_pkey_path(pkey)
137145
self.cert_file = _validate_pkey_path(cert_file)
138146
self.forward_ssh_agent = forward_ssh_agent
@@ -239,6 +247,7 @@ def _make_ssh_client(self, host_i, host):
239247
gssapi_client_identity=self.gssapi_client_identity,
240248
gssapi_delegate_credentials=self.gssapi_delegate_credentials,
241249
identity_auth=self.identity_auth,
250+
ipv6_only=self.ipv6_only,
242251
)
243252
self._host_clients[(host_i, host)] = _client
244253
# TODO - Add forward agent functionality

pssh/clients/ssh/single.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def __init__(self, host,
4949
gssapi_server_identity=None,
5050
gssapi_client_identity=None,
5151
gssapi_delegate_credentials=False,
52+
ipv6_only=False,
5253
_auth_thread_pool=True):
5354
""":param host: Host name or IP to connect to.
5455
:type host: str
@@ -97,6 +98,10 @@ def __init__(self, host,
9798
:param gssapi_delegate_credentials: Enable/disable server credentials
9899
delegation.
99100
:type gssapi_delegate_credentials: bool
101+
:param ipv6_only: Choose IPv6 addresses only if multiple are available
102+
for the host or raise NoIPv6AddressFoundError otherwise. Note this will
103+
disable connecting to an IPv4 address if an IP address is provided instead.
104+
:type ipv6_only: bool
100105
101106
:raises: :py:class:`pssh.exceptions.PKeyFileError` on errors finding
102107
provided private key.
@@ -112,7 +117,9 @@ def __init__(self, host,
112117
allow_agent=allow_agent,
113118
_auth_thread_pool=_auth_thread_pool,
114119
timeout=timeout,
115-
identity_auth=identity_auth)
120+
identity_auth=identity_auth,
121+
ipv6_only=ipv6_only,
122+
)
116123

117124
def disconnect(self):
118125
"""Close socket if needed."""
@@ -141,9 +148,9 @@ def _init_session(self, retries=1):
141148
if self.gssapi_client_identity or self.gssapi_server_identity:
142149
self.session.options_set_gssapi_delegate_credentials(
143150
self.gssapi_delegate_credentials)
144-
self.session.set_socket(self.sock)
145151
logger.debug("Session started, connecting with existing socket")
146152
try:
153+
self.session.set_socket(self.sock)
147154
self._session_connect()
148155
except Exception as ex:
149156
if retries < self.num_retries:

pssh/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class HostConfig(object):
2727
__slots__ = ('user', 'port', 'password', 'private_key', 'allow_agent',
2828
'num_retries', 'retry_delay', 'timeout', 'identity_auth',
2929
'proxy_host', 'proxy_port', 'proxy_user', 'proxy_password', 'proxy_pkey',
30-
'keepalive_seconds',
30+
'keepalive_seconds', 'ipv6_only',
3131
)
3232

3333
def __init__(self, user=None, port=None, password=None, private_key=None,
@@ -36,6 +36,7 @@ def __init__(self, user=None, port=None, password=None, private_key=None,
3636
proxy_host=None, proxy_port=None, proxy_user=None, proxy_password=None,
3737
proxy_pkey=None,
3838
keepalive_seconds=None,
39+
ipv6_only=None,
3940
):
4041
"""
4142
:param user: Username to login as.
@@ -72,6 +73,8 @@ def __init__(self, user=None, port=None, password=None, private_key=None,
7273
:param keepalive_seconds: Seconds between keepalive packets being sent.
7374
0 to disable.
7475
:type keepalive_seconds: int
76+
:param ipv6_only: Use IPv6 addresses only. Currently unused.
77+
:type ipv6_only: bool
7578
"""
7679
self.user = user
7780
self.port = port
@@ -88,6 +91,7 @@ def __init__(self, user=None, port=None, password=None, private_key=None,
8891
self.proxy_password = proxy_password
8992
self.proxy_pkey = proxy_pkey
9093
self.keepalive_seconds = keepalive_seconds
94+
self.ipv6_only = ipv6_only
9195
self._sanity_checks()
9296

9397
def _sanity_checks(self):
@@ -121,3 +125,5 @@ def _sanity_checks(self):
121125
raise ValueError("Proxy pkey %s is not a string" % (self.proxy_pkey,))
122126
if self.keepalive_seconds is not None and not isinstance(self.keepalive_seconds, int):
123127
raise ValueError("Keepalive seconds %s is not an integer" % (self.keepalive_seconds,))
128+
if self.ipv6_only is not None and not isinstance(self.ipv6_only, bool):
129+
raise ValueError("IPv6 only %s is not a boolean value" % (self.ipv6_only,))

pssh/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@
1919
"""Exceptions raised by parallel-ssh classes."""
2020

2121

22+
class NoIPv6AddressFoundError(Exception):
23+
"""Raised when an IPV6 only address was requested but none are
24+
available for a host.
25+
26+
This exception is raised instead of UnknownHostError
27+
in the case where only IPV4 addresses are available via DNS for a host,
28+
or an IPV4 address was provided but IPV6 only was requested.
29+
"""
30+
31+
2232
class UnknownHostError(Exception):
2333
"""Raised when a host is unknown (dns failure)"""
2434
pass

0 commit comments

Comments
 (0)