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
This commit is contained in:
Marcin Kuzminski 2017-08-18 14:32:00 +02:00
parent 3ea8958a92
commit 034abca8dc
16 changed files with 1813 additions and 37 deletions

View file

@ -603,15 +603,37 @@ 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 as an SSH wrapper, available from
## https://code.rhodecode.com/rhodecode-ssh
ssh.wrapper_cmd = /home/USER/rhodecode-ssh/sshwrapper.py
## 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 command
## 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
@ -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 ##
##############

View file

@ -572,15 +572,37 @@ 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 as an SSH wrapper, available from
## https://code.rhodecode.com/rhodecode-ssh
ssh.wrapper_cmd = /home/USER/rhodecode-ssh/sshwrapper.py
## 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 command
## 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
@ -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 ##
##############

View file

@ -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__)
@ -74,6 +75,35 @@ def get_server_info(request, apiuser):
return server_info
@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 : <id_given_in_input>
result : {
'modules': [<module name>,...]
'py_version': <python version>,
'platform': <platform type>,
'rhodecode_version': <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'))):
"""

View file

@ -35,14 +35,30 @@ def _sanitize_settings_and_apply_defaults(settings):
"""
_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

View file

@ -26,3 +26,11 @@ 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'
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'

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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/

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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<version>\d+)'
capabilities_re = r'\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)'
url_re = r'\d+\:(?P<url>[\W\w]+)'
ra_client_re = r'(\d+\:(?P<ra_client>[\W\w]+)\s)'
client_re = r'(\d+\:(?P<client>[\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())

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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]

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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()

View file

@ -31,8 +31,9 @@ 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('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 debug:
assert '--debug' in content
if allow_shell:
assert '--shell --user' in content
else:
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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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

View file

@ -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(registry):
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

View file

@ -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 ##
##############

View file

@ -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',