##// END OF EJS Templates
ssh-support: enabled full handling of all backends via SSH....
marcink -
r2187:47a0c0ed default
parent child Browse files
Show More
@@ -0,0 +1,202 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import os
22 import re
23 import logging
24 import datetime
25 import ConfigParser
26
27 from rhodecode.model.db import Session, User, UserSshKeys
28 from rhodecode.model.scm import ScmModel
29
30 from .hg import MercurialServer
31 from .git import GitServer
32 from .svn import SubversionServer
33 log = logging.getLogger(__name__)
34
35
36 class SshWrapper(object):
37
38 def __init__(self, command, connection_info, mode,
39 user, user_id, key_id, shell, ini_path, env):
40 self.command = command
41 self.connection_info = connection_info
42 self.mode = mode
43 self.user = user
44 self.user_id = user_id
45 self.key_id = key_id
46 self.shell = shell
47 self.ini_path = ini_path
48 self.env = env
49
50 self.config = self.parse_config(ini_path)
51 self.server_impl = None
52
53 def parse_config(self, config_path):
54 parser = ConfigParser.ConfigParser()
55 parser.read(config_path)
56 return parser
57
58 def update_key_access_time(self, key_id):
59 key = UserSshKeys().query().filter(
60 UserSshKeys.ssh_key_id == key_id).scalar()
61 if key:
62 key.accessed_on = datetime.datetime.utcnow()
63 Session().add(key)
64 Session().commit()
65 log.debug('Update key `%s` access time', key_id)
66
67 def get_connection_info(self):
68 """
69 connection_info
70
71 Identifies the client and server ends of the connection.
72 The variable contains four space-separated values: client IP address,
73 client port number, server IP address, and server port number.
74 """
75 conn = dict(
76 client_ip=None,
77 client_port=None,
78 server_ip=None,
79 server_port=None,
80 )
81
82 info = self.connection_info.split(' ')
83 if len(info) == 4:
84 conn['client_ip'] = info[0]
85 conn['client_port'] = info[1]
86 conn['server_ip'] = info[2]
87 conn['server_port'] = info[3]
88
89 return conn
90
91 def get_repo_details(self, mode):
92 vcs_type = mode if mode in ['svn', 'hg', 'git'] else None
93 mode = mode
94 repo_name = None
95
96 hg_pattern = r'^hg\s+\-R\s+(\S+)\s+serve\s+\-\-stdio$'
97 hg_match = re.match(hg_pattern, self.command)
98 if hg_match is not None:
99 vcs_type = 'hg'
100 repo_name = hg_match.group(1).strip('/')
101 return vcs_type, repo_name, mode
102
103 git_pattern = (
104 r'^git-(receive-pack|upload-pack)\s\'[/]?(\S+?)(|\.git)\'$')
105 git_match = re.match(git_pattern, self.command)
106 if git_match is not None:
107 vcs_type = 'git'
108 repo_name = git_match.group(2).strip('/')
109 mode = git_match.group(1)
110 return vcs_type, repo_name, mode
111
112 svn_pattern = r'^svnserve -t'
113 svn_match = re.match(svn_pattern, self.command)
114
115 if svn_match is not None:
116 vcs_type = 'svn'
117 # Repo name should be extracted from the input stream
118 return vcs_type, repo_name, mode
119
120 return vcs_type, repo_name, mode
121
122 def serve(self, vcs, repo, mode, user, permissions):
123 store = ScmModel().repos_path
124
125 log.debug(
126 'VCS detected:`%s` mode: `%s` repo_name: %s', vcs, mode, repo)
127
128 if vcs == 'hg':
129 server = MercurialServer(
130 store=store, ini_path=self.ini_path,
131 repo_name=repo, user=user,
132 user_permissions=permissions, config=self.config, env=self.env)
133 self.server_impl = server
134 return server.run()
135
136 elif vcs == 'git':
137 server = GitServer(
138 store=store, ini_path=self.ini_path,
139 repo_name=repo, repo_mode=mode, user=user,
140 user_permissions=permissions, config=self.config, env=self.env)
141 self.server_impl = server
142 return server.run()
143
144 elif vcs == 'svn':
145 server = SubversionServer(
146 store=store, ini_path=self.ini_path,
147 repo_name=None, user=user,
148 user_permissions=permissions, config=self.config, env=self.env)
149 self.server_impl = server
150 return server.run()
151
152 else:
153 raise Exception('Unrecognised VCS: {}'.format(vcs))
154
155 def wrap(self):
156 mode = self.mode
157 user = self.user
158 user_id = self.user_id
159 key_id = self.key_id
160 shell = self.shell
161
162 scm_detected, scm_repo, scm_mode = self.get_repo_details(mode)
163
164 log.debug(
165 'Mode: `%s` User: `%s:%s` Shell: `%s` SSH Command: `\"%s\"` '
166 'SCM_DETECTED: `%s` SCM Mode: `%s` SCM Repo: `%s`',
167 mode, user, user_id, shell, self.command,
168 scm_detected, scm_mode, scm_repo)
169
170 # update last access time for this key
171 self.update_key_access_time(key_id)
172
173 log.debug('SSH Connection info %s', self.get_connection_info())
174
175 if shell and self.command is None:
176 log.info(
177 'Dropping to shell, no command given and shell is allowed')
178 os.execl('/bin/bash', '-l')
179 exit_code = 1
180
181 elif scm_detected:
182 user = User.get(user_id)
183 auth_user = user.AuthUser()
184 permissions = auth_user.permissions['repositories']
185
186 try:
187 exit_code, is_updated = self.serve(
188 scm_detected, scm_repo, scm_mode, user, permissions)
189 except Exception:
190 log.exception('Error occurred during execution of SshWrapper')
191 exit_code = -1
192
193 elif self.command is None and shell is False:
194 log.error('No Command given.')
195 exit_code = -1
196
197 else:
198 log.error(
199 'Unhandled Command: "%s" Aborting.', self.command)
200 exit_code = -1
201
202 return exit_code
@@ -0,0 +1,149 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import os
22 import sys
23 import json
24 import logging
25
26 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
27 from rhodecode.lib import hooks_utils
28 from rhodecode.model.scm import ScmModel
29
30 log = logging.getLogger(__name__)
31
32
33 class VcsServer(object):
34 _path = None # set executable path for hg/git/svn binary
35 backend = None # set in child classes
36 tunnel = None # subprocess handling tunnel
37 write_perms = ['repository.admin', 'repository.write']
38 read_perms = ['repository.read', 'repository.admin', 'repository.write']
39
40 def __init__(self, user, user_permissions, config, env):
41 self.user = user
42 self.user_permissions = user_permissions
43 self.config = config
44 self.env = env
45 self.stdin = sys.stdin
46
47 self.repo_name = None
48 self.repo_mode = None
49 self.store = ''
50 self.ini_path = ''
51
52 def _invalidate_cache(self, repo_name):
53 """
54 Set's cache for this repository for invalidation on next access
55
56 :param repo_name: full repo name, also a cache key
57 """
58 ScmModel().mark_for_invalidation(repo_name)
59
60 def has_write_perm(self):
61 permission = self.user_permissions.get(self.repo_name)
62 if permission in ['repository.write', 'repository.admin']:
63 return True
64
65 return False
66
67 def _check_permissions(self, action):
68 permission = self.user_permissions.get(self.repo_name)
69 log.debug(
70 'permission for %s on %s are: %s',
71 self.user, self.repo_name, permission)
72
73 if action == 'pull':
74 if permission in self.read_perms:
75 log.info(
76 'READ Permissions for User "%s" detected to repo "%s"!',
77 self.user, self.repo_name)
78 return 0
79 else:
80 if permission in self.write_perms:
81 log.info(
82 'WRITE+ Permissions for User "%s" detected to repo "%s"!',
83 self.user, self.repo_name)
84 return 0
85
86 log.error('Cannot properly fetch or allow user permissions. '
87 'Return value is: %s, req action: %s', permission, action)
88 return -2
89
90 def update_environment(self, action, extras=None):
91
92 scm_data = {
93 'ip': os.environ['SSH_CLIENT'].split()[0],
94 'username': self.user.username,
95 'action': action,
96 'repository': self.repo_name,
97 'scm': self.backend,
98 'config': self.ini_path,
99 'make_lock': None,
100 'locked_by': [None, None],
101 'server_url': None,
102 'is_shadow_repo': False,
103 'hooks_module': 'rhodecode.lib.hooks_daemon',
104 'hooks': ['push', 'pull'],
105 'SSH': True,
106 'SSH_PERMISSIONS': self.user_permissions.get(self.repo_name)
107 }
108 if extras:
109 scm_data.update(extras)
110 os.putenv("RC_SCM_DATA", json.dumps(scm_data))
111
112 def get_root_store(self):
113 root_store = self.store
114 if not root_store.endswith('/'):
115 # always append trailing slash
116 root_store = root_store + '/'
117 return root_store
118
119 def _handle_tunnel(self, extras):
120 # pre-auth
121 action = 'pull'
122 exit_code = self._check_permissions(action)
123 if exit_code:
124 return exit_code, False
125
126 req = self.env['request']
127 server_url = req.host_url + req.script_name
128 extras['server_url'] = server_url
129
130 log.debug('Using %s binaries from path %s', self.backend, self._path)
131 exit_code = self.tunnel.run(extras)
132
133 return exit_code, action == "push"
134
135 def run(self):
136 extras = {}
137 HOOKS_PROTOCOL = self.config.get('app:main', 'vcs.hooks.protocol')
138
139 callback_daemon, extras = prepare_callback_daemon(
140 extras, protocol=HOOKS_PROTOCOL,
141 use_direct_calls=False)
142
143 with callback_daemon:
144 try:
145 return self._handle_tunnel(extras)
146 finally:
147 log.debug('Running cleanup with cache invalidation')
148 if self.repo_name:
149 self._invalidate_cache(self.repo_name)
@@ -0,0 +1,75 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import os
22 import sys
23 import logging
24
25 from .base import VcsServer
26
27 log = logging.getLogger(__name__)
28
29
30 class GitTunnelWrapper(object):
31 process = None
32
33 def __init__(self, server):
34 self.server = server
35 self.stdin = sys.stdin
36 self.stdout = sys.stdout
37
38 def create_hooks_env(self):
39 pass
40
41 def command(self):
42 root = self.server.get_root_store()
43 command = "cd {root}; {git_path} {mode} '{root}{repo_name}'".format(
44 root=root, git_path=self.server.git_path,
45 mode=self.server.repo_mode, repo_name=self.server.repo_name)
46 log.debug("Final CMD: %s", command)
47 return command
48
49 def run(self, extras):
50 action = "push" if self.server.repo_mode == "receive-pack" else "pull"
51 exit_code = self.server._check_permissions(action)
52 if exit_code:
53 return exit_code
54
55 self.server.update_environment(action=action, extras=extras)
56 self.create_hooks_env()
57 return os.system(self.command())
58
59
60 class GitServer(VcsServer):
61 backend = 'git'
62
63 def __init__(self, store, ini_path, repo_name, repo_mode,
64 user, user_permissions, config, env):
65 super(GitServer, self).\
66 __init__(user, user_permissions, config, env)
67
68 self.store = store
69 self.ini_path = ini_path
70 self.repo_name = repo_name
71 self._path = self.git_path = config.get(
72 'app:main', 'ssh.executable.git')
73
74 self.repo_mode = repo_mode
75 self.tunnel = GitTunnelWrapper(server=self)
@@ -0,0 +1,123 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import os
22 import sys
23 import shutil
24 import logging
25 import tempfile
26 import textwrap
27
28 from .base import VcsServer
29
30 log = logging.getLogger(__name__)
31
32
33 class MercurialTunnelWrapper(object):
34 process = None
35
36 def __init__(self, server):
37 self.server = server
38 self.stdin = sys.stdin
39 self.stdout = sys.stdout
40 self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp()
41 self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp()
42
43 def create_hooks_env(self):
44
45 content = textwrap.dedent(
46 '''
47 # SSH hooks version=1.0.0
48 [hooks]
49 pretxnchangegroup.ssh_auth=python:vcsserver.hooks.pre_push_ssh_auth
50 pretxnchangegroup.ssh=python:vcsserver.hooks.pre_push_ssh
51 changegroup.ssh=python:vcsserver.hooks.post_push_ssh
52
53 preoutgoing.ssh=python:vcsserver.hooks.pre_pull_ssh
54 outgoing.ssh=python:vcsserver.hooks.post_pull_ssh
55
56 '''
57 )
58
59 with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file:
60 hooks_env_file.write(content)
61 root = self.server.get_root_store()
62
63 hgrc_custom = os.path.join(
64 root, self.server.repo_name, '.hg', 'hgrc_rhodecode')
65 log.debug('Wrote custom hgrc file under %s', hgrc_custom)
66 shutil.move(
67 self.hooks_env_path, hgrc_custom)
68
69 hgrc_main = os.path.join(
70 root, self.server.repo_name, '.hg', 'hgrc')
71 include_marker = '%include hgrc_rhodecode'
72
73 if not os.path.isfile(hgrc_main):
74 os.mknod(hgrc_main)
75
76 with open(hgrc_main, 'rb') as f:
77 data = f.read()
78 has_marker = include_marker in data
79
80 if not has_marker:
81 log.debug('Adding include marker for hooks')
82 with open(hgrc_main, 'wa') as f:
83 f.write(textwrap.dedent('''
84 # added by RhodeCode
85 {}
86 '''.format(include_marker)))
87
88 def command(self):
89 root = self.server.get_root_store()
90
91 command = (
92 "cd {root}; {hg_path} -R {root}{repo_name} "
93 "serve --stdio".format(
94 root=root, hg_path=self.server.hg_path,
95 repo_name=self.server.repo_name))
96 log.debug("Final CMD: %s", command)
97 return command
98
99 def run(self, extras):
100 # at this point we cannot tell, we do further ACL checks
101 # inside the hooks
102 action = '?'
103 # permissions are check via `pre_push_ssh_auth` hook
104 self.server.update_environment(action=action, extras=extras)
105 self.create_hooks_env()
106 return os.system(self.command())
107
108
109 class MercurialServer(VcsServer):
110 backend = 'hg'
111
112 def __init__(self, store, ini_path, repo_name,
113 user, user_permissions, config, env):
114 super(MercurialServer, self).\
115 __init__(user, user_permissions, config, env)
116
117 self.store = store
118 self.ini_path = ini_path
119 self.repo_name = repo_name
120 self._path = self.hg_path = config.get(
121 'app:main', 'ssh.executable.hg')
122
123 self.tunnel = MercurialTunnelWrapper(server=self)
@@ -0,0 +1,228 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import os
22 import re
23 import sys
24 import logging
25 import signal
26 import tempfile
27 from subprocess import Popen, PIPE
28 import urlparse
29
30 from .base import VcsServer
31
32 log = logging.getLogger(__name__)
33
34
35 class SubversionTunnelWrapper(object):
36 process = None
37
38 def __init__(self, server):
39 self.server = server
40 self.timeout = 30
41 self.stdin = sys.stdin
42 self.stdout = sys.stdout
43 self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp()
44 self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp()
45
46 self.read_only = True # flag that we set to make the hooks readonly
47
48 def create_svn_config(self):
49 content = (
50 '[general]\n'
51 'hooks-env = {}\n').format(self.hooks_env_path)
52 with os.fdopen(self.svn_conf_fd, 'w') as config_file:
53 config_file.write(content)
54
55 def create_hooks_env(self):
56 content = (
57 '[default]\n'
58 'LANG = en_US.UTF-8\n')
59 if self.read_only:
60 content += 'SSH_READ_ONLY = 1\n'
61 with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file:
62 hooks_env_file.write(content)
63
64 def remove_configs(self):
65 os.remove(self.svn_conf_path)
66 os.remove(self.hooks_env_path)
67
68 def command(self):
69 root = self.server.get_root_store()
70 command = [
71 self.server.svn_path, '-t',
72 '--config-file', self.svn_conf_path,
73 '-r', root]
74 log.debug("Final CMD: %s", command)
75 return command
76
77 def start(self):
78 command = self.command()
79 self.process = Popen(command, stdin=PIPE)
80
81 def sync(self):
82 while self.process.poll() is None:
83 next_byte = self.stdin.read(1)
84 if not next_byte:
85 break
86 self.process.stdin.write(next_byte)
87 self.remove_configs()
88
89 @property
90 def return_code(self):
91 return self.process.returncode
92
93 def get_first_client_response(self):
94 signal.signal(signal.SIGALRM, self.interrupt)
95 signal.alarm(self.timeout)
96 first_response = self._read_first_client_response()
97 signal.alarm(0)
98 return (
99 self._parse_first_client_response(first_response)
100 if first_response else None)
101
102 def patch_first_client_response(self, response, **kwargs):
103 self.create_hooks_env()
104 data = response.copy()
105 data.update(kwargs)
106 data['url'] = self._svn_string(data['url'])
107 data['ra_client'] = self._svn_string(data['ra_client'])
108 data['client'] = data['client'] or ''
109 buffer_ = (
110 "( {version} ( {capabilities} ) {url}{ra_client}"
111 "( {client}) ) ".format(**data))
112 self.process.stdin.write(buffer_)
113
114 def fail(self, message):
115 print(
116 "( failure ( ( 210005 {message} 0: 0 ) ) )".format(
117 message=self._svn_string(message)))
118 self.remove_configs()
119 self.process.kill()
120
121 def interrupt(self, signum, frame):
122 self.fail("Exited by timeout")
123
124 def _svn_string(self, str_):
125 if not str_:
126 return ''
127 return '{length}:{string} '.format(length=len(str_), string=str_)
128
129 def _read_first_client_response(self):
130 buffer_ = ""
131 brackets_stack = []
132 while True:
133 next_byte = self.stdin.read(1)
134 buffer_ += next_byte
135 if next_byte == "(":
136 brackets_stack.append(next_byte)
137 elif next_byte == ")":
138 brackets_stack.pop()
139 elif next_byte == " " and not brackets_stack:
140 break
141 return buffer_
142
143 def _parse_first_client_response(self, buffer_):
144 """
145 According to the Subversion RA protocol, the first request
146 should look like:
147
148 ( version:number ( cap:word ... ) url:string ? ra-client:string
149 ( ? client:string ) )
150
151 Please check https://svn.apache.org/repos/asf/subversion/trunk/
152 subversion/libsvn_ra_svn/protocol
153 """
154 version_re = r'(?P<version>\d+)'
155 capabilities_re = r'\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)'
156 url_re = r'\d+\:(?P<url>[\W\w]+)'
157 ra_client_re = r'(\d+\:(?P<ra_client>[\W\w]+)\s)'
158 client_re = r'(\d+\:(?P<client>[\W\w]+)\s)*'
159 regex = re.compile(
160 r'^\(\s{version}\s{capabilities}\s{url}\s{ra_client}'
161 r'\(\s{client}\)\s\)\s*$'.format(
162 version=version_re, capabilities=capabilities_re,
163 url=url_re, ra_client=ra_client_re, client=client_re))
164 matcher = regex.match(buffer_)
165 return matcher.groupdict() if matcher else None
166
167 def run(self, extras):
168 action = 'pull'
169 self.create_svn_config()
170 self.start()
171
172 first_response = self.get_first_client_response()
173 if not first_response:
174 self.fail("Repository name cannot be extracted")
175 return 1
176
177 url_parts = urlparse.urlparse(first_response['url'])
178 self.server.repo_name = url_parts.path.strip('/')
179
180 exit_code = self.server._check_permissions(action)
181 if exit_code:
182 return exit_code
183
184 # set the readonly flag to False if we have proper permissions
185 if self.server.has_write_perm():
186 self.read_only = False
187 self.server.update_environment(action=action, extras=extras)
188
189 self.patch_first_client_response(first_response)
190 self.sync()
191 return self.return_code
192
193
194 class SubversionServer(VcsServer):
195 backend = 'svn'
196
197 def __init__(self, store, ini_path, repo_name,
198 user, user_permissions, config, env):
199 super(SubversionServer, self)\
200 .__init__(user, user_permissions, config, env)
201 self.store = store
202 self.ini_path = ini_path
203 # this is set in .run() from input stream
204 self.repo_name = repo_name
205 self._path = self.svn_path = config.get(
206 'app:main', 'ssh.executable.svn')
207
208 self.tunnel = SubversionTunnelWrapper(server=self)
209
210 def _handle_tunnel(self, extras):
211
212 # pre-auth
213 action = 'pull'
214 # Special case for SVN, we extract repo name at later stage
215 # exit_code = self._check_permissions(action)
216 # if exit_code:
217 # return exit_code, False
218
219 req = self.env['request']
220 server_url = req.host_url + req.script_name
221 extras['server_url'] = server_url
222
223 log.debug('Using %s binaries from path %s', self.backend, self._path)
224 exit_code = self.tunnel.run(extras)
225
226 return exit_code, action == "push"
227
228
@@ -0,0 +1,62 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import os
22 import pytest
23 import ConfigParser
24
25 from rhodecode.apps.ssh_support.lib.ssh_wrapper import SshWrapper
26 from rhodecode.lib.utils2 import AttributeDict
27
28
29 @pytest.fixture
30 def dummy_conf_file(tmpdir):
31 conf = ConfigParser.ConfigParser()
32 conf.add_section('app:main')
33 conf.set('app:main', 'ssh.executable.hg', '/usr/bin/hg')
34 conf.set('app:main', 'ssh.executable.git', '/usr/bin/git')
35 conf.set('app:main', 'ssh.executable.svn', '/usr/bin/svnserve')
36
37 f_path = os.path.join(str(tmpdir), 'ssh_wrapper_test.ini')
38 with open(f_path, 'wb') as f:
39 conf.write(f)
40
41 return os.path.join(f_path)
42
43
44 @pytest.fixture
45 def dummy_env():
46 return {
47 'request':
48 AttributeDict(host_url='http://localhost', script_name='/')
49 }
50
51
52 @pytest.fixture
53 def dummy_user():
54 return AttributeDict(username='test_user')
55
56
57 @pytest.fixture
58 def ssh_wrapper(app, dummy_conf_file, dummy_env):
59 conn_info = '127.0.0.1 22 10.0.0.1 443'
60 return SshWrapper(
61 'random command', conn_info, 'auto', 'admin', '1', key_id='1',
62 shell=False, ini_path=dummy_conf_file, env=dummy_env)
@@ -1,81 +1,83 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import os
21 import os
22 import sys
22 import sys
23 import logging
23 import logging
24
24
25 import click
25 import click
26
26
27 from pyramid.paster import bootstrap, setup_logging
27 from pyramid.paster import bootstrap, setup_logging
28 from pyramid.request import Request
28 from pyramid.request import Request
29
29
30 from .backends import SshWrapper
30 from .backends import SshWrapper
31
31
32 log = logging.getLogger(__name__)
32 log = logging.getLogger(__name__)
33
33
34
34
35 def setup_custom_logging(ini_path, debug):
35 def setup_custom_logging(ini_path, debug):
36 if debug:
36 if debug:
37 # enabled rhodecode.ini controlled logging setup
37 # enabled rhodecode.ini controlled logging setup
38 setup_logging(ini_path)
38 setup_logging(ini_path)
39 else:
39 else:
40 # configure logging in a mode that doesn't print anything.
40 # configure logging in a mode that doesn't print anything.
41 # in case of regularly configured logging it gets printed out back
41 # in case of regularly configured logging it gets printed out back
42 # to the client doing an SSH command.
42 # to the client doing an SSH command.
43 logger = logging.getLogger('')
43 logger = logging.getLogger('')
44 null = logging.NullHandler()
44 null = logging.NullHandler()
45 # add the handler to the root logger
45 # add the handler to the root logger
46 logger.handlers = [null]
46 logger.handlers = [null]
47
47
48
48
49 @click.command()
49 @click.command()
50 @click.argument('ini_path', type=click.Path(exists=True))
50 @click.argument('ini_path', type=click.Path(exists=True))
51 @click.option(
51 @click.option(
52 '--mode', '-m', required=False, default='auto',
52 '--mode', '-m', required=False, default='auto',
53 type=click.Choice(['auto', 'vcs', 'git', 'hg', 'svn', 'test']),
53 type=click.Choice(['auto', 'vcs', 'git', 'hg', 'svn', 'test']),
54 help='mode of operation')
54 help='mode of operation')
55 @click.option('--user', help='Username for which the command will be executed')
55 @click.option('--user', help='Username for which the command will be executed')
56 @click.option('--user-id', help='User ID for which the command will be executed')
56 @click.option('--user-id', help='User ID for which the command will be executed')
57 @click.option('--key-id', help='ID of the key from the database')
57 @click.option('--key-id', help='ID of the key from the database')
58 @click.option('--shell', '-s', is_flag=True, help='Allow Shell')
58 @click.option('--shell', '-s', is_flag=True, help='Allow Shell')
59 @click.option('--debug', is_flag=True, help='Enabled detailed output logging')
59 @click.option('--debug', is_flag=True, help='Enabled detailed output logging')
60 def main(ini_path, mode, user, user_id, key_id, shell, debug):
60 def main(ini_path, mode, user, user_id, key_id, shell, debug):
61 setup_custom_logging(ini_path, debug)
61 setup_custom_logging(ini_path, debug)
62
62
63 command = os.environ.get('SSH_ORIGINAL_COMMAND', '')
63 command = os.environ.get('SSH_ORIGINAL_COMMAND', '')
64 if not command and mode not in ['test']:
64 if not command and mode not in ['test']:
65 raise ValueError(
65 raise ValueError(
66 'Unable to fetch SSH_ORIGINAL_COMMAND from environment.'
66 'Unable to fetch SSH_ORIGINAL_COMMAND from environment.'
67 'Please make sure this is set and available during execution '
67 'Please make sure this is set and available during execution '
68 'of this script.')
68 'of this script.')
69 connection_info = os.environ.get('SSH_CONNECTION', '')
69 connection_info = os.environ.get('SSH_CONNECTION', '')
70 request = Request.blank('/', base_url='http://rhodecode-ssh-wrapper/')
70
71 # TODO(marcink): configure the running host...
72 request = Request.blank('/', base_url='http://localhost:8080')
71 with bootstrap(ini_path, request=request) as env:
73 with bootstrap(ini_path, request=request) as env:
72 try:
74 try:
73 ssh_wrapper = SshWrapper(
75 ssh_wrapper = SshWrapper(
74 command, connection_info, mode,
76 command, connection_info, mode,
75 user, user_id, key_id, shell, ini_path)
77 user, user_id, key_id, shell, ini_path, env)
76 except Exception:
78 except Exception:
77 log.exception('Failed to execute SshWrapper')
79 log.exception('Failed to execute SshWrapper')
78 sys.exit(-5)
80 sys.exit(-5)
79
81
80 return_code = ssh_wrapper.wrap()
82 return_code = ssh_wrapper.wrap()
81 sys.exit(return_code)
83 sys.exit(return_code)
@@ -1,196 +1,145 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import json
21 import json
22
22 import mock
23 import pytest
23 import pytest
24 from mock import Mock, patch
25
24
26 from rhodecode.apps.ssh_support.lib.backends.git import GitServer
25 from rhodecode.apps.ssh_support.lib.backends.git import GitServer
27
26 from rhodecode.apps.ssh_support.tests.conftest import dummy_env, dummy_user
28
29 @pytest.fixture
30 def git_server():
31 return GitServerCreator()
32
27
33
28
34 class GitServerCreator(object):
29 class GitServerCreator(object):
35 root = '/tmp/repo/path/'
30 root = '/tmp/repo/path/'
36 git_path = '/usr/local/bin/'
31 git_path = '/usr/local/bin/git'
37 config_data = {
32 config_data = {
38 'app:main': {
33 'app:main': {
39 'ssh.executable.git': git_path
34 'ssh.executable.git': git_path,
35 'vcs.hooks.protocol': 'http',
40 }
36 }
41 }
37 }
42 repo_name = 'test_git'
38 repo_name = 'test_git'
43 repo_mode = 'receive-pack'
39 repo_mode = 'receive-pack'
44 user = 'vcs'
40 user = dummy_user()
45
41
46 def __init__(self):
42 def __init__(self):
47 def config_get(part, key):
43 def config_get(part, key):
48 return self.config_data.get(part, {}).get(key)
44 return self.config_data.get(part, {}).get(key)
49 self.config_mock = Mock()
45 self.config_mock = mock.Mock()
50 self.config_mock.get = Mock(side_effect=config_get)
46 self.config_mock.get = mock.Mock(side_effect=config_get)
51
47
52 def create(self, **kwargs):
48 def create(self, **kwargs):
53 parameters = {
49 parameters = {
54 'store': {'path': self.root},
50 'store': self.root,
55 'ini_path': '',
51 'ini_path': '',
56 'user': self.user,
52 'user': self.user,
57 'repo_name': self.repo_name,
53 'repo_name': self.repo_name,
58 'repo_mode': self.repo_mode,
54 'repo_mode': self.repo_mode,
59 'user_permissions': {
55 'user_permissions': {
60 self.repo_name: 'repo_admin'
56 self.repo_name: 'repository.admin'
61 },
57 },
62 'config': self.config_mock,
58 'config': self.config_mock,
59 'env': dummy_env()
63 }
60 }
64 parameters.update(kwargs)
61 parameters.update(kwargs)
65 server = GitServer(**parameters)
62 server = GitServer(**parameters)
66 return server
63 return server
67
64
68
65
66 @pytest.fixture
67 def git_server(app):
68 return GitServerCreator()
69
70
69 class TestGitServer(object):
71 class TestGitServer(object):
72
70 def test_command(self, git_server):
73 def test_command(self, git_server):
71 server = git_server.create()
74 server = git_server.create()
72 server.read_only = False
73 expected_command = (
75 expected_command = (
74 'cd {root}; {git_path}-{repo_mode}'
76 'cd {root}; {git_path} {repo_mode} \'{root}{repo_name}\''.format(
75 ' \'{root}{repo_name}\''.format(
76 root=git_server.root, git_path=git_server.git_path,
77 root=git_server.root, git_path=git_server.git_path,
77 repo_mode=git_server.repo_mode, repo_name=git_server.repo_name)
78 repo_mode=git_server.repo_mode, repo_name=git_server.repo_name)
78 )
79 )
79 assert expected_command == server.command
80 assert expected_command == server.tunnel.command()
81
82 @pytest.mark.parametrize('permissions, action, code', [
83 ({}, 'pull', -2),
84 ({'test_git': 'repository.read'}, 'pull', 0),
85 ({'test_git': 'repository.read'}, 'push', -2),
86 ({'test_git': 'repository.write'}, 'push', 0),
87 ({'test_git': 'repository.admin'}, 'push', 0),
80
88
81 def test_run_returns_exit_code_2_when_no_permissions(self, git_server, caplog):
89 ])
90 def test_permission_checks(self, git_server, permissions, action, code):
91 server = git_server.create(user_permissions=permissions)
92 result = server._check_permissions(action)
93 assert result is code
94
95 @pytest.mark.parametrize('permissions, value', [
96 ({}, False),
97 ({'test_git': 'repository.read'}, False),
98 ({'test_git': 'repository.write'}, True),
99 ({'test_git': 'repository.admin'}, True),
100
101 ])
102 def test_has_write_permissions(self, git_server, permissions, value):
103 server = git_server.create(user_permissions=permissions)
104 result = server.has_write_perm()
105 assert result is value
106
107 def test_run_returns_executes_command(self, git_server):
82 server = git_server.create()
108 server = git_server.create()
83 with patch.object(server, '_check_permissions') as permissions_mock:
109 from rhodecode.apps.ssh_support.lib.backends.git import GitTunnelWrapper
84 with patch.object(server, '_update_environment'):
110 with mock.patch.object(GitTunnelWrapper, 'create_hooks_env') as _patch:
85 permissions_mock.return_value = 2
111 _patch.return_value = 0
112 with mock.patch.object(GitTunnelWrapper, 'command', return_value='date'):
86 exit_code = server.run()
113 exit_code = server.run()
87
114
88 assert exit_code == (2, False)
115 assert exit_code == (0, False)
89
90 def test_run_returns_executes_command(self, git_server, caplog):
91 server = git_server.create()
92 with patch.object(server, '_check_permissions') as permissions_mock:
93 with patch('os.system') as system_mock:
94 with patch.object(server, '_update_environment') as (
95 update_mock):
96 permissions_mock.return_value = 0
97 system_mock.return_value = 0
98 exit_code = server.run()
99
100 system_mock.assert_called_once_with(server.command)
101 update_mock.assert_called_once_with()
102
103 assert exit_code == (0, True)
104
116
105 @pytest.mark.parametrize(
117 @pytest.mark.parametrize(
106 'repo_mode, action', [
118 'repo_mode, action', [
107 ['receive-pack', 'push'],
119 ['receive-pack', 'push'],
108 ['upload-pack', 'pull']
120 ['upload-pack', 'pull']
109 ])
121 ])
110 def test_update_environment(self, git_server, repo_mode, action):
122 def test_update_environment(self, git_server, repo_mode, action):
111 server = git_server.create(repo_mode=repo_mode)
123 server = git_server.create(repo_mode=repo_mode)
112 with patch('os.environ', {'SSH_CLIENT': '10.10.10.10 b'}):
124 with mock.patch('os.environ', {'SSH_CLIENT': '10.10.10.10 b'}):
113 with patch('os.putenv') as putenv_mock:
125 with mock.patch('os.putenv') as putenv_mock:
114 server._update_environment()
126 server.update_environment(action)
115
127
116 expected_data = {
128 expected_data = {
117 "username": git_server.user,
129 'username': git_server.user.username,
118 "scm": "git",
130 'scm': 'git',
119 "repository": git_server.repo_name,
131 'repository': git_server.repo_name,
120 "make_lock": None,
132 'make_lock': None,
121 "action": [action],
133 'action': action,
122 "ip": "10.10.10.10",
134 'ip': '10.10.10.10',
123 "locked_by": [None, None],
135 'locked_by': [None, None],
124 "config": ""
136 'config': '',
137 'server_url': None,
138 'hooks': ['push', 'pull'],
139 'is_shadow_repo': False,
140 'hooks_module': 'rhodecode.lib.hooks_daemon',
141 'SSH': True,
142 'SSH_PERMISSIONS': 'repository.admin',
125 }
143 }
126 args, kwargs = putenv_mock.call_args
144 args, kwargs = putenv_mock.call_args
127 assert json.loads(args[1]) == expected_data
145 assert json.loads(args[1]) == expected_data
128
129
130 class TestGitServerCheckPermissions(object):
131 def test_returns_2_when_no_permissions_found(self, git_server, caplog):
132 user_permissions = {}
133 server = git_server.create(user_permissions=user_permissions)
134 result = server._check_permissions()
135 assert result == 2
136
137 log_msg = 'permission for vcs on test_git are: None'
138 assert log_msg in [t[2] for t in caplog.record_tuples]
139
140 def test_returns_2_when_no_permissions(self, git_server, caplog):
141 user_permissions = {git_server.repo_name: 'repository.none'}
142 server = git_server.create(user_permissions=user_permissions)
143 result = server._check_permissions()
144 assert result == 2
145
146 log_msg = 'repo not found or no permissions'
147 assert log_msg in [t[2] for t in caplog.record_tuples]
148
149 @pytest.mark.parametrize(
150 'permission', ['repository.admin', 'repository.write'])
151 def test_access_allowed_when_user_has_write_permissions(
152 self, git_server, permission, caplog):
153 user_permissions = {git_server.repo_name: permission}
154 server = git_server.create(user_permissions=user_permissions)
155 result = server._check_permissions()
156 assert result is None
157
158 log_msg = 'Write Permissions for User "%s" granted to repo "%s"!' % (
159 git_server.user, git_server.repo_name)
160 assert log_msg in [t[2] for t in caplog.record_tuples]
161
162 def test_write_access_is_not_allowed_when_user_has_read_permission(
163 self, git_server, caplog):
164 user_permissions = {git_server.repo_name: 'repository.read'}
165 server = git_server.create(
166 user_permissions=user_permissions, repo_mode='receive-pack')
167 result = server._check_permissions()
168 assert result == -3
169
170 log_msg = 'Only Read Only access for User "%s" granted to repo "%s"! Failing!' % (
171 git_server.user, git_server.repo_name)
172 assert log_msg in [t[2] for t in caplog.record_tuples]
173
174 def test_read_access_allowed_when_user_has_read_permission(
175 self, git_server, caplog):
176 user_permissions = {git_server.repo_name: 'repository.read'}
177 server = git_server.create(
178 user_permissions=user_permissions, repo_mode='upload-pack')
179 result = server._check_permissions()
180 assert result is None
181
182 log_msg = 'Only Read Only access for User "%s" granted to repo "%s"!' % (
183 git_server.user, git_server.repo_name)
184 assert log_msg in [t[2] for t in caplog.record_tuples]
185
186 def test_returns_error_when_permission_not_recognised(
187 self, git_server, caplog):
188 user_permissions = {git_server.repo_name: 'repository.whatever'}
189 server = git_server.create(
190 user_permissions=user_permissions, repo_mode='upload-pack')
191 result = server._check_permissions()
192 assert result == -2
193
194 log_msg = 'Cannot properly fetch user permission. ' \
195 'Return value is: repository.whatever'
196 assert log_msg in [t[2] for t in caplog.record_tuples] No newline at end of file
@@ -1,146 +1,116 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import mock
21 import pytest
22 import pytest
22 from mock import Mock, patch
23
23
24 from rhodecode.apps.ssh_support.lib.backends.hg import MercurialServer
24 from rhodecode.apps.ssh_support.lib.backends.hg import MercurialServer
25
25 from rhodecode.apps.ssh_support.tests.conftest import dummy_env, dummy_user
26
27 @pytest.fixture
28 def hg_server():
29 return MercurialServerCreator()
30
26
31
27
32 class MercurialServerCreator(object):
28 class MercurialServerCreator(object):
33 root = '/tmp/repo/path/'
29 root = '/tmp/repo/path/'
34 hg_path = '/usr/local/bin/hg'
30 hg_path = '/usr/local/bin/hg'
35
31
36 config_data = {
32 config_data = {
37 'app:main': {
33 'app:main': {
38 'ssh.executable.hg': hg_path
34 'ssh.executable.hg': hg_path,
35 'vcs.hooks.protocol': 'http',
39 }
36 }
40 }
37 }
41 repo_name = 'test_hg'
38 repo_name = 'test_hg'
42 user = 'vcs'
39 user = dummy_user()
43
40
44 def __init__(self):
41 def __init__(self):
45 def config_get(part, key):
42 def config_get(part, key):
46 return self.config_data.get(part, {}).get(key)
43 return self.config_data.get(part, {}).get(key)
47 self.config_mock = Mock()
44 self.config_mock = mock.Mock()
48 self.config_mock.get = Mock(side_effect=config_get)
45 self.config_mock.get = mock.Mock(side_effect=config_get)
49
46
50 def create(self, **kwargs):
47 def create(self, **kwargs):
51 parameters = {
48 parameters = {
52 'store': {'path': self.root},
49 'store': self.root,
53 'ini_path': '',
50 'ini_path': '',
54 'user': self.user,
51 'user': self.user,
55 'repo_name': self.repo_name,
52 'repo_name': self.repo_name,
56 'user_permissions': {
53 'user_permissions': {
57 'test_hg': 'repo_admin'
54 'test_hg': 'repository.admin'
58 },
55 },
59 'config': self.config_mock,
56 'config': self.config_mock,
57 'env': dummy_env()
60 }
58 }
61 parameters.update(kwargs)
59 parameters.update(kwargs)
62 server = MercurialServer(**parameters)
60 server = MercurialServer(**parameters)
63 return server
61 return server
64
62
65
63
64 @pytest.fixture
65 def hg_server(app):
66 return MercurialServerCreator()
67
68
66 class TestMercurialServer(object):
69 class TestMercurialServer(object):
67 def test_read_only_command(self, hg_server):
70
71 def test_command(self, hg_server):
68 server = hg_server.create()
72 server = hg_server.create()
69 server.read_only = True
70 expected_command = (
73 expected_command = (
71 'cd {root}; {hg_path} -R {root}{repo_name} serve --stdio'
74 'cd {root}; {hg_path} -R {root}{repo_name} serve --stdio'.format(
72 ' --config hooks.pretxnchangegroup="false"'.format(
73 root=hg_server.root, hg_path=hg_server.hg_path,
74 repo_name=hg_server.repo_name)
75 )
76 assert expected_command == server.command
77
78 def test_normal_command(self, hg_server):
79 server = hg_server.create()
80 server.read_only = False
81 expected_command = (
82 'cd {root}; {hg_path} -R {root}{repo_name} serve --stdio '.format(
83 root=hg_server.root, hg_path=hg_server.hg_path,
75 root=hg_server.root, hg_path=hg_server.hg_path,
84 repo_name=hg_server.repo_name)
76 repo_name=hg_server.repo_name)
85 )
77 )
86 assert expected_command == server.command
78 assert expected_command == server.tunnel.command()
87
88 def test_access_rejected_when_permissions_are_not_found(self, hg_server, caplog):
89 user_permissions = {}
90 server = hg_server.create(user_permissions=user_permissions)
91 result = server._check_permissions()
92 assert result is False
93
94 log_msg = 'repo not found or no permissions'
95 assert log_msg in [t[2] for t in caplog.record_tuples]
96
79
97 def test_access_rejected_when_no_permissions(self, hg_server, caplog):
80 @pytest.mark.parametrize('permissions, action, code', [
98 user_permissions = {hg_server.repo_name: 'repository.none'}
81 ({}, 'pull', -2),
99 server = hg_server.create(user_permissions=user_permissions)
82 ({'test_hg': 'repository.read'}, 'pull', 0),
100 result = server._check_permissions()
83 ({'test_hg': 'repository.read'}, 'push', -2),
101 assert result is False
84 ({'test_hg': 'repository.write'}, 'push', 0),
102
85 ({'test_hg': 'repository.admin'}, 'push', 0),
103 log_msg = 'repo not found or no permissions'
104 assert log_msg in [t[2] for t in caplog.record_tuples]
105
86
106 @pytest.mark.parametrize(
87 ])
107 'permission', ['repository.admin', 'repository.write'])
88 def test_permission_checks(self, hg_server, permissions, action, code):
108 def test_access_allowed_when_user_has_write_permissions(
89 server = hg_server.create(user_permissions=permissions)
109 self, hg_server, permission, caplog):
90 result = server._check_permissions(action)
110 user_permissions = {hg_server.repo_name: permission}
91 assert result is code
111 server = hg_server.create(user_permissions=user_permissions)
112 result = server._check_permissions()
113 assert result is True
114
92
115 assert server.read_only is False
93 @pytest.mark.parametrize('permissions, value', [
116 log_msg = 'Write Permissions for User "vcs" granted to repo "test_hg"!'
94 ({}, False),
117 assert log_msg in [t[2] for t in caplog.record_tuples]
95 ({'test_hg': 'repository.read'}, False),
118
96 ({'test_hg': 'repository.write'}, True),
119 def test_access_allowed_when_user_has_read_permissions(self, hg_server, caplog):
97 ({'test_hg': 'repository.admin'}, True),
120 user_permissions = {hg_server.repo_name: 'repository.read'}
121 server = hg_server.create(user_permissions=user_permissions)
122 result = server._check_permissions()
123 assert result is True
124
98
125 assert server.read_only is True
99 ])
126 log_msg = 'Only Read Only access for User "%s" granted to repo "%s"!' % (
100 def test_has_write_permissions(self, hg_server, permissions, value):
127 hg_server.user, hg_server.repo_name)
101 server = hg_server.create(user_permissions=permissions)
128 assert log_msg in [t[2] for t in caplog.record_tuples]
102 result = server.has_write_perm()
103 assert result is value
129
104
130 def test_run_returns_exit_code_2_when_no_permissions(self, hg_server, caplog):
105 def test_run_returns_executes_command(self, hg_server):
131 server = hg_server.create()
106 server = hg_server.create()
132 with patch.object(server, '_check_permissions') as permissions_mock:
107 from rhodecode.apps.ssh_support.lib.backends.hg import MercurialTunnelWrapper
133 permissions_mock.return_value = False
108 with mock.patch.object(MercurialTunnelWrapper, 'create_hooks_env') as _patch:
134 exit_code = server.run()
109 _patch.return_value = 0
135 assert exit_code == (2, False)
110 with mock.patch.object(MercurialTunnelWrapper, 'command', return_value='date'):
136
137 def test_run_returns_executes_command(self, hg_server, caplog):
138 server = hg_server.create()
139 with patch.object(server, '_check_permissions') as permissions_mock:
140 with patch('os.system') as system_mock:
141 permissions_mock.return_value = True
142 system_mock.return_value = 0
143 exit_code = server.run()
111 exit_code = server.run()
144
112
145 system_mock.assert_called_once_with(server.command)
146 assert exit_code == (0, False)
113 assert exit_code == (0, False)
114
115
116
@@ -1,136 +1,124 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import mock
21 import pytest
22 import pytest
22 from mock import Mock, patch
23
23
24 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionServer
24 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionServer
25
25 from rhodecode.apps.ssh_support.tests.conftest import dummy_env, dummy_user
26
27 @pytest.fixture
28 def svn_server():
29 return SubversionServerCreator()
30
26
31
27
32 class SubversionServerCreator(object):
28 class SubversionServerCreator(object):
33 root = '/tmp/repo/path/'
29 root = '/tmp/repo/path/'
34 svn_path = '/usr/local/bin/svnserve'
30 svn_path = '/usr/local/bin/svnserve'
35 config_data = {
31 config_data = {
36 'app:main': {
32 'app:main': {
37 'ssh.executable.svn': svn_path
33 'ssh.executable.svn': svn_path,
34 'vcs.hooks.protocol': 'http',
38 }
35 }
39 }
36 }
40 repo_name = 'test-svn'
37 repo_name = 'test-svn'
41 user = 'vcs'
38 user = dummy_user()
42
39
43 def __init__(self):
40 def __init__(self):
44 def config_get(part, key):
41 def config_get(part, key):
45 return self.config_data.get(part, {}).get(key)
42 return self.config_data.get(part, {}).get(key)
46 self.config_mock = Mock()
43 self.config_mock = mock.Mock()
47 self.config_mock.get = Mock(side_effect=config_get)
44 self.config_mock.get = mock.Mock(side_effect=config_get)
48
45
49 def create(self, **kwargs):
46 def create(self, **kwargs):
50 parameters = {
47 parameters = {
51 'store': {'path': self.root},
48 'store': self.root,
49 'repo_name': self.repo_name,
52 'ini_path': '',
50 'ini_path': '',
53 'user': self.user,
51 'user': self.user,
54 'user_permissions': {
52 'user_permissions': {
55 self.repo_name: 'repo_admin'
53 self.repo_name: 'repository.admin'
56 },
54 },
57 'config': self.config_mock,
55 'config': self.config_mock,
56 'env': dummy_env()
58 }
57 }
58
59 parameters.update(kwargs)
59 parameters.update(kwargs)
60 server = SubversionServer(**parameters)
60 server = SubversionServer(**parameters)
61 return server
61 return server
62
62
63
63
64 @pytest.fixture
65 def svn_server(app):
66 return SubversionServerCreator()
67
68
64 class TestSubversionServer(object):
69 class TestSubversionServer(object):
65 def test_timeout_returns_value_from_config(self, svn_server):
70 def test_command(self, svn_server):
66 server = svn_server.create()
71 server = svn_server.create()
67 assert server.timeout == 30
72 expected_command = [
73 svn_server.svn_path, '-t', '--config-file',
74 server.tunnel.svn_conf_path, '-r', svn_server.root
75 ]
68
76
69 @pytest.mark.parametrize(
77 assert expected_command == server.tunnel.command()
70 'permission', ['repository.admin', 'repository.write'])
71 def test_check_permissions_with_write_permissions(
72 self, svn_server, permission):
73 user_permissions = {svn_server.repo_name: permission}
74 server = svn_server.create(user_permissions=user_permissions)
75 server.tunnel = Mock()
76 server.repo_name = svn_server.repo_name
77 result = server._check_permissions()
78 assert result is True
79 assert server.tunnel.read_only is False
80
78
81 def test_check_permissions_with_read_permissions(self, svn_server):
79 @pytest.mark.parametrize('permissions, action, code', [
82 user_permissions = {svn_server.repo_name: 'repository.read'}
80 ({}, 'pull', -2),
83 server = svn_server.create(user_permissions=user_permissions)
81 ({'test-svn': 'repository.read'}, 'pull', 0),
84 server.tunnel = Mock()
82 ({'test-svn': 'repository.read'}, 'push', -2),
85 server.repo_name = svn_server.repo_name
83 ({'test-svn': 'repository.write'}, 'push', 0),
86 result = server._check_permissions()
84 ({'test-svn': 'repository.admin'}, 'push', 0),
87 assert result is True
85
88 assert server.tunnel.read_only is True
86 ])
87 def test_permission_checks(self, svn_server, permissions, action, code):
88 server = svn_server.create(user_permissions=permissions)
89 result = server._check_permissions(action)
90 assert result is code
89
91
90 def test_check_permissions_with_no_permissions(self, svn_server, caplog):
92 def test_run_returns_executes_command(self, svn_server):
91 tunnel_mock = Mock()
92 user_permissions = {}
93 server = svn_server.create(user_permissions=user_permissions)
94 server.tunnel = tunnel_mock
95 server.repo_name = svn_server.repo_name
96 result = server._check_permissions()
97 assert result is False
98 tunnel_mock.fail.assert_called_once_with(
99 "Not enough permissions for repository {}".format(
100 svn_server.repo_name))
101
102 def test_run_returns_1_when_repository_name_cannot_be_extracted(
103 self, svn_server):
104 server = svn_server.create()
105 with patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.SubversionTunnelWrapper') as tunnel_mock:
106 tunnel_mock().get_first_client_response.return_value = None
107 exit_code = server.run()
108 assert exit_code == (1, False)
109 tunnel_mock().fail.assert_called_once_with(
110 'Repository name cannot be extracted')
111
112 def test_run_returns_tunnel_return_code(self, svn_server, caplog):
113 server = svn_server.create()
93 server = svn_server.create()
114 fake_response = {
94 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionTunnelWrapper
115 'url': 'ssh+svn://test@example.com/test-svn/'
95 with mock.patch.object(
116 }
96 SubversionTunnelWrapper, 'get_first_client_response',
117 with patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.SubversionTunnelWrapper') as tunnel_mock:
97 return_value={'url': 'http://server/test-svn'}):
118 with patch.object(server, '_check_permissions') as (
98 with mock.patch.object(
119 permissions_mock):
99 SubversionTunnelWrapper, 'patch_first_client_response',
120 permissions_mock.return_value = True
100 return_value=0):
121 tunnel = tunnel_mock()
101 with mock.patch.object(
122 tunnel.get_first_client_response.return_value = fake_response
102 SubversionTunnelWrapper, 'sync',
123 tunnel.return_code = 0
103 return_value=0):
124 exit_code = server.run()
104 with mock.patch.object(
125 permissions_mock.assert_called_once_with()
105 SubversionTunnelWrapper, 'command',
106 return_value='date'):
126
107
127 expected_log_calls = sorted([
108 exit_code = server.run()
128 "Using subversion binaries from '%s'" % svn_server.svn_path
109 # SVN has this differently configured, and we get in our mock env
129 ])
110 # None as return code
111 assert exit_code == (None, False)
130
112
131 assert expected_log_calls == [t[2] for t in caplog.record_tuples]
113 def test_run_returns_executes_command_that_cannot_extract_repo_name(self, svn_server):
114 server = svn_server.create()
115 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionTunnelWrapper
116 with mock.patch.object(
117 SubversionTunnelWrapper, 'command',
118 return_value='date'):
119 with mock.patch.object(
120 SubversionTunnelWrapper, 'get_first_client_response',
121 return_value=None):
122 exit_code = server.run()
132
123
133 assert exit_code == (0, False)
124 assert exit_code == (1, False)
134 tunnel.patch_first_client_response.assert_called_once_with(
135 fake_response)
136 tunnel.sync.assert_called_once_with()
@@ -1,201 +1,54 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2017 RhodeCode GmbH
3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 import os
20
21 import mock
22 import pytest
21 import pytest
23 import ConfigParser
24
25 from rhodecode.apps.ssh_support.lib.ssh_wrapper import SshWrapper
26
27
28 @pytest.fixture
29 def dummy_conf(tmpdir):
30 conf = ConfigParser.ConfigParser()
31 conf.add_section('app:main')
32 conf.set('app:main', 'ssh.executable.hg', '/usr/bin/hg')
33 conf.set('app:main', 'ssh.executable.git', '/usr/bin/git')
34 conf.set('app:main', 'ssh.executable.svn', '/usr/bin/svnserve')
35
36 f_path = os.path.join(str(tmpdir), 'ssh_wrapper_test.ini')
37 with open(f_path, 'wb') as f:
38 conf.write(f)
39
40 return os.path.join(f_path)
41
42
43 class TestGetRepoDetails(object):
44 @pytest.mark.parametrize(
45 'command', [
46 'hg -R test-repo serve --stdio',
47 'hg -R test-repo serve --stdio'
48 ])
49 def test_hg_command_matched(self, command, dummy_conf):
50 wrapper = SshWrapper(command, 'auto', 'admin', '3', 'False', dummy_conf)
51 type_, name, mode = wrapper.get_repo_details('auto')
52 assert type_ == 'hg'
53 assert name == 'test-repo'
54 assert mode is 'auto'
55
56 @pytest.mark.parametrize(
57 'command', [
58 'hg test-repo serve --stdio',
59 'hg -R test-repo serve',
60 'hg serve --stdio',
61 'hg serve -R test-repo'
62 ])
63 def test_hg_command_not_matched(self, command, dummy_conf):
64 wrapper = SshWrapper(command, 'auto', 'admin', '3', 'False', dummy_conf)
65 type_, name, mode = wrapper.get_repo_details('auto')
66 assert type_ is None
67 assert name is None
68 assert mode is 'auto'
69
70
71 class TestServe(object):
72 def test_serve_raises_an_exception_when_vcs_is_not_recognized(self, dummy_conf):
73 with mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store'):
74 wrapper = SshWrapper('random command', 'auto', 'admin', '3', 'False', dummy_conf)
75
76 with pytest.raises(Exception) as exc_info:
77 wrapper.serve(
78 vcs='microsoft-tfs', repo='test-repo', mode=None, user='test',
79 permissions={})
80 assert exc_info.value.message == 'Unrecognised VCS: microsoft-tfs'
81
22
82
23
83 class TestServeHg(object):
24 class TestSSHWrapper(object):
84
85 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache')
86 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions')
87 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store')
88 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.MercurialServer.run')
89 def test_serve_creates_hg_instance(
90 self, mercurial_run_mock, get_repo_store_mock, get_user_mock,
91 invalidate_cache_mock, dummy_conf):
92
93 repo_name = None
94 mercurial_run_mock.return_value = 0, True
95 get_user_mock.return_value = {repo_name: 'repository.admin'}
96 get_repo_store_mock.return_value = {'path': '/tmp'}
97
98 wrapper = SshWrapper('date', 'hg', 'admin', '3', 'False',
99 dummy_conf)
100 exit_code = wrapper.wrap()
101 assert exit_code == 0
102 assert mercurial_run_mock.called
103
104 assert get_repo_store_mock.called
105 assert get_user_mock.called
106 invalidate_cache_mock.assert_called_once_with(repo_name)
107
25
108 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache')
26 def test_serve_raises_an_exception_when_vcs_is_not_recognized(self, ssh_wrapper):
109 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions')
27 with pytest.raises(Exception) as exc_info:
110 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store')
28 ssh_wrapper.serve(
111 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.MercurialServer.run')
29 vcs='microsoft-tfs', repo='test-repo', mode=None, user='test',
112 def test_serve_hg_invalidates_cache(
30 permissions={})
113 self, mercurial_run_mock, get_repo_store_mock, get_user_mock,
31 assert exc_info.value.message == 'Unrecognised VCS: microsoft-tfs'
114 invalidate_cache_mock, dummy_conf):
115
116 repo_name = None
117 mercurial_run_mock.return_value = 0, True
118 get_user_mock.return_value = {repo_name: 'repository.admin'}
119 get_repo_store_mock.return_value = {'path': '/tmp'}
120
32
121 wrapper = SshWrapper('date', 'hg', 'admin', '3', 'False',
33 def test_parse_config(self, ssh_wrapper):
122 dummy_conf)
34 config = ssh_wrapper.parse_config(ssh_wrapper.ini_path)
123 exit_code = wrapper.wrap()
35 assert config
124 assert exit_code == 0
125 assert mercurial_run_mock.called
126
127 assert get_repo_store_mock.called
128 assert get_user_mock.called
129 invalidate_cache_mock.assert_called_once_with(repo_name)
130
131
132 class TestServeGit(object):
133
36
134 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache')
37 def test_get_connection_info(self, ssh_wrapper):
135 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions')
38 conn_info = ssh_wrapper.get_connection_info()
136 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store')
39 assert {'client_ip': '127.0.0.1',
137 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.GitServer.run')
40 'client_port': '22',
138 def test_serve_creates_git_instance(self, git_run_mock, get_repo_store_mock, get_user_mock,
41 'server_ip': '10.0.0.1',
139 invalidate_cache_mock, dummy_conf):
42 'server_port': '443'} == conn_info
140 repo_name = None
141 git_run_mock.return_value = 0, True
142 get_user_mock.return_value = {repo_name: 'repository.admin'}
143 get_repo_store_mock.return_value = {'path': '/tmp'}
144
145 wrapper = SshWrapper('date', 'git', 'admin', '3', 'False',
146 dummy_conf)
147
148 exit_code = wrapper.wrap()
149 assert exit_code == 0
150 assert git_run_mock.called
151 assert get_repo_store_mock.called
152 assert get_user_mock.called
153 invalidate_cache_mock.assert_called_once_with(repo_name)
154
155 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache')
156 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions')
157 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store')
158 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.GitServer.run')
159 def test_serve_git_invalidates_cache(
160 self, git_run_mock, get_repo_store_mock, get_user_mock,
161 invalidate_cache_mock, dummy_conf):
162 repo_name = None
163 git_run_mock.return_value = 0, True
164 get_user_mock.return_value = {repo_name: 'repository.admin'}
165 get_repo_store_mock.return_value = {'path': '/tmp'}
166
43
167 wrapper = SshWrapper('date', 'git', 'admin', '3', 'False', dummy_conf)
44 @pytest.mark.parametrize('command, vcs', [
168
45 ('xxx', None),
169 exit_code = wrapper.wrap()
46 ('svnserve -t', 'svn'),
170 assert exit_code == 0
47 ('hg -R repo serve --stdio', 'hg'),
171 assert git_run_mock.called
48 ('git-receive-pack \'repo.git\'', 'git'),
172
173 assert get_repo_store_mock.called
174 assert get_user_mock.called
175 invalidate_cache_mock.assert_called_once_with(repo_name)
176
177
178 class TestServeSvn(object):
179
49
180 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.invalidate_cache')
50 ])
181 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_user_permissions')
51 def test_get_repo_details(self, ssh_wrapper, command, vcs):
182 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.RhodeCodeApiClient.get_repo_store')
52 ssh_wrapper.command = command
183 @mock.patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.SubversionServer.run')
53 vcs_type, repo_name, mode = ssh_wrapper.get_repo_details(mode='auto')
184 def test_serve_creates_svn_instance(
54 assert vcs_type == vcs
185 self, svn_run_mock, get_repo_store_mock, get_user_mock,
186 invalidate_cache_mock, dummy_conf):
187
188 repo_name = None
189 svn_run_mock.return_value = 0, True
190 get_user_mock.return_value = {repo_name: 'repository.admin'}
191 get_repo_store_mock.return_value = {'path': '/tmp'}
192
193 wrapper = SshWrapper('date', 'svn', 'admin', '3', 'False', dummy_conf)
194
195 exit_code = wrapper.wrap()
196 assert exit_code == 0
197 assert svn_run_mock.called
198
199 assert get_repo_store_mock.called
200 assert get_user_mock.called
201 invalidate_cache_mock.assert_called_once_with(repo_name)
@@ -1,635 +1,634 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2010-2017 RhodeCode GmbH
3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 """
21 """
22 The base Controller API
22 The base Controller API
23 Provides the BaseController class for subclassing. And usage in different
23 Provides the BaseController class for subclassing. And usage in different
24 controllers
24 controllers
25 """
25 """
26
26
27 import logging
27 import logging
28 import socket
28 import socket
29
29
30 import markupsafe
30 import markupsafe
31 import ipaddress
31 import ipaddress
32 import pyramid.threadlocal
32 import pyramid.threadlocal
33
33
34 from paste.auth.basic import AuthBasicAuthenticator
34 from paste.auth.basic import AuthBasicAuthenticator
35 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
35 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
36 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
36 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
37
37
38 import rhodecode
38 import rhodecode
39 from rhodecode.authentication.base import VCS_TYPE
39 from rhodecode.authentication.base import VCS_TYPE
40 from rhodecode.lib import auth, utils2
40 from rhodecode.lib import auth, utils2
41 from rhodecode.lib import helpers as h
41 from rhodecode.lib import helpers as h
42 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
42 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
43 from rhodecode.lib.exceptions import UserCreationError
43 from rhodecode.lib.exceptions import UserCreationError
44 from rhodecode.lib.utils import (
44 from rhodecode.lib.utils import (
45 get_repo_slug, set_rhodecode_config, password_changed,
45 get_repo_slug, set_rhodecode_config, password_changed,
46 get_enabled_hook_classes)
46 get_enabled_hook_classes)
47 from rhodecode.lib.utils2 import (
47 from rhodecode.lib.utils2 import (
48 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist, safe_str)
48 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist, safe_str)
49 from rhodecode.model import meta
49 from rhodecode.model import meta
50 from rhodecode.model.db import Repository, User, ChangesetComment
50 from rhodecode.model.db import Repository, User, ChangesetComment
51 from rhodecode.model.notification import NotificationModel
51 from rhodecode.model.notification import NotificationModel
52 from rhodecode.model.scm import ScmModel
52 from rhodecode.model.scm import ScmModel
53 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
53 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
54
54
55 # NOTE(marcink): remove after base controller is no longer required
55 # NOTE(marcink): remove after base controller is no longer required
56 from pylons.controllers import WSGIController
56 from pylons.controllers import WSGIController
57 from pylons.i18n import translation
57 from pylons.i18n import translation
58
58
59 log = logging.getLogger(__name__)
59 log = logging.getLogger(__name__)
60
60
61
61
62 # hack to make the migration to pyramid easier
62 # hack to make the migration to pyramid easier
63 def render(template_name, extra_vars=None, cache_key=None,
63 def render(template_name, extra_vars=None, cache_key=None,
64 cache_type=None, cache_expire=None):
64 cache_type=None, cache_expire=None):
65 """Render a template with Mako
65 """Render a template with Mako
66
66
67 Accepts the cache options ``cache_key``, ``cache_type``, and
67 Accepts the cache options ``cache_key``, ``cache_type``, and
68 ``cache_expire``.
68 ``cache_expire``.
69
69
70 """
70 """
71 from pylons.templating import literal
71 from pylons.templating import literal
72 from pylons.templating import cached_template, pylons_globals
72 from pylons.templating import cached_template, pylons_globals
73
73
74 # Create a render callable for the cache function
74 # Create a render callable for the cache function
75 def render_template():
75 def render_template():
76 # Pull in extra vars if needed
76 # Pull in extra vars if needed
77 globs = extra_vars or {}
77 globs = extra_vars or {}
78
78
79 # Second, get the globals
79 # Second, get the globals
80 globs.update(pylons_globals())
80 globs.update(pylons_globals())
81
81
82 globs['_ungettext'] = globs['ungettext']
82 globs['_ungettext'] = globs['ungettext']
83 # Grab a template reference
83 # Grab a template reference
84 template = globs['app_globals'].mako_lookup.get_template(template_name)
84 template = globs['app_globals'].mako_lookup.get_template(template_name)
85
85
86 return literal(template.render_unicode(**globs))
86 return literal(template.render_unicode(**globs))
87
87
88 return cached_template(template_name, render_template, cache_key=cache_key,
88 return cached_template(template_name, render_template, cache_key=cache_key,
89 cache_type=cache_type, cache_expire=cache_expire)
89 cache_type=cache_type, cache_expire=cache_expire)
90
90
91 def _filter_proxy(ip):
91 def _filter_proxy(ip):
92 """
92 """
93 Passed in IP addresses in HEADERS can be in a special format of multiple
93 Passed in IP addresses in HEADERS can be in a special format of multiple
94 ips. Those comma separated IPs are passed from various proxies in the
94 ips. Those comma separated IPs are passed from various proxies in the
95 chain of request processing. The left-most being the original client.
95 chain of request processing. The left-most being the original client.
96 We only care about the first IP which came from the org. client.
96 We only care about the first IP which came from the org. client.
97
97
98 :param ip: ip string from headers
98 :param ip: ip string from headers
99 """
99 """
100 if ',' in ip:
100 if ',' in ip:
101 _ips = ip.split(',')
101 _ips = ip.split(',')
102 _first_ip = _ips[0].strip()
102 _first_ip = _ips[0].strip()
103 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
103 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
104 return _first_ip
104 return _first_ip
105 return ip
105 return ip
106
106
107
107
108 def _filter_port(ip):
108 def _filter_port(ip):
109 """
109 """
110 Removes a port from ip, there are 4 main cases to handle here.
110 Removes a port from ip, there are 4 main cases to handle here.
111 - ipv4 eg. 127.0.0.1
111 - ipv4 eg. 127.0.0.1
112 - ipv6 eg. ::1
112 - ipv6 eg. ::1
113 - ipv4+port eg. 127.0.0.1:8080
113 - ipv4+port eg. 127.0.0.1:8080
114 - ipv6+port eg. [::1]:8080
114 - ipv6+port eg. [::1]:8080
115
115
116 :param ip:
116 :param ip:
117 """
117 """
118 def is_ipv6(ip_addr):
118 def is_ipv6(ip_addr):
119 if hasattr(socket, 'inet_pton'):
119 if hasattr(socket, 'inet_pton'):
120 try:
120 try:
121 socket.inet_pton(socket.AF_INET6, ip_addr)
121 socket.inet_pton(socket.AF_INET6, ip_addr)
122 except socket.error:
122 except socket.error:
123 return False
123 return False
124 else:
124 else:
125 # fallback to ipaddress
125 # fallback to ipaddress
126 try:
126 try:
127 ipaddress.IPv6Address(safe_unicode(ip_addr))
127 ipaddress.IPv6Address(safe_unicode(ip_addr))
128 except Exception:
128 except Exception:
129 return False
129 return False
130 return True
130 return True
131
131
132 if ':' not in ip: # must be ipv4 pure ip
132 if ':' not in ip: # must be ipv4 pure ip
133 return ip
133 return ip
134
134
135 if '[' in ip and ']' in ip: # ipv6 with port
135 if '[' in ip and ']' in ip: # ipv6 with port
136 return ip.split(']')[0][1:].lower()
136 return ip.split(']')[0][1:].lower()
137
137
138 # must be ipv6 or ipv4 with port
138 # must be ipv6 or ipv4 with port
139 if is_ipv6(ip):
139 if is_ipv6(ip):
140 return ip
140 return ip
141 else:
141 else:
142 ip, _port = ip.split(':')[:2] # means ipv4+port
142 ip, _port = ip.split(':')[:2] # means ipv4+port
143 return ip
143 return ip
144
144
145
145
146 def get_ip_addr(environ):
146 def get_ip_addr(environ):
147 proxy_key = 'HTTP_X_REAL_IP'
147 proxy_key = 'HTTP_X_REAL_IP'
148 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
148 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
149 def_key = 'REMOTE_ADDR'
149 def_key = 'REMOTE_ADDR'
150 _filters = lambda x: _filter_port(_filter_proxy(x))
150 _filters = lambda x: _filter_port(_filter_proxy(x))
151
151
152 ip = environ.get(proxy_key)
152 ip = environ.get(proxy_key)
153 if ip:
153 if ip:
154 return _filters(ip)
154 return _filters(ip)
155
155
156 ip = environ.get(proxy_key2)
156 ip = environ.get(proxy_key2)
157 if ip:
157 if ip:
158 return _filters(ip)
158 return _filters(ip)
159
159
160 ip = environ.get(def_key, '0.0.0.0')
160 ip = environ.get(def_key, '0.0.0.0')
161 return _filters(ip)
161 return _filters(ip)
162
162
163
163
164 def get_server_ip_addr(environ, log_errors=True):
164 def get_server_ip_addr(environ, log_errors=True):
165 hostname = environ.get('SERVER_NAME')
165 hostname = environ.get('SERVER_NAME')
166 try:
166 try:
167 return socket.gethostbyname(hostname)
167 return socket.gethostbyname(hostname)
168 except Exception as e:
168 except Exception as e:
169 if log_errors:
169 if log_errors:
170 # in some cases this lookup is not possible, and we don't want to
170 # in some cases this lookup is not possible, and we don't want to
171 # make it an exception in logs
171 # make it an exception in logs
172 log.exception('Could not retrieve server ip address: %s', e)
172 log.exception('Could not retrieve server ip address: %s', e)
173 return hostname
173 return hostname
174
174
175
175
176 def get_server_port(environ):
176 def get_server_port(environ):
177 return environ.get('SERVER_PORT')
177 return environ.get('SERVER_PORT')
178
178
179
179
180 def get_access_path(environ):
180 def get_access_path(environ):
181 path = environ.get('PATH_INFO')
181 path = environ.get('PATH_INFO')
182 org_req = environ.get('pylons.original_request')
182 org_req = environ.get('pylons.original_request')
183 if org_req:
183 if org_req:
184 path = org_req.environ.get('PATH_INFO')
184 path = org_req.environ.get('PATH_INFO')
185 return path
185 return path
186
186
187
187
188 def get_user_agent(environ):
188 def get_user_agent(environ):
189 return environ.get('HTTP_USER_AGENT')
189 return environ.get('HTTP_USER_AGENT')
190
190
191
191
192 def vcs_operation_context(
192 def vcs_operation_context(
193 environ, repo_name, username, action, scm, check_locking=True,
193 environ, repo_name, username, action, scm, check_locking=True,
194 is_shadow_repo=False):
194 is_shadow_repo=False):
195 """
195 """
196 Generate the context for a vcs operation, e.g. push or pull.
196 Generate the context for a vcs operation, e.g. push or pull.
197
197
198 This context is passed over the layers so that hooks triggered by the
198 This context is passed over the layers so that hooks triggered by the
199 vcs operation know details like the user, the user's IP address etc.
199 vcs operation know details like the user, the user's IP address etc.
200
200
201 :param check_locking: Allows to switch of the computation of the locking
201 :param check_locking: Allows to switch of the computation of the locking
202 data. This serves mainly the need of the simplevcs middleware to be
202 data. This serves mainly the need of the simplevcs middleware to be
203 able to disable this for certain operations.
203 able to disable this for certain operations.
204
204
205 """
205 """
206 # Tri-state value: False: unlock, None: nothing, True: lock
206 # Tri-state value: False: unlock, None: nothing, True: lock
207 make_lock = None
207 make_lock = None
208 locked_by = [None, None, None]
208 locked_by = [None, None, None]
209 is_anonymous = username == User.DEFAULT_USER
209 is_anonymous = username == User.DEFAULT_USER
210 if not is_anonymous and check_locking:
210 if not is_anonymous and check_locking:
211 log.debug('Checking locking on repository "%s"', repo_name)
211 log.debug('Checking locking on repository "%s"', repo_name)
212 user = User.get_by_username(username)
212 user = User.get_by_username(username)
213 repo = Repository.get_by_repo_name(repo_name)
213 repo = Repository.get_by_repo_name(repo_name)
214 make_lock, __, locked_by = repo.get_locking_state(
214 make_lock, __, locked_by = repo.get_locking_state(
215 action, user.user_id)
215 action, user.user_id)
216
216
217 settings_model = VcsSettingsModel(repo=repo_name)
217 settings_model = VcsSettingsModel(repo=repo_name)
218 ui_settings = settings_model.get_ui_settings()
218 ui_settings = settings_model.get_ui_settings()
219
219
220 extras = {
220 extras = {
221 'ip': get_ip_addr(environ),
221 'ip': get_ip_addr(environ),
222 'username': username,
222 'username': username,
223 'action': action,
223 'action': action,
224 'repository': repo_name,
224 'repository': repo_name,
225 'scm': scm,
225 'scm': scm,
226 'config': rhodecode.CONFIG['__file__'],
226 'config': rhodecode.CONFIG['__file__'],
227 'make_lock': make_lock,
227 'make_lock': make_lock,
228 'locked_by': locked_by,
228 'locked_by': locked_by,
229 'server_url': utils2.get_server_url(environ),
229 'server_url': utils2.get_server_url(environ),
230 'user_agent': get_user_agent(environ),
230 'user_agent': get_user_agent(environ),
231 'hooks': get_enabled_hook_classes(ui_settings),
231 'hooks': get_enabled_hook_classes(ui_settings),
232 'is_shadow_repo': is_shadow_repo,
232 'is_shadow_repo': is_shadow_repo,
233 }
233 }
234 return extras
234 return extras
235
235
236
236
237 class BasicAuth(AuthBasicAuthenticator):
237 class BasicAuth(AuthBasicAuthenticator):
238
238
239 def __init__(self, realm, authfunc, registry, auth_http_code=None,
239 def __init__(self, realm, authfunc, registry, auth_http_code=None,
240 initial_call_detection=False, acl_repo_name=None):
240 initial_call_detection=False, acl_repo_name=None):
241 self.realm = realm
241 self.realm = realm
242 self.initial_call = initial_call_detection
242 self.initial_call = initial_call_detection
243 self.authfunc = authfunc
243 self.authfunc = authfunc
244 self.registry = registry
244 self.registry = registry
245 self.acl_repo_name = acl_repo_name
245 self.acl_repo_name = acl_repo_name
246 self._rc_auth_http_code = auth_http_code
246 self._rc_auth_http_code = auth_http_code
247
247
248 def _get_response_from_code(self, http_code):
248 def _get_response_from_code(self, http_code):
249 try:
249 try:
250 return get_exception(safe_int(http_code))
250 return get_exception(safe_int(http_code))
251 except Exception:
251 except Exception:
252 log.exception('Failed to fetch response for code %s' % http_code)
252 log.exception('Failed to fetch response for code %s' % http_code)
253 return HTTPForbidden
253 return HTTPForbidden
254
254
255 def get_rc_realm(self):
255 def get_rc_realm(self):
256 return safe_str(self.registry.rhodecode_settings.get('rhodecode_realm'))
256 return safe_str(self.registry.rhodecode_settings.get('rhodecode_realm'))
257
257
258 def build_authentication(self):
258 def build_authentication(self):
259 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
259 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
260 if self._rc_auth_http_code and not self.initial_call:
260 if self._rc_auth_http_code and not self.initial_call:
261 # return alternative HTTP code if alternative http return code
261 # return alternative HTTP code if alternative http return code
262 # is specified in RhodeCode config, but ONLY if it's not the
262 # is specified in RhodeCode config, but ONLY if it's not the
263 # FIRST call
263 # FIRST call
264 custom_response_klass = self._get_response_from_code(
264 custom_response_klass = self._get_response_from_code(
265 self._rc_auth_http_code)
265 self._rc_auth_http_code)
266 return custom_response_klass(headers=head)
266 return custom_response_klass(headers=head)
267 return HTTPUnauthorized(headers=head)
267 return HTTPUnauthorized(headers=head)
268
268
269 def authenticate(self, environ):
269 def authenticate(self, environ):
270 authorization = AUTHORIZATION(environ)
270 authorization = AUTHORIZATION(environ)
271 if not authorization:
271 if not authorization:
272 return self.build_authentication()
272 return self.build_authentication()
273 (authmeth, auth) = authorization.split(' ', 1)
273 (authmeth, auth) = authorization.split(' ', 1)
274 if 'basic' != authmeth.lower():
274 if 'basic' != authmeth.lower():
275 return self.build_authentication()
275 return self.build_authentication()
276 auth = auth.strip().decode('base64')
276 auth = auth.strip().decode('base64')
277 _parts = auth.split(':', 1)
277 _parts = auth.split(':', 1)
278 if len(_parts) == 2:
278 if len(_parts) == 2:
279 username, password = _parts
279 username, password = _parts
280 auth_data = self.authfunc(
280 auth_data = self.authfunc(
281 username, password, environ, VCS_TYPE,
281 username, password, environ, VCS_TYPE,
282 registry=self.registry, acl_repo_name=self.acl_repo_name)
282 registry=self.registry, acl_repo_name=self.acl_repo_name)
283 if auth_data:
283 if auth_data:
284 return {'username': username, 'auth_data': auth_data}
284 return {'username': username, 'auth_data': auth_data}
285 if username and password:
285 if username and password:
286 # we mark that we actually executed authentication once, at
286 # we mark that we actually executed authentication once, at
287 # that point we can use the alternative auth code
287 # that point we can use the alternative auth code
288 self.initial_call = False
288 self.initial_call = False
289
289
290 return self.build_authentication()
290 return self.build_authentication()
291
291
292 __call__ = authenticate
292 __call__ = authenticate
293
293
294
294
295 def calculate_version_hash(config):
295 def calculate_version_hash(config):
296 return md5(
296 return md5(
297 config.get('beaker.session.secret', '') +
297 config.get('beaker.session.secret', '') +
298 rhodecode.__version__)[:8]
298 rhodecode.__version__)[:8]
299
299
300
300
301 def get_current_lang(request):
301 def get_current_lang(request):
302 # NOTE(marcink): remove after pyramid move
302 # NOTE(marcink): remove after pyramid move
303 try:
303 try:
304 return translation.get_lang()[0]
304 return translation.get_lang()[0]
305 except:
305 except:
306 pass
306 pass
307
307
308 return getattr(request, '_LOCALE_', request.locale_name)
308 return getattr(request, '_LOCALE_', request.locale_name)
309
309
310
310
311 def attach_context_attributes(context, request, user_id):
311 def attach_context_attributes(context, request, user_id):
312 """
312 """
313 Attach variables into template context called `c`, please note that
313 Attach variables into template context called `c`, please note that
314 request could be pylons or pyramid request in here.
314 request could be pylons or pyramid request in here.
315 """
315 """
316 # NOTE(marcink): remove check after pyramid migration
316 # NOTE(marcink): remove check after pyramid migration
317 if hasattr(request, 'registry'):
317 if hasattr(request, 'registry'):
318 config = request.registry.settings
318 config = request.registry.settings
319 else:
319 else:
320 from pylons import config
320 from pylons import config
321
321
322 rc_config = SettingsModel().get_all_settings(cache=True)
322 rc_config = SettingsModel().get_all_settings(cache=True)
323
323
324 context.rhodecode_version = rhodecode.__version__
324 context.rhodecode_version = rhodecode.__version__
325 context.rhodecode_edition = config.get('rhodecode.edition')
325 context.rhodecode_edition = config.get('rhodecode.edition')
326 # unique secret + version does not leak the version but keep consistency
326 # unique secret + version does not leak the version but keep consistency
327 context.rhodecode_version_hash = calculate_version_hash(config)
327 context.rhodecode_version_hash = calculate_version_hash(config)
328
328
329 # Default language set for the incoming request
329 # Default language set for the incoming request
330 context.language = get_current_lang(request)
330 context.language = get_current_lang(request)
331
331
332 # Visual options
332 # Visual options
333 context.visual = AttributeDict({})
333 context.visual = AttributeDict({})
334
334
335 # DB stored Visual Items
335 # DB stored Visual Items
336 context.visual.show_public_icon = str2bool(
336 context.visual.show_public_icon = str2bool(
337 rc_config.get('rhodecode_show_public_icon'))
337 rc_config.get('rhodecode_show_public_icon'))
338 context.visual.show_private_icon = str2bool(
338 context.visual.show_private_icon = str2bool(
339 rc_config.get('rhodecode_show_private_icon'))
339 rc_config.get('rhodecode_show_private_icon'))
340 context.visual.stylify_metatags = str2bool(
340 context.visual.stylify_metatags = str2bool(
341 rc_config.get('rhodecode_stylify_metatags'))
341 rc_config.get('rhodecode_stylify_metatags'))
342 context.visual.dashboard_items = safe_int(
342 context.visual.dashboard_items = safe_int(
343 rc_config.get('rhodecode_dashboard_items', 100))
343 rc_config.get('rhodecode_dashboard_items', 100))
344 context.visual.admin_grid_items = safe_int(
344 context.visual.admin_grid_items = safe_int(
345 rc_config.get('rhodecode_admin_grid_items', 100))
345 rc_config.get('rhodecode_admin_grid_items', 100))
346 context.visual.repository_fields = str2bool(
346 context.visual.repository_fields = str2bool(
347 rc_config.get('rhodecode_repository_fields'))
347 rc_config.get('rhodecode_repository_fields'))
348 context.visual.show_version = str2bool(
348 context.visual.show_version = str2bool(
349 rc_config.get('rhodecode_show_version'))
349 rc_config.get('rhodecode_show_version'))
350 context.visual.use_gravatar = str2bool(
350 context.visual.use_gravatar = str2bool(
351 rc_config.get('rhodecode_use_gravatar'))
351 rc_config.get('rhodecode_use_gravatar'))
352 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
352 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
353 context.visual.default_renderer = rc_config.get(
353 context.visual.default_renderer = rc_config.get(
354 'rhodecode_markup_renderer', 'rst')
354 'rhodecode_markup_renderer', 'rst')
355 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
355 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
356 context.visual.rhodecode_support_url = \
356 context.visual.rhodecode_support_url = \
357 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
357 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
358
358
359 context.visual.affected_files_cut_off = 60
359 context.visual.affected_files_cut_off = 60
360
360
361 context.pre_code = rc_config.get('rhodecode_pre_code')
361 context.pre_code = rc_config.get('rhodecode_pre_code')
362 context.post_code = rc_config.get('rhodecode_post_code')
362 context.post_code = rc_config.get('rhodecode_post_code')
363 context.rhodecode_name = rc_config.get('rhodecode_title')
363 context.rhodecode_name = rc_config.get('rhodecode_title')
364 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
364 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
365 # if we have specified default_encoding in the request, it has more
365 # if we have specified default_encoding in the request, it has more
366 # priority
366 # priority
367 if request.GET.get('default_encoding'):
367 if request.GET.get('default_encoding'):
368 context.default_encodings.insert(0, request.GET.get('default_encoding'))
368 context.default_encodings.insert(0, request.GET.get('default_encoding'))
369 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
369 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
370
370
371 # INI stored
371 # INI stored
372 context.labs_active = str2bool(
372 context.labs_active = str2bool(
373 config.get('labs_settings_active', 'false'))
373 config.get('labs_settings_active', 'false'))
374 context.visual.allow_repo_location_change = str2bool(
374 context.visual.allow_repo_location_change = str2bool(
375 config.get('allow_repo_location_change', True))
375 config.get('allow_repo_location_change', True))
376 context.visual.allow_custom_hooks_settings = str2bool(
376 context.visual.allow_custom_hooks_settings = str2bool(
377 config.get('allow_custom_hooks_settings', True))
377 config.get('allow_custom_hooks_settings', True))
378 context.debug_style = str2bool(config.get('debug_style', False))
378 context.debug_style = str2bool(config.get('debug_style', False))
379
379
380 context.rhodecode_instanceid = config.get('instance_id')
380 context.rhodecode_instanceid = config.get('instance_id')
381
381
382 context.visual.cut_off_limit_diff = safe_int(
382 context.visual.cut_off_limit_diff = safe_int(
383 config.get('cut_off_limit_diff'))
383 config.get('cut_off_limit_diff'))
384 context.visual.cut_off_limit_file = safe_int(
384 context.visual.cut_off_limit_file = safe_int(
385 config.get('cut_off_limit_file'))
385 config.get('cut_off_limit_file'))
386
386
387 # AppEnlight
387 # AppEnlight
388 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
388 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
389 context.appenlight_api_public_key = config.get(
389 context.appenlight_api_public_key = config.get(
390 'appenlight.api_public_key', '')
390 'appenlight.api_public_key', '')
391 context.appenlight_server_url = config.get('appenlight.server_url', '')
391 context.appenlight_server_url = config.get('appenlight.server_url', '')
392
392
393 # JS template context
393 # JS template context
394 context.template_context = {
394 context.template_context = {
395 'repo_name': None,
395 'repo_name': None,
396 'repo_type': None,
396 'repo_type': None,
397 'repo_landing_commit': None,
397 'repo_landing_commit': None,
398 'rhodecode_user': {
398 'rhodecode_user': {
399 'username': None,
399 'username': None,
400 'email': None,
400 'email': None,
401 'notification_status': False
401 'notification_status': False
402 },
402 },
403 'visual': {
403 'visual': {
404 'default_renderer': None
404 'default_renderer': None
405 },
405 },
406 'commit_data': {
406 'commit_data': {
407 'commit_id': None
407 'commit_id': None
408 },
408 },
409 'pull_request_data': {'pull_request_id': None},
409 'pull_request_data': {'pull_request_id': None},
410 'timeago': {
410 'timeago': {
411 'refresh_time': 120 * 1000,
411 'refresh_time': 120 * 1000,
412 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
412 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
413 },
413 },
414 'pyramid_dispatch': {
414 'pyramid_dispatch': {
415
415
416 },
416 },
417 'extra': {'plugins': {}}
417 'extra': {'plugins': {}}
418 }
418 }
419 # END CONFIG VARS
419 # END CONFIG VARS
420
420
421 # TODO: This dosn't work when called from pylons compatibility tween.
421 # TODO: This dosn't work when called from pylons compatibility tween.
422 # Fix this and remove it from base controller.
422 # Fix this and remove it from base controller.
423 # context.repo_name = get_repo_slug(request) # can be empty
423 # context.repo_name = get_repo_slug(request) # can be empty
424
424
425 diffmode = 'sideside'
425 diffmode = 'sideside'
426 if request.GET.get('diffmode'):
426 if request.GET.get('diffmode'):
427 if request.GET['diffmode'] == 'unified':
427 if request.GET['diffmode'] == 'unified':
428 diffmode = 'unified'
428 diffmode = 'unified'
429 elif request.session.get('diffmode'):
429 elif request.session.get('diffmode'):
430 diffmode = request.session['diffmode']
430 diffmode = request.session['diffmode']
431
431
432 context.diffmode = diffmode
432 context.diffmode = diffmode
433
433
434 if request.session.get('diffmode') != diffmode:
434 if request.session.get('diffmode') != diffmode:
435 request.session['diffmode'] = diffmode
435 request.session['diffmode'] = diffmode
436
436
437 context.csrf_token = auth.get_csrf_token(session=request.session)
437 context.csrf_token = auth.get_csrf_token(session=request.session)
438 context.backends = rhodecode.BACKENDS.keys()
438 context.backends = rhodecode.BACKENDS.keys()
439 context.backends.sort()
439 context.backends.sort()
440 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(user_id)
440 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(user_id)
441
441
442 # NOTE(marcink): when migrated to pyramid we don't need to set this anymore,
442 # NOTE(marcink): when migrated to pyramid we don't need to set this anymore,
443 # given request will ALWAYS be pyramid one
443 # given request will ALWAYS be pyramid one
444 pyramid_request = pyramid.threadlocal.get_current_request()
444 pyramid_request = pyramid.threadlocal.get_current_request()
445 context.pyramid_request = pyramid_request
445 context.pyramid_request = pyramid_request
446
446
447 # web case
447 # web case
448 if hasattr(pyramid_request, 'user'):
448 if hasattr(pyramid_request, 'user'):
449 context.auth_user = pyramid_request.user
449 context.auth_user = pyramid_request.user
450 context.rhodecode_user = pyramid_request.user
450 context.rhodecode_user = pyramid_request.user
451
451
452 # api case
452 # api case
453 if hasattr(pyramid_request, 'rpc_user'):
453 if hasattr(pyramid_request, 'rpc_user'):
454 context.auth_user = pyramid_request.rpc_user
454 context.auth_user = pyramid_request.rpc_user
455 context.rhodecode_user = pyramid_request.rpc_user
455 context.rhodecode_user = pyramid_request.rpc_user
456
456
457 # attach the whole call context to the request
457 # attach the whole call context to the request
458 request.call_context = context
458 request.call_context = context
459
459
460
460
461 def get_auth_user(request):
461 def get_auth_user(request):
462 environ = request.environ
462 environ = request.environ
463 session = request.session
463 session = request.session
464
464
465 ip_addr = get_ip_addr(environ)
465 ip_addr = get_ip_addr(environ)
466 # make sure that we update permissions each time we call controller
466 # make sure that we update permissions each time we call controller
467 _auth_token = (request.GET.get('auth_token', '') or
467 _auth_token = (request.GET.get('auth_token', '') or
468 request.GET.get('api_key', ''))
468 request.GET.get('api_key', ''))
469
469
470 if _auth_token:
470 if _auth_token:
471 # when using API_KEY we assume user exists, and
471 # when using API_KEY we assume user exists, and
472 # doesn't need auth based on cookies.
472 # doesn't need auth based on cookies.
473 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
473 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
474 authenticated = False
474 authenticated = False
475 else:
475 else:
476 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
476 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
477 try:
477 try:
478 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
478 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
479 ip_addr=ip_addr)
479 ip_addr=ip_addr)
480 except UserCreationError as e:
480 except UserCreationError as e:
481 h.flash(e, 'error')
481 h.flash(e, 'error')
482 # container auth or other auth functions that create users
482 # container auth or other auth functions that create users
483 # on the fly can throw this exception signaling that there's
483 # on the fly can throw this exception signaling that there's
484 # issue with user creation, explanation should be provided
484 # issue with user creation, explanation should be provided
485 # in Exception itself. We then create a simple blank
485 # in Exception itself. We then create a simple blank
486 # AuthUser
486 # AuthUser
487 auth_user = AuthUser(ip_addr=ip_addr)
487 auth_user = AuthUser(ip_addr=ip_addr)
488
488
489 if password_changed(auth_user, session):
489 if password_changed(auth_user, session):
490 session.invalidate()
490 session.invalidate()
491 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
491 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
492 auth_user = AuthUser(ip_addr=ip_addr)
492 auth_user = AuthUser(ip_addr=ip_addr)
493
493
494 authenticated = cookie_store.get('is_authenticated')
494 authenticated = cookie_store.get('is_authenticated')
495
495
496 if not auth_user.is_authenticated and auth_user.is_user_object:
496 if not auth_user.is_authenticated and auth_user.is_user_object:
497 # user is not authenticated and not empty
497 # user is not authenticated and not empty
498 auth_user.set_authenticated(authenticated)
498 auth_user.set_authenticated(authenticated)
499
499
500 return auth_user
500 return auth_user
501
501
502
502
503 class BaseController(WSGIController):
503 class BaseController(WSGIController):
504
504
505 def __before__(self):
505 def __before__(self):
506 """
506 """
507 __before__ is called before controller methods and after __call__
507 __before__ is called before controller methods and after __call__
508 """
508 """
509 # on each call propagate settings calls into global settings.
509 # on each call propagate settings calls into global settings.
510 from pylons import config
510 from pylons import config
511 from pylons import tmpl_context as c, request, url
511 from pylons import tmpl_context as c, request, url
512 set_rhodecode_config(config)
512 set_rhodecode_config(config)
513 attach_context_attributes(c, request, self._rhodecode_user.user_id)
513 attach_context_attributes(c, request, self._rhodecode_user.user_id)
514
514
515 # TODO: Remove this when fixed in attach_context_attributes()
515 # TODO: Remove this when fixed in attach_context_attributes()
516 c.repo_name = get_repo_slug(request) # can be empty
516 c.repo_name = get_repo_slug(request) # can be empty
517
517
518 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
518 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
519 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
519 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
520 self.sa = meta.Session
520 self.sa = meta.Session
521 self.scm_model = ScmModel(self.sa)
521 self.scm_model = ScmModel(self.sa)
522
522
523 # set user language
523 # set user language
524 user_lang = getattr(c.pyramid_request, '_LOCALE_', None)
524 user_lang = getattr(c.pyramid_request, '_LOCALE_', None)
525 if user_lang:
525 if user_lang:
526 translation.set_lang(user_lang)
526 translation.set_lang(user_lang)
527 log.debug('set language to %s for user %s',
527 log.debug('set language to %s for user %s',
528 user_lang, self._rhodecode_user)
528 user_lang, self._rhodecode_user)
529
529
530 def _dispatch_redirect(self, with_url, environ, start_response):
530 def _dispatch_redirect(self, with_url, environ, start_response):
531 from webob.exc import HTTPFound
531 from webob.exc import HTTPFound
532 resp = HTTPFound(with_url)
532 resp = HTTPFound(with_url)
533 environ['SCRIPT_NAME'] = '' # handle prefix middleware
533 environ['SCRIPT_NAME'] = '' # handle prefix middleware
534 environ['PATH_INFO'] = with_url
534 environ['PATH_INFO'] = with_url
535 return resp(environ, start_response)
535 return resp(environ, start_response)
536
536
537 def __call__(self, environ, start_response):
537 def __call__(self, environ, start_response):
538 """Invoke the Controller"""
538 """Invoke the Controller"""
539 # WSGIController.__call__ dispatches to the Controller method
539 # WSGIController.__call__ dispatches to the Controller method
540 # the request is routed to. This routing information is
540 # the request is routed to. This routing information is
541 # available in environ['pylons.routes_dict']
541 # available in environ['pylons.routes_dict']
542 from rhodecode.lib import helpers as h
542 from rhodecode.lib import helpers as h
543 from pylons import tmpl_context as c, request, url
543 from pylons import tmpl_context as c, request, url
544
544
545 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
545 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
546 if environ.get('debugtoolbar.wants_pylons_context', False):
546 if environ.get('debugtoolbar.wants_pylons_context', False):
547 environ['debugtoolbar.pylons_context'] = c._current_obj()
547 environ['debugtoolbar.pylons_context'] = c._current_obj()
548
548
549 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
549 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
550 environ['pylons.routes_dict']['action']])
550 environ['pylons.routes_dict']['action']])
551
551
552 self.rc_config = SettingsModel().get_all_settings(cache=True)
552 self.rc_config = SettingsModel().get_all_settings(cache=True)
553 self.ip_addr = get_ip_addr(environ)
553 self.ip_addr = get_ip_addr(environ)
554
554
555 # The rhodecode auth user is looked up and passed through the
555 # The rhodecode auth user is looked up and passed through the
556 # environ by the pylons compatibility tween in pyramid.
556 # environ by the pylons compatibility tween in pyramid.
557 # So we can just grab it from there.
557 # So we can just grab it from there.
558 auth_user = environ['rc_auth_user']
558 auth_user = environ['rc_auth_user']
559
559
560 # set globals for auth user
560 # set globals for auth user
561 request.user = auth_user
561 request.user = auth_user
562 self._rhodecode_user = auth_user
562 self._rhodecode_user = auth_user
563
563
564 log.info('IP: %s User: %s accessed %s [%s]' % (
564 log.info('IP: %s User: %s accessed %s [%s]' % (
565 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
565 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
566 _route_name)
566 _route_name)
567 )
567 )
568
568
569 user_obj = auth_user.get_instance()
569 user_obj = auth_user.get_instance()
570 if user_obj and user_obj.user_data.get('force_password_change'):
570 if user_obj and user_obj.user_data.get('force_password_change'):
571 h.flash('You are required to change your password', 'warning',
571 h.flash('You are required to change your password', 'warning',
572 ignore_duplicate=True)
572 ignore_duplicate=True)
573 return self._dispatch_redirect(
573 return self._dispatch_redirect(
574 url('my_account_password'), environ, start_response)
574 url('my_account_password'), environ, start_response)
575
575
576 return WSGIController.__call__(self, environ, start_response)
576 return WSGIController.__call__(self, environ, start_response)
577
577
578
578
579 def h_filter(s):
579 def h_filter(s):
580 """
580 """
581 Custom filter for Mako templates. Mako by standard uses `markupsafe.escape`
581 Custom filter for Mako templates. Mako by standard uses `markupsafe.escape`
582 we wrap this with additional functionality that converts None to empty
582 we wrap this with additional functionality that converts None to empty
583 strings
583 strings
584 """
584 """
585 if s is None:
585 if s is None:
586 return markupsafe.Markup()
586 return markupsafe.Markup()
587 return markupsafe.escape(s)
587 return markupsafe.escape(s)
588
588
589
589
590 def add_events_routes(config):
590 def add_events_routes(config):
591 """
591 """
592 Adds routing that can be used in events. Because some events are triggered
592 Adds routing that can be used in events. Because some events are triggered
593 outside of pyramid context, we need to bootstrap request with some
593 outside of pyramid context, we need to bootstrap request with some
594 routing registered
594 routing registered
595 """
595 """
596 config.add_route(name='home', pattern='/')
596 config.add_route(name='home', pattern='/')
597
597
598 config.add_route(name='repo_summary', pattern='/{repo_name}')
598 config.add_route(name='repo_summary', pattern='/{repo_name}')
599 config.add_route(name='repo_summary_explicit', pattern='/{repo_name}/summary')
599 config.add_route(name='repo_summary_explicit', pattern='/{repo_name}/summary')
600 config.add_route(name='repo_group_home', pattern='/{repo_group_name}')
600 config.add_route(name='repo_group_home', pattern='/{repo_group_name}')
601
601
602 config.add_route(name='pullrequest_show',
602 config.add_route(name='pullrequest_show',
603 pattern='/{repo_name}/pull-request/{pull_request_id}')
603 pattern='/{repo_name}/pull-request/{pull_request_id}')
604 config.add_route(name='pull_requests_global',
604 config.add_route(name='pull_requests_global',
605 pattern='/pull-request/{pull_request_id}')
605 pattern='/pull-request/{pull_request_id}')
606
606
607 config.add_route(name='repo_commit',
607 config.add_route(name='repo_commit',
608 pattern='/{repo_name}/changeset/{commit_id}')
608 pattern='/{repo_name}/changeset/{commit_id}')
609 config.add_route(name='repo_files',
609 config.add_route(name='repo_files',
610 pattern='/{repo_name}/files/{commit_id}/{f_path}')
610 pattern='/{repo_name}/files/{commit_id}/{f_path}')
611
611
612
612
613 def bootstrap_request(**kwargs):
613 def bootstrap_request(**kwargs):
614 import pyramid.testing
614 import pyramid.testing
615
615
616 class TestRequest(pyramid.testing.DummyRequest):
616 class TestRequest(pyramid.testing.DummyRequest):
617 application_url = kwargs.pop('application_url', 'http://example.com')
617 application_url = kwargs.pop('application_url', 'http://example.com')
618 host = kwargs.pop('host', 'example.com:80')
618 host = kwargs.pop('host', 'example.com:80')
619 domain = kwargs.pop('domain', 'example.com')
619 domain = kwargs.pop('domain', 'example.com')
620
620
621 def translate(self, msg):
621 def translate(self, msg):
622 return msg
622 return msg
623
623
624 class TestDummySession(pyramid.testing.DummySession):
624 class TestDummySession(pyramid.testing.DummySession):
625 def save(*arg, **kw):
625 def save(*arg, **kw):
626 pass
626 pass
627
627
628 request = TestRequest(**kwargs)
628 request = TestRequest(**kwargs)
629 request.session = TestDummySession()
629 request.session = TestDummySession()
630
630
631
632 config = pyramid.testing.setUp(request=request)
631 config = pyramid.testing.setUp(request=request)
633 add_events_routes(config)
632 add_events_routes(config)
634 return request
633 return request
635
634
@@ -1,241 +1,242 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2010-2017 RhodeCode GmbH
3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import json
21 import json
22 import logging
22 import logging
23 import traceback
23 import traceback
24 import threading
24 import threading
25 from BaseHTTPServer import BaseHTTPRequestHandler
25 from BaseHTTPServer import BaseHTTPRequestHandler
26 from SocketServer import TCPServer
26 from SocketServer import TCPServer
27
27
28 import rhodecode
28 import rhodecode
29 from rhodecode.model import meta
29 from rhodecode.model import meta
30 from rhodecode.lib.base import bootstrap_request
30 from rhodecode.lib.base import bootstrap_request
31 from rhodecode.lib import hooks_base
31 from rhodecode.lib import hooks_base
32 from rhodecode.lib.utils2 import AttributeDict
32 from rhodecode.lib.utils2 import AttributeDict
33
33
34
34
35 log = logging.getLogger(__name__)
35 log = logging.getLogger(__name__)
36
36
37
37
38 class HooksHttpHandler(BaseHTTPRequestHandler):
38 class HooksHttpHandler(BaseHTTPRequestHandler):
39 def do_POST(self):
39 def do_POST(self):
40 method, extras = self._read_request()
40 method, extras = self._read_request()
41 try:
41 try:
42 result = self._call_hook(method, extras)
42 result = self._call_hook(method, extras)
43 except Exception as e:
43 except Exception as e:
44 exc_tb = traceback.format_exc()
44 exc_tb = traceback.format_exc()
45 result = {
45 result = {
46 'exception': e.__class__.__name__,
46 'exception': e.__class__.__name__,
47 'exception_traceback': exc_tb,
47 'exception_traceback': exc_tb,
48 'exception_args': e.args
48 'exception_args': e.args
49 }
49 }
50 self._write_response(result)
50 self._write_response(result)
51
51
52 def _read_request(self):
52 def _read_request(self):
53 length = int(self.headers['Content-Length'])
53 length = int(self.headers['Content-Length'])
54 body = self.rfile.read(length).decode('utf-8')
54 body = self.rfile.read(length).decode('utf-8')
55 data = json.loads(body)
55 data = json.loads(body)
56 return data['method'], data['extras']
56 return data['method'], data['extras']
57
57
58 def _write_response(self, result):
58 def _write_response(self, result):
59 self.send_response(200)
59 self.send_response(200)
60 self.send_header("Content-type", "text/json")
60 self.send_header("Content-type", "text/json")
61 self.end_headers()
61 self.end_headers()
62 self.wfile.write(json.dumps(result))
62 self.wfile.write(json.dumps(result))
63
63
64 def _call_hook(self, method, extras):
64 def _call_hook(self, method, extras):
65 hooks = Hooks()
65 hooks = Hooks()
66 try:
66 try:
67 result = getattr(hooks, method)(extras)
67 result = getattr(hooks, method)(extras)
68 finally:
68 finally:
69 meta.Session.remove()
69 meta.Session.remove()
70 return result
70 return result
71
71
72 def log_message(self, format, *args):
72 def log_message(self, format, *args):
73 """
73 """
74 This is an overridden method of BaseHTTPRequestHandler which logs using
74 This is an overridden method of BaseHTTPRequestHandler which logs using
75 logging library instead of writing directly to stderr.
75 logging library instead of writing directly to stderr.
76 """
76 """
77
77
78 message = format % args
78 message = format % args
79
79
80 # TODO: mikhail: add different log levels support
80 # TODO: mikhail: add different log levels support
81 log.debug(
81 log.debug(
82 "%s - - [%s] %s", self.client_address[0],
82 "%s - - [%s] %s", self.client_address[0],
83 self.log_date_time_string(), message)
83 self.log_date_time_string(), message)
84
84
85
85
86 class DummyHooksCallbackDaemon(object):
86 class DummyHooksCallbackDaemon(object):
87 def __init__(self):
87 def __init__(self):
88 self.hooks_module = Hooks.__module__
88 self.hooks_module = Hooks.__module__
89
89
90 def __enter__(self):
90 def __enter__(self):
91 log.debug('Running dummy hooks callback daemon')
91 log.debug('Running dummy hooks callback daemon')
92 return self
92 return self
93
93
94 def __exit__(self, exc_type, exc_val, exc_tb):
94 def __exit__(self, exc_type, exc_val, exc_tb):
95 log.debug('Exiting dummy hooks callback daemon')
95 log.debug('Exiting dummy hooks callback daemon')
96
96
97
97
98 class ThreadedHookCallbackDaemon(object):
98 class ThreadedHookCallbackDaemon(object):
99
99
100 _callback_thread = None
100 _callback_thread = None
101 _daemon = None
101 _daemon = None
102 _done = False
102 _done = False
103
103
104 def __init__(self):
104 def __init__(self):
105 self._prepare()
105 self._prepare()
106
106
107 def __enter__(self):
107 def __enter__(self):
108 self._run()
108 self._run()
109 return self
109 return self
110
110
111 def __exit__(self, exc_type, exc_val, exc_tb):
111 def __exit__(self, exc_type, exc_val, exc_tb):
112 log.debug('Callback daemon exiting now...')
112 log.debug('Callback daemon exiting now...')
113 self._stop()
113 self._stop()
114
114
115 def _prepare(self):
115 def _prepare(self):
116 raise NotImplementedError()
116 raise NotImplementedError()
117
117
118 def _run(self):
118 def _run(self):
119 raise NotImplementedError()
119 raise NotImplementedError()
120
120
121 def _stop(self):
121 def _stop(self):
122 raise NotImplementedError()
122 raise NotImplementedError()
123
123
124
124
125 class HttpHooksCallbackDaemon(ThreadedHookCallbackDaemon):
125 class HttpHooksCallbackDaemon(ThreadedHookCallbackDaemon):
126 """
126 """
127 Context manager which will run a callback daemon in a background thread.
127 Context manager which will run a callback daemon in a background thread.
128 """
128 """
129
129
130 hooks_uri = None
130 hooks_uri = None
131
131
132 IP_ADDRESS = '127.0.0.1'
132 IP_ADDRESS = '127.0.0.1'
133
133
134 # From Python docs: Polling reduces our responsiveness to a shutdown
134 # From Python docs: Polling reduces our responsiveness to a shutdown
135 # request and wastes cpu at all other times.
135 # request and wastes cpu at all other times.
136 POLL_INTERVAL = 0.1
136 POLL_INTERVAL = 0.1
137
137
138 def _prepare(self):
138 def _prepare(self):
139 log.debug("Preparing HTTP callback daemon and registering hook object")
139 log.debug("Preparing HTTP callback daemon and registering hook object")
140
140
141 self._done = False
141 self._done = False
142 self._daemon = TCPServer((self.IP_ADDRESS, 0), HooksHttpHandler)
142 self._daemon = TCPServer((self.IP_ADDRESS, 0), HooksHttpHandler)
143 _, port = self._daemon.server_address
143 _, port = self._daemon.server_address
144 self.hooks_uri = '{}:{}'.format(self.IP_ADDRESS, port)
144 self.hooks_uri = '{}:{}'.format(self.IP_ADDRESS, port)
145
145
146 log.debug("Hooks uri is: %s", self.hooks_uri)
146 log.debug("Hooks uri is: %s", self.hooks_uri)
147
147
148 def _run(self):
148 def _run(self):
149 log.debug("Running event loop of callback daemon in background thread")
149 log.debug("Running event loop of callback daemon in background thread")
150 callback_thread = threading.Thread(
150 callback_thread = threading.Thread(
151 target=self._daemon.serve_forever,
151 target=self._daemon.serve_forever,
152 kwargs={'poll_interval': self.POLL_INTERVAL})
152 kwargs={'poll_interval': self.POLL_INTERVAL})
153 callback_thread.daemon = True
153 callback_thread.daemon = True
154 callback_thread.start()
154 callback_thread.start()
155 self._callback_thread = callback_thread
155 self._callback_thread = callback_thread
156
156
157 def _stop(self):
157 def _stop(self):
158 log.debug("Waiting for background thread to finish.")
158 log.debug("Waiting for background thread to finish.")
159 self._daemon.shutdown()
159 self._daemon.shutdown()
160 self._callback_thread.join()
160 self._callback_thread.join()
161 self._daemon = None
161 self._daemon = None
162 self._callback_thread = None
162 self._callback_thread = None
163
163
164
164
165 def prepare_callback_daemon(extras, protocol, use_direct_calls):
165 def prepare_callback_daemon(extras, protocol, use_direct_calls):
166 callback_daemon = None
166 callback_daemon = None
167
167
168 if use_direct_calls:
168 if use_direct_calls:
169 callback_daemon = DummyHooksCallbackDaemon()
169 callback_daemon = DummyHooksCallbackDaemon()
170 extras['hooks_module'] = callback_daemon.hooks_module
170 extras['hooks_module'] = callback_daemon.hooks_module
171 else:
171 else:
172 if protocol == 'http':
172 if protocol == 'http':
173 callback_daemon = HttpHooksCallbackDaemon()
173 callback_daemon = HttpHooksCallbackDaemon()
174 else:
174 else:
175 log.error('Unsupported callback daemon protocol "%s"', protocol)
175 log.error('Unsupported callback daemon protocol "%s"', protocol)
176 raise Exception('Unsupported callback daemon protocol.')
176 raise Exception('Unsupported callback daemon protocol.')
177
177
178 extras['hooks_uri'] = callback_daemon.hooks_uri
178 extras['hooks_uri'] = callback_daemon.hooks_uri
179 extras['hooks_protocol'] = protocol
179 extras['hooks_protocol'] = protocol
180
180
181 log.debug('Prepared a callback daemon: %s', callback_daemon)
181 log.debug('Prepared a callback daemon: %s', callback_daemon)
182 return callback_daemon, extras
182 return callback_daemon, extras
183
183
184
184
185 class Hooks(object):
185 class Hooks(object):
186 """
186 """
187 Exposes the hooks for remote call backs
187 Exposes the hooks for remote call backs
188 """
188 """
189
189
190 def repo_size(self, extras):
190 def repo_size(self, extras):
191 log.debug("Called repo_size of %s object", self)
191 log.debug("Called repo_size of %s object", self)
192 return self._call_hook(hooks_base.repo_size, extras)
192 return self._call_hook(hooks_base.repo_size, extras)
193
193
194 def pre_pull(self, extras):
194 def pre_pull(self, extras):
195 log.debug("Called pre_pull of %s object", self)
195 log.debug("Called pre_pull of %s object", self)
196 return self._call_hook(hooks_base.pre_pull, extras)
196 return self._call_hook(hooks_base.pre_pull, extras)
197
197
198 def post_pull(self, extras):
198 def post_pull(self, extras):
199 log.debug("Called post_pull of %s object", self)
199 log.debug("Called post_pull of %s object", self)
200 return self._call_hook(hooks_base.post_pull, extras)
200 return self._call_hook(hooks_base.post_pull, extras)
201
201
202 def pre_push(self, extras):
202 def pre_push(self, extras):
203 log.debug("Called pre_push of %s object", self)
203 log.debug("Called pre_push of %s object", self)
204 return self._call_hook(hooks_base.pre_push, extras)
204 return self._call_hook(hooks_base.pre_push, extras)
205
205
206 def post_push(self, extras):
206 def post_push(self, extras):
207 log.debug("Called post_push of %s object", self)
207 log.debug("Called post_push of %s object", self)
208 return self._call_hook(hooks_base.post_push, extras)
208 return self._call_hook(hooks_base.post_push, extras)
209
209
210 def _call_hook(self, hook, extras):
210 def _call_hook(self, hook, extras):
211 extras = AttributeDict(extras)
211 extras = AttributeDict(extras)
212 server_url = extras['server_url']
212
213
213 extras.request = bootstrap_request(
214 extras.request = bootstrap_request(application_url=server_url)
214 application_url=extras['server_url'])
215
215
216 try:
216 try:
217 result = hook(extras)
217 result = hook(extras)
218 except Exception as error:
218 except Exception as error:
219 exc_tb = traceback.format_exc()
219 exc_tb = traceback.format_exc()
220 log.exception('Exception when handling hook %s', hook)
220 log.exception('Exception when handling hook %s', hook)
221 error_args = error.args
221 error_args = error.args
222 return {
222 return {
223 'status': 128,
223 'status': 128,
224 'output': '',
224 'output': '',
225 'exception': type(error).__name__,
225 'exception': type(error).__name__,
226 'exception_traceback': exc_tb,
226 'exception_traceback': exc_tb,
227 'exception_args': error_args,
227 'exception_args': error_args,
228 }
228 }
229 finally:
229 finally:
230 meta.Session.remove()
230 meta.Session.remove()
231
231
232 log.debug('Got hook call response %s', result)
232 return {
233 return {
233 'status': result.status,
234 'status': result.status,
234 'output': result.output,
235 'output': result.output,
235 }
236 }
236
237
237 def __enter__(self):
238 def __enter__(self):
238 return self
239 return self
239
240
240 def __exit__(self, exc_type, exc_val, exc_tb):
241 def __exit__(self, exc_type, exc_val, exc_tb):
241 pass
242 pass
1 NO CONTENT: file was removed
NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now