diff --git a/.travis.yml b/.travis.yml index e42da917..74003680 100644 --- a/.travis.yml +++ b/.travis.yml @@ -34,6 +34,7 @@ virtualenv: install: - git clone --depth=1 https://github.com/OCA/maintainer-quality-tools.git ${HOME}/maintainer-quality-tools - export PATH=${HOME}/maintainer-quality-tools/travis:${PATH} + - pip install --upgrade paramiko - travis_install_nightly script: diff --git a/document_sftp/README.rst b/document_sftp/README.rst new file mode 100644 index 00000000..5a637b99 --- /dev/null +++ b/document_sftp/README.rst @@ -0,0 +1,91 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +==== +SFTP +==== + +This module allows you to access your data (= documents attached to records) +via SFTP. There will be different virtual roots to get your browsing started, +for the time being, there's only one map `By model` that lets you browse ids +of the records of the different models. + +Installation +============ + +To install this module, you need to: + +#. install paramiko. You need version 2.0 or higher +#. install the module. It will generate a new host key during installation + +Configuration +============= + +To configure this module, you need to: + +#. be sure there's a proper hostkey in config parameter ``document_sftp.hostkey`` +#. add some authorized key on the users' form who should be allowed to login via SFTP + +Usage +===== + +To use this module, you need to: + +#. add some keys to your user's authorized key field +#. say ``sftp -p 2222 $yourodoohost`` and browse through the possibilities +#. when you found what you want, say ``sshfs -p 2222 "admin@localhost:By model/res.company/1" $yourmountpoint`` to see all attachments of your main company in ``$yourmountpoint`` +#. pay someone to implement locking (see below) in order to be able to write on this + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/118/8.0 + +Known issues / Roadmap +====================== + +* implement writing. This is not trivial, because we need some concept of locking, UI for that and take care of some corner cases +* allow to restrict actions on SFTP that are allowed normally (because ``rf -rf $yourmountpoint/`` can become a serious problem) +* should support directories +* implement some other useful virtual roots (Mails, By Directory, By name, ...) + This should look like symlinks to the rigid designator (model+id) from the point of view of the user. +* with this, `auth_ssh` should be simple to implement enabling passwordless logins for your scripts +* if you want to see error messages from the SFTP server thread, use ``--log-handler=paramiko:DEBUG`` + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Holger Brunn + +Do not contact contributors directly about help with questions or problems concerning this addon, but use the `community mailing list `_ or the `appropriate specialized mailinglist `_ for help, and the bug tracker linked in `Bug Tracker`_ above for technical issues. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/document_sftp/__init__.py b/document_sftp/__init__.py new file mode 100644 index 00000000..f58eeba9 --- /dev/null +++ b/document_sftp/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import models +from .hooks import post_init_hook diff --git a/document_sftp/__openerp__.py b/document_sftp/__openerp__.py new file mode 100644 index 00000000..43b2c1c7 --- /dev/null +++ b/document_sftp/__openerp__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "SFTP", + "version": "8.0.1.0.0", + "author": "Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Knowledge Management", + "summary": "Access your documents via SFTP", + "depends": [ + 'base', + ], + "demo": [ + "demo/res_users.xml", + ], + "data": [ + "demo/res_users.xml", + "views/res_users.xml", + "data/ir_config_parameter.xml", + ], + "post_init_hook": 'post_init_hook', + "external_dependencies": { + 'python': ['paramiko'], + }, +} diff --git a/document_sftp/data/ir_config_parameter.xml b/document_sftp/data/ir_config_parameter.xml new file mode 100644 index 00000000..38c3928a --- /dev/null +++ b/document_sftp/data/ir_config_parameter.xml @@ -0,0 +1,15 @@ + + + + + document_sftp.hostkey + If you see this text, initialization went wrong + + + + document_sftp.bind + localhost:2222 + + + + diff --git a/document_sftp/demo/demo.key b/document_sftp/demo/demo.key new file mode 100644 index 00000000..0dd6133d --- /dev/null +++ b/document_sftp/demo/demo.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/document_sftp/demo/res_users.xml b/document_sftp/demo/res_users.xml new file mode 100644 index 00000000..b400539e --- /dev/null +++ b/document_sftp/demo/res_users.xml @@ -0,0 +1,8 @@ + + + + + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCuJbpX5OB8tnXzn+T72D+uIHTvCnpuDMD2So5lfkJsU3M4TvidERP0MtZykNNjs2RmIV97CLTdfJXxWpjYm92KWhd9X52lFBKVIJO2FHayhWw4VQGBeNXd9iGs3h9g5/B5LPvdPeZ4OtgpuTv06fTJTdBmzjmOCSa+5j/FWWbEr3F5TTRcba7v3W2a+qvzh1PODpeBFpXEGQqK2DnAA5XwmZG17fo0hHdLhQD1vb//vL1E8sMHWc/zDn8tef8JAmWpkK7Cuib7oB55/+7PkgcsRqQx3FRhg0hcmSwPWLBT+GfU0fUfAILUPVukkOiCl8GSqN+St0fYhPtbFPwLkAscmZCTWbI0HViLXR5nsWHd1eziimpDJXeo7d2wo7cvQpSdZFKlERws5gErEgewPYFYW7SqPFVf0hZ9AqHiX01Nu3tcVUdxQvw7+92ShAcar5CVcrWdD2T5YdxDNapUJ0uL6t/vvLYz8OpcsoyfXbMFG/KPxs9UTKx7UqaPOK6pyc12MxJ+EksEMhqGY+dyPwBfwce4R9FybRhz+BhYP5ABl54dxrF894xrrkHeqS1avgslx9fUU9mXb6LyAbuyykCI6JUsKeR+O3SnLv3PdAVYmqqa/7MhMQWz+jeX3nrs+9OtETpB8P0EtcyzDNwS0keLu4pHhXH9hPmLBRDiTQ4F0Q== + + + diff --git a/document_sftp/document_sftp_handle.py b/document_sftp/document_sftp_handle.py new file mode 100644 index 00000000..a5e0b53c --- /dev/null +++ b/document_sftp/document_sftp_handle.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from paramiko import SFTP_EOF, SFTPHandle +from base64 import b64decode + + +class DocumentSFTPHandle(SFTPHandle): + def __init__(self, attachment, flags=0): + self.attachment = attachment + super(DocumentSFTPHandle, self).__init__(flags) + + def stat(self): + return self.attachment.env['document.sftp.root']._file(self.attachment) + + def read(self, offset, length): + data = b64decode(self.attachment.datas) + if offset > len(data): + return SFTP_EOF + return data[offset:offset + length] diff --git a/document_sftp/document_sftp_server.py b/document_sftp/document_sftp_server.py new file mode 100644 index 00000000..383ac6e8 --- /dev/null +++ b/document_sftp/document_sftp_server.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from paramiko import AUTH_SUCCESSFUL, AUTH_FAILED,\ + OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED, OPEN_SUCCEEDED,\ + RSAKey, ServerInterface +from paramiko.py3compat import decodebytes +from openerp.exceptions import AccessDenied + + +class DocumentSFTPServer(ServerInterface): + def __init__(self, env): + self.env = env + super(DocumentSFTPServer, self).__init__() + + def check_auth_password(self, username, password): + try: + user = self.env['res.users'].search([('login', '=', username)]) + if not user: + return AUTH_FAILED + user.sudo(user.id).check_credentials(password) + return AUTH_SUCCESSFUL + except AccessDenied: + pass + return AUTH_FAILED + + def check_auth_publickey(self, username, key): + user = self.env['res.users'].search([('login', '=', username)]) + if not user: + return AUTH_FAILED + for line in (user.authorized_keys or '').split('\n'): + if not line or line.startswith('#'): + continue + key_type, key_data = line.split(' ', 2)[:2] + if key_type != 'ssh-rsa': + self.logger.info( + 'Ignoring key of unknown type for line %s', line) + continue + if RSAKey(data=decodebytes(key_data)) == key: + return AUTH_SUCCESSFUL + return AUTH_FAILED + + def get_allowed_auths(self, username): + return 'password,publickey' + + def check_channel_request(self, kind, chanid): + if kind in ('session',): + return OPEN_SUCCEEDED + return OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED diff --git a/document_sftp/document_sftp_sftp_server.py b/document_sftp/document_sftp_sftp_server.py new file mode 100644 index 00000000..ed4e7597 --- /dev/null +++ b/document_sftp/document_sftp_sftp_server.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from paramiko import SFTP_PERMISSION_DENIED, SFTPServerInterface, SFTPServer +from openerp import api + + +class DocumentSFTPSftpServerInterface(SFTPServerInterface): + def __init__(self, server, env): + self.env = env + + def list_folder(self, path): + if not path or path in ('/', '.'): + return self.env['document.sftp']._get_root_entries() + handler = self.env['document.sftp']._get_handler_for(path) + if handler is None: + return SFTP_PERMISSION_DENIED + return handler._list_folder(path) + + def lstat(self, path): + if path == '.': + return self.env['document.sftp.root']._directory('/') + handler = self.env['document.sftp']._get_handler_for(path) + if handler is None: + return SFTP_PERMISSION_DENIED + return handler._lstat(path) + + def stat(self, path): + handler = self.env['document.sftp']._get_handler_for(path) + if handler is None: + return SFTP_PERMISSION_DENIED + return handler._stat(path) + + def open(self, path, flags, attr): + handler = self.env['document.sftp']._get_handler_for(path) + if handler is None: + return SFTP_PERMISSION_DENIED + return handler._open(path, flags, attr) + + def session_ended(self): + self.env.cr.close() + return super(DocumentSFTPSftpServerInterface, self).session_ended() + + def session_started(self): + self.env = self.env(cr=self.env.registry.cursor()) + + +class DocumentSFTPSftpServer(SFTPServer): + def start_subsystem(self, name, transport, channel): + with api.Environment.manage(): + return super(DocumentSFTPSftpServer, self).start_subsystem( + name, transport, channel) diff --git a/document_sftp/document_sftp_transport.py b/document_sftp/document_sftp_transport.py new file mode 100644 index 00000000..8e5f0b8f --- /dev/null +++ b/document_sftp/document_sftp_transport.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from paramiko import Transport +from paramiko.transport import DEFAULT_WINDOW_SIZE, DEFAULT_MAX_PACKET_SIZE +from openerp import api, SUPERUSER_ID +from openerp.modules.registry import RegistryManager + + +class DocumentSFTPTransport(Transport): + def __init__( + self, dbname, sock, default_window_size=DEFAULT_WINDOW_SIZE, + default_max_packet_size=DEFAULT_MAX_PACKET_SIZE, gss_kex=False, + gss_deleg_creds=True + ): + self.dbname = dbname + super(DocumentSFTPTransport, self).__init__( + sock, default_window_size=default_window_size, + default_max_packet_size=default_max_packet_size, gss_kex=gss_kex, + gss_deleg_creds=gss_deleg_creds + ) + + def run(self): + with api.Environment.manage(): + self.env = api.Environment( + RegistryManager.get(self.dbname).cursor(), SUPERUSER_ID, {}) + result = super(DocumentSFTPTransport, self).run() + self.env.cr.close() + return result diff --git a/document_sftp/hooks.py b/document_sftp/hooks.py new file mode 100644 index 00000000..3cbd932c --- /dev/null +++ b/document_sftp/hooks.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging +import StringIO +import socket +from lxml import etree +from paramiko.ecdsakey import ECDSAKey +from openerp import SUPERUSER_ID, tools +_logger = logging.getLogger(__name__) + + +def post_init_hook(cr, pool): + if socket.getfqdn().endswith('odoo-community.org'): # pragma: no cover + # we need a different default listeing address on runbot + pool['ir.config_parameter'].set_param( + cr, SUPERUSER_ID, 'document_sftp.bind', '%s:0' % socket.getfqdn()) + hostkey = pool['ir.config_parameter'].get_param( + cr, SUPERUSER_ID, 'document_sftp.hostkey') + parameters = etree.parse( + tools.file_open('document_sftp/data/ir_config_parameter.xml')) + default_value = None + for node in parameters.xpath( + "//record[@id='param_hostkey']//field[@name='value']" + ): + default_value = node.text + if not hostkey or hostkey == default_value: + _logger.info('Generating host key for database %s', cr.dbname) + key = StringIO.StringIO() + ECDSAKey.generate().write_private_key(key) + pool['ir.config_parameter'].set_param( + cr, SUPERUSER_ID, 'document_sftp.hostkey', key.getvalue()) + key.close() diff --git a/document_sftp/models/__init__.py b/document_sftp/models/__init__.py new file mode 100644 index 00000000..89087772 --- /dev/null +++ b/document_sftp/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import document_sftp +from . import res_users +from . import document_sftp_root +from . import document_sftp_root_by_model diff --git a/document_sftp/models/document_sftp.py b/document_sftp/models/document_sftp.py new file mode 100644 index 00000000..fcebd03e --- /dev/null +++ b/document_sftp/models/document_sftp.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import paramiko +import logging +import socket +import StringIO +import threading +from openerp import SUPERUSER_ID, api, models +from openerp.modules.registry import RegistryManager +try: + from ..document_sftp_transport import DocumentSFTPTransport + from ..document_sftp_server import DocumentSFTPServer + from ..document_sftp_sftp_server import DocumentSFTPSftpServerInterface,\ + DocumentSFTPSftpServer +except ImportError: # pragma: no cover + pass +_db2thread = {} +_channels = [] +_logger = logging.getLogger(__name__) + + +class DocumentSFTP(models.AbstractModel): + _name = 'document.sftp' + _description = 'SFTP server' + + def _run_server(self, dbname, stop): + with api.Environment.manage(): + with RegistryManager.get(dbname).cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + env[self._name].__run_server(stop) + + @api.model + def __run_server(self, stop): + # this is heavily inspired by + # https://github.com/rspivak/sftpserver/blob/master/src/sftpserver + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + host, port = self.env['ir.config_parameter'].get_param( + 'document_sftp.bind', 'localhost:0' + ).split(':') + _logger.info('Binding to %s:%s', host, port) + server_socket.bind((host, int(port))) + host_real, port_real = server_socket.getsockname() + _logger.info( + 'Listening to SFTP connections on %s:%s', host_real, port_real) + if host_real != host or port_real != port: + self.env['ir.config_parameter'].set_param( + 'document_sftp.bind', '%s:%s' % (host_real, port_real)) + server_socket.listen(5) + server_socket.settimeout(2) + + while not stop.is_set(): + try: + conn, addr = server_socket.accept() + except socket.timeout: + while _channels and\ + not _channels[0].get_transport().is_active(): + _channels.pop(0) + continue + + _logger.debug('Accepted connection from %s', addr) + + key = StringIO.StringIO(self.env['ir.config_parameter'].get_param( + 'document_sftp.hostkey')) + host_key = paramiko.ecdsakey.ECDSAKey.from_private_key(key) + key.close() + transport = DocumentSFTPTransport(self.env.cr.dbname, conn) + transport.add_server_key(host_key) + transport.set_subsystem_handler( + 'sftp', DocumentSFTPSftpServer, + DocumentSFTPSftpServerInterface, self.env) + + server = DocumentSFTPServer(self.env) + try: + transport.start_server(server=server) + channel = transport.accept() + if channel: + _channels.append(channel) + except (paramiko.SSHException, EOFError): + continue + + @api.model + def _get_root_handlers(self): + return [ + self.env['document.sftp.root.by_model'], + ] + + @api.model + def _get_root_entries(self): + entries = [] + for model in self._get_root_handlers(): + entries.append(model._get_root_attributes()) + return entries + + @api.model + def _get_handler_for(self, path): + # TODO: this can be smarter + return self.env['document.sftp.root.by_model'] + + def _register_hook(self, cr): + if cr.dbname not in _db2thread: + stop = threading.Event() + _db2thread[cr.dbname] = ( + threading.Thread( + target=self._run_server, args=(cr.dbname, stop)), + stop, + ) + _db2thread[cr.dbname][0].start() + from openerp.service.server import server + old_stop = server.stop + + def new_stop(): + stop.set() + old_stop() + + server.stop = new_stop + return super(DocumentSFTP, self)._register_hook(cr) diff --git a/document_sftp/models/document_sftp_root.py b/document_sftp/models/document_sftp_root.py new file mode 100644 index 00000000..093a2814 --- /dev/null +++ b/document_sftp/models/document_sftp_root.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import stat +try: + from paramiko import SFTPAttributes + from ..document_sftp_handle import DocumentSFTPHandle +except ImportError: # pragma: no cover + pass +from openerp import api, models + + +class DocumentSFTPRoot(models.AbstractModel): + _name = 'document.sftp.root' + _description = 'Base class for virtual roots in SFTP' + + @api.model + def _directory(self, name): + """Return SFTPAttributes for a directory with given name""" + result = SFTPAttributes() + result.filename = name + result.st_uid = 0 + result.st_group = 0 + result.st_size = 0 + result.st_mode = stat.S_IFDIR | stat.S_IRUSR | stat.S_IXUSR + return result + + @api.model + def _file(self, attachment): + """Return SFTPAttributes for a given attachment""" + if not hasattr(attachment, '_ids'): + attachment = self.env['ir.attachment'].browse(attachment) + result = SFTPAttributes() + result.filename = attachment.datas_fname or attachment.name + result.st_uid = 0 + result.st_group = 0 + result.st_size = attachment.file_size + result.st_mode = stat.S_IFREG | stat.S_IRUSR + return result + + @api.model + def _file_handle(self, attachment): + """Return a DocumentSFTPHandle for a given attachment""" + return DocumentSFTPHandle(attachment) + + @api.model + def _get_root_attributes(self): + """Return the entry in the root folder as SFTPAttributes""" + raise NotImplementedError() + + @api.model + def _stat(self, path): + """Return file attributes""" + raise NotImplementedError() + + @api.model + def _open(self, path, flags, attr): + """Return file attributes""" + raise NotImplementedError() + + @api.model + def _lstat(self, path): + """Return attributes about a link""" + return self._stat(path) diff --git a/document_sftp/models/document_sftp_root_by_model.py b/document_sftp/models/document_sftp_root_by_model.py new file mode 100644 index 00000000..76d0c818 --- /dev/null +++ b/document_sftp/models/document_sftp_root_by_model.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import os +from openerp import api, models +try: + from paramiko import SFTP_NO_SUCH_FILE, SFTP_PERMISSION_DENIED +except ImportError: # pragma: no cover + pass + + +class DocumentSFTPRootByModel(models.Model): + _inherit = 'document.sftp.root' + _name = 'document.sftp.root.by_model' + _virtual_root = 'By model' + _virtual_root_by_id = 'By id' + + @api.model + def _get_root_attributes(self): + return self._directory(self._virtual_root) + + @api.model + def _stat(self, path): + path = path.strip('/') + if not path.startswith(self._virtual_root): + return SFTP_NO_SUCH_FILE + components = path.split('/') + if len(components) == 1: + return self._get_root_attributes() + elif len(components) in (2, 3): + return self._directory(components[-1]) + elif len(components) == 4: + return self._file(self.env['ir.attachment'].search([ + ('res_model', '=', components[-3]), + ('res_id', '=', components[-2]), + '|', + ('datas_fname', '=', components[-1]), + ('name', '=', components[-1]), + ], limit=1)) + return SFTP_NO_SUCH_FILE + + @api.model + def _list_folder(self, path): + path = path.strip('/') + components = path.split('/') + result = [] + if len(components) == 1: + for model in self.env['ir.model'].search([ + ('osv_memory', '=', False), + ]): + if not self.env['ir.model.access'].check( + model.model, raise_exception=False + ): + continue + result.append(self._directory(model.model)) + elif len(components) == 2: + model = components[-1] + seen = set([]) + if model not in self.env.registry: + return SFTP_NO_SUCH_FILE + for attachment in self.env['ir.attachment'].search([ + ('res_model', '=', model), + ('res_id', '!=', False), + ], order='res_id asc'): + # TODO: better lump ids together in steps of 100 or something? + if attachment.res_id not in seen: + seen.add(attachment.res_id) + result.append(self._directory(str(attachment.res_id))) + elif len(components) == 3: + model = components[-2] + res_id = int(components[-1]) + for attachment in self.env['ir.attachment'].search([ + ('res_model', '=', model), + ('res_id', '=', res_id), + ]): + result.append(self._file(attachment)) + else: + return SFTP_NO_SUCH_FILE + return result + + @api.model + def _open(self, path, flags, attr): + if flags & os.O_WRONLY or flags & os.O_RDWR: + # TODO: do something more sensible here + return SFTP_PERMISSION_DENIED + path = path.strip('/') + components = path.split('/') + if len(components) == 4: + return self._file_handle(self.env['ir.attachment'].search([ + ('res_model', '=', components[-3]), + ('res_id', '=', components[-2]), + '|', + ('datas_fname', '=', components[-1]), + ('name', '=', components[-1]), + ], limit=1)) + return SFTP_PERMISSION_DENIED diff --git a/document_sftp/models/res_users.py b/document_sftp/models/res_users.py new file mode 100644 index 00000000..a4ffd72a --- /dev/null +++ b/document_sftp/models/res_users.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp import fields, models + + +class ResUsers(models.Model): + _inherit = 'res.users' + + authorized_keys = fields.Text( + 'Authorized keys', + help='An authorized key file as in ~/.ssh/authorized_keys') + + def _register_hook(self, cr): + if 'authorized_keys' not in self.SELF_WRITEABLE_FIELDS: + self.SELF_WRITEABLE_FIELDS.append('authorized_keys') + self.SELF_READABLE_FIELDS.append('authorized_keys') + return super(ResUsers, self)._register_hook(cr) diff --git a/document_sftp/static/description/icon.png b/document_sftp/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/document_sftp/static/description/icon.png differ diff --git a/document_sftp/tests/__init__.py b/document_sftp/tests/__init__.py new file mode 100644 index 00000000..a1e95acb --- /dev/null +++ b/document_sftp/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import test_document_sftp diff --git a/document_sftp/tests/test_document_sftp.py b/document_sftp/tests/test_document_sftp.py new file mode 100644 index 00000000..0dfedb27 --- /dev/null +++ b/document_sftp/tests/test_document_sftp.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import time +import paramiko +from openerp import tools +from openerp.modules.registry import RegistryManager +from openerp.tests.common import TransactionCase +from openerp.addons.document_sftp.models.document_sftp import _db2thread +from ..hooks import post_init_hook + + +class TestDocumentSftp(TransactionCase): + def test_document_sftp(self): + # without this, your ssh thread gets a real registry with blocks + RegistryManager.enter_test_mode() + # be sure to set a hostkey + post_init_hook(self.env.cr, self.registry) + self.assertTrue( + 'PRIVATE KEY' in + self.env['ir.config_parameter'].get_param('document_sftp.hostkey') + ) + # be sure to start our server + self.env['document.sftp']._register_hook() + # give it some time + time.sleep(5) + # use this to bind to our server + bind = self.env['ir.config_parameter'].get_param('document_sftp.bind') + host, port = bind.split(':') + transport = paramiko.Transport((host, int(port))) + demo_key = paramiko.rsakey.RSAKey( + file_obj=tools.file_open('document_sftp/demo/demo.key')) + transport.connect(username='demo', pkey=demo_key) + + sftp = paramiko.SFTPClient.from_transport(transport) + self.assertTrue('By model' in sftp.listdir('.')) + self.assertTrue('res.company' in sftp.listdir('/By model')) + sftp.close() + # we need to stop our thread before leaving test mode, otherwise: lock + thread = _db2thread[self.env.cr.dbname][0] + _db2thread[self.env.cr.dbname][1].set() + thread.join() + RegistryManager.leave_test_mode() diff --git a/document_sftp/views/res_users.xml b/document_sftp/views/res_users.xml new file mode 100644 index 00000000..aacc1c51 --- /dev/null +++ b/document_sftp/views/res_users.xml @@ -0,0 +1,29 @@ + + + + + res.users + + + + + + + + + + + + + res.users + + + + + + + + + + +