Skip to content

Commit f180fa1

Browse files
authored
Added support for DSS, ECDSA, and Ed25519 private keys in SSHHook (#12467)
1 parent 3d3a219 commit f180fa1

2 files changed

Lines changed: 67 additions & 7 deletions

File tree

‎airflow/providers/ssh/hooks/ssh.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ class SSHHook(BaseHook): # pylint: disable=too-many-instance-attributes
5858
:type keepalive_interval: int
5959
"""
6060

61+
# key type name to paramiko PKey class
62+
_default_pkey_mappings = {
63+
'dsa': paramiko.DSSKey,
64+
'ecdsa': paramiko.ECDSAKey,
65+
'ed25519': paramiko.Ed25519Key,
66+
'rsa': paramiko.RSAKey,
67+
}
68+
6169
conn_name_attr = 'ssh_conn_id'
6270
default_conn_name = 'ssh_default'
6371
conn_type = 'ssh'
@@ -124,13 +132,8 @@ def __init__( # pylint: disable=too-many-statements
124132

125133
private_key = extra_options.get('private_key')
126134
private_key_passphrase = extra_options.get('private_key_passphrase')
127-
if private_key and private_key_passphrase:
128-
self.pkey = paramiko.RSAKey.from_private_key(
129-
StringIO(private_key), password=private_key_passphrase
130-
)
131-
elif private_key and not private_key_passphrase:
132-
self.pkey = paramiko.RSAKey.from_private_key(StringIO(private_key))
133-
135+
if private_key:
136+
self.pkey = self._pkey_from_private_key(private_key, passphrase=private_key_passphrase)
134137
if "timeout" in extra_options:
135138
self.timeout = int(extra_options["timeout"], 10)
136139

@@ -320,3 +323,24 @@ def create_tunnel(
320323
)
321324

322325
return self.get_tunnel(remote_port, remote_host, local_port)
326+
327+
def _pkey_from_private_key(self, private_key: str, passphrase: Optional[str] = None) -> paramiko.PKey:
328+
"""
329+
Creates appropriate paramiko key for given private key
330+
331+
:param private_key: string containing private key
332+
:return: `paramiko.PKey` appropriate for given key
333+
:raises AirflowException: if key cannot be read
334+
"""
335+
allowed_pkey_types = self._default_pkey_mappings.values()
336+
for pkey_type in allowed_pkey_types:
337+
try:
338+
key = pkey_type.from_private_key(StringIO(private_key), password=passphrase)
339+
return key
340+
except paramiko.ssh_exception.SSHException:
341+
continue
342+
raise AirflowException(
343+
'Private key provided cannot be read by paramiko.'
344+
'Ensure key provided is valid for one of the following'
345+
'key formats: RSA, DSS, ECDSA, or Ed25519'
346+
)

‎tests/providers/ssh/hooks/test_ssh.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,16 @@ def generate_host_key(pkey: paramiko.PKey):
6363
TEST_PRIVATE_KEY = generate_key_string(pkey=TEST_PKEY)
6464
TEST_HOST_KEY = generate_host_key(pkey=TEST_PKEY)
6565

66+
TEST_PKEY_ECDSA = paramiko.ECDSAKey.generate()
67+
TEST_PRIVATE_KEY_ECDSA = generate_key_string(pkey=TEST_PKEY_ECDSA)
68+
6669
PASSPHRASE = ''.join(random.choice(string.ascii_letters) for i in range(10))
6770
TEST_ENCRYPTED_PRIVATE_KEY = generate_key_string(pkey=TEST_PKEY, passphrase=PASSPHRASE)
6871

6972

7073
class TestSSHHook(unittest.TestCase):
7174
CONN_SSH_WITH_PRIVATE_KEY_EXTRA = 'ssh_with_private_key_extra'
75+
CONN_SSH_WITH_PRIVATE_KEY_ECDSA_EXTRA = 'ssh_with_private_key_ecdsa_extra'
7276
CONN_SSH_WITH_PRIVATE_KEY_PASSPHRASE_EXTRA = 'ssh_with_private_key_passphrase_extra'
7377
CONN_SSH_WITH_EXTRA = 'ssh_with_extra'
7478
CONN_SSH_WITH_EXTRA_FALSE_LOOK_FOR_KEYS = 'ssh_with_extra_false_look_for_keys'
@@ -83,6 +87,7 @@ def tearDownClass(cls) -> None:
8387
conns_to_reset = [
8488
cls.CONN_SSH_WITH_PRIVATE_KEY_EXTRA,
8589
cls.CONN_SSH_WITH_PRIVATE_KEY_PASSPHRASE_EXTRA,
90+
cls.CONN_SSH_WITH_PRIVATE_KEY_ECDSA_EXTRA,
8691
cls.CONN_SSH_WITH_EXTRA,
8792
cls.CONN_SSH_WITH_HOST_KEY_EXTRA,
8893
cls.CONN_SSH_WITH_HOST_KEY_AND_NO_HOST_KEY_CHECK_FALSE,
@@ -134,6 +139,14 @@ def setUpClass(cls) -> None:
134139
),
135140
)
136141
)
142+
db.merge_conn(
143+
Connection(
144+
conn_id=cls.CONN_SSH_WITH_PRIVATE_KEY_ECDSA_EXTRA,
145+
host='localhost',
146+
conn_type='ssh',
147+
extra=json.dumps({"private_key": TEST_PRIVATE_KEY_ECDSA}),
148+
)
149+
)
137150
db.merge_conn(
138151
Connection(
139152
conn_id=cls.CONN_SSH_WITH_HOST_KEY_EXTRA,
@@ -313,6 +326,29 @@ def test_tunnel_with_private_key_passphrase(self, ssh_mock):
313326
logger=hook.log,
314327
)
315328

329+
@mock.patch('airflow.providers.ssh.hooks.ssh.SSHTunnelForwarder')
330+
def test_tunnel_with_private_key_ecdsa(self, ssh_mock):
331+
hook = SSHHook(
332+
ssh_conn_id=self.CONN_SSH_WITH_PRIVATE_KEY_ECDSA_EXTRA,
333+
remote_host='remote_host',
334+
port='port',
335+
username='username',
336+
timeout=10,
337+
)
338+
339+
with hook.get_tunnel(1234):
340+
ssh_mock.assert_called_once_with(
341+
'remote_host',
342+
ssh_port='port',
343+
ssh_username='username',
344+
ssh_pkey=TEST_PKEY_ECDSA,
345+
ssh_proxy=None,
346+
local_bind_address=('localhost',),
347+
remote_bind_address=('localhost', 1234),
348+
host_pkey_directories=[],
349+
logger=hook.log,
350+
)
351+
316352
def test_ssh_connection(self):
317353
hook = SSHHook(ssh_conn_id='ssh_default')
318354
with hook.get_conn() as client:

0 commit comments

Comments
 (0)