# HG changeset patch # User Marcin Kuzminski # Date 2017-08-18 12:32:00 # Node ID 338dc54d523fbaa937b77aae02046d4b1c6712c8 # Parent e367c94ae66f9918c42d695454c7e6f427b28318 ssh: embedded ssh support - updated command generation and added debug flag - updated .ini config to have ALL required components to run SSH commands - rcssh-wrapper now is embedded and pinned into version of enterprise - update ssh_support configration - implements #5343 diff --git a/configs/development.ini b/configs/development.ini --- a/configs/development.ini +++ b/configs/development.ini @@ -603,14 +603,36 @@ ssh.generate_authorized_keyfile = false # ssh.authorized_keys_ssh_opts = ## File to generate the authorized keys together with options -ssh.authorized_keys_file_path = /home/USER/.ssh/authorized_keys +## It is possible to have multiple key files specified in `sshd_config` e.g. +## AuthorizedKeysFile %h/.ssh/authorized_keys %h/.ssh/authorized_keys_rhodecode +ssh.authorized_keys_file_path = ~/.ssh/authorized_keys_rhodecode + +## Command to execute the SSH wrapper. The binary is available in the +## rhodecode installation directory. +## e.g ~/.rccontrol/community-1/profile/bin/rcssh-wrapper +ssh.wrapper_cmd = ~/.rccontrol/community-1/rcssh-wrapper + +## Allow shell when executing the ssh-wrapper command +ssh.wrapper_cmd_allow_shell = false -## Command to execute as an SSH wrapper, available from -## https://code.rhodecode.com/rhodecode-ssh -ssh.wrapper_cmd = /home/USER/rhodecode-ssh/sshwrapper.py +## Enables logging, and detailed output send back to the client. Usefull for +## debugging, shouldn't be used in production. +ssh.enable_debug_logging = false + +## API KEY for user who has access to fetch other user permission information +## most likely an super-admin account with some IP restrictions. +ssh.api_key = -## Allow shell when executing the command -ssh.wrapper_cmd_allow_shell = false +## API Host, the server address of RhodeCode instance that the api_key will +## access +ssh.api_host = http://localhost + +## Paths to binary executrables, by default they are the names, but we can +## override them if we want to use a custom one +ssh.executable.hg = ~/.rccontrol/vcsserver-1/profile/bin/hg +ssh.executable.git = ~/.rccontrol/vcsserver-1/profile/bin/git +ssh.executable.svn = ~/.rccontrol/vcsserver-1/profile/bin/svnserve + ## Dummy marker to add new entries after. ## Add any custom entries below. Please don't remove. @@ -621,7 +643,7 @@ custom.conf = 1 ### LOGGING CONFIGURATION #### ################################ [loggers] -keys = root, routes, rhodecode, sqlalchemy, beaker, templates +keys = root, routes, rhodecode, sqlalchemy, beaker, templates, ssh_wrapper [handlers] keys = console, console_sql @@ -667,6 +689,13 @@ handlers = console_sql qualname = sqlalchemy.engine propagate = 0 +[logger_ssh_wrapper] +level = DEBUG +handlers = +qualname = ssh_wrapper +propagate = 1 + + ############## ## HANDLERS ## ############## diff --git a/configs/production.ini b/configs/production.ini --- a/configs/production.ini +++ b/configs/production.ini @@ -572,14 +572,36 @@ ssh.generate_authorized_keyfile = false # ssh.authorized_keys_ssh_opts = ## File to generate the authorized keys together with options -ssh.authorized_keys_file_path = /home/USER/.ssh/authorized_keys +## It is possible to have multiple key files specified in `sshd_config` e.g. +## AuthorizedKeysFile %h/.ssh/authorized_keys %h/.ssh/authorized_keys_rhodecode +ssh.authorized_keys_file_path = ~/.ssh/authorized_keys_rhodecode + +## Command to execute the SSH wrapper. The binary is available in the +## rhodecode installation directory. +## e.g ~/.rccontrol/community-1/profile/bin/rcssh-wrapper +ssh.wrapper_cmd = ~/.rccontrol/community-1/rcssh-wrapper + +## Allow shell when executing the ssh-wrapper command +ssh.wrapper_cmd_allow_shell = false -## Command to execute as an SSH wrapper, available from -## https://code.rhodecode.com/rhodecode-ssh -ssh.wrapper_cmd = /home/USER/rhodecode-ssh/sshwrapper.py +## Enables logging, and detailed output send back to the client. Usefull for +## debugging, shouldn't be used in production. +ssh.enable_debug_logging = false + +## API KEY for user who has access to fetch other user permission information +## most likely an super-admin account with some IP restrictions. +ssh.api_key = -## Allow shell when executing the command -ssh.wrapper_cmd_allow_shell = false +## API Host, the server address of RhodeCode instance that the api_key will +## access +ssh.api_host = http://localhost + +## Paths to binary executrables, by default they are the names, but we can +## override them if we want to use a custom one +ssh.executable.hg = ~/.rccontrol/vcsserver-1/profile/bin/hg +ssh.executable.git = ~/.rccontrol/vcsserver-1/profile/bin/git +ssh.executable.svn = ~/.rccontrol/vcsserver-1/profile/bin/svnserve + ## Dummy marker to add new entries after. ## Add any custom entries below. Please don't remove. @@ -590,7 +612,7 @@ custom.conf = 1 ### LOGGING CONFIGURATION #### ################################ [loggers] -keys = root, routes, rhodecode, sqlalchemy, beaker, templates +keys = root, routes, rhodecode, sqlalchemy, beaker, templates, ssh_wrapper [handlers] keys = console, console_sql @@ -636,6 +658,13 @@ handlers = console_sql qualname = sqlalchemy.engine propagate = 0 +[logger_ssh_wrapper] +level = DEBUG +handlers = +qualname = ssh_wrapper +propagate = 1 + + ############## ## HANDLERS ## ############## diff --git a/rhodecode/api/views/server_api.py b/rhodecode/api/views/server_api.py --- a/rhodecode/api/views/server_api.py +++ b/rhodecode/api/views/server_api.py @@ -33,6 +33,7 @@ from rhodecode.lib import user_sessions from rhodecode.lib.utils2 import safe_int from rhodecode.model.db import UserIpMap from rhodecode.model.scm import ScmModel +from rhodecode.model.settings import VcsSettingsModel log = logging.getLogger(__name__) @@ -75,6 +76,35 @@ def get_server_info(request, apiuser): @jsonrpc_method() +def get_repo_store(request, apiuser): + """ + Returns the |RCE| repository storage information. + + :param apiuser: This is filled automatically from the |authtoken|. + :type apiuser: AuthUser + + Example output: + + .. code-block:: bash + + id : + result : { + 'modules': [,...] + 'py_version': , + 'platform': , + 'rhodecode_version': + } + error : null + """ + + if not has_superadmin_permission(apiuser): + raise JSONRPCForbidden() + + path = VcsSettingsModel().get_repos_location() + return {"path": path} + + +@jsonrpc_method() def get_ip(request, apiuser, userid=Optional(OAttr('apiuser'))): """ Displays the IP Address as seen from the |RCE| server. diff --git a/rhodecode/apps/ssh_support/__init__.py b/rhodecode/apps/ssh_support/__init__.py --- a/rhodecode/apps/ssh_support/__init__.py +++ b/rhodecode/apps/ssh_support/__init__.py @@ -35,14 +35,30 @@ def _sanitize_settings_and_apply_default """ _bool_setting(settings, config_keys.generate_authorized_keyfile, 'false') _bool_setting(settings, config_keys.wrapper_allow_shell, 'false') + _bool_setting(settings, config_keys.enable_debug_logging, 'false') - _string_setting(settings, config_keys.authorized_keys_file_path, '', + _string_setting(settings, config_keys.authorized_keys_file_path, + '~/.ssh/authorized_keys_rhodecode', lower=False) _string_setting(settings, config_keys.wrapper_cmd, '', lower=False) _string_setting(settings, config_keys.authorized_keys_line_ssh_opts, '', lower=False) + _string_setting(settings, config_keys.ssh_api_key, '', + lower=False) + _string_setting(settings, config_keys.ssh_api_host, '', + lower=False) + _string_setting(settings, config_keys.ssh_hg_bin, + '~/.rccontrol/vcsserver-1/profile/bin/hg', + lower=False) + _string_setting(settings, config_keys.ssh_git_bin, + '~/.rccontrol/vcsserver-1/profile/bin/git', + lower=False) + _string_setting(settings, config_keys.ssh_svn_bin, + '~/.rccontrol/vcsserver-1/profile/bin/svnserve', + lower=False) + def includeme(config): settings = config.registry.settings diff --git a/rhodecode/apps/ssh_support/config_keys.py b/rhodecode/apps/ssh_support/config_keys.py --- a/rhodecode/apps/ssh_support/config_keys.py +++ b/rhodecode/apps/ssh_support/config_keys.py @@ -26,3 +26,11 @@ authorized_keys_file_path = 'ssh.authori authorized_keys_line_ssh_opts = 'ssh.authorized_keys_ssh_opts' wrapper_cmd = 'ssh.wrapper_cmd' wrapper_allow_shell = 'ssh.wrapper_cmd_allow_shell' +enable_debug_logging = 'ssh.enable_debug_logging' + +ssh_api_key = 'ssh.api_key' +ssh_api_host = 'ssh.api_host' + +ssh_hg_bin = 'ssh.executable.hg' +ssh_git_bin = 'ssh.executable.git' +ssh_svn_bin = 'ssh.executable.svn' diff --git a/rhodecode/apps/ssh_support/lib/__init__.py b/rhodecode/apps/ssh_support/lib/__init__.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/lib/__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/lib/ssh_wrapper.py b/rhodecode/apps/ssh_support/lib/ssh_wrapper.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/lib/ssh_wrapper.py @@ -0,0 +1,607 @@ +# -*- 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 re +import sys +import json +import logging +import random +import signal +import tempfile +from subprocess import Popen, PIPE, check_output, CalledProcessError +import ConfigParser +import urllib2 +import urlparse + +import click +import pyramid.paster + + +log = logging.getLogger(__name__) + + +def setup_logging(ini_path, debug): + if debug: + # enabled rhodecode.ini controlled logging setup + pyramid.paster.setup_logging(ini_path) + else: + # configure logging in a mode that doesn't print anything. + # in case of regularly configured logging it gets printed out back + # to the client doing an SSH command. + logger = logging.getLogger('') + null = logging.NullHandler() + # add the handler to the root logger + logger.handlers = [null] + + +class SubversionTunnelWrapper(object): + process = None + + def __init__(self, timeout, repositories_root=None, svn_path=None): + self.timeout = timeout + self.stdin = sys.stdin + self.repositories_root = repositories_root + self.svn_path = svn_path or 'svnserve' + self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp() + self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp() + self.read_only = False + self.create_svn_config() + + def create_svn_config(self): + content = ( + '[general]\n' + 'hooks-env = {}\n').format(self.hooks_env_path) + with os.fdopen(self.svn_conf_fd, 'w') as config_file: + config_file.write(content) + + def create_hooks_env(self): + content = ( + '[default]\n' + 'LANG = en_US.UTF-8\n') + if self.read_only: + content += 'SSH_READ_ONLY = 1\n' + with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file: + hooks_env_file.write(content) + + def remove_configs(self): + os.remove(self.svn_conf_path) + os.remove(self.hooks_env_path) + + def start(self): + config = ['--config-file', self.svn_conf_path] + command = [self.svn_path, '-t'] + config + if self.repositories_root: + command.extend(['-r', self.repositories_root]) + self.process = Popen(command, stdin=PIPE) + + def sync(self): + while self.process.poll() is None: + next_byte = self.stdin.read(1) + if not next_byte: + break + self.process.stdin.write(next_byte) + self.remove_configs() + + @property + def return_code(self): + return self.process.returncode + + def get_first_client_response(self): + signal.signal(signal.SIGALRM, self.interrupt) + signal.alarm(self.timeout) + first_response = self._read_first_client_response() + signal.alarm(0) + return ( + self._parse_first_client_response(first_response) + if first_response else None) + + def patch_first_client_response(self, response, **kwargs): + self.create_hooks_env() + data = response.copy() + data.update(kwargs) + data['url'] = self._svn_string(data['url']) + data['ra_client'] = self._svn_string(data['ra_client']) + data['client'] = data['client'] or '' + buffer_ = ( + "( {version} ( {capabilities} ) {url}{ra_client}" + "( {client}) ) ".format(**data)) + self.process.stdin.write(buffer_) + + def fail(self, message): + print( + "( failure ( ( 210005 {message} 0: 0 ) ) )".format( + message=self._svn_string(message))) + self.remove_configs() + self.process.kill() + + def interrupt(self, signum, frame): + self.fail("Exited by timeout") + + def _svn_string(self, str_): + if not str_: + return '' + return '{length}:{string} '.format(length=len(str_), string=str_) + + def _read_first_client_response(self): + buffer_ = "" + brackets_stack = [] + while True: + next_byte = self.stdin.read(1) + buffer_ += next_byte + if next_byte == "(": + brackets_stack.append(next_byte) + elif next_byte == ")": + brackets_stack.pop() + elif next_byte == " " and not brackets_stack: + break + return buffer_ + + def _parse_first_client_response(self, buffer_): + """ + According to the Subversion RA protocol, the first request + should look like: + + ( version:number ( cap:word ... ) url:string ? ra-client:string + ( ? client:string ) ) + + Please check https://svn.apache.org/repos/asf/subversion/trunk/ + subversion/libsvn_ra_svn/protocol + """ + version_re = r'(?P\d+)' + capabilities_re = r'\(\s(?P[\w\d\-\ ]+)\s\)' + url_re = r'\d+\:(?P[\W\w]+)' + ra_client_re = r'(\d+\:(?P[\W\w]+)\s)' + client_re = r'(\d+\:(?P[\W\w]+)\s)*' + regex = re.compile( + r'^\(\s{version}\s{capabilities}\s{url}\s{ra_client}' + r'\(\s{client}\)\s\)\s*$'.format( + version=version_re, capabilities=capabilities_re, + url=url_re, ra_client=ra_client_re, client=client_re)) + matcher = regex.match(buffer_) + return matcher.groupdict() if matcher else None + + +class RhodeCodeApiClient(object): + def __init__(self, api_key, api_host): + self.api_key = api_key + self.api_host = api_host + + if not api_host: + raise ValueError('api_key:{} not defined'.format(api_key)) + if not api_host: + raise ValueError('api_host:{} not defined '.format(api_host)) + + def request(self, method, args): + id_ = random.randrange(1, 9999) + args = { + 'id': id_, + 'api_key': self.api_key, + 'method': method, + 'args': args + } + host = '{host}/_admin/api'.format(host=self.api_host) + + log.debug('Doing API call to %s method:%s', host, method) + req = urllib2.Request( + host, + data=json.dumps(args), + headers={'content-type': 'text/plain'}) + ret = urllib2.urlopen(req) + raw_json = ret.read() + json_data = json.loads(raw_json) + id_ret = json_data['id'] + + if id_ret != id_: + raise Exception('something went wrong. ' + 'ID mismatch got %s, expected %s | %s' + % (id_ret, id_, raw_json)) + + result = json_data['result'] + error = json_data['error'] + return result, error + + def get_user_permissions(self, user, user_id): + result, error = self.request('get_user', {'userid': int(user_id)}) + if result is None and error: + raise Exception( + 'User "%s" not found or another error happened: %s!' % ( + user, error)) + log.debug( + 'Given User: `%s` Fetched User: `%s`', user, result.get('username')) + return result.get('permissions').get('repositories') + + def invalidate_cache(self, repo_name): + log.debug('Invalidate cache for repo:%s', repo_name) + return self.request('invalidate_cache', {'repoid': repo_name}) + + def get_repo_store(self): + result, error = self.request('get_repo_store', {}) + return result + + +class VcsServer(object): + + def __init__(self, user, user_permissions, config): + self.user = user + self.user_permissions = user_permissions + self.config = config + self.repo_name = None + self.repo_mode = None + self.store = {} + self.ini_path = '' + + def run(self): + raise NotImplementedError() + + def get_root_store(self): + root_store = self.store['path'] + if not root_store.endswith('/'): + # always append trailing slash + root_store = root_store + '/' + return root_store + + +class MercurialServer(VcsServer): + read_only = False + + def __init__(self, store, ini_path, repo_name, + user, user_permissions, config): + super(MercurialServer, self).__init__(user, user_permissions, config) + self.store = store + self.repo_name = repo_name + self.ini_path = ini_path + self.hg_path = config.get('app:main', 'ssh.executable.hg') + + def run(self): + if not self._check_permissions(): + return 2, False + + tip_before = self.tip() + exit_code = os.system(self.command) + tip_after = self.tip() + return exit_code, tip_before != tip_after + + def tip(self): + root = self.get_root_store() + command = ( + 'cd {root}; {hg_path} -R {root}{repo_name} tip --template "{{node}}\n"' + ''.format( + root=root, hg_path=self.hg_path, repo_name=self.repo_name)) + try: + tip = check_output(command, shell=True).strip() + except CalledProcessError: + tip = None + return tip + + @property + def command(self): + root = self.get_root_store() + arguments = ( + '--config hooks.pretxnchangegroup=\"false\"' + if self.read_only else '') + + command = ( + "cd {root}; {hg_path} -R {root}{repo_name} serve --stdio" + " {arguments}".format( + root=root, hg_path=self.hg_path, repo_name=self.repo_name, + arguments=arguments)) + log.debug("Final CMD: %s", command) + return command + + def _check_permissions(self): + permission = self.user_permissions.get(self.repo_name) + if permission is None or permission == 'repository.none': + log.error('repo not found or no permissions') + return False + + elif permission in ['repository.admin', 'repository.write']: + log.info( + 'Write Permissions for User "%s" granted to repo "%s"!' % ( + self.user, self.repo_name)) + else: + self.read_only = True + log.info( + 'Only Read Only access for User "%s" granted to repo "%s"!', + self.user, self.repo_name) + return True + + +class GitServer(VcsServer): + def __init__(self, store, ini_path, repo_name, repo_mode, + user, user_permissions, config): + super(GitServer, self).__init__(user, user_permissions, config) + self.store = store + self.ini_path = ini_path + self.repo_name = repo_name + self.repo_mode = repo_mode + self.git_path = config.get('app:main', 'ssh.executable.git') + + def run(self): + exit_code = self._check_permissions() + if exit_code: + return exit_code, False + + self._update_environment() + exit_code = os.system(self.command) + return exit_code, self.repo_mode == "receive-pack" + + @property + def command(self): + root = self.get_root_store() + command = "cd {root}; {git_path}-{mode} '{root}{repo_name}'".format( + root=root, git_path=self.git_path, mode=self.repo_mode, + repo_name=self.repo_name) + log.debug("Final CMD: %s", command) + return command + + def _update_environment(self): + action = "push" if self.repo_mode == "receive-pack" else "pull", + scm_data = { + "ip": os.environ["SSH_CLIENT"].split()[0], + "username": self.user, + "action": action, + "repository": self.repo_name, + "scm": "git", + "config": self.ini_path, + "make_lock": None, + "locked_by": [None, None] + } + os.putenv("RC_SCM_DATA", json.dumps(scm_data)) + + def _check_permissions(self): + permission = self.user_permissions.get(self.repo_name) + log.debug( + 'permission for %s on %s are: %s', + self.user, self.repo_name, permission) + + if permission is None or permission == 'repository.none': + log.error('repo not found or no permissions') + return 2 + elif permission in ['repository.admin', 'repository.write']: + log.info( + 'Write Permissions for User "%s" granted to repo "%s"!', + self.user, self.repo_name) + elif (permission == 'repository.read' and + self.repo_mode == 'upload-pack'): + log.info( + 'Only Read Only access for User "%s" granted to repo "%s"!', + self.user, self.repo_name) + elif (permission == 'repository.read' + and self.repo_mode == 'receive-pack'): + log.error( + 'Only Read Only access for User "%s" granted to repo "%s"!' + ' Failing!', self.user, self.repo_name) + return -3 + else: + log.error('Cannot properly fetch user permission. ' + 'Return value is: %s', permission) + return -2 + + +class SubversionServer(VcsServer): + + def __init__(self, store, ini_path, + user, user_permissions, config): + super(SubversionServer, self).__init__(user, user_permissions, config) + self.store = store + self.ini_path = ini_path + # this is set in .run() from input stream + self.repo_name = None + self.svn_path = config.get('app:main', 'ssh.executable.svn') + + def run(self): + root = self.get_root_store() + log.debug("Using subversion binaries from '%s'", self.svn_path) + + self.tunnel = SubversionTunnelWrapper( + timeout=self.timeout, repositories_root=root, svn_path=self.svn_path) + self.tunnel.start() + first_response = self.tunnel.get_first_client_response() + if not first_response: + self.tunnel.fail("Repository name cannot be extracted") + return 1, False + + url_parts = urlparse.urlparse(first_response['url']) + self.repo_name = url_parts.path.strip('/') + if not self._check_permissions(): + self.tunnel.fail("Not enough permissions") + return 1, False + + self.tunnel.patch_first_client_response(first_response) + self.tunnel.sync() + return self.tunnel.return_code, False + + @property + def timeout(self): + timeout = 30 + return timeout + + def _check_permissions(self): + permission = self.user_permissions.get(self.repo_name) + + if permission in ['repository.admin', 'repository.write']: + self.tunnel.read_only = False + return True + + elif permission == 'repository.read': + self.tunnel.read_only = True + return True + + else: + self.tunnel.fail("Not enough permissions for repository {}".format( + self.repo_name)) + return False + + +class SshWrapper(object): + + def __init__(self, command, mode, user, user_id, shell, ini_path): + self.command = command + self.mode = mode + self.user = user + self.user_id = user_id + self.shell = shell + self.ini_path = ini_path + + self.config = self.parse_config(ini_path) + api_key = self.config.get('app:main', 'ssh.api_key') + api_host = self.config.get('app:main', 'ssh.api_host') + self.api = RhodeCodeApiClient(api_key, api_host) + + def parse_config(self, config): + parser = ConfigParser.ConfigParser() + parser.read(config) + return parser + + def get_repo_details(self, mode): + type_ = mode if mode in ['svn', 'hg', 'git'] else None + mode = mode + name = None + + hg_pattern = r'^hg\s+\-R\s+(\S+)\s+serve\s+\-\-stdio$' + hg_match = re.match(hg_pattern, self.command) + if hg_match is not None: + type_ = 'hg' + name = hg_match.group(1).strip('/') + return type_, name, mode + + git_pattern = ( + r'^git-(receive-pack|upload-pack)\s\'[/]?(\S+?)(|\.git)\'$') + git_match = re.match(git_pattern, self.command) + if git_match is not None: + type_ = 'git' + name = git_match.group(2).strip('/') + mode = git_match.group(1) + return type_, name, mode + + svn_pattern = r'^svnserve -t' + svn_match = re.match(svn_pattern, self.command) + if svn_match is not None: + type_ = 'svn' + # Repo name should be extracted from the input stream + return type_, name, mode + + return type_, name, mode + + def serve(self, vcs, repo, mode, user, permissions): + store = self.api.get_repo_store() + + log.debug( + 'VCS detected:`%s` mode: `%s` repo: %s', vcs, mode, repo) + + if vcs == 'hg': + server = MercurialServer( + store=store, ini_path=self.ini_path, + repo_name=repo, user=user, + user_permissions=permissions, config=self.config) + return server.run() + + elif vcs == 'git': + server = GitServer( + store=store, ini_path=self.ini_path, + repo_name=repo, repo_mode=mode, user=user, + user_permissions=permissions, config=self.config) + return server.run() + + elif vcs == 'svn': + server = SubversionServer( + store=store, ini_path=self.ini_path, + user=user, + user_permissions=permissions, config=self.config) + return server.run() + + else: + raise Exception('Unrecognised VCS: {}'.format(vcs)) + + def wrap(self): + mode = self.mode + user = self.user + user_id = self.user_id + shell = self.shell + + scm_detected, scm_repo, scm_mode = self.get_repo_details(mode) + log.debug( + 'Mode: `%s` User: `%s:%s` Shell: `%s` SSH Command: `\"%s\"` ' + 'SCM_DETECTED: `%s` SCM Mode: `%s` SCM Repo: `%s`', + mode, user, user_id, shell, self.command, + scm_detected, scm_mode, scm_repo) + + try: + permissions = self.api.get_user_permissions(user, user_id) + except Exception as e: + log.exception('Failed to fetch user permissions') + return 1 + + if shell and self.command is None: + log.info( + 'Dropping to shell, no command given and shell is allowed') + os.execl('/bin/bash', '-l') + exit_code = 1 + + elif scm_detected: + try: + exit_code, is_updated = self.serve( + scm_detected, scm_repo, scm_mode, user, permissions) + if exit_code == 0 and is_updated: + self.api.invalidate_cache(scm_repo) + except Exception: + log.exception('Error occurred during execution of SshWrapper') + exit_code = -1 + + elif self.command is None and shell is False: + log.error('No Command given.') + exit_code = -1 + + else: + log.error( + 'Unhandled Command: "%s" Aborting.', self.command) + exit_code = -1 + + return exit_code + + +@click.command() +@click.argument('ini_path', type=click.Path(exists=True)) +@click.option( + '--mode', '-m', required=False, default='auto', + type=click.Choice(['auto', 'vcs', 'git', 'hg', 'svn', 'test']), + help='mode of operation') +@click.option('--user', help='Username for which the command will be executed') +@click.option('--user-id', help='User ID for which the command will be executed') +@click.option('--shell', '-s', is_flag=True, help='Allow Shell') +@click.option('--debug', is_flag=True, help='Enabled detailed output logging') +def main(ini_path, mode, user, user_id, shell, debug): + setup_logging(ini_path, debug) + + command = os.environ.get('SSH_ORIGINAL_COMMAND', '') + if not command and mode not in ['test']: + raise ValueError( + 'Unable to fetch SSH_ORIGINAL_COMMAND from environment.' + 'Please make sure this is set and available during execution ' + 'of this script.') + + try: + ssh_wrapper = SshWrapper(command, mode, user, user_id, shell, ini_path) + except Exception: + log.exception('Failed to execute SshWrapper') + sys.exit(-5) + + sys.exit(ssh_wrapper.wrap()) \ No newline at end of file diff --git a/rhodecode/apps/ssh_support/tests/test_server_git.py b/rhodecode/apps/ssh_support/tests/test_server_git.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/tests/test_server_git.py @@ -0,0 +1,195 @@ +# -*- 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 json + +import pytest +from mock import Mock, patch, call + +from rhodecode.apps.ssh_support.lib.ssh_wrapper import GitServer + + +@pytest.fixture +def git_server(): + return GitServerCreator() + + +class GitServerCreator(object): + root = '/tmp/repo/path/' + git_path = '/usr/local/bin/' + config_data = { + 'app:main': { + 'ssh.executable.git': git_path + } + } + repo_name = 'test_git' + repo_mode = 'receive-pack' + user = 'vcs' + + def __init__(self): + def config_get(part, key): + return self.config_data.get(part, {}).get(key) + self.config_mock = Mock() + self.config_mock.get = Mock(side_effect=config_get) + + def create(self, **kwargs): + parameters = { + 'store': {'path': self.root}, + 'ini_path': '', + 'user': self.user, + 'repo_name': self.repo_name, + 'repo_mode': self.repo_mode, + 'user_permissions': { + self.repo_name: 'repo_admin' + }, + 'config': self.config_mock, + } + parameters.update(kwargs) + server = GitServer(**parameters) + return server + + +class TestGitServer(object): + def test_command(self, git_server): + server = git_server.create() + server.read_only = False + expected_command = ( + 'cd {root}; {git_path}-{repo_mode}' + ' \'{root}{repo_name}\''.format( + root=git_server.root, git_path=git_server.git_path, + repo_mode=git_server.repo_mode, repo_name=git_server.repo_name) + ) + assert expected_command == server.command + + def test_run_returns_exit_code_2_when_no_permissions(self, git_server, caplog): + server = git_server.create() + with patch.object(server, '_check_permissions') as permissions_mock: + with patch.object(server, '_update_environment'): + permissions_mock.return_value = 2 + exit_code = server.run() + + assert exit_code == (2, False) + + def test_run_returns_executes_command(self, git_server, caplog): + server = git_server.create() + with patch.object(server, '_check_permissions') as permissions_mock: + with patch('os.system') as system_mock: + with patch.object(server, '_update_environment') as ( + update_mock): + permissions_mock.return_value = 0 + system_mock.return_value = 0 + exit_code = server.run() + + system_mock.assert_called_once_with(server.command) + update_mock.assert_called_once_with() + + assert exit_code == (0, True) + + @pytest.mark.parametrize( + 'repo_mode, action', [ + ['receive-pack', 'push'], + ['upload-pack', 'pull'] + ]) + def test_update_environment(self, git_server, repo_mode, action): + server = git_server.create(repo_mode=repo_mode) + with patch('os.environ', {'SSH_CLIENT': '10.10.10.10 b'}): + with patch('os.putenv') as putenv_mock: + server._update_environment() + expected_data = { + "username": git_server.user, + "scm": "git", + "repository": git_server.repo_name, + "make_lock": None, + "action": [action], + "ip": "10.10.10.10", + "locked_by": [None, None], + "config": "" + } + putenv_mock.assert_called_once_with( + 'RC_SCM_DATA', json.dumps(expected_data)) + + +class TestGitServerCheckPermissions(object): + def test_returns_2_when_no_permissions_found(self, git_server, caplog): + user_permissions = {} + server = git_server.create(user_permissions=user_permissions) + result = server._check_permissions() + assert result == 2 + + log_msg = 'permission for vcs on test_git are: None' + assert log_msg in [t[2] for t in caplog.record_tuples] + + def test_returns_2_when_no_permissions(self, git_server, caplog): + user_permissions = {git_server.repo_name: 'repository.none'} + server = git_server.create(user_permissions=user_permissions) + result = server._check_permissions() + assert result == 2 + + log_msg = 'repo not found or no permissions' + assert log_msg in [t[2] for t in caplog.record_tuples] + + @pytest.mark.parametrize( + 'permission', ['repository.admin', 'repository.write']) + def test_access_allowed_when_user_has_write_permissions( + self, git_server, permission, caplog): + user_permissions = {git_server.repo_name: permission} + server = git_server.create(user_permissions=user_permissions) + result = server._check_permissions() + assert result is None + + log_msg = 'Write Permissions for User "%s" granted to repo "%s"!' % ( + git_server.user, git_server.repo_name) + assert log_msg in [t[2] for t in caplog.record_tuples] + + def test_write_access_is_not_allowed_when_user_has_read_permission( + self, git_server, caplog): + user_permissions = {git_server.repo_name: 'repository.read'} + server = git_server.create( + user_permissions=user_permissions, repo_mode='receive-pack') + result = server._check_permissions() + assert result == -3 + + log_msg = 'Only Read Only access for User "%s" granted to repo "%s"! Failing!' % ( + git_server.user, git_server.repo_name) + assert log_msg in [t[2] for t in caplog.record_tuples] + + def test_read_access_allowed_when_user_has_read_permission( + self, git_server, caplog): + user_permissions = {git_server.repo_name: 'repository.read'} + server = git_server.create( + user_permissions=user_permissions, repo_mode='upload-pack') + result = server._check_permissions() + assert result is None + + log_msg = 'Only Read Only access for User "%s" granted to repo "%s"!' % ( + git_server.user, git_server.repo_name) + assert log_msg in [t[2] for t in caplog.record_tuples] + + def test_returns_error_when_permission_not_recognised( + self, git_server, caplog): + user_permissions = {git_server.repo_name: 'repository.whatever'} + server = git_server.create( + user_permissions=user_permissions, repo_mode='upload-pack') + result = server._check_permissions() + assert result == -2 + + log_msg = 'Cannot properly fetch user permission. ' \ + 'Return value is: repository.whatever' + assert log_msg in [t[2] for t in caplog.record_tuples] \ No newline at end of file diff --git a/rhodecode/apps/ssh_support/tests/test_server_hg.py b/rhodecode/apps/ssh_support/tests/test_server_hg.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/tests/test_server_hg.py @@ -0,0 +1,146 @@ +# -*- 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 pytest +from mock import Mock, patch, call + +from rhodecode.apps.ssh_support.lib.ssh_wrapper import MercurialServer + + +@pytest.fixture +def hg_server(): + return MercurialServerCreator() + + +class MercurialServerCreator(object): + root = '/tmp/repo/path/' + hg_path = '/usr/local/bin/hg' + + config_data = { + 'app:main': { + 'ssh.executable.hg': hg_path + } + } + repo_name = 'test_hg' + user = 'vcs' + + def __init__(self): + def config_get(part, key): + return self.config_data.get(part, {}).get(key) + self.config_mock = Mock() + self.config_mock.get = Mock(side_effect=config_get) + + def create(self, **kwargs): + parameters = { + 'store': {'path': self.root}, + 'ini_path': '', + 'user': self.user, + 'repo_name': self.repo_name, + 'user_permissions': { + 'test_hg': 'repo_admin' + }, + 'config': self.config_mock, + } + parameters.update(kwargs) + server = MercurialServer(**parameters) + return server + + +class TestMercurialServer(object): + def test_read_only_command(self, hg_server): + server = hg_server.create() + server.read_only = True + expected_command = ( + 'cd {root}; {hg_path} -R {root}{repo_name} serve --stdio' + ' --config hooks.pretxnchangegroup="false"'.format( + root=hg_server.root, hg_path=hg_server.hg_path, + repo_name=hg_server.repo_name) + ) + assert expected_command == server.command + + def test_normal_command(self, hg_server): + server = hg_server.create() + server.read_only = False + expected_command = ( + 'cd {root}; {hg_path} -R {root}{repo_name} serve --stdio '.format( + root=hg_server.root, hg_path=hg_server.hg_path, + repo_name=hg_server.repo_name) + ) + assert expected_command == server.command + + def test_access_rejected_when_permissions_are_not_found(self, hg_server, caplog): + user_permissions = {} + server = hg_server.create(user_permissions=user_permissions) + result = server._check_permissions() + assert result is False + + log_msg = 'repo not found or no permissions' + assert log_msg in [t[2] for t in caplog.record_tuples] + + def test_access_rejected_when_no_permissions(self, hg_server, caplog): + user_permissions = {hg_server.repo_name: 'repository.none'} + server = hg_server.create(user_permissions=user_permissions) + result = server._check_permissions() + assert result is False + + log_msg = 'repo not found or no permissions' + assert log_msg in [t[2] for t in caplog.record_tuples] + + @pytest.mark.parametrize( + 'permission', ['repository.admin', 'repository.write']) + def test_access_allowed_when_user_has_write_permissions( + self, hg_server, permission, caplog): + user_permissions = {hg_server.repo_name: permission} + server = hg_server.create(user_permissions=user_permissions) + result = server._check_permissions() + assert result is True + + assert server.read_only is False + log_msg = 'Write Permissions for User "vcs" granted to repo "test_hg"!' + assert log_msg in [t[2] for t in caplog.record_tuples] + + def test_access_allowed_when_user_has_read_permissions(self, hg_server, caplog): + user_permissions = {hg_server.repo_name: 'repository.read'} + server = hg_server.create(user_permissions=user_permissions) + result = server._check_permissions() + assert result is True + + assert server.read_only is True + log_msg = 'Only Read Only access for User "%s" granted to repo "%s"!' % ( + hg_server.user, hg_server.repo_name) + assert log_msg in [t[2] for t in caplog.record_tuples] + + def test_run_returns_exit_code_2_when_no_permissions(self, hg_server, caplog): + server = hg_server.create() + with patch.object(server, '_check_permissions') as permissions_mock: + permissions_mock.return_value = False + exit_code = server.run() + assert exit_code == (2, False) + + def test_run_returns_executes_command(self, hg_server, caplog): + server = hg_server.create() + with patch.object(server, '_check_permissions') as permissions_mock: + with patch('os.system') as system_mock: + permissions_mock.return_value = True + system_mock.return_value = 0 + exit_code = server.run() + + system_mock.assert_called_once_with(server.command) + assert exit_code == (0, False) diff --git a/rhodecode/apps/ssh_support/tests/test_server_svn.py b/rhodecode/apps/ssh_support/tests/test_server_svn.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/tests/test_server_svn.py @@ -0,0 +1,136 @@ +# -*- 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 pytest +from mock import Mock, patch, call + +from rhodecode.apps.ssh_support.lib.ssh_wrapper import SubversionServer + + +@pytest.fixture +def svn_server(): + return SubversionServerCreator() + + +class SubversionServerCreator(object): + root = '/tmp/repo/path/' + svn_path = '/usr/local/bin/svnserve' + config_data = { + 'app:main': { + 'ssh.executable.svn': svn_path + } + } + repo_name = 'test-svn' + user = 'vcs' + + def __init__(self): + def config_get(part, key): + return self.config_data.get(part, {}).get(key) + self.config_mock = Mock() + self.config_mock.get = Mock(side_effect=config_get) + + def create(self, **kwargs): + parameters = { + 'store': {'path': self.root}, + 'ini_path': '', + 'user': self.user, + 'user_permissions': { + self.repo_name: 'repo_admin' + }, + 'config': self.config_mock, + } + parameters.update(kwargs) + server = SubversionServer(**parameters) + return server + + +class TestSubversionServer(object): + def test_timeout_returns_value_from_config(self, svn_server): + server = svn_server.create() + assert server.timeout == 30 + + @pytest.mark.parametrize( + 'permission', ['repository.admin', 'repository.write']) + def test_check_permissions_with_write_permissions( + self, svn_server, permission): + user_permissions = {svn_server.repo_name: permission} + server = svn_server.create(user_permissions=user_permissions) + server.tunnel = Mock() + server.repo_name = svn_server.repo_name + result = server._check_permissions() + assert result is True + assert server.tunnel.read_only is False + + def test_check_permissions_with_read_permissions(self, svn_server): + user_permissions = {svn_server.repo_name: 'repository.read'} + server = svn_server.create(user_permissions=user_permissions) + server.tunnel = Mock() + server.repo_name = svn_server.repo_name + result = server._check_permissions() + assert result is True + assert server.tunnel.read_only is True + + def test_check_permissions_with_no_permissions(self, svn_server, caplog): + tunnel_mock = Mock() + user_permissions = {} + server = svn_server.create(user_permissions=user_permissions) + server.tunnel = tunnel_mock + server.repo_name = svn_server.repo_name + result = server._check_permissions() + assert result is False + tunnel_mock.fail.assert_called_once_with( + "Not enough permissions for repository {}".format( + svn_server.repo_name)) + + def test_run_returns_1_when_repository_name_cannot_be_extracted( + self, svn_server): + server = svn_server.create() + with patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.SubversionTunnelWrapper') as tunnel_mock: + tunnel_mock().get_first_client_response.return_value = None + exit_code = server.run() + assert exit_code == (1, False) + tunnel_mock().fail.assert_called_once_with( + 'Repository name cannot be extracted') + + def test_run_returns_tunnel_return_code(self, svn_server, caplog): + server = svn_server.create() + fake_response = { + 'url': 'ssh+svn://test@example.com/test-svn/' + } + with patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.SubversionTunnelWrapper') as tunnel_mock: + with patch.object(server, '_check_permissions') as ( + permissions_mock): + permissions_mock.return_value = True + tunnel = tunnel_mock() + tunnel.get_first_client_response.return_value = fake_response + tunnel.return_code = 0 + exit_code = server.run() + permissions_mock.assert_called_once_with() + + expected_log_calls = sorted([ + "Using subversion binaries from '%s'" % svn_server.svn_path + ]) + + assert expected_log_calls == [t[2] for t in caplog.record_tuples] + + assert exit_code == (0, False) + tunnel.patch_first_client_response.assert_called_once_with( + fake_response) + tunnel.sync.assert_called_once_with() 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 --- a/rhodecode/apps/ssh_support/tests/test_ssh_authorized_keys_gen.py +++ b/rhodecode/apps/ssh_support/tests/test_ssh_authorized_keys_gen.py @@ -31,8 +31,9 @@ from rhodecode.lib.utils2 import Attribu class TestSshKeyFileGeneration(object): @pytest.mark.parametrize('ssh_wrapper_cmd', ['/tmp/sshwrapper.py']) @pytest.mark.parametrize('allow_shell', [True, False]) + @pytest.mark.parametrize('debug', [True, False]) @pytest.mark.parametrize('ssh_opts', [None, 'mycustom,option']) - def test_write_keyfile(self, tmpdir, ssh_wrapper_cmd, allow_shell, ssh_opts): + def test_write_keyfile(self, tmpdir, ssh_wrapper_cmd, allow_shell, debug, ssh_opts): authorized_keys_file_path = os.path.join(str(tmpdir), 'authorized_keys') @@ -43,26 +44,30 @@ class TestSshKeyFileGeneration(object): 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 - ) + with mock.patch.dict('rhodecode.CONFIG', {'__file__': '/tmp/file.ini'}): + utils._generate_ssh_authorized_keys_file( + authorized_keys_file_path, ssh_wrapper_cmd, + allow_shell, ssh_opts, debug + ) - assert os.path.isfile(authorized_keys_file_path) - with open(authorized_keys_file_path) as f: - content = f.read() + 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 + 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' in content - if allow_shell: - assert '--shell --user' in content - else: + if debug: + assert '--debug' in content + assert '--user' in content + assert '--user-id' in content - if ssh_opts: - assert ssh_opts in content + if ssh_opts: + assert ssh_opts in content diff --git a/rhodecode/apps/ssh_support/tests/test_ssh_wrapper.py b/rhodecode/apps/ssh_support/tests/test_ssh_wrapper.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/tests/test_ssh_wrapper.py @@ -0,0 +1,204 @@ +# -*- 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 mock +import pytest +import ConfigParser + +from rhodecode.apps.ssh_support.lib.ssh_wrapper import SshWrapper + + +@pytest.fixture +def dummy_conf(tmpdir): + conf = ConfigParser.ConfigParser() + conf.add_section('app:main') + conf.set('app:main', 'ssh.executable.hg', '/usr/bin/hg') + conf.set('app:main', 'ssh.executable.git', '/usr/bin/git') + conf.set('app:main', 'ssh.executable.svn', '/usr/bin/svnserve') + + conf.set('app:main', 'ssh.api_key', 'xxx') + conf.set('app:main', 'ssh.api_host', 'http://localhost') + + f_path = os.path.join(str(tmpdir), 'ssh_wrapper_test.ini') + with open(f_path, 'wb') as f: + conf.write(f) + + return os.path.join(f_path) + + +class TestGetRepoDetails(object): + @pytest.mark.parametrize( + 'command', [ + 'hg -R test-repo serve --stdio', + 'hg -R test-repo serve --stdio' + ]) + def test_hg_command_matched(self, command, dummy_conf): + wrapper = SshWrapper(command, 'auto', 'admin', '3', 'False', dummy_conf) + type_, name, mode = wrapper.get_repo_details('auto') + assert type_ == 'hg' + assert name == 'test-repo' + assert mode is 'auto' + + @pytest.mark.parametrize( + 'command', [ + 'hg test-repo serve --stdio', + 'hg -R test-repo serve', + 'hg serve --stdio', + 'hg serve -R test-repo' + ]) + def test_hg_command_not_matched(self, command, dummy_conf): + wrapper = SshWrapper(command, 'auto', 'admin', '3', 'False', dummy_conf) + type_, name, mode = wrapper.get_repo_details('auto') + assert type_ is None + assert name is None + assert mode is 'auto' + + +class TestServe(object): + def test_serve_raises_an_exception_when_vcs_is_not_recognized(self, dummy_conf): + with mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store'): + wrapper = SshWrapper('random command', 'auto', 'admin', '3', 'False', dummy_conf) + + with pytest.raises(Exception) as exc_info: + wrapper.serve( + vcs='microsoft-tfs', repo='test-repo', mode=None, user='test', + permissions={}) + assert exc_info.value.message == 'Unrecognised VCS: microsoft-tfs' + + +class TestServeHg(object): + + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.MercurialServer.run') + def test_serve_creates_hg_instance( + self, mercurial_run_mock, get_repo_store_mock, get_user_mock, + invalidate_cache_mock, dummy_conf): + + repo_name = None + mercurial_run_mock.return_value = 0, True + get_user_mock.return_value = {repo_name: 'repository.admin'} + get_repo_store_mock.return_value = {'path': '/tmp'} + + wrapper = SshWrapper('date', 'hg', 'admin', '3', 'False', + dummy_conf) + exit_code = wrapper.wrap() + assert exit_code == 0 + assert mercurial_run_mock.called + + assert get_repo_store_mock.called + assert get_user_mock.called + invalidate_cache_mock.assert_called_once_with(repo_name) + + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.MercurialServer.run') + def test_serve_hg_invalidates_cache( + self, mercurial_run_mock, get_repo_store_mock, get_user_mock, + invalidate_cache_mock, dummy_conf): + + repo_name = None + mercurial_run_mock.return_value = 0, True + get_user_mock.return_value = {repo_name: 'repository.admin'} + get_repo_store_mock.return_value = {'path': '/tmp'} + + wrapper = SshWrapper('date', 'hg', 'admin', '3', 'False', + dummy_conf) + exit_code = wrapper.wrap() + assert exit_code == 0 + assert mercurial_run_mock.called + + assert get_repo_store_mock.called + assert get_user_mock.called + invalidate_cache_mock.assert_called_once_with(repo_name) + + +class TestServeGit(object): + + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.GitServer.run') + def test_serve_creates_git_instance(self, git_run_mock, get_repo_store_mock, get_user_mock, + invalidate_cache_mock, dummy_conf): + repo_name = None + git_run_mock.return_value = 0, True + get_user_mock.return_value = {repo_name: 'repository.admin'} + get_repo_store_mock.return_value = {'path': '/tmp'} + + wrapper = SshWrapper('date', 'git', 'admin', '3', 'False', + dummy_conf) + + exit_code = wrapper.wrap() + assert exit_code == 0 + assert git_run_mock.called + assert get_repo_store_mock.called + assert get_user_mock.called + invalidate_cache_mock.assert_called_once_with(repo_name) + + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.GitServer.run') + def test_serve_git_invalidates_cache( + self, git_run_mock, get_repo_store_mock, get_user_mock, + invalidate_cache_mock, dummy_conf): + repo_name = None + git_run_mock.return_value = 0, True + get_user_mock.return_value = {repo_name: 'repository.admin'} + get_repo_store_mock.return_value = {'path': '/tmp'} + + wrapper = SshWrapper('date', 'git', 'admin', '3', 'False', dummy_conf) + + exit_code = wrapper.wrap() + assert exit_code == 0 + assert git_run_mock.called + + assert get_repo_store_mock.called + assert get_user_mock.called + invalidate_cache_mock.assert_called_once_with(repo_name) + + +class TestServeSvn(object): + + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store') + @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.SubversionServer.run') + def test_serve_creates_svn_instance( + self, svn_run_mock, get_repo_store_mock, get_user_mock, + invalidate_cache_mock, dummy_conf): + + repo_name = None + svn_run_mock.return_value = 0, True + get_user_mock.return_value = {repo_name: 'repository.admin'} + get_repo_store_mock.return_value = {'path': '/tmp'} + + wrapper = SshWrapper('date', 'svn', 'admin', '3', 'False', dummy_conf) + + exit_code = wrapper.wrap() + assert exit_code == 0 + assert svn_run_mock.called + + assert get_repo_store_mock.called + assert get_user_mock.called + invalidate_cache_mock.assert_called_once_with(repo_name) diff --git a/rhodecode/apps/ssh_support/tests/test_svn_tunnel_wrapper.py b/rhodecode/apps/ssh_support/tests/test_svn_tunnel_wrapper.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/ssh_support/tests/test_svn_tunnel_wrapper.py @@ -0,0 +1,285 @@ +# -*- 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 subprocess +from io import BytesIO +from time import sleep + +import pytest +from mock import patch, Mock, MagicMock, call + +from rhodecode.apps.ssh_support.lib.ssh_wrapper import SubversionTunnelWrapper +from rhodecode.tests import no_newline_id_generator + + +class TestSubversionTunnelWrapper(object): + @pytest.mark.parametrize( + 'input_string, output_string', [ + [None, ''], + ['abcde', '5:abcde '], + ['abcdefghijk', '11:abcdefghijk '] + ]) + def test_svn_string(self, input_string, output_string): + wrapper = SubversionTunnelWrapper(timeout=5) + assert wrapper._svn_string(input_string) == output_string + + def test_read_first_client_response(self): + wrapper = SubversionTunnelWrapper(timeout=5) + buffer_ = '( abcd ( efg hij ) ) ' + wrapper.stdin = BytesIO(buffer_) + result = wrapper._read_first_client_response() + assert result == buffer_ + + def test_parse_first_client_response_returns_dict(self): + response = ( + '( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo' + ' log-revprops ) 26:svn+ssh://vcs@vm/hello-svn 38:SVN/1.8.11' + ' (x86_64-apple-darwin14.1.0) ( ) ) ') + wrapper = SubversionTunnelWrapper(timeout=5) + result = wrapper._parse_first_client_response(response) + assert result['version'] == '2' + assert ( + result['capabilities'] == + 'edit-pipeline svndiff1 absent-entries depth mergeinfo' + ' log-revprops') + assert result['url'] == 'svn+ssh://vcs@vm/hello-svn' + assert result['ra_client'] == 'SVN/1.8.11 (x86_64-apple-darwin14.1.0)' + assert result['client'] is None + + def test_parse_first_client_response_returns_none_when_not_matched(self): + response = ( + '( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo' + ' log-revprops ) ) ') + wrapper = SubversionTunnelWrapper(timeout=5) + result = wrapper._parse_first_client_response(response) + assert result is None + + def test_interrupt(self): + wrapper = SubversionTunnelWrapper(timeout=5) + with patch.object(wrapper, 'fail') as fail_mock: + wrapper.interrupt(1, 'frame') + fail_mock.assert_called_once_with("Exited by timeout") + + def test_fail(self): + process_mock = Mock() + wrapper = SubversionTunnelWrapper(timeout=5) + with patch.object(wrapper, 'remove_configs') as remove_configs_mock: + with patch('sys.stdout', new_callable=BytesIO) as stdout_mock: + with patch.object(wrapper, 'process') as process_mock: + wrapper.fail('test message') + assert ( + stdout_mock.getvalue() == + '( failure ( ( 210005 12:test message 0: 0 ) ) )\n') + process_mock.kill.assert_called_once_with() + remove_configs_mock.assert_called_once_with() + + @pytest.mark.parametrize( + 'client, expected_client', [ + ['test ', 'test '], + ['', ''], + [None, ''] + ]) + def test_client_in_patch_first_client_response( + self, client, expected_client): + response = { + 'version': 2, + 'capabilities': 'edit-pipeline svndiff1 absent-entries depth', + 'url': 'svn+ssh://example.com/svn', + 'ra_client': 'SVN/1.8.11 (x86_64-apple-darwin14.1.0)', + 'client': client + } + wrapper = SubversionTunnelWrapper(timeout=5) + stdin = BytesIO() + with patch.object(wrapper, 'process') as process_mock: + process_mock.stdin = stdin + wrapper.patch_first_client_response(response) + assert ( + stdin.getvalue() == + '( 2 ( edit-pipeline svndiff1 absent-entries depth )' + ' 25:svn+ssh://example.com/svn 38:SVN/1.8.11' + ' (x86_64-apple-darwin14.1.0) ( {expected_client}) ) '.format( + expected_client=expected_client)) + + def test_kwargs_override_data_in_patch_first_client_response(self): + response = { + 'version': 2, + 'capabilities': 'edit-pipeline svndiff1 absent-entries depth', + 'url': 'svn+ssh://example.com/svn', + 'ra_client': 'SVN/1.8.11 (x86_64-apple-darwin14.1.0)', + 'client': 'test' + } + wrapper = SubversionTunnelWrapper(timeout=5) + stdin = BytesIO() + with patch.object(wrapper, 'process') as process_mock: + process_mock.stdin = stdin + wrapper.patch_first_client_response( + response, version=3, client='abcde ', + capabilities='absent-entries depth', + url='svn+ssh://example.org/test', + ra_client='SVN/1.8.12 (ubuntu 14.04)') + assert ( + stdin.getvalue() == + '( 3 ( absent-entries depth ) 26:svn+ssh://example.org/test' + ' 25:SVN/1.8.12 (ubuntu 14.04) ( abcde ) ) ') + + def test_patch_first_client_response_sets_environment(self): + response = { + 'version': 2, + 'capabilities': 'edit-pipeline svndiff1 absent-entries depth', + 'url': 'svn+ssh://example.com/svn', + 'ra_client': 'SVN/1.8.11 (x86_64-apple-darwin14.1.0)', + 'client': 'test' + } + wrapper = SubversionTunnelWrapper(timeout=5) + stdin = BytesIO() + with patch.object(wrapper, 'create_hooks_env') as create_hooks_mock: + with patch.object(wrapper, 'process') as process_mock: + process_mock.stdin = stdin + wrapper.patch_first_client_response(response) + create_hooks_mock.assert_called_once_with() + + def test_get_first_client_response_exits_by_signal(self): + wrapper = SubversionTunnelWrapper(timeout=1) + read_patch = patch.object(wrapper, '_read_first_client_response') + parse_patch = patch.object(wrapper, '_parse_first_client_response') + interrupt_patch = patch.object(wrapper, 'interrupt') + + with read_patch as read_mock, parse_patch as parse_mock, \ + interrupt_patch as interrupt_mock: + read_mock.side_effect = lambda: sleep(3) + wrapper.get_first_client_response() + + assert parse_mock.call_count == 0 + assert interrupt_mock.call_count == 1 + + def test_get_first_client_response_parses_data(self): + wrapper = SubversionTunnelWrapper(timeout=5) + response = ( + '( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo' + ' log-revprops ) 26:svn+ssh://vcs@vm/hello-svn 38:SVN/1.8.11' + ' (x86_64-apple-darwin14.1.0) ( ) ) ') + read_patch = patch.object(wrapper, '_read_first_client_response') + parse_patch = patch.object(wrapper, '_parse_first_client_response') + + with read_patch as read_mock, parse_patch as parse_mock: + read_mock.return_value = response + wrapper.get_first_client_response() + + parse_mock.assert_called_once_with(response) + + def test_return_code(self): + wrapper = SubversionTunnelWrapper(timeout=5) + with patch.object(wrapper, 'process') as process_mock: + process_mock.returncode = 1 + assert wrapper.return_code == 1 + + def test_sync_loop_breaks_when_process_cannot_be_polled(self): + self.counter = 0 + buffer_ = 'abcdefghij' + + wrapper = SubversionTunnelWrapper(timeout=5) + wrapper.stdin = BytesIO(buffer_) + with patch.object(wrapper, 'remove_configs') as remove_configs_mock: + with patch.object(wrapper, 'process') as process_mock: + process_mock.poll.side_effect = self._poll + process_mock.stdin = BytesIO() + wrapper.sync() + assert process_mock.stdin.getvalue() == 'abcde' + remove_configs_mock.assert_called_once_with() + + def test_sync_loop_breaks_when_nothing_to_read(self): + self.counter = 0 + buffer_ = 'abcdefghij' + + wrapper = SubversionTunnelWrapper(timeout=5) + wrapper.stdin = BytesIO(buffer_) + with patch.object(wrapper, 'remove_configs') as remove_configs_mock: + with patch.object(wrapper, 'process') as process_mock: + process_mock.poll.return_value = None + process_mock.stdin = BytesIO() + wrapper.sync() + assert process_mock.stdin.getvalue() == buffer_ + remove_configs_mock.assert_called_once_with() + + def test_start_without_repositories_root(self): + svn_path = '/usr/local/bin/svnserve' + wrapper = SubversionTunnelWrapper(timeout=5, svn_path=svn_path) + with patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.Popen') as popen_mock: + wrapper.start() + expected_command = [ + svn_path, '-t', '--config-file', wrapper.svn_conf_path] + popen_mock.assert_called_once_with( + expected_command, stdin=subprocess.PIPE) + assert wrapper.process == popen_mock() + + def test_start_with_repositories_root(self): + svn_path = '/usr/local/bin/svnserve' + repositories_root = '/home/repos' + wrapper = SubversionTunnelWrapper( + timeout=5, svn_path=svn_path, repositories_root=repositories_root) + with patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.Popen') as popen_mock: + wrapper.start() + expected_command = [ + svn_path, '-t', '--config-file', wrapper.svn_conf_path, + '-r', repositories_root] + popen_mock.assert_called_once_with( + expected_command, stdin=subprocess.PIPE) + assert wrapper.process == popen_mock() + + def test_create_svn_config(self): + wrapper = SubversionTunnelWrapper(timeout=5) + file_mock = MagicMock(spec=file) + with patch('os.fdopen', create=True) as open_mock: + open_mock.return_value = file_mock + wrapper.create_svn_config() + open_mock.assert_called_once_with(wrapper.svn_conf_fd, 'w') + expected_content = '[general]\nhooks-env = {}\n'.format( + wrapper.hooks_env_path) + file_handle = file_mock.__enter__.return_value + file_handle.write.assert_called_once_with(expected_content) + + @pytest.mark.parametrize( + 'read_only, expected_content', [ + [True, '[default]\nLANG = en_US.UTF-8\nSSH_READ_ONLY = 1\n'], + [False, '[default]\nLANG = en_US.UTF-8\n'] + ], ids=no_newline_id_generator) + def test_create_hooks_env(self, read_only, expected_content): + wrapper = SubversionTunnelWrapper(timeout=5) + wrapper.read_only = read_only + file_mock = MagicMock(spec=file) + with patch('os.fdopen', create=True) as open_mock: + open_mock.return_value = file_mock + wrapper.create_hooks_env() + open_mock.assert_called_once_with(wrapper.hooks_env_fd, 'w') + file_handle = file_mock.__enter__.return_value + file_handle.write.assert_called_once_with(expected_content) + + def test_remove_configs(self): + wrapper = SubversionTunnelWrapper(timeout=5) + with patch('os.remove') as remove_mock: + wrapper.remove_configs() + expected_calls = [ + call(wrapper.svn_conf_path), call(wrapper.hooks_env_path)] + assert sorted(remove_mock.call_args_list) == sorted(expected_calls) + + def _poll(self): + self.counter += 1 + return None if self.counter < 6 else 1 diff --git a/rhodecode/apps/ssh_support/utils.py b/rhodecode/apps/ssh_support/utils.py --- a/rhodecode/apps/ssh_support/utils.py +++ b/rhodecode/apps/ssh_support/utils.py @@ -48,11 +48,15 @@ def get_all_active_keys(): def _generate_ssh_authorized_keys_file( - authorized_keys_file_path, ssh_wrapper_cmd, allow_shell, ssh_opts): + authorized_keys_file_path, ssh_wrapper_cmd, allow_shell, ssh_opts, debug): + + import rhodecode all_active_keys = get_all_active_keys() if allow_shell: ssh_wrapper_cmd = ssh_wrapper_cmd + ' --shell' + if debug: + ssh_wrapper_cmd = ssh_wrapper_cmd + ' --debug' if not os.path.isfile(authorized_keys_file_path): with open(authorized_keys_file_path, 'w'): @@ -62,7 +66,7 @@ def _generate_ssh_authorized_keys_file( 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' + line_tmpl = '{ssh_opts},command="{wrapper_command} {ini_path} --user-id={user_id} --user={user}" {key}\n' fd, tmp_authorized_keys = tempfile.mkstemp( '.authorized_keys_write', @@ -71,13 +75,18 @@ def _generate_ssh_authorized_keys_file( now = datetime.datetime.utcnow().isoformat() keys_file = os.fdopen(fd, 'wb') keys_file.write(HEADER.format(len(all_active_keys), now)) + ini_path = rhodecode.CONFIG['__file__'] for user_key in all_active_keys: username = user_key.user.username + user_id = user_key.user.user_id + keys_file.write( line_tmpl.format( ssh_opts=ssh_opts or SSH_OPTS, wrapper_command=ssh_wrapper_cmd, + ini_path=ini_path, + user_id=user_id, user=username, key=user_key.ssh_key_data)) log.debug('addkey: Key added for user: `%s`', username) keys_file.close() @@ -100,8 +109,11 @@ def generate_ssh_authorized_keys_file(re config_keys.wrapper_allow_shell) ssh_opts = registry.settings.get( config_keys.authorized_keys_line_ssh_opts) + debug = registry.settings.get( + config_keys.enable_debug_logging) _generate_ssh_authorized_keys_file( - authorized_keys_file_path, ssh_wrapper_cmd, allow_shell, ssh_opts) + authorized_keys_file_path, ssh_wrapper_cmd, allow_shell, ssh_opts, + debug) return 0 diff --git a/rhodecode/tests/rhodecode.ini b/rhodecode/tests/rhodecode.ini --- a/rhodecode/tests/rhodecode.ini +++ b/rhodecode/tests/rhodecode.ini @@ -249,14 +249,18 @@ gist_alias_url = ## used for access. ## Adding ?auth_token=TOKEN_HASH to the url authenticates this request as if it ## came from the the logged in user who own this authentication token. +## Additionally @TOKEN syntaxt can be used to bound the view to specific +## authentication token. Such view would be only accessible when used together +## with this authentication token ## -## list of all views can be found under `_admin/permissions/auth_token_access` +## list of all views can be found under `/_admin/permissions/auth_token_access` ## The list should be "," separated and on a single line. ## ## Most common views to enable: # RepoCommitsView:repo_commit_download # RepoCommitsView:repo_commit_patch # RepoCommitsView:repo_commit_raw +# RepoCommitsView:repo_commit_raw@TOKEN # RepoFilesView:repo_files_diff # RepoFilesView:repo_archivefile # RepoFilesView:repo_file_raw @@ -587,7 +591,7 @@ vcs.server = localhost:9901 vcs.server.protocol = http ## Push/Pull operations protocol, available options are: -## `rhodecode.lib.middleware.utils.scm_app_http` - Http based, recommended +## `http` - use http-rpc backend (default) ## `vcsserver.scm_app` - internal app (EE only) vcs.scm_app_implementation = http @@ -607,7 +611,7 @@ vcs.backends = hg, git, svn vcs.connection_timeout = 3600 ## Compatibility version when creating SVN repositories. Defaults to newest version when commented out. -## Available options are: pre-1.4-compatible, pre-1.5-compatible, pre-1.6-compatible, pre-1.8-compatible +## Available options are: pre-1.4-compatible, pre-1.5-compatible, pre-1.6-compatible, pre-1.8-compatible, pre-1.9-compatible #vcs.svn.compatible_version = pre-1.8-compatible @@ -631,6 +635,49 @@ 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 +## It is possible to have multiple key files specified in `sshd_config` e.g. +## AuthorizedKeysFile %h/.ssh/authorized_keys %h/.ssh/authorized_keys_rhodecode +ssh.authorized_keys_file_path = ~/.ssh/authorized_keys_rhodecode + +## Command to execute the SSH wrapper. The binary is available in the +## rhodecode installation directory. +## e.g ~/.rccontrol/community-1/profile/bin/rcssh-wrapper +ssh.wrapper_cmd = ~/.rccontrol/community-1/rcssh-wrapper + +## Allow shell when executing the ssh-wrapper command +ssh.wrapper_cmd_allow_shell = false + +## Enables logging, and detailed output send back to the client. Usefull for +## debugging, shouldn't be used in production. +ssh.enable_debug_logging = false + +## API KEY for user who has access to fetch other user permission information +## most likely an super-admin account with some IP restrictions. +ssh.api_key = + +## API Host, the server address of RhodeCode instance that the api_key will +## access +ssh.api_host = http://localhost + +## Paths to binary executrables, by default they are the names, but we can +## override them if we want to use a custom one +ssh.executable.hg = ~/.rccontrol/vcsserver-1/profile/bin/hg +ssh.executable.git = ~/.rccontrol/vcsserver-1/profile/bin/git +ssh.executable.svn = ~/.rccontrol/vcsserver-1/profile/bin/svnserve + + ## Dummy marker to add new entries after. ## Add any custom entries below. Please don't remove. custom.conf = 1 @@ -640,7 +687,7 @@ custom.conf = 1 ### LOGGING CONFIGURATION #### ################################ [loggers] -keys = root, routes, rhodecode, sqlalchemy, beaker, templates +keys = root, routes, rhodecode, sqlalchemy, beaker, templates, ssh_wrapper [handlers] keys = console, console_sql @@ -686,6 +733,13 @@ handlers = console_sql qualname = sqlalchemy.engine propagate = 0 +[logger_ssh_wrapper] +level = DEBUG +handlers = +qualname = ssh_wrapper +propagate = 1 + + ############## ## HANDLERS ## ############## diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -251,6 +251,7 @@ setup( 'rcserver=rhodecode.rcserver:main', 'rcsetup-app=rhodecode.lib.rc_commands.setup_rc:main', 'rcupgrade-db=rhodecode.lib.rc_commands.upgrade_db:main', + 'rcssh-wrapper=rhodecode.apps.ssh_support.lib.ssh_wrapper:main', ], 'beaker.backends': [ 'memorylru_base=rhodecode.lib.memory_lru_debug:MemoryLRUNamespaceManagerBase',