From c0b3315eff395397e0b7270ec65b1b9bd36ba97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 29 Jan 2024 11:13:50 +0100 Subject: [PATCH 1/4] fs_storage: support SSH private keys authentication SSH connections can now be done with private keys by setting the `pkey` + `passphrase` options. Coupled with the `eval_options_from_env` this allows to set these ones from the environment, e.g: `{"host": "sftp.example.net", "username": "sftp", "pkey": "$SSH_KEY", "passphrase": "$SSH_PASSPHRASE", "port": 22}` --- fs_storage/models/fs_storage.py | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/fs_storage/models/fs_storage.py b/fs_storage/models/fs_storage.py index a7c6503569..77ea18c2d3 100644 --- a/fs_storage/models/fs_storage.py +++ b/fs_storage/models/fs_storage.py @@ -5,6 +5,7 @@ import base64 import functools import inspect +import io import json import logging import os.path @@ -21,6 +22,19 @@ _logger = logging.getLogger(__name__) +try: + import paramiko + + SSH_PKEYS = { + "DSS": paramiko.DSSKey, + "RSA": paramiko.RSAKey, + "ECDSA": paramiko.ECDSAKey, + "OPENSSH": paramiko.Ed25519Key, + } +except ImportError: # pragma: no cover + _logger.debug("Cannot `import paramiko`.") + SSH_PKEYS = {} + # TODO: useful for the whole OCA? def deprecated(reason): @@ -358,6 +372,18 @@ def _get_filesystem(self) -> fsspec.AbstractFileSystem: and isinstance(options["auth"], list) ): options["auth"] = tuple(options["auth"]) + if ( + self.protocol in ("sftp", "ssh") + and "pkey" in options + and isinstance(options["pkey"], str) + ): + # Handle SSH private keys by replacing 'pkey' parameter by a + # paramiko.pkey.PKey object + pkey_file = io.StringIO(options["pkey"]) + pkey = self._get_ssh_private_key( + pkey_file, passphrase=options.get("passphrase") + ) + options["pkey"] = pkey options = self._recursive_add_odoo_storage_path(options) fs = fsspec.filesystem(self.protocol, **options) directory_path = self.directory_path @@ -365,6 +391,33 @@ def _get_filesystem(self) -> fsspec.AbstractFileSystem: fs = fsspec.filesystem("rooted_dir", path=directory_path, fs=fs) return fs + def _detect_ssh_private_key_type(self, pkey_file): + """Detect SSH private key type (RSA, DSS...).""" + # Code copied and adapted from 'paramiko.pkey.PKey._read_private_key' method + # https://github.com/paramiko/paramiko/blob/main/paramiko/pkey.py#L498C9-L498C26 + pkey_file.seek(0) + lines = pkey_file.readlines() + pkey_file.seek(0) + if not lines: + raise paramiko.SSHException("no lines in private key file") + start = 0 + m = paramiko.pkey.PKey.BEGIN_TAG.match(lines[start]) + line_range = len(lines) - 1 + while start < line_range and not m: + start += 1 + m = paramiko.pkey.PKey.BEGIN_TAG.match(lines[start]) + start += 1 + keytype = m.group(1) if m else None + if keytype: + return keytype + + def _get_ssh_private_key(self, pkey_file, passphrase=None): + """Build the expected `paramiko.pkey.PKey` object.""" + keytype = self._detect_ssh_private_key_type(pkey_file) + if not keytype: + raise paramiko.SSHException("not a valid private key file") + return SSH_PKEYS[keytype].from_private_key(pkey_file, password=passphrase) + # Deprecated methods used to ease the migration from the storage_backend addons # to the fs_storage addons. These methods will be removed in the future (Odoo 18) @deprecated("Please use _get_filesystem() instead and the fsspec API directly.") From 6f3388582393d270e0405b5527015592b5a8078f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 29 Jan 2024 17:24:57 +0100 Subject: [PATCH 2/4] fixup! fs_storage: support SSH private keys authentication --- fs_storage/models/fs_storage.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/fs_storage/models/fs_storage.py b/fs_storage/models/fs_storage.py index 77ea18c2d3..db28fa3853 100644 --- a/fs_storage/models/fs_storage.py +++ b/fs_storage/models/fs_storage.py @@ -381,7 +381,9 @@ def _get_filesystem(self) -> fsspec.AbstractFileSystem: # paramiko.pkey.PKey object pkey_file = io.StringIO(options["pkey"]) pkey = self._get_ssh_private_key( - pkey_file, passphrase=options.get("passphrase") + pkey_file, + pkey_type=options.pop("pkey_type", None), # Remove extra parameter + passphrase=options.get("passphrase"), ) options["pkey"] = pkey options = self._recursive_add_odoo_storage_path(options) @@ -411,12 +413,13 @@ def _detect_ssh_private_key_type(self, pkey_file): if keytype: return keytype - def _get_ssh_private_key(self, pkey_file, passphrase=None): + def _get_ssh_private_key(self, pkey_file, pkey_type=None, passphrase=None): """Build the expected `paramiko.pkey.PKey` object.""" - keytype = self._detect_ssh_private_key_type(pkey_file) - if not keytype: + if not pkey_type: + pkey_type = self._detect_ssh_private_key_type(pkey_file) + if not pkey_type: raise paramiko.SSHException("not a valid private key file") - return SSH_PKEYS[keytype].from_private_key(pkey_file, password=passphrase) + return SSH_PKEYS[pkey_type].from_private_key(pkey_file, password=passphrase) # Deprecated methods used to ease the migration from the storage_backend addons # to the fs_storage addons. These methods will be removed in the future (Odoo 18) From 81e22e9b8b7aac499dc98bc21ccf8834221d1c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 29 Jan 2024 18:23:26 +0100 Subject: [PATCH 3/4] fixup! fixup! fs_storage: support SSH private keys authentication --- fs_storage/models/fs_storage.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fs_storage/models/fs_storage.py b/fs_storage/models/fs_storage.py index db28fa3853..fa40ea3186 100644 --- a/fs_storage/models/fs_storage.py +++ b/fs_storage/models/fs_storage.py @@ -382,7 +382,6 @@ def _get_filesystem(self) -> fsspec.AbstractFileSystem: pkey_file = io.StringIO(options["pkey"]) pkey = self._get_ssh_private_key( pkey_file, - pkey_type=options.pop("pkey_type", None), # Remove extra parameter passphrase=options.get("passphrase"), ) options["pkey"] = pkey @@ -413,10 +412,9 @@ def _detect_ssh_private_key_type(self, pkey_file): if keytype: return keytype - def _get_ssh_private_key(self, pkey_file, pkey_type=None, passphrase=None): + def _get_ssh_private_key(self, pkey_file, passphrase=None): """Build the expected `paramiko.pkey.PKey` object.""" - if not pkey_type: - pkey_type = self._detect_ssh_private_key_type(pkey_file) + pkey_type = self._detect_ssh_private_key_type(pkey_file) if not pkey_type: raise paramiko.SSHException("not a valid private key file") return SSH_PKEYS[pkey_type].from_private_key(pkey_file, password=passphrase) From 7f530cfd4adc39f9776cd703e2be8a73898e3254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 29 Jan 2024 18:29:35 +0100 Subject: [PATCH 4/4] fixup! fixup! fixup! fs_storage: support SSH private keys authentication --- fs_storage/models/fs_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fs_storage/models/fs_storage.py b/fs_storage/models/fs_storage.py index fa40ea3186..bf775a5a51 100644 --- a/fs_storage/models/fs_storage.py +++ b/fs_storage/models/fs_storage.py @@ -28,7 +28,7 @@ SSH_PKEYS = { "DSS": paramiko.DSSKey, "RSA": paramiko.RSAKey, - "ECDSA": paramiko.ECDSAKey, + "EC": paramiko.ECDSAKey, "OPENSSH": paramiko.Ed25519Key, } except ImportError: # pragma: no cover