Repository: iterative/dvc

Issue:
ssh: Support Kerberos Keytab
Keytab files for Kerberos authentication are binary files, however, `dvc remote modify myremote keyfile /etc/krb5.keytab` followed by `dvc --verbose push -r myremote` throws
```
Preparing to upload data to 'ssh:xxx'
Preparing to collect status from ssh:xxx
Collecting information from local cache...
[##############################] 100% 

Collecting information from remote cache...
ERROR: unexpected error - 'utf-8' codec can't decode byte 0x87 in position 38: invalid start byte
```
Looks like dvc does not want to accept the binary file.  Would a fix to reading the file allow for kerberos authentication or is there more to be done here?  I cannot tell from the [documentation](https://dvc.org/doc/commands-reference/remote-modify) if `dvc remote modify myremote keyfile` supports multiple types of authentication or only ssh keyfiles.

Apologies for another kerberos related issue.


Comments:
Hi @JoshuaPostel !

Looks like to use kerberos with paramiko(the lib that we are using for ssh connections) you need to specify special `gss_auth` and `gss_kex`(optional) parameters to the ssh connection https://github.com/iterative/dvc/blob/master/dvc/remote/ssh/connection.py#L61 . We won't be able to use `keyfile` for that (at least as is) and will need to create a special config option(s) for ssh remote. Should be pretty straightforward though. Let us know if you'd be willing to contribute a patch for this, we'll be happy to help with everything we can. :slightly_smiling_face: 
I'll take a crack at it! Might be a week or two before I can put some effort into it, but I'll keep this updated. 

Patch:
diff --git a/dvc/config.py b/dvc/config.py
--- a/dvc/config.py
+++ b/dvc/config.py
@@ -253,6 +253,7 @@ class Config(object):  # pylint: disable=too-many-instance-attributes
     SECTION_REMOTE_TIMEOUT = "timeout"
     SECTION_REMOTE_PASSWORD = "password"
     SECTION_REMOTE_ASK_PASSWORD = "ask_password"
+    SECTION_REMOTE_GSS_AUTH = "gss_auth"
     SECTION_REMOTE_NO_TRAVERSE = "no_traverse"
     SECTION_REMOTE_SCHEMA = {
         SECTION_REMOTE_URL: str,
@@ -273,6 +274,7 @@ class Config(object):  # pylint: disable=too-many-instance-attributes
         Optional(SECTION_REMOTE_TIMEOUT): Use(int),
         Optional(SECTION_REMOTE_PASSWORD): str,
         Optional(SECTION_REMOTE_ASK_PASSWORD): BOOL_SCHEMA,
+        Optional(SECTION_REMOTE_GSS_AUTH): BOOL_SCHEMA,
         Optional(SECTION_AZURE_CONNECTION_STRING): str,
         Optional(SECTION_OSS_ACCESS_KEY_ID): str,
         Optional(SECTION_OSS_ACCESS_KEY_SECRET): str,
diff --git a/dvc/remote/ssh/__init__.py b/dvc/remote/ssh/__init__.py
--- a/dvc/remote/ssh/__init__.py
+++ b/dvc/remote/ssh/__init__.py
@@ -87,6 +87,7 @@ def __init__(self, repo, config):
         self.ask_password = config.get(
             Config.SECTION_REMOTE_ASK_PASSWORD, False
         )
+        self.gss_auth = config.get(Config.SECTION_REMOTE_GSS_AUTH, False)
 
     @staticmethod
     def ssh_config_filename():
@@ -145,6 +146,7 @@ def ssh(self, path_info):
             key_filename=self.keyfile,
             timeout=self.timeout,
             password=self.password,
+            gss_auth=self.gss_auth,
         )
 
     def exists(self, path_info):
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -75,7 +75,7 @@ def run(self):
 s3 = ["boto3==1.9.115"]
 azure = ["azure-storage-blob==2.1.0"]
 oss = ["oss2==2.6.1"]
-ssh = ["paramiko>=2.5.0"]
+ssh = ["paramiko[gssapi]>=2.5.0"]
 hdfs = ["pyarrow==0.14.0"]
 all_remotes = gs + s3 + azure + ssh + oss
 


Test patch:
diff --git a/tests/unit/remote/ssh/test_ssh.py b/tests/unit/remote/ssh/test_ssh.py
--- a/tests/unit/remote/ssh/test_ssh.py
+++ b/tests/unit/remote/ssh/test_ssh.py
@@ -160,3 +160,25 @@ def test_ssh_keyfile(mock_file, mock_exists, config, expected_keyfile):
     mock_exists.assert_called_with(RemoteSSH.ssh_config_filename())
     mock_file.assert_called_with(RemoteSSH.ssh_config_filename())
     assert remote.keyfile == expected_keyfile
+
+
+@pytest.mark.parametrize(
+    "config,expected_gss_auth",
+    [
+        ({"url": "ssh://example.com", "gss_auth": True}, True),
+        ({"url": "ssh://example.com", "gss_auth": False}, False),
+        ({"url": "ssh://not_in_ssh_config.com"}, False),
+    ],
+)
+@patch("os.path.exists", return_value=True)
+@patch(
+    "{}.open".format(builtin_module_name),
+    new_callable=mock_open,
+    read_data=mock_ssh_config,
+)
+def test_ssh_gss_auth(mock_file, mock_exists, config, expected_gss_auth):
+    remote = RemoteSSH(None, config)
+
+    mock_exists.assert_called_with(RemoteSSH.ssh_config_filename())
+    mock_file.assert_called_with(RemoteSSH.ssh_config_filename())
+    assert remote.gss_auth == expected_gss_auth