diff --git a/configs/development.ini b/configs/development.ini --- a/configs/development.ini +++ b/configs/development.ini @@ -587,6 +587,27 @@ svn.proxy.location_root = / ## be killed. Setting it to zero means no timeout. Defaults to 10 seconds. #svn.proxy.reload_timeout = 10 +############################################################ +### SSH Support Settings ### +############################################################ + +## Defines if the authorized_keys file should be written on any change of +## user ssh keys +ssh.generate_authorized_keyfile = false + +## Options for ssh, default is `no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding` +# ssh.authorized_keys_ssh_opts = + +## File to generate the authorized keys together with options +ssh.authorized_keys_file_path = /home/USER/.ssh/authorized_keys + +## Command to execute as an SSH wrapper, available from +## https://code.rhodecode.com/rhodecode-ssh +ssh.wrapper_cmd = /home/USER/rhodecode-ssh/sshwrapper.py + +## Allow shell when executing the command +ssh.wrapper_cmd_allow_shell = false + ## Dummy marker to add new entries after. ## Add any custom entries below. Please don't remove. custom.conf = 1 diff --git a/configs/production.ini b/configs/production.ini --- a/configs/production.ini +++ b/configs/production.ini @@ -556,6 +556,27 @@ svn.proxy.location_root = / ## be killed. Setting it to zero means no timeout. Defaults to 10 seconds. #svn.proxy.reload_timeout = 10 +############################################################ +### SSH Support Settings ### +############################################################ + +## Defines if the authorized_keys file should be written on any change of +## user ssh keys +ssh.generate_authorized_keyfile = false + +## Options for ssh, default is `no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding` +# ssh.authorized_keys_ssh_opts = + +## File to generate the authorized keys together with options +ssh.authorized_keys_file_path = /home/USER/.ssh/authorized_keys + +## Command to execute as an SSH wrapper, available from +## https://code.rhodecode.com/rhodecode-ssh +ssh.wrapper_cmd = /home/USER/rhodecode-ssh/sshwrapper.py + +## Allow shell when executing the command +ssh.wrapper_cmd_allow_shell = false + ## Dummy marker to add new entries after. ## Add any custom entries below. Please don't remove. custom.conf = 1 diff --git a/rhodecode/apps/admin/views/users.py b/rhodecode/apps/admin/views/users.py --- a/rhodecode/apps/admin/views/users.py +++ b/rhodecode/apps/admin/views/users.py @@ -28,6 +28,8 @@ from sqlalchemy.sql.functions import coa from sqlalchemy.exc import IntegrityError from rhodecode.apps._base import BaseAppView, DataGridAppView +from rhodecode.apps.ssh_support import SshKeyFileChangeEvent +from rhodecode.events import trigger from rhodecode.lib import audit_logger from rhodecode.lib.ext_json import json @@ -327,6 +329,9 @@ class AdminUsersView(BaseAppView, DataGr user=self._rhodecode_user, ) Session().commit() + # Trigger an event on change of keys. + trigger(SshKeyFileChangeEvent(), self.request.registry) + h.flash(_("Ssh Key successfully created"), category='success') except IntegrityError: @@ -368,6 +373,8 @@ class AdminUsersView(BaseAppView, DataGr 'data': {'ssh_key': ssh_key_data, 'user': user_data}}, user=self._rhodecode_user,) Session().commit() + # Trigger an event on change of keys. + trigger(SshKeyFileChangeEvent(), self.request.registry) h.flash(_("Ssh key successfully deleted"), category='success') return HTTPFound(h.route_path('edit_user_ssh_keys', user_id=user_id)) diff --git a/rhodecode/apps/ssh_support/__init__.py b/rhodecode/apps/ssh_support/__init__.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/__init__.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2016-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import logging + +from . import config_keys +from .events import SshKeyFileChangeEvent +from .subscribers import generate_ssh_authorized_keys_file_subscriber + +from rhodecode.config.middleware import _bool_setting, _string_setting + +log = logging.getLogger(__name__) + + +def _sanitize_settings_and_apply_defaults(settings): + """ + Set defaults, convert to python types and validate settings. + """ + _bool_setting(settings, config_keys.generate_authorized_keyfile, 'false') + _bool_setting(settings, config_keys.wrapper_allow_shell, 'false') + + _string_setting(settings, config_keys.authorized_keys_file_path, '', + lower=False) + _string_setting(settings, config_keys.wrapper_cmd, '', + lower=False) + _string_setting(settings, config_keys.authorized_keys_line_ssh_opts, '', + lower=False) + + +def includeme(config): + settings = config.registry.settings + _sanitize_settings_and_apply_defaults(settings) + + # if we have enable generation of file, subscribe to event + if settings[config_keys.generate_authorized_keyfile]: + config.add_subscriber( + generate_ssh_authorized_keys_file_subscriber, SshKeyFileChangeEvent) diff --git a/rhodecode/apps/ssh_support/config_keys.py b/rhodecode/apps/ssh_support/config_keys.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/config_keys.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2016-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + + +# Definition of setting keys used to configure this module. Defined here to +# avoid repetition of keys throughout the module. +generate_authorized_keyfile = 'ssh.generate_authorized_keyfile' +authorized_keys_file_path = 'ssh.authorized_keys_file_path' +authorized_keys_line_ssh_opts = 'ssh.authorized_keys_ssh_opts' +wrapper_cmd = 'ssh.wrapper_cmd' +wrapper_allow_shell = 'ssh.wrapper_cmd_allow_shell' diff --git a/rhodecode/apps/ssh_support/events.py b/rhodecode/apps/ssh_support/events.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/events.py @@ -0,0 +1,29 @@ +# Copyright (C) 2016-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + + +from rhodecode.events.base import RhodecodeEvent +from rhodecode.translation import _ + + +class SshKeyFileChangeEvent(RhodecodeEvent): + """ + This event will be triggered on every modification of the stored SSH keys + """ + name = 'rhodecode-ssh-key-file-change' + display_name = _('RhodeCode SSH Key files changed.') diff --git a/rhodecode/apps/ssh_support/subscribers.py b/rhodecode/apps/ssh_support/subscribers.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/subscribers.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2016-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import logging + + +from .utils import generate_ssh_authorized_keys_file + + +log = logging.getLogger(__name__) + + +def generate_ssh_authorized_keys_file_subscriber(event): + """ + Subscriber to the `SshKeyFileChangeEvent`. This triggers the + automatic generation of authorized_keys file on any change in + ssh keys management + """ + generate_ssh_authorized_keys_file(event.request.registry) diff --git a/rhodecode/apps/ssh_support/tests/__init__.py b/rhodecode/apps/ssh_support/tests/__init__.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/tests/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2016-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ diff --git a/rhodecode/apps/ssh_support/tests/test_ssh_authorized_keys_gen.py b/rhodecode/apps/ssh_support/tests/test_ssh_authorized_keys_gen.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/tests/test_ssh_authorized_keys_gen.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2016-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + + + +import os +import pytest +import mock + +from rhodecode.apps.ssh_support import utils +from rhodecode.lib.utils2 import AttributeDict + + +class TestSshKeyFileGeneration(object): + @pytest.mark.parametrize('ssh_wrapper_cmd', ['/tmp/sshwrapper.py']) + @pytest.mark.parametrize('allow_shell', [True, False]) + @pytest.mark.parametrize('ssh_opts', [None, 'mycustom,option']) + def test_write_keyfile(self, tmpdir, ssh_wrapper_cmd, allow_shell, ssh_opts): + + authorized_keys_file_path = os.path.join(str(tmpdir), 'authorized_keys') + + def keys(): + return [ + AttributeDict({'user': AttributeDict(username='admin'), + 'ssh_key_data': 'ssh-rsa ADMIN_KEY'}), + AttributeDict({'user': AttributeDict(username='user'), + 'ssh_key_data': 'ssh-rsa USER_KEY'}), + ] + + with mock.patch('rhodecode.apps.ssh_support.utils.get_all_active_keys', + return_value=keys()): + utils._generate_ssh_authorized_keys_file( + authorized_keys_file_path, ssh_wrapper_cmd, + allow_shell, ssh_opts + ) + + assert os.path.isfile(authorized_keys_file_path) + with open(authorized_keys_file_path) as f: + content = f.read() + + assert 'command="/tmp/sshwrapper.py' in content + assert 'This file is managed by RhodeCode, ' \ + 'please do not edit it manually.' in content + + if allow_shell: + assert '--shell --user' in content + else: + assert '--user' in content + + if ssh_opts: + assert ssh_opts in content diff --git a/rhodecode/apps/ssh_support/utils.py b/rhodecode/apps/ssh_support/utils.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/utils.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2016-2017 RhodeCode GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3 +# (only), as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is dual-licensed. If you wish to learn more about the +# RhodeCode Enterprise Edition, including its added features, Support services, +# and proprietary license terms, please see https://rhodecode.com/licenses/ + +import os +import stat +import logging +import tempfile +import datetime + +from . import config_keys +from rhodecode.model.db import true, joinedload, User, UserSshKeys + + +log = logging.getLogger(__name__) + +HEADER = \ + "# This file is managed by RhodeCode, please do not edit it manually. # \n" \ + "# Current entries: {}, create date: UTC:{}.\n" + +# Default SSH options for authorized_keys file, can be override via .ini +SSH_OPTS = 'no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding' + + +def get_all_active_keys(): + result = UserSshKeys.query() \ + .options(joinedload(UserSshKeys.user)) \ + .filter(UserSshKeys.user != User.get_default_user()) \ + .filter(User.active == true()) \ + .all() + return result + + +def _generate_ssh_authorized_keys_file( + authorized_keys_file_path, ssh_wrapper_cmd, allow_shell, ssh_opts): + all_active_keys = get_all_active_keys() + + if allow_shell: + ssh_wrapper_cmd = ssh_wrapper_cmd + ' --shell' + + if not os.path.isfile(authorized_keys_file_path): + with open(authorized_keys_file_path, 'w'): + pass + + if not os.access(authorized_keys_file_path, os.R_OK): + raise OSError('Access to file {} is without read access'.format( + authorized_keys_file_path)) + + line_tmpl = '{ssh_opts},command="{wrapper_command} --user {user}" {key}\n' + + fd, tmp_authorized_keys = tempfile.mkstemp( + '.authorized_keys_write', + dir=os.path.dirname(authorized_keys_file_path)) + + now = datetime.datetime.utcnow().isoformat() + keys_file = os.fdopen(fd, 'wb') + keys_file.write(HEADER.format(len(all_active_keys), now)) + + for user_key in all_active_keys: + username = user_key.user.username + keys_file.write( + line_tmpl.format( + ssh_opts=ssh_opts or SSH_OPTS, + wrapper_command=ssh_wrapper_cmd, + user=username, key=user_key.ssh_key_data)) + log.debug('addkey: Key added for user: `%s`', username) + keys_file.close() + + # Explicitly setting read-only permissions to authorized_keys + os.chmod(tmp_authorized_keys, stat.S_IRUSR | stat.S_IWUSR) + # Rename is atomic operation + os.rename(tmp_authorized_keys, authorized_keys_file_path) + + +def generate_ssh_authorized_keys_file(registry): + log.info('Generating new authorized key file') + + authorized_keys_file_path = registry.settings.get( + config_keys.authorized_keys_file_path) + + ssh_wrapper_cmd = registry.settings.get( + config_keys.wrapper_cmd) + allow_shell = registry.settings.get( + config_keys.wrapper_allow_shell) + ssh_opts = registry.settings.get( + config_keys.authorized_keys_line_ssh_opts) + + _generate_ssh_authorized_keys_file( + authorized_keys_file_path, ssh_wrapper_cmd, allow_shell, ssh_opts) + + return 0 diff --git a/rhodecode/config/middleware.py b/rhodecode/config/middleware.py --- a/rhodecode/config/middleware.py +++ b/rhodecode/config/middleware.py @@ -300,6 +300,7 @@ def includeme(config): config.include('rhodecode.apps.user_profile') config.include('rhodecode.apps.my_account') config.include('rhodecode.apps.svn_support') + config.include('rhodecode.apps.ssh_support') config.include('rhodecode.apps.gist') config.include('rhodecode.apps.debug_style')