Repository: iterative/dvc

Issue:
Connecting to SSH remote with a custom port and a private key
Hello,

I would like to use DVC to push data to my personal server. For security reasons, I decided to use key-based authentication and I changed the default SSH port. Currently (from my understanding), it doesn't seem possible to use DVC with such configuration. Would it be possible/hard to support this?


Discussion:
Hi @gcoter !

You are correct about the ssh port being not configurable right now. I've created https://github.com/iterative/dvc/issues/1060 to track the progress on that. I will send a patch very soon.

About the keys, did you mean an ssh keys at a custom location? Because dvc currently supports both password and default key for ssh. Just making sure I understand you correctly.

Thanks,
Ruslan
Hi @efiop,

Thank you for your quick answer! Yes, I mean an ssh key at a custom location.
Ah, got it. Thank you for clarifying! I'll repurpose this issue to track progress on 'configurable ssh key location'. ETA for both patches is today's evening :slightly_smiling_face: 

Thanks,
Ruslan

Resolving patch:
diff --git a/dvc/config.py b/dvc/config.py
--- a/dvc/config.py
+++ b/dvc/config.py
@@ -132,6 +132,8 @@ class Config(object):
     SECTION_REMOTE_FMT = 'remote "{}"'
     SECTION_REMOTE_URL = 'url'
     SECTION_REMOTE_USER = 'user'
+    SECTION_REMOTE_PORT = 'port'
+    SECTION_REMOTE_KEY_FILE = 'keyfile'
     SECTION_REMOTE_SCHEMA = {
         SECTION_REMOTE_URL: And(supported_url, error="Unsupported URL"),
         Optional(SECTION_AWS_REGION): str,
@@ -141,6 +143,8 @@ class Config(object):
         Optional(SECTION_GCP_PROJECTNAME): str,
         Optional(SECTION_CACHE_TYPE): SECTION_CACHE_TYPE_SCHEMA,
         Optional(SECTION_REMOTE_USER): str,
+        Optional(SECTION_REMOTE_PORT): int,
+        Optional(SECTION_REMOTE_KEY_FILE): str,
     }
 
     SCHEMA = {
diff --git a/dvc/dependency/ssh.py b/dvc/dependency/ssh.py
--- a/dvc/dependency/ssh.py
+++ b/dvc/dependency/ssh.py
@@ -18,6 +18,7 @@ def __init__(self, stage, path, info=None, remote=None):
         self.remote = remote if remote else RemoteSSH(stage.project, {})
 
         host = remote.host if remote else self.group('host')
+        port = remote.port if remote else RemoteSSH.DEFAULT_PORT
         user = remote.user if remote else self.group('user')
         if remote:
             path = posixpath.join(remote.prefix,
@@ -27,6 +28,7 @@ def __init__(self, stage, path, info=None, remote=None):
 
         self.path_info = {'scheme': 'ssh',
                           'host': host,
+                          'port': port,
                           'user': user,
                           'path': path}
 
diff --git a/dvc/remote/ssh.py b/dvc/remote/ssh.py
--- a/dvc/remote/ssh.py
+++ b/dvc/remote/ssh.py
@@ -46,6 +46,8 @@ class RemoteSSH(RemoteBase):
     REQUIRES = {'paramiko': paramiko}
     PARAM_MD5 = 'md5'
 
+    DEFAULT_PORT = 22
+
     def __init__(self, project, config):
         self.project = project
         self.url = config.get(Config.SECTION_REMOTE_URL, '/')
@@ -55,30 +57,39 @@ def __init__(self, project, config):
             self.user = config.get(Config.SECTION_REMOTE_USER,
                                    getpass.getuser())
         self.prefix = self.group('path')
+        self.port = config.get(Config.SECTION_REMOTE_PORT, self.DEFAULT_PORT)
+        self.keyfile = config.get(Config.SECTION_REMOTE_KEY_FILE, None)
 
     def md5s_to_path_infos(self, md5s):
         return [{'scheme': 'ssh',
                  'host': self.host,
                  'user': self.user,
+                 'port': self.port,
                  'path': posixpath.join(self.prefix,
                                         md5[0:2], md5[2:])} for md5 in md5s]
 
-    def ssh(self, host=None, user=None):
-        msg = "Establishing ssh connection with '{}' as user '{}'"
-        Logger.debug(msg.format(host, user))
+    def ssh(self, host=None, user=None, port=None):
+        msg = "Establishing ssh connection with '{}' " \
+              "through port '{}' as user '{}'"
+        Logger.debug(msg.format(host, port, user))
 
         ssh = paramiko.SSHClient()
 
         ssh.load_system_host_keys()
         ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
 
-        ssh.connect(host, username=user)
+        ssh.connect(host,
+                    username=user,
+                    port=port,
+                    key_filename=self.keyfile)
 
         return ssh
 
     def exists(self, path_infos):
         ret = []
-        ssh = self.ssh(host=self.host, user=self.user)
+        ssh = self.ssh(host=self.host,
+                       user=self.user,
+                       port=self.port)
         cmd = 'find {} -type f -follow -print'.format(self.prefix)
         stdout = self._exec(ssh, cmd)
         plist = stdout.split()
@@ -103,7 +114,9 @@ def md5(self, path_info):
         if path_info['scheme'] != 'ssh':
             raise NotImplementedError
 
-        ssh = self.ssh(host=path_info['host'], user=path_info['user'])
+        ssh = self.ssh(host=path_info['host'],
+                       user=path_info['user'],
+                       port=path_info['port'])
 
         # Use different md5 commands depending on os
         stdout = self._exec(ssh, 'uname').strip()
@@ -133,7 +146,8 @@ def cp(self, from_info, to_info, ssh=None):
         assert from_info['user'] == to_info['user']
 
         s = ssh if ssh else self.ssh(host=from_info['host'],
-                                     user=from_info['user'])
+                                     user=from_info['user'],
+                                     port=from_info['port'])
 
         dname = posixpath.dirname(to_info['path'])
         self._exec(s, 'mkdir -p {}'.format(dname))
@@ -159,6 +173,7 @@ def changed_cache(self, md5):
         cache = {}
         cache['scheme'] = 'ssh'
         cache['host'] = self.host
+        cache['port'] = self.port
         cache['user'] = self.user
         cache['path'] = posixpath.join(self.prefix, md5[0:2], md5[2:])
 
@@ -236,7 +251,9 @@ def remove(self, path_info):
                                                       path_info['host'],
                                                       path_info['path']))
 
