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:
parent
3ea8958a92
commit
034abca8dc
16 changed files with 1813 additions and 37 deletions
|
|
@ -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 ##
|
||||
##############
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
##############
|
||||
|
|
|
|||
|
|
@ -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'))):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
19
rhodecode/apps/ssh_support/lib/__init__.py
Normal file
19
rhodecode/apps/ssh_support/lib/__init__.py
Normal 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/
|
||||
607
rhodecode/apps/ssh_support/lib/ssh_wrapper.py
Normal file
607
rhodecode/apps/ssh_support/lib/ssh_wrapper.py
Normal 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())
|
||||
195
rhodecode/apps/ssh_support/tests/test_server_git.py
Normal file
195
rhodecode/apps/ssh_support/tests/test_server_git.py
Normal 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]
|
||||
146
rhodecode/apps/ssh_support/tests/test_server_hg.py
Normal file
146
rhodecode/apps/ssh_support/tests/test_server_hg.py
Normal 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)
|
||||
136
rhodecode/apps/ssh_support/tests/test_server_svn.py
Normal file
136
rhodecode/apps/ssh_support/tests/test_server_svn.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
204
rhodecode/apps/ssh_support/tests/test_ssh_wrapper.py
Normal file
204
rhodecode/apps/ssh_support/tests/test_ssh_wrapper.py
Normal 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)
|
||||
285
rhodecode/apps/ssh_support/tests/test_svn_tunnel_wrapper.py
Normal file
285
rhodecode/apps/ssh_support/tests/test_svn_tunnel_wrapper.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
##############
|
||||
|
|
|
|||
1
setup.py
1
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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue