diff --git a/pyproject.toml b/pyproject.toml index 1daaf02..2f187d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ license = {text="BSD"} requires-python = ">=3.9" dependencies = [ - "paramiko", + "paramiko >= 1.9.1", # 1.9.1+ supports multiple IdentityKey entries in .ssh/config ] [project.urls] diff --git a/src/borgstore/backends/sftp.py b/src/borgstore/backends/sftp.py index 1d25e0c..d626427 100644 --- a/src/borgstore/backends/sftp.py +++ b/src/borgstore/backends/sftp.py @@ -26,11 +26,11 @@ def get_sftp_backend(url): """ m = re.match(sftp_regex, url, re.VERBOSE) if m: - return Sftp(username=m["username"], hostname=m["hostname"], port=int(m["port"] or "22"), path=m["path"]) + return Sftp(username=m["username"], hostname=m["hostname"], port=int(m["port"] or "0"), path=m["path"]) class Sftp(BackendBase): - def __init__(self, hostname: str, path: str, port: int = 22, username: Optional[str] = None): + def __init__(self, hostname: str, path: str, port: int = 0, username: Optional[str] = None): self.username = username self.hostname = hostname self.port = port @@ -40,7 +40,27 @@ def __init__(self, hostname: str, path: str, port: int = 22, username: Optional[ def _connect(self): ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(hostname=self.hostname, username=self.username, port=self.port, allow_agent=True) + user_config_path = Path("~/.ssh/config").expanduser() + system_config_path = Path("/etc/ssh/ssh_config") # no support yet for ssh_config.d/* + ssh_config = paramiko.SSHConfig() + try: + with open(user_config_path) as f: + ssh_config.parse(f) + except FileNotFoundError: + pass + try: + with open(system_config_path) as f: + ssh_config.parse(f) + except FileNotFoundError: + pass + host_config = ssh_config.lookup(self.hostname) + ssh.connect( + hostname=host_config.get("hostname", self.hostname), # prefer config here + username=self.username if self.username else host_config.get("user"), + port=self.port if self.port else (host_config.as_int("port") if "port" in host_config else 22), + key_filename=host_config.get("identityfile", None), # list of keys + allow_agent=True, + ) self.client = ssh.open_sftp() def _disconnect(self): diff --git a/tests/test_backends.py b/tests/test_backends.py index 7a15ed4..9a15a35 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -31,13 +31,23 @@ def posixfs_backend_created(tmp_path): be.destroy() -def _get_sftp_backend(): +def _get_sftp_backend(local=False, remote_url=False, remote_config=True): # needs an authorized key loaded into the ssh agent. pytest works, tox doesn't: - # return Sftp(username="tw", hostname="localhost", path="/Users/tw/w/borgstore/temp-store") + if local: + return Sftp(username="tw", hostname="localhost", path="/Users/tw/w/borgstore/temp-store") # for tests with higher latency: - return Sftp( - username="twaldmann", hostname="shell.ipv4.thinkmo.de", port=2222, path="/home/twaldmann/borgstore/temp-store" - ) + if remote_url: + return Sftp( + username="twaldmann", + hostname="shell.ipv4.thinkmo.de", + port=2222, + path="/home/twaldmann/borgstore/temp-store", + ) + # same as previous, but loads config for host "shell" from ~/.ssh/config: + if remote_config: + return Sftp(hostname="shell", path="/home/twaldmann/borgstore/temp-store") + + raise ValueError("check _get_sftp_backend() parameter defaults!") def check_sftp_available(): @@ -96,8 +106,8 @@ def test_file_url(url, path): "url,username,hostname,port,path", [ ("sftp://username@hostname:2222/some/path", "username", "hostname", 2222, "/some/path"), - ("sftp://username@hostname/some/path", "username", "hostname", 22, "/some/path"), - ("sftp://hostname/some/path", None, "hostname", 22, "/some/path"), + ("sftp://username@hostname/some/path", "username", "hostname", 0, "/some/path"), + ("sftp://hostname/some/path", None, "hostname", 0, "/some/path"), ], ) def test_sftp_url(url, username, hostname, port, path): @@ -105,7 +115,7 @@ def test_sftp_url(url, username, hostname, port, path): assert isinstance(backend, Sftp) assert backend.username == username assert backend.hostname == hostname - assert backend.port == port + assert backend.port == port # note: 0 means "not given" (and will usually mean 22 in the end) assert backend.base_path == path