-        ssh = self.ssh(host=path_info['host'], user=path_info['user'])
+        ssh = self.ssh(host=path_info['host'],
+                       user=path_info['user'],
+                       port=path_info['port'])
         ssh.open_sftp().remove(path_info['path'])
         ssh.close()
 
@@ -247,7 +264,9 @@ def download(self,
                  names=None):
         names = self._verify_path_args(from_infos, to_infos, names)
 
-        ssh = self.ssh(host=from_infos[0]['host'], user=from_infos[0]['user'])
+        ssh = self.ssh(host=from_infos[0]['host'],
+                       user=from_infos[0]['user'],
+                       port=from_infos[0]['port'])
 
         for to_info, from_info, name in zip(to_infos, from_infos, names):
             if from_info['scheme'] != 'ssh':
@@ -255,6 +274,7 @@ def download(self,
 
             if to_info['scheme'] == 'ssh':
                 assert from_info['host'] == to_info['host']
+                assert from_info['port'] == to_info['port']
                 assert from_info['user'] == to_info['user']
                 self.cp(from_info, to_info, ssh=ssh)
                 continue
@@ -291,7 +311,9 @@ def download(self,
     def upload(self, from_infos, to_infos, names=None):
         names = self._verify_path_args(to_infos, from_infos, names)
 
-        ssh = self.ssh(host=to_infos[0]['host'], user=to_infos[0]['user'])
+        ssh = self.ssh(host=to_infos[0]['host'],
+                       user=to_infos[0]['user'],
+                       port=to_infos[0]['port'])
         sftp = ssh.open_sftp()
 
         for from_info, to_info, name in zip(from_infos, to_infos, names):
@@ -332,7 +354,9 @@ def _path_to_md5(self, path):
         return posixpath.dirname(relpath) + posixpath.basename(relpath)
 
     def _all_md5s(self):
-        ssh = self.ssh(host=self.host, user=self.user)
+        ssh = self.ssh(host=self.host,
+                       user=self.user,
+                       port=self.port)
         cmd = 'find {} -type f -follow -print'.format(self.prefix)
         stdout = self._exec(ssh, cmd)
         flist = stdout.split()
@@ -351,6 +375,7 @@ def gc(self, cinfos):
             path_info = {'scheme': 'ssh',
                          'user': self.user,
                          'host': self.host,
+                         'port': self.port,
                          'path': posixpath.join(self.prefix,
                                                 md5[0:2], md5[2:])}
             self.remove(path_info)


Test patch: