Projet

Général

Profil

0006-add-utilities-to-access-SFTP-servers-31595.patch

Benjamin Dauvergne, 09 avril 2019 13:33

Télécharger (19,6 ko)

Voir les différences:

Subject: [PATCH 06/10] add utilities to access SFTP servers (#31595)

 .../templates/passerelle/widgets/sftp.html    |  16 ++
 passerelle/utils/sftp.py                      | 212 ++++++++++++++++++
 setup.py                                      |   1 +
 tests/ssh_key                                 |  27 +++
 tests/ssh_key_with_password                   |  30 +++
 tests/test_utils_sftp.py                      | 170 ++++++++++++++
 tox.ini                                       |   1 +
 7 files changed, 457 insertions(+)
 create mode 100644 passerelle/base/templates/passerelle/widgets/sftp.html
 create mode 100644 passerelle/utils/sftp.py
 create mode 100644 tests/ssh_key
 create mode 100644 tests/ssh_key_with_password
 create mode 100644 tests/test_utils_sftp.py
passerelle/base/templates/passerelle/widgets/sftp.html
1
{% load i18n %}
2
<div class="sftp-widget-url">
3
    <label for="{{ widget.subwidgets.0.attrs.id }}">{% trans "URL" %}</label>
4
    {% include widget.subwidgets.0.template_name with widget=widget.subwidgets.0 %}
5
</div>
6
<div class="sftp-widget-private-key">
7
    <label for="{{ widget.subwidgets.1.attrs.id }}">{% trans "SSH private key" %}</label>
8
    {% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
9
</div>
10
<div class="sftp-widget-private-key-text">
11
    {% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
12
</div>
13
<div class="sftp-widget-private-key-password">
14
    <label for="{{ widget.subwidgets.3.attrs.id }}">{% trans "SSH private key password" %}</label>
15
    {% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
16
</div>
passerelle/utils/sftp.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import re
18
import io
19
import json
20

  
21
from django import forms
22
from django.core import validators
23
from django.db import models
24
from django.utils import six
25
from django.utils.translation import ugettext_lazy as _
26
from django.utils.six.moves.urllib import parse as urlparse
27
from django.utils.encoding import force_bytes
28

  
29
import paramiko
30
from paramiko.dsskey import DSSKey
31
from paramiko.ecdsakey import ECDSAKey
32
from paramiko.ed25519key import Ed25519Key
33
from paramiko.rsakey import RSAKey
34

  
35

  
36
def _load_private_key(content_or_file, password=None):
37
    if not hasattr(content_or_file, 'read'):
38
        content_or_file = io.BytesIO(force_bytes(content_or_file))
39
    for pkey_class in RSAKey, DSSKey, Ed25519Key, ECDSAKey:
40
        try:
41
            return pkey_class.from_private_key(
42
                content_or_file,
43
                password=password)
44
        except paramiko.PasswordRequiredException:
45
            raise
46
        except paramiko.SSHException:
47
            pass
48

  
49

  
50
@six.python_2_unicode_compatible
51
class SFTP(object):
52
    def __init__(self, url, private_key_content=None, private_key_password=None):
53
        self.url = url
54
        parsed = urlparse.urlparse(url)
55
        if not parsed.scheme == 'sftp':
56
            raise ValueError('invalid scheme %s' % parsed.scheme)
57
        if not parsed.hostname:
58
            raise ValueError('missing hostname')
59
        self.username = parsed.username or None
60
        self.password = parsed.password or None
61
        self.hostname = parsed.hostname
62
        self.port = parsed.port or 22
63
        self.path = parsed.path.strip('/')
64
        self.private_key_content = private_key_content
65
        self.private_key_password = private_key_password
66
        if private_key_content:
67
            self.private_key = _load_private_key(private_key_content, private_key_password)
68
        else:
69
            self.private_key = None
70
        self._client = None
71
        self._transport = None
72

  
73
    def __client(self):
74
        if not self._client:
75
            self._ssh_client = paramiko.SSHClient()
76
            self._ssh_client.set_missing_host_key_policy(paramiko.client.AutoAddPolicy)
77
            self._ssh_client.connect(hostname=self.hostname, port=self.port,
78
                                     timeout=5,
79
                                     pkey=self.private_key,
80
                                     look_for_keys=False,
81
                                     allow_agent=False,
82
                                     username=self.username,
83
                                     password=self.password)
84
            self._client = self._ssh_client.open_sftp()
85
            if self.path:
86
                self._client.chdir(self.path)
87
        return self._client
88

  
89
    @property
90
    def __json__(self):
91
        return {
92
            'url': self.url,
93
            'private_key_content': self.private_key_content,
94
            'private_key_password': self.private_key_password,
95
        }
96

  
97
    def __str__(self):
98
        return re.sub(r'://([^/]*:[^/]*?)@', '://***:***@', self.url)
99

  
100
    # Paramiko can hang processes if not closed, it's important to use it as a
101
    # contextmanager
102
    def __enter__(self):
103
        return self.__client()
104

  
105
    def __exit__(self, exc_type, exc_val, exc_tb):
106
        self._client.close()
107
        self._ssh_client.close()
108

  
109

  
110
class SFTPURLField(forms.URLField):
111
    default_validators = [validators.URLValidator(schemes=['sftp'])]
112

  
113

  
114
class SFTPWidget(forms.MultiWidget):
115
    template_name = 'passerelle/widgets/sftp.html'
116

  
117
    def __init__(self, **kwargs):
118
        widgets = [
119
            forms.TextInput,
120
            forms.FileInput,
121
            forms.Textarea,
122
            forms.TextInput,
123
        ]
124
        super(SFTPWidget, self).__init__(widgets=widgets, **kwargs)
125

  
126
    def decompress(self, value):
127
        if not value:
128
            return [None, None, None, None]
129
        if hasattr(value, '__json__'):
130
            value = value.__json__
131
        return [
132
            value['url'],
133
            None,
134
            value.get('private_key_content'),
135
            value.get('private_key_password'),
136
        ]
137

  
138
    # XXX: bug in Django https://code.djangoproject.com/ticket/29205
139
    # required_attribute is initialized from the parent.field required
140
    # attribute and not from each sub-field attribute
141
    def use_required_attribute(self, initial):
142
        return False
143

  
144

  
145
class SFTPFormField(forms.MultiValueField):
146
    widget = SFTPWidget
147

  
148
    def __init__(self, **kwargs):
149
        fields = [
150
            SFTPURLField(),
151
            forms.FileField(required=False),
152
            forms.CharField(required=False),
153
            forms.CharField(required=False),
154
        ]
155
        super(SFTPFormField, self).__init__(
156
            fields=fields,
157
            require_all_fields=False, **kwargs)
158

  
159
#    def clean(self, value):
160
#        import pdb
161
#        pdb.set_trace()
162
#        return super(SFTPFormField, self).clean(value)
163

  
164
    def compress(self, data_list):
165
        url, private_key_file, private_key_content, private_key_password = data_list
166
        if private_key_file:
167
            private_key_content = private_key_file.read().decode('ascii')
168
        if private_key_content:
169
            try:
170
                pkey = _load_private_key(private_key_content, private_key_password)
171
            except paramiko.PasswordRequiredException:
172
                raise forms.ValidationError(_('SSH private key needs a password'))
173
            if not pkey:
174
                raise forms.ValidationError(_('SSH private key invalid'))
175
        return SFTP(
176
            url=url,
177
            private_key_content=private_key_content,
178
            private_key_password=private_key_password)
179

  
180

  
181
class SFTPField(models.Field):
182
    description = 'A SFTP connection'
183

  
184
    def __init__(self, **kwargs):
185
        kwargs.setdefault('default', None)
186
        super(SFTPField, self).__init__(**kwargs)
187

  
188
    def get_internal_type(self):
189
        return 'TextField'
190

  
191
    def from_db_value(self, value, *args, **kwargs):
192
        return self.to_python(value)
193

  
194
    def to_python(self, value):
195
        if not value:
196
            return None
197
        if isinstance(value, SFTP):
198
            return value
199
        return SFTP(**json.loads(value))
200

  
201
    def get_prep_value(self, value):
202
        if not value:
203
            return ''
204
        return json.dumps(value.__json__)
205

  
206
    def formfield(self, **kwargs):
207
        defaults = {
208
            'form_class': SFTPFormField,
209
        }
210
        defaults.update(**kwargs)
211
        return super(SFTPField, self).formfield(**defaults)
212

  
setup.py
108 108
            'zeep < 3.0',
109 109
            'pycrypto',
110 110
            'unidecode',
111
            'paramiko',
111 112
        ],
112 113
        cmdclass={
113 114
            'build': build,
tests/ssh_key
1
-----BEGIN RSA PRIVATE KEY-----
2
MIIEpAIBAAKCAQEAqH/MTAXMKqz3uQDcliK9udOXlGNUlPN/gVMdxsw0kwmrKdrW
3
svuIa3UhrHI/6bPnTTniKvXmccAIm8aAdEg8rAgcksAada6qUYbc0aZbR0WyC+3/
4
fvaLbD9WutD05frrIEwjIUkcXavWSlaHrEzV2T3mGGI8amqR//bjwksRkClx2vvW
5
apc5FkzGtxKsc0tA6ZwyA3riqCeEVwOx+ezV8B/631q62h6AAt0j4cb+THzD1t3w
6
6KAsxt72lNw6Bp55uR5Uubin3OpijEVL4Gruy1sSJO6pQ5YHpDFTS9ZAHLLldUea
7
w+hNha+fGM/EvQJEGZsZSA/urEAuC6Xly8T/JwIDAQABAoIBAGjMe4s4+9/7DmQB
8
VjEG0IvYP2mqUfwGamJMCLQRZA2jsNJqaqiNay6yfkwcDwZSv2S3wKRJppdPAcup
9
LVGlcB7rOKJJWuugxAvK3mKCnjj47yEeWI9l1hdwWYf92KOFaWIAGMVmDH9yFejM
10
YrvWWhcwuYCm8L6bI81YiBXazMSlD+UXB9WOityCc5DayXhTvKiP6mzVQHJJ4p4o
11
q/LtBI4jOI7ZEnlI+lYNBvCfZLaPIi7vvLpOXG263ZRlwYr+IjED+7Ex6zjL3YEd
12
VFfp2MX5uY3IsXmjyhHokm2f2JRH1JtigjkRysT4AYHk76XktrsczfY6Hgs6Lwz8
13
n6/o2sECgYEA09ErhKlObtWfWdp0HXd/HRubvBQBQ+oYPxNcb6QdWOaXzG8jDBuN
14
bxOFXdqknkWzG6pNlurjVW4njeoovts4Wmza5U4Ju9IL2lrUNXqRkopbdcPCw0v6
15
kdJqbui3DwOA/gmphO802jCXu+xASSdoVK8/sCC/itOcAzi8E0YUHCECgYEAy6V+
16
3ivJczRT9xM7hOJA1pJDpLbbuE6XTQBn9QfoP90zAC6Tbr5k7dh0Uyc4JcTOPK7C
17
aMmr8ScRL9UxZKsDwxFQPJwz4/R3VCcmJt0j0vgilwm2ujiqWmgqgvM4YBg+dzlU
18
J05PenMgzYAp9HD9a8B6d6lOvioF0jx1nbZa8kcCgYEAqJhhDyLDryyRva9HpPys
19
TLrg5n710tzNl8cNWD9ErLI+ORZsywJTPQpIqT+Sr/fCbE7Nm0Yy1JjtGuQ6sk9D
20
N5ZVVRccYEb78D1Dk52PqRg/XCkJKPGc69yTotvQeT7MuWdvasQLSXBMFeQh9xhK
21
zrz+8G3gh9uO3nGWIbEx6IECgYBUW73uMp1Eh8ywcNsa9M5/FB/JP6ZM9uFeGGj3
22
68qdiffyf1i7a0tL63pkZ76uhpQYNxx5Y/FB+Dj6Y4oOdXkdeTKPqPUl3MMBrSX0
23
u253mipZ/sAe7BJFWRkjHbWguOpHYQwnLB1oUACqoAjBJX0VAaq5nvzrcWTv7fOa
24
3UtXSQKBgQC8un5szEXQth2viUe5P7FyMsz+34XRKL7qCaCv1/4kGgPhlSbkYHao
25
PZ8u5hue/hnmUfpbXNJfc+zazTIojuZ047Z5rJYpKCfs5JozuGOGVOVIf5hunWfJ
26
FU3c4umawKvh5tUUeyrXilzEWnVECGYDyzj9BumGT+YkG94yll7iZw==
27
-----END RSA PRIVATE KEY-----
tests/ssh_key_with_password
1
-----BEGIN RSA PRIVATE KEY-----
2
Proc-Type: 4,ENCRYPTED
3
DEK-Info: AES-128-CBC,AE8F07BCBA597172BBC8D034B3C24C30
4

  
5
eg/EgNvQzSeH+W7GP23U2XIbzqg3UQgnSqi+3GbZcZUnqYmsmChiMZy+Wb0b1BIY
6
QOFLvOkaiP3hSnfFvx8oSD7/J2h633uSvhtGrq9wIMVUCXLLgBL2k2pzbWHPz1WG
7
6eJRAdWQ8c7jCCVkLdFf077ebJXL3tCU/1gP7RJ2wLhkfRXtWARgYPrmw2+ZY6l1
8
tre1O0AS+yjI6ZAsVjPCPqq9WrP4CggtQ3W33/AERGrAWi9PJZFjQUE0cMj6J+R0
9
uciudN+PgO3SRKc+uWX3YrUc5zb+4SMvRO2uMiULyVMahJnnpHSO5qB+dcn6vjcU
10
F6ogpfyJpNgm4dCvEwtEiuva1HKlzCQRpWVlHhsRM6l7/faxON8QNND2ZKgO5XS6
11
QFvBbshOLssM7XQQIfdk4yNjx7lWA63W0E+t6sQqTMTeLbWePIq5IlvazFlSqkIS
12
I2B3e0Dd3emStwhZhEDncPhysh0P0WOCqnXiql3uGZGkL6bl47qRD2Ft0DnWoNoJ
13
0LUTClhInvgi9K50iMup/pq4HN8bo3fAgAcMePF3AqktFTzyIzKK7/pt0jjdX+7N
14
MBU9YkrDA3BzwupT71M/hQJIoqGBD68RGFR2Wmxc73xy6Pmv5DDv0e/fGpo0dnHC
15
IuhKMs24uRn85OWIGUi+GAbwp9OlJ6Vlh59ZAl4KVSySAnaGCZiAHfWpkWrGMlkd
16
wMi1V/L/NGXyzaereK4PUT3IKPFglRr2bTfwrhX83zO5fU4vMuMy22KNQpoX9agG
17
J2IkyhMsd56x2JmAhQKLi3rLagzEdx2a4pRJyoU8n95/NhLYnPBBl73weJKtZj/t
18
8KNhqjgerPPXdgYua2TE+4e81XrYWhbqdepG0GiYZ23XBIAIor2jgGjswENUw2TZ
19
TEFrs9MAx/0r1isz4UurXvyA4IX8TANfJzEqZwimNcn9Eehj6drnvO5GRKCbVRhe
20
p0mqMqXnTZGdJyT4EXSNnbRxW5jkaLbDSaiJ+6U0fLawqGRR0VazT4JlOAVz8i3i
21
BJxRpYZ/PvRXevh1QrUpgS/wds7+b6nyf4XgV1RsvRS2uuyqx6smE5d+sW9qVZsU
22
kuTgI99zr7JSXUO1IDqfBXldnfMVJyGvYMGXdd3UGOis23wDlAd61H6YJAwp75oR
23
pOv45Jn8JVQ0w1yFZXTZz2PyDztVpH46RQLxHMPjKber7qN8Lfhc97+01tisV0UB
24
k6cWuAXZo61uBr35jr2BRHS1xnrK8Ul4sJPq9LUUaJU2+oZeKNmytTbMRel6w156
25
d64QU75OsT5tZHKKV/NQDLuAWY1ZB+useZNHSkqz860zNzRA+gyb6pLL88Z/Q2YS
26
z0OJMXNHxBZhNKk6SPZQTIo6Q0bn8dxY8BwCLSwJGi0NMEugm5f+9+GTjYNplGTm
27
z0itYZ8dyhk+rc/48eUg/ctCGM7FbvBaLGkBGqSUrPDFzZjvqASLtKD2IqUyU1bW
28
EDlz9PHSX+SGP1A3Y5IKd1L3dbq7lO0SlJnkAOReaagjwNJ+f3dxkDikkBRO+/SB
29
V7zuK8XIc1D0Hy13AGdGsiiBF8BXCW7hrhnY+7hKHvtw4nljQ5CGKWhPBauohMdG
30
-----END RSA PRIVATE KEY-----
tests/test_utils_sftp.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import os
18
import io
19

  
20
import pytest
21

  
22
from django.core.files.uploadedfile import SimpleUploadedFile
23
from django.db import models
24

  
25
from passerelle.utils.sftp import SFTP, SFTPFormField, SFTPField
26

  
27

  
28
@pytest.fixture
29
def ssh_key():
30
    with open(
31
        os.path.join(
32
            os.path.dirname(__file__),
33
            'ssh_key'), 'rb') as fd:
34
        yield fd.read()
35

  
36

  
37
@pytest.fixture
38
def ssh_key_with_password():
39
    with open(
40
        os.path.join(
41
            os.path.dirname(__file__),
42
            'ssh_key_with_password'), 'rb') as fd:
43
        yield fd.read()
44

  
45

  
46
def test_sftp_object(sftpserver):
47
    with pytest.raises(ValueError):
48
        SFTP('https://coin.org/')
49

  
50
    with pytest.raises(ValueError):
51
        SFTP('sftp://a:x@/hop/')
52

  
53
    with sftpserver.serve_content({'DILA': {'a.zip': 'a'}}):
54
        with SFTP('sftp://john:doe@{server.host}:{server.port}/DILA/'.format(server=sftpserver)) as sftp:
55
            assert sftp.listdir() == ['a.zip']
56

  
57

  
58
def test_form_field(sftpserver, ssh_key, ssh_key_with_password):
59
    from django import forms
60

  
61
    class Form(forms.Form):
62
        sftp = SFTPFormField(label='sftp')
63

  
64
    with sftpserver.serve_content({'DILA': {'a.zip': 'a'}}):
65
        url = 'sftp://john:doe@{server.host}:{server.port}/DILA/'.format(server=sftpserver)
66

  
67
        form = Form(data={'sftp_0': 'http://coin.org'})
68
        assert not form.is_valid()
69
        assert 'Enter a valid URL.' in str(form.errors)
70

  
71
        form = Form(data={'sftp_0': url})
72
        assert form.is_valid()
73
        sftp = form.cleaned_data['sftp']
74
        assert isinstance(sftp, SFTP)
75
        assert sftp.url == url
76
        assert sftp.username == 'john'
77
        assert sftp.password == 'doe'
78
        assert sftp.hostname == sftpserver.host
79
        assert sftp.port == sftpserver.port
80
        assert sftp.path == 'DILA'
81
        assert not sftp.private_key
82
        with form.cleaned_data['sftp'] as sftp:
83
            assert sftp.listdir() == ['a.zip']
84

  
85
        form = Form(data={'sftp_0': url, 'sftp_2': ssh_key.decode('ascii')})
86
        assert form.is_valid()
87
        sftp = form.cleaned_data['sftp']
88
        assert isinstance(sftp, SFTP)
89
        assert sftp.url == url
90
        assert sftp.username == 'john'
91
        assert sftp.password == 'doe'
92
        assert sftp.hostname == sftpserver.host
93
        assert sftp.port == sftpserver.port
94
        assert sftp.path == 'DILA'
95
        assert sftp.private_key
96
        with form.cleaned_data['sftp'] as sftp:
97
            assert sftp.listdir() == ['a.zip']
98

  
99
        form = Form(
100
            data={'sftp_0': url},
101
            files={'sftp_1': SimpleUploadedFile('ssh_key', ssh_key, 'application/octet-stream')})
102
        assert form.is_valid()
103
        sftp = form.cleaned_data['sftp']
104
        assert isinstance(sftp, SFTP)
105
        assert sftp.url == url
106
        assert sftp.username == 'john'
107
        assert sftp.password == 'doe'
108
        assert sftp.hostname == sftpserver.host
109
        assert sftp.port == sftpserver.port
110
        assert sftp.path == 'DILA'
111
        assert sftp.private_key
112
        with form.cleaned_data['sftp'] as sftp:
113
            assert sftp.listdir() == ['a.zip']
114

  
115
        form = Form(data={'sftp_0': url, 'sftp_2': ssh_key_with_password.decode('ascii')})
116
        assert not form.is_valid()
117
        assert 'key invalid' in str(form.errors)
118

  
119
        form = Form(data={
120
            'sftp_0': url,
121
            'sftp_2': ssh_key_with_password.decode('ascii'),
122
            'sftp_3': 'coucou',
123
        })
124
        assert form.is_valid()
125
        with form.cleaned_data['sftp'] as sftp:
126
            assert sftp.listdir() == ['a.zip']
127

  
128

  
129
@pytest.fixture
130
def temp_model(db):
131
    from django.db import connection
132

  
133
    class Model(models.Model):
134
        sftp = SFTPField(blank=True)
135

  
136
        class Meta:
137
            app_label = 'test'
138

  
139
    with connection.schema_editor() as editor:
140
        editor.create_model(Model)
141
        yield Model
142
        editor.delete_model(Model)
143

  
144

  
145
def test_model_field(temp_model, ssh_key_with_password):
146
    instance = temp_model.objects.create()
147
    assert instance.sftp is None
148

  
149
    url = 'sftp://john:doe@example.com:45/a/b'
150
    sftp = SFTP(url=url, private_key_content=ssh_key_with_password, private_key_password='coucou')
151
    instance.sftp = sftp
152
    instance.save()
153

  
154
    instance = temp_model.objects.get()
155
    assert instance.sftp is not None
156
    assert instance.sftp.url == url
157
    assert instance.sftp.private_key is not None
158
    instance.sftp = None
159
    instance.save()
160

  
161
    instance = temp_model.objects.get()
162
    assert instance.sftp is None
163
    instance.delete()
164

  
165
    temp_model.objects.create(sftp=sftp)
166

  
167
    instance = temp_model.objects.get()
168
    assert instance.sftp is not None
169
    assert instance.sftp.url == url
170
    assert instance.sftp.private_key is not None
tox.ini
31 31
  pytest-freezegun
32 32
  pytest-httpbin
33 33
  pytest-localserver
34
  pytest-sftpserver
34 35
commands =
35 36
  django18: py.test {posargs: {env:FAST:} --junitxml=test_{envname}_results.xml --cov-report xml --cov-report html --cov=passerelle/ --cov-config .coveragerc tests/}
36 37
  django18: ./pylint.sh passerelle/
37
-