##// END OF EJS Templates
refactor(ssh-wrapper): changed SSHVcsServer to SshVcsServer, updated call_service_api method.
ilin.s -
r5326:cede61e3 default
parent child Browse files
Show More
@@ -1,417 +1,387 b''
1 1 # Copyright (C) 2016-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import os
20 20 import re
21 21 import logging
22 22 import datetime
23 23 from sqlalchemy import Table
24 24
25 25 from rhodecode.lib.api_utils import call_service_api
26 26 from rhodecode.lib.utils2 import AttributeDict
27 from rhodecode.lib.vcs.exceptions import ImproperlyConfiguredError
28 27
29 28 from .hg import MercurialServer
30 29 from .git import GitServer
31 30 from .svn import SubversionServer
32 31 log = logging.getLogger(__name__)
33 32
34 33
35 34 class SshWrapper(object):
36 35 hg_cmd_pat = re.compile(r'^hg\s+\-R\s+(\S+)\s+serve\s+\-\-stdio$')
37 36 git_cmd_pat = re.compile(r'^git-(receive-pack|upload-pack)\s\'[/]?(\S+?)(|\.git)\'$')
38 37 svn_cmd_pat = re.compile(r'^svnserve -t')
39 38
40 39 def __init__(self, command, connection_info, mode,
41 40 user, user_id, key_id: int, shell, ini_path: str, settings, env):
42 41 self.command = command
43 42 self.connection_info = connection_info
44 43 self.mode = mode
45 44 self.username = user
46 45 self.user_id = user_id
47 46 self.key_id = key_id
48 47 self.shell = shell
49 48 self.ini_path = ini_path
50 49 self.env = env
51 50 self.settings = settings
52 51 self.server_impl = None
53 52
54 53 def update_key_access_time(self, key_id):
55 54 from rhodecode.model.meta import raw_query_executor, Base
56 55
57 56 table = Table('user_ssh_keys', Base.metadata, autoload=False)
58 57 atime = datetime.datetime.utcnow()
59 58 stmt = (
60 59 table.update()
61 60 .where(table.c.ssh_key_id == key_id)
62 61 .values(accessed_on=atime)
63 62 # no MySQL Support for .returning :((
64 63 #.returning(table.c.accessed_on, table.c.ssh_key_fingerprint)
65 64 )
66 65
67 66 res_count = None
68 67 with raw_query_executor() as session:
69 68 result = session.execute(stmt)
70 69 if result.rowcount:
71 70 res_count = result.rowcount
72 71
73 72 if res_count:
74 73 log.debug('Update key id:`%s` access time', key_id)
75 74
76 75 def get_user(self, user_id):
77 76 user = AttributeDict()
78 77 # lazy load db imports
79 78 from rhodecode.model.db import User
80 79 dbuser = User.get(user_id)
81 80 if not dbuser:
82 81 return None
83 82 user.user_id = dbuser.user_id
84 83 user.username = dbuser.username
85 84 user.auth_user = dbuser.AuthUser()
86 85 return user
87 86
88 87 def get_connection_info(self):
89 88 """
90 89 connection_info
91 90
92 91 Identifies the client and server ends of the connection.
93 92 The variable contains four space-separated values: client IP address,
94 93 client port number, server IP address, and server port number.
95 94 """
96 95 conn = dict(
97 96 client_ip=None,
98 97 client_port=None,
99 98 server_ip=None,
100 99 server_port=None,
101 100 )
102 101
103 102 info = self.connection_info.split(' ')
104 103 if len(info) == 4:
105 104 conn['client_ip'] = info[0]
106 105 conn['client_port'] = info[1]
107 106 conn['server_ip'] = info[2]
108 107 conn['server_port'] = info[3]
109 108
110 109 return conn
111 110
112 111 def maybe_translate_repo_uid(self, repo_name):
113 112 _org_name = repo_name
114 113 if _org_name.startswith('_'):
115 114 # remove format of _ID/subrepo
116 115 _org_name = _org_name.split('/', 1)[0]
117 116
118 117 if repo_name.startswith('_'):
119 118 from rhodecode.model.repo import RepoModel
120 119 org_repo_name = repo_name
121 120 log.debug('translating UID repo %s', org_repo_name)
122 121 by_id_match = RepoModel().get_repo_by_id(repo_name)
123 122 if by_id_match:
124 123 repo_name = by_id_match.repo_name
125 124 log.debug('translation of UID repo %s got `%s`', org_repo_name, repo_name)
126 125
127 126 return repo_name, _org_name
128 127
129 128 def get_repo_details(self, mode):
130 129 vcs_type = mode if mode in ['svn', 'hg', 'git'] else None
131 130 repo_name = None
132 131
133 132 hg_match = self.hg_cmd_pat.match(self.command)
134 133 if hg_match is not None:
135 134 vcs_type = 'hg'
136 135 repo_id = hg_match.group(1).strip('/')
137 136 repo_name, org_name = self.maybe_translate_repo_uid(repo_id)
138 137 return vcs_type, repo_name, mode
139 138
140 139 git_match = self.git_cmd_pat.match(self.command)
141 140 if git_match is not None:
142 141 mode = git_match.group(1)
143 142 vcs_type = 'git'
144 143 repo_id = git_match.group(2).strip('/')
145 144 repo_name, org_name = self.maybe_translate_repo_uid(repo_id)
146 145 return vcs_type, repo_name, mode
147 146
148 147 svn_match = self.svn_cmd_pat.match(self.command)
149 148 if svn_match is not None:
150 149 vcs_type = 'svn'
151 150 # Repo name should be extracted from the input stream, we're unable to
152 151 # extract it at this point in execution
153 152 return vcs_type, repo_name, mode
154 153
155 154 return vcs_type, repo_name, mode
156 155
157 156 def serve(self, vcs, repo, mode, user, permissions, branch_permissions):
158 157 # TODO: remove this once we have .ini defined access path...
159 158 from rhodecode.model.scm import ScmModel
160 159
161 160 store = ScmModel().repos_path
162 161
163 162 check_branch_perms = False
164 163 detect_force_push = False
165 164
166 165 if branch_permissions:
167 166 check_branch_perms = True
168 167 detect_force_push = True
169 168
170 169 log.debug(
171 170 'VCS detected:`%s` mode: `%s` repo_name: %s, branch_permission_checks:%s',
172 171 vcs, mode, repo, check_branch_perms)
173 172
174 173 # detect if we have to check branch permissions
175 174 extras = {
176 175 'detect_force_push': detect_force_push,
177 176 'check_branch_perms': check_branch_perms,
178 177 'config': self.ini_path
179 178 }
180 179
181 180 if vcs == 'hg':
182 181 server = MercurialServer(
183 182 store=store, ini_path=self.ini_path,
184 183 repo_name=repo, user=user,
185 184 user_permissions=permissions, settings=self.settings, env=self.env)
186 185 self.server_impl = server
187 186 return server.run(tunnel_extras=extras)
188 187
189 188 elif vcs == 'git':
190 189 server = GitServer(
191 190 store=store, ini_path=self.ini_path,
192 191 repo_name=repo, repo_mode=mode, user=user,
193 192 user_permissions=permissions, settings=self.settings, env=self.env)
194 193 self.server_impl = server
195 194 return server.run(tunnel_extras=extras)
196 195
197 196 elif vcs == 'svn':
198 197 server = SubversionServer(
199 198 store=store, ini_path=self.ini_path,
200 199 repo_name=None, user=user,
201 200 user_permissions=permissions, settings=self.settings, env=self.env)
202 201 self.server_impl = server
203 202 return server.run(tunnel_extras=extras)
204 203
205 204 else:
206 205 raise Exception(f'Unrecognised VCS: {vcs}')
207 206
208 207 def wrap(self):
209 208 mode = self.mode
210 209 username = self.username
211 210 user_id = self.user_id
212 211 key_id = self.key_id
213 212 shell = self.shell
214 213
215 214 scm_detected, scm_repo, scm_mode = self.get_repo_details(mode)
216 215
217 216 log.debug(
218 217 'Mode: `%s` User: `name:%s : id:%s` Shell: `%s` SSH Command: `\"%s\"` '
219 218 'SCM_DETECTED: `%s` SCM Mode: `%s` SCM Repo: `%s`',
220 219 mode, username, user_id, shell, self.command,
221 220 scm_detected, scm_mode, scm_repo)
222 221
223 222 log.debug('SSH Connection info %s', self.get_connection_info())
224 223
225 224 # update last access time for this key
226 225 if key_id:
227 226 self.update_key_access_time(key_id)
228 227
229 228 if shell and self.command is None:
230 229 log.info('Dropping to shell, no command given and shell is allowed')
231 230 os.execl('/bin/bash', '-l')
232 231 exit_code = 1
233 232
234 233 elif scm_detected:
235 234 user = self.get_user(user_id)
236 235 if not user:
237 236 log.warning('User with id %s not found', user_id)
238 237 exit_code = -1
239 238 return exit_code
240 239
241 240 auth_user = user.auth_user
242 241 permissions = auth_user.permissions['repositories']
243 242 repo_branch_permissions = auth_user.get_branch_permissions(scm_repo)
244 243 try:
245 244 exit_code, is_updated = self.serve(
246 245 scm_detected, scm_repo, scm_mode, user, permissions,
247 246 repo_branch_permissions)
248 247 except Exception:
249 248 log.exception('Error occurred during execution of SshWrapper')
250 249 exit_code = -1
251 250
252 251 elif self.command is None and shell is False:
253 252 log.error('No Command given.')
254 253 exit_code = -1
255 254
256 255 else:
257 256 log.error('Unhandled Command: "%s" Aborting.', self.command)
258 257 exit_code = -1
259 258
260 259 return exit_code
261 260
262 261
263 262 class SshWrapperStandalone(SshWrapper):
264 263 """
265 264 New version of SshWrapper designed to be depended only on service API
266 265 """
267 266 repos_path = None
268 service_api_host: str
269 service_api_token: str
270 api_url: str
271
272 def __init__(self, command, connection_info, mode,
273 user, user_id, key_id: int, shell, ini_path: str, settings, env):
274
275 # validate our settings for making a standalone calls
276 try:
277 self.service_api_host = settings['app.service_api.host']
278 self.service_api_token = settings['app.service_api.token']
279 except KeyError:
280 raise ImproperlyConfiguredError(
281 "app.service_api.host or app.service_api.token are missing. "
282 "Please ensure that app.service_api.host and app.service_api.token are "
283 "defined inside of .ini configuration file."
284 )
285
286 try:
287 self.api_url = settings['rhodecode.api.url']
288 except KeyError:
289 raise ImproperlyConfiguredError(
290 "rhodecode.api.url is missing. "
291 "Please ensure that rhodecode.api.url is "
292 "defined inside of .ini configuration file."
293 )
294
295 super(SshWrapperStandalone, self).__init__(
296 command, connection_info, mode, user, user_id, key_id, shell, ini_path, settings, env)
297 267
298 268 @staticmethod
299 269 def parse_user_related_data(user_data):
300 270 user = AttributeDict()
301 271 user.user_id = user_data['user_id']
302 272 user.username = user_data['username']
303 273 user.repo_permissions = user_data['repo_permissions']
304 274 user.branch_permissions = user_data['branch_permissions']
305 275 return user
306 276
307 277 def wrap(self):
308 278 mode = self.mode
309 279 username = self.username
310 280 user_id = self.user_id
311 281 shell = self.shell
312 282
313 283 scm_detected, scm_repo, scm_mode = self.get_repo_details(mode)
314 284
315 285 log.debug(
316 286 'Mode: `%s` User: `name:%s : id:%s` Shell: `%s` SSH Command: `\"%s\"` '
317 287 'SCM_DETECTED: `%s` SCM Mode: `%s` SCM Repo: `%s`',
318 288 mode, username, user_id, shell, self.command,
319 289 scm_detected, scm_mode, scm_repo)
320 290
321 291 log.debug('SSH Connection info %s', self.get_connection_info())
322 292
323 293 if shell and self.command is None:
324 294 log.info('Dropping to shell, no command given and shell is allowed')
325 295 os.execl('/bin/bash', '-l')
326 296 exit_code = 1
327 297
328 298 elif scm_detected:
329 data = call_service_api(self.service_api_host, self.service_api_token, self.api_url, {
299 data = call_service_api(self.settings, {
330 300 "method": "service_get_data_for_ssh_wrapper",
331 301 "args": {"user_id": user_id, "repo_name": scm_repo, "key_id": self.key_id}
332 302 })
333 303 user = self.parse_user_related_data(data)
334 304 if not user:
335 305 log.warning('User with id %s not found', user_id)
336 306 exit_code = -1
337 307 return exit_code
338 308 self.repos_path = data['repos_path']
339 309 permissions = user.repo_permissions
340 310 repo_branch_permissions = user.branch_permissions
341 311 try:
342 312 exit_code, is_updated = self.serve(
343 313 scm_detected, scm_repo, scm_mode, user, permissions,
344 314 repo_branch_permissions)
345 315 except Exception:
346 316 log.exception('Error occurred during execution of SshWrapper')
347 317 exit_code = -1
348 318
349 319 elif self.command is None and shell is False:
350 320 log.error('No Command given.')
351 321 exit_code = -1
352 322
353 323 else:
354 324 log.error('Unhandled Command: "%s" Aborting.', self.command)
355 325 exit_code = -1
356 326
357 327 return exit_code
358 328
359 329 def maybe_translate_repo_uid(self, repo_name):
360 330 _org_name = repo_name
361 331 if _org_name.startswith('_'):
362 332 _org_name = _org_name.split('/', 1)[0]
363 333
364 334 if repo_name.startswith('_'):
365 335 org_repo_name = repo_name
366 336 log.debug('translating UID repo %s', org_repo_name)
367 by_id_match = call_service_api(self.service_api_host, self.service_api_token, self.api_url, {
337 by_id_match = call_service_api(self.settings, {
368 338 'method': 'service_get_repo_name_by_id',
369 339 "args": {"repo_id": repo_name}
370 340 })
371 341 if by_id_match:
372 342 repo_name = by_id_match['repo_name']
373 343 log.debug('translation of UID repo %s got `%s`', org_repo_name, repo_name)
374 344
375 345 return repo_name, _org_name
376 346
377 347 def serve(self, vcs, repo, mode, user, permissions, branch_permissions):
378 348 store = self.repos_path
379 349
380 350 check_branch_perms = False
381 351 detect_force_push = False
382 352
383 353 if branch_permissions:
384 354 check_branch_perms = True
385 355 detect_force_push = True
386 356
387 357 log.debug(
388 358 'VCS detected:`%s` mode: `%s` repo_name: %s, branch_permission_checks:%s',
389 359 vcs, mode, repo, check_branch_perms)
390 360
391 361 # detect if we have to check branch permissions
392 362 extras = {
393 363 'detect_force_push': detect_force_push,
394 364 'check_branch_perms': check_branch_perms,
395 365 'config': self.ini_path
396 366 }
397 367
398 368 match vcs:
399 369 case 'hg':
400 370 server = MercurialServer(
401 371 store=store, ini_path=self.ini_path,
402 372 repo_name=repo, user=user,
403 373 user_permissions=permissions, settings=self.settings, env=self.env)
404 374 case 'git':
405 375 server = GitServer(
406 376 store=store, ini_path=self.ini_path,
407 377 repo_name=repo, repo_mode=mode, user=user,
408 378 user_permissions=permissions, settings=self.settings, env=self.env)
409 379 case 'svn':
410 380 server = SubversionServer(
411 381 store=store, ini_path=self.ini_path,
412 382 repo_name=None, user=user,
413 383 user_permissions=permissions, settings=self.settings, env=self.env)
414 384 case _:
415 385 raise Exception(f'Unrecognised VCS: {vcs}')
416 386 self.server_impl = server
417 387 return server.run(tunnel_extras=extras)
@@ -1,179 +1,175 b''
1 1 # Copyright (C) 2016-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import os
20 20 import sys
21 21 import logging
22 22
23 23 from rhodecode.lib.hook_daemon.base import prepare_callback_daemon
24 24 from rhodecode.lib.ext_json import sjson as json
25 25 from rhodecode.lib.vcs.conf import settings as vcs_settings
26 26 from rhodecode.lib.api_utils import call_service_api
27 27
28 28 log = logging.getLogger(__name__)
29 29
30 30
31 class SSHVcsServer(object):
31 class SshVcsServer(object):
32 32 repo_user_agent = None # set in child classes
33 33 _path = None # set executable path for hg/git/svn binary
34 34 backend = None # set in child classes
35 35 tunnel = None # subprocess handling tunnel
36 36 settings = None # parsed settings module
37 37 write_perms = ['repository.admin', 'repository.write']
38 38 read_perms = ['repository.read', 'repository.admin', 'repository.write']
39 39
40 40 def __init__(self, user, user_permissions, settings, env):
41 41 self.user = user
42 42 self.user_permissions = user_permissions
43 43 self.settings = settings
44 44 self.env = env
45 45 self.stdin = sys.stdin
46 46
47 47 self.repo_name = None
48 48 self.repo_mode = None
49 49 self.store = ''
50 50 self.ini_path = ''
51 51 self.hooks_protocol = None
52 52
53 53 def _invalidate_cache(self, repo_name):
54 54 """
55 55 Set's cache for this repository for invalidation on next access
56 56
57 57 :param repo_name: full repo name, also a cache key
58 58 """
59 59 # Todo: Leave only "celery" case after transition.
60 60 match self.hooks_protocol:
61 61 case 'http':
62 62 from rhodecode.model.scm import ScmModel
63 63 ScmModel().mark_for_invalidation(repo_name)
64 64 case 'celery':
65 service_api_host = self.settings['app.service_api.host']
66 service_api_token = self.settings['app.service_api.token']
67 api_url = self.settings['rhodecode.api.url']
68
69 call_service_api(service_api_host, service_api_token, api_url, {
65 call_service_api(self.settings, {
70 66 "method": "service_mark_for_invalidation",
71 67 "args": {"repo_name": repo_name}
72 68 })
73 69
74 70 def has_write_perm(self):
75 71 permission = self.user_permissions.get(self.repo_name)
76 72 if permission in ['repository.write', 'repository.admin']:
77 73 return True
78 74
79 75 return False
80 76
81 77 def _check_permissions(self, action):
82 78 permission = self.user_permissions.get(self.repo_name)
83 79 user_info = f'{self.user["user_id"]}:{self.user["username"]}'
84 80 log.debug('permission for %s on %s are: %s',
85 81 user_info, self.repo_name, permission)
86 82
87 83 if not permission:
88 84 log.error('user `%s` permissions to repo:%s are empty. Forbidding access.',
89 85 user_info, self.repo_name)
90 86 return -2
91 87
92 88 if action == 'pull':
93 89 if permission in self.read_perms:
94 90 log.info(
95 91 'READ Permissions for User "%s" detected to repo "%s"!',
96 92 user_info, self.repo_name)
97 93 return 0
98 94 else:
99 95 if permission in self.write_perms:
100 96 log.info(
101 97 'WRITE, or Higher Permissions for User "%s" detected to repo "%s"!',
102 98 user_info, self.repo_name)
103 99 return 0
104 100
105 101 log.error('Cannot properly fetch or verify user `%s` permissions. '
106 102 'Permissions: %s, vcs action: %s',
107 103 user_info, permission, action)
108 104 return -2
109 105
110 106 def update_environment(self, action, extras=None):
111 107
112 108 scm_data = {
113 109 'ip': os.environ['SSH_CLIENT'].split()[0],
114 110 'username': self.user.username,
115 111 'user_id': self.user.user_id,
116 112 'action': action,
117 113 'repository': self.repo_name,
118 114 'scm': self.backend,
119 115 'config': self.ini_path,
120 116 'repo_store': self.store,
121 117 'make_lock': None,
122 118 'locked_by': [None, None],
123 119 'server_url': None,
124 120 'user_agent': f'{self.repo_user_agent}/ssh-user-agent',
125 121 'hooks': ['push', 'pull'],
126 122 'hooks_module': 'rhodecode.lib.hook_daemon.hook_module',
127 123 'is_shadow_repo': False,
128 124 'detect_force_push': False,
129 125 'check_branch_perms': False,
130 126
131 127 'SSH': True,
132 128 'SSH_PERMISSIONS': self.user_permissions.get(self.repo_name),
133 129 }
134 130 if extras:
135 131 scm_data.update(extras)
136 132 os.putenv("RC_SCM_DATA", json.dumps(scm_data))
137 133 return scm_data
138 134
139 135 def get_root_store(self):
140 136 root_store = self.store
141 137 if not root_store.endswith('/'):
142 138 # always append trailing slash
143 139 root_store = root_store + '/'
144 140 return root_store
145 141
146 142 def _handle_tunnel(self, extras):
147 143 # pre-auth
148 144 action = 'pull'
149 145 exit_code = self._check_permissions(action)
150 146 if exit_code:
151 147 return exit_code, False
152 148
153 149 req = self.env.get('request')
154 150 if req:
155 151 server_url = req.host_url + req.script_name
156 152 extras['server_url'] = server_url
157 153
158 154 log.debug('Using %s binaries from path %s', self.backend, self._path)
159 155 exit_code = self.tunnel.run(extras)
160 156
161 157 return exit_code, action == "push"
162 158
163 159 def run(self, tunnel_extras=None):
164 160 self.hooks_protocol = self.settings['vcs.hooks.protocol']
165 161 tunnel_extras = tunnel_extras or {}
166 162 extras = {}
167 163 extras.update(tunnel_extras)
168 164
169 165 callback_daemon, extras = prepare_callback_daemon(
170 166 extras, protocol=self.hooks_protocol,
171 167 host=vcs_settings.HOOKS_HOST)
172 168
173 169 with callback_daemon:
174 170 try:
175 171 return self._handle_tunnel(extras)
176 172 finally:
177 173 log.debug('Running cleanup with cache invalidation')
178 174 if self.repo_name:
179 175 self._invalidate_cache(self.repo_name)
@@ -1,86 +1,86 b''
1 1 # Copyright (C) 2016-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import sys
20 20 import logging
21 21 import subprocess
22 22
23 23 from vcsserver import hooks
24 from .base import SSHVcsServer
24 from .base import SshVcsServer
25 25
26 26 log = logging.getLogger(__name__)
27 27
28 28
29 29 class GitTunnelWrapper(object):
30 30 process = None
31 31
32 32 def __init__(self, server):
33 33 self.server = server
34 34 self.stdin = sys.stdin
35 35 self.stdout = sys.stdout
36 36
37 37 def create_hooks_env(self):
38 38 pass
39 39
40 40 def command(self):
41 41 root = self.server.get_root_store()
42 42 command = "cd {root}; {git_path} {mode} '{root}{repo_name}'".format(
43 43 root=root, git_path=self.server.git_path,
44 44 mode=self.server.repo_mode, repo_name=self.server.repo_name)
45 45 log.debug("Final CMD: %s", command)
46 46 return command
47 47
48 48 def run(self, extras):
49 49 action = "push" if self.server.repo_mode == "receive-pack" else "pull"
50 50 exit_code = self.server._check_permissions(action)
51 51 if exit_code:
52 52 return exit_code
53 53
54 54 scm_extras = self.server.update_environment(action=action, extras=extras)
55 55
56 56 if action == "pull":
57 57 hook_response = hooks.git_pre_pull(scm_extras)
58 58 pre_pull_messages = hook_response.output
59 59 sys.stdout.write(pre_pull_messages)
60 60
61 61 self.create_hooks_env()
62 62 result = subprocess.run(self.command(), shell=True)
63 63 result = result.returncode
64 64
65 65 # Upload-pack == clone
66 66 if action == "pull":
67 67 hook_response = hooks.git_post_pull(scm_extras)
68 68 post_pull_messages = hook_response.output
69 69 sys.stderr.write(post_pull_messages)
70 70 return result
71 71
72 72
73 class GitServer(SSHVcsServer):
73 class GitServer(SshVcsServer):
74 74 backend = 'git'
75 75 repo_user_agent = 'git'
76 76
77 77 def __init__(self, store, ini_path, repo_name, repo_mode, user, user_permissions, settings, env):
78 78 super().__init__(user, user_permissions, settings, env)
79 79
80 80 self.store = store
81 81 self.ini_path = ini_path
82 82 self.repo_name = repo_name
83 83 self._path = self.git_path = settings['ssh.executable.git']
84 84
85 85 self.repo_mode = repo_mode
86 86 self.tunnel = GitTunnelWrapper(server=self)
@@ -1,160 +1,157 b''
1 1 # Copyright (C) 2016-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import os
20 20 import sys
21 21 import logging
22 22 import tempfile
23 23 import textwrap
24 24 import collections
25 25
26 from .base import SSHVcsServer
26 from .base import SshVcsServer
27 27
28 28 from rhodecode.lib.api_utils import call_service_api
29 29
30 30 log = logging.getLogger(__name__)
31 31
32 32
33 33 class MercurialTunnelWrapper(object):
34 34 process = None
35 35
36 36 def __init__(self, server):
37 37 self.server = server
38 38 self.stdin = sys.stdin
39 39 self.stdout = sys.stdout
40 40 self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp(prefix='hgrc_rhodecode_')
41 41
42 42 def create_hooks_env(self):
43 43 repo_name = self.server.repo_name
44 44 hg_flags = self.server.config_to_hgrc(repo_name)
45 45
46 46 content = textwrap.dedent(
47 47 '''
48 48 # RhodeCode SSH hooks version=2.0.0
49 49 {custom}
50 50 '''
51 51 ).format(custom='\n'.join(hg_flags))
52 52
53 53 root = self.server.get_root_store()
54 54 hgrc_custom = os.path.join(root, repo_name, '.hg', 'hgrc_rhodecode')
55 55 hgrc_main = os.path.join(root, repo_name, '.hg', 'hgrc')
56 56
57 57 # cleanup custom hgrc file
58 58 if os.path.isfile(hgrc_custom):
59 59 with open(hgrc_custom, 'wb') as f:
60 60 f.write(b'')
61 61 log.debug('Cleanup custom hgrc file under %s', hgrc_custom)
62 62
63 63 # write temp
64 64 with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file:
65 65 hooks_env_file.write(content)
66 66
67 67 return self.hooks_env_path
68 68
69 69 def remove_configs(self):
70 70 os.remove(self.hooks_env_path)
71 71
72 72 def command(self, hgrc_path):
73 73 root = self.server.get_root_store()
74 74
75 75 command = (
76 76 "cd {root}; HGRCPATH={hgrc} {hg_path} -R {root}{repo_name} "
77 77 "serve --stdio".format(
78 78 root=root, hg_path=self.server.hg_path,
79 79 repo_name=self.server.repo_name, hgrc=hgrc_path))
80 80 log.debug("Final CMD: %s", command)
81 81 return command
82 82
83 83 def run(self, extras):
84 84 # at this point we cannot tell, we do further ACL checks
85 85 # inside the hooks
86 86 action = '?'
87 87 # permissions are check via `pre_push_ssh_auth` hook
88 88 self.server.update_environment(action=action, extras=extras)
89 89 custom_hgrc_file = self.create_hooks_env()
90 90
91 91 try:
92 92 return os.system(self.command(custom_hgrc_file))
93 93 finally:
94 94 self.remove_configs()
95 95
96 96
97 class MercurialServer(SSHVcsServer):
97 class MercurialServer(SshVcsServer):
98 98 backend = 'hg'
99 99 repo_user_agent = 'mercurial'
100 100 cli_flags = ['phases', 'largefiles', 'extensions', 'experimental', 'hooks']
101 101
102 102 def __init__(self, store, ini_path, repo_name, user, user_permissions, settings, env):
103 103 super().__init__(user, user_permissions, settings, env)
104 104
105 105 self.store = store
106 106 self.ini_path = ini_path
107 107 self.repo_name = repo_name
108 108 self._path = self.hg_path = settings['ssh.executable.hg']
109 109 self.tunnel = MercurialTunnelWrapper(server=self)
110 110
111 111 def config_to_hgrc(self, repo_name):
112 112 # Todo: once transition is done only call to service api should exist
113 113 if self.hooks_protocol == 'celery':
114 service_api_host = self.settings['app.service_api.host']
115 service_api_token = self.settings['app.service_api.token']
116 api_url = self.settings['rhodecode.api.url']
117 data = call_service_api(service_api_host, service_api_token, api_url, {
114 data = call_service_api(self.settings, {
118 115 "method": "service_config_to_hgrc",
119 116 "args": {"cli_flags": self.cli_flags, "repo_name": repo_name}
120 117 })
121 118 return data['flags']
122 119 else:
123 120 from rhodecode.model.db import RhodeCodeUi
124 121 from rhodecode.model.settings import VcsSettingsModel
125 122 ui_sections = collections.defaultdict(list)
126 123 ui = VcsSettingsModel(repo=repo_name).get_ui_settings(section=None, key=None)
127 124
128 125 # write default hooks
129 126 default_hooks = [
130 127 ('pretxnchangegroup.ssh_auth', 'python:vcsserver.hooks.pre_push_ssh_auth'),
131 128 ('pretxnchangegroup.ssh', 'python:vcsserver.hooks.pre_push_ssh'),
132 129 ('changegroup.ssh', 'python:vcsserver.hooks.post_push_ssh'),
133 130
134 131 ('preoutgoing.ssh', 'python:vcsserver.hooks.pre_pull_ssh'),
135 132 ('outgoing.ssh', 'python:vcsserver.hooks.post_pull_ssh'),
136 133 ]
137 134
138 135 for k, v in default_hooks:
139 136 ui_sections['hooks'].append((k, v))
140 137
141 138 for entry in ui:
142 139 if not entry.active:
143 140 continue
144 141 sec = entry.section
145 142 key = entry.key
146 143
147 144 if sec in self.cli_flags:
148 145 # we want only custom hooks, so we skip builtins
149 146 if sec == 'hooks' and key in RhodeCodeUi.HOOKS_BUILTIN:
150 147 continue
151 148
152 149 ui_sections[sec].append([key, entry.value])
153 150
154 151 flags = []
155 152 for _sec, key_val in ui_sections.items():
156 153 flags.append(' ')
157 154 flags.append(f'[{_sec}]')
158 155 for key, val in key_val:
159 156 flags.append(f'{key}= {val}')
160 157 return flags
@@ -1,254 +1,254 b''
1 1 # Copyright (C) 2016-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import os
20 20 import re
21 21 import sys
22 22 import logging
23 23 import signal
24 24 import tempfile
25 25 from subprocess import Popen, PIPE
26 26 import urllib.parse
27 27
28 from .base import SSHVcsServer
28 from .base import SshVcsServer
29 29
30 30 log = logging.getLogger(__name__)
31 31
32 32
33 33 class SubversionTunnelWrapper(object):
34 34 process = None
35 35
36 36 def __init__(self, server):
37 37 self.server = server
38 38 self.timeout = 30
39 39 self.stdin = sys.stdin
40 40 self.stdout = sys.stdout
41 41 self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp()
42 42 self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp()
43 43
44 44 self.read_only = True # flag that we set to make the hooks readonly
45 45
46 46 def create_svn_config(self):
47 47 content = (
48 48 '[general]\n'
49 49 'hooks-env = {}\n').format(self.hooks_env_path)
50 50 with os.fdopen(self.svn_conf_fd, 'w') as config_file:
51 51 config_file.write(content)
52 52
53 53 def create_hooks_env(self):
54 54 content = (
55 55 '[default]\n'
56 56 'LANG = en_US.UTF-8\n')
57 57 if self.read_only:
58 58 content += 'SSH_READ_ONLY = 1\n'
59 59 with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file:
60 60 hooks_env_file.write(content)
61 61
62 62 def remove_configs(self):
63 63 os.remove(self.svn_conf_path)
64 64 os.remove(self.hooks_env_path)
65 65
66 66 def command(self):
67 67 root = self.server.get_root_store()
68 68 username = self.server.user.username
69 69
70 70 command = [
71 71 self.server.svn_path, '-t',
72 72 '--config-file', self.svn_conf_path,
73 73 '--tunnel-user', username,
74 74 '-r', root]
75 75 log.debug("Final CMD: %s", ' '.join(command))
76 76 return command
77 77
78 78 def start(self):
79 79 command = self.command()
80 80 self.process = Popen(' '.join(command), stdin=PIPE, shell=True)
81 81
82 82 def sync(self):
83 83 while self.process.poll() is None:
84 84 next_byte = self.stdin.read(1)
85 85 if not next_byte:
86 86 break
87 87 self.process.stdin.write(next_byte)
88 88 self.remove_configs()
89 89
90 90 @property
91 91 def return_code(self):
92 92 return self.process.returncode
93 93
94 94 def get_first_client_response(self):
95 95 signal.signal(signal.SIGALRM, self.interrupt)
96 96 signal.alarm(self.timeout)
97 97 first_response = self._read_first_client_response()
98 98 signal.alarm(0)
99 99 return (self._parse_first_client_response(first_response)
100 100 if first_response else None)
101 101
102 102 def patch_first_client_response(self, response, **kwargs):
103 103 self.create_hooks_env()
104 104 data = response.copy()
105 105 data.update(kwargs)
106 106 data['url'] = self._svn_string(data['url'])
107 107 data['ra_client'] = self._svn_string(data['ra_client'])
108 108 data['client'] = data['client'] or ''
109 109 buffer_ = (
110 110 "( {version} ( {capabilities} ) {url}{ra_client}"
111 111 "( {client}) ) ".format(**data))
112 112 self.process.stdin.write(buffer_)
113 113
114 114 def fail(self, message):
115 115 print("( failure ( ( 210005 {message} 0: 0 ) ) )".format(
116 116 message=self._svn_string(message)))
117 117 self.remove_configs()
118 118 self.process.kill()
119 119 return 1
120 120
121 121 def interrupt(self, signum, frame):
122 122 self.fail("Exited by timeout")
123 123
124 124 def _svn_string(self, str_):
125 125 if not str_:
126 126 return ''
127 127 return f'{len(str_)}:{str_} '
128 128
129 129 def _read_first_client_response(self):
130 130 buffer_ = ""
131 131 brackets_stack = []
132 132 while True:
133 133 next_byte = self.stdin.read(1)
134 134 buffer_ += next_byte
135 135 if next_byte == "(":
136 136 brackets_stack.append(next_byte)
137 137 elif next_byte == ")":
138 138 brackets_stack.pop()
139 139 elif next_byte == " " and not brackets_stack:
140 140 break
141 141
142 142 return buffer_
143 143
144 144 def _parse_first_client_response(self, buffer_):
145 145 """
146 146 According to the Subversion RA protocol, the first request
147 147 should look like:
148 148
149 149 ( version:number ( cap:word ... ) url:string ? ra-client:string
150 150 ( ? client:string ) )
151 151
152 152 Please check https://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
153 153 """
154 154 version_re = r'(?P<version>\d+)'
155 155 capabilities_re = r'\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)'
156 156 url_re = r'\d+\:(?P<url>[\W\w]+)'
157 157 ra_client_re = r'(\d+\:(?P<ra_client>[\W\w]+)\s)'
158 158 client_re = r'(\d+\:(?P<client>[\W\w]+)\s)*'
159 159 regex = re.compile(
160 160 r'^\(\s{version}\s{capabilities}\s{url}\s{ra_client}'
161 161 r'\(\s{client}\)\s\)\s*$'.format(
162 162 version=version_re, capabilities=capabilities_re,
163 163 url=url_re, ra_client=ra_client_re, client=client_re))
164 164 matcher = regex.match(buffer_)
165 165
166 166 return matcher.groupdict() if matcher else None
167 167
168 168 def _match_repo_name(self, url):
169 169 """
170 170 Given an server url, try to match it against ALL known repository names.
171 171 This handles a tricky SVN case for SSH and subdir commits.
172 172 E.g if our repo name is my-svn-repo, a svn commit on file in a subdir would
173 173 result in the url with this subdir added.
174 174 """
175 175 # case 1 direct match, we don't do any "heavy" lookups
176 176 if url in self.server.user_permissions:
177 177 return url
178 178
179 179 log.debug('Extracting repository name from subdir path %s', url)
180 180 # case 2 we check all permissions, and match closes possible case...
181 181 # NOTE(dan): In this case we only know that url has a subdir parts, it's safe
182 182 # to assume that it will have the repo name as prefix, we ensure the prefix
183 183 # for similar repositories isn't matched by adding a /
184 184 # e.g subgroup/repo-name/ and subgroup/repo-name-1/ would work correct.
185 185 for repo_name in self.server.user_permissions:
186 186 repo_name_prefix = repo_name + '/'
187 187 if url.startswith(repo_name_prefix):
188 188 log.debug('Found prefix %s match, returning proper repository name',
189 189 repo_name_prefix)
190 190 return repo_name
191 191
192 192 return
193 193
194 194 def run(self, extras):
195 195 action = 'pull'
196 196 self.create_svn_config()
197 197 self.start()
198 198
199 199 first_response = self.get_first_client_response()
200 200 if not first_response:
201 201 return self.fail("Repository name cannot be extracted")
202 202
203 203 url_parts = urllib.parse.urlparse(first_response['url'])
204 204
205 205 self.server.repo_name = self._match_repo_name(url_parts.path.strip('/'))
206 206
207 207 exit_code = self.server._check_permissions(action)
208 208 if exit_code:
209 209 return exit_code
210 210
211 211 # set the readonly flag to False if we have proper permissions
212 212 if self.server.has_write_perm():
213 213 self.read_only = False
214 214 self.server.update_environment(action=action, extras=extras)
215 215
216 216 self.patch_first_client_response(first_response)
217 217 self.sync()
218 218 return self.return_code
219 219
220 220
221 class SubversionServer(SSHVcsServer):
221 class SubversionServer(SshVcsServer):
222 222 backend = 'svn'
223 223 repo_user_agent = 'svn'
224 224
225 225 def __init__(self, store, ini_path, repo_name, user, user_permissions, settings, env):
226 226 super().__init__(user, user_permissions, settings, env)
227 227 self.store = store
228 228 self.ini_path = ini_path
229 229 # NOTE(dan): repo_name at this point is empty,
230 230 # this is set later in .run() based from parsed input stream
231 231 self.repo_name = repo_name
232 232 self._path = self.svn_path = settings['ssh.executable.svn']
233 233
234 234 self.tunnel = SubversionTunnelWrapper(server=self)
235 235
236 236 def _handle_tunnel(self, extras):
237 237
238 238 # pre-auth
239 239 action = 'pull'
240 240 # Special case for SVN, we extract repo name at later stage
241 241 # exit_code = self._check_permissions(action)
242 242 # if exit_code:
243 243 # return exit_code, False
244 244
245 245 req = self.env['request']
246 246 server_url = req.host_url + req.script_name
247 247 extras['server_url'] = server_url
248 248
249 249 log.debug('Using %s binaries from path %s', self.backend, self._path)
250 250 exit_code = self.tunnel.run(extras)
251 251
252 252 return exit_code, action == "push"
253 253
254 254
@@ -1,38 +1,47 b''
1 1 # Copyright (C) 2010-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import urllib.parse
20 20
21 21 from rhodecode.lib.vcs import CurlSession
22 22 from rhodecode.lib.ext_json import json
23 from rhodecode.lib.vcs.exceptions import ImproperlyConfiguredError
23 24
24 25
25 def call_service_api(service_api_host, service_api_token, api_url, payload):
26
26 def call_service_api(settings, payload):
27 try:
28 api_host = settings['app.service_api.host']
29 api_token = settings['app.service_api.token']
30 api_url = settings['rhodecode.api.url']
31 except KeyError as exc:
32 raise ImproperlyConfiguredError(
33 f"{str(exc)} is missing. "
34 "Please ensure that app.service_api.host, app.service_api.token and rhodecode.api.url are "
35 "defined inside of .ini configuration file."
36 )
27 37 payload.update({
28 38 'id': 'service',
29 'auth_token': service_api_token
39 'auth_token': api_token
30 40 })
31
32 service_api_url = urllib.parse.urljoin(service_api_host, api_url)
41 service_api_url = urllib.parse.urljoin(api_host, api_url)
33 42 response = CurlSession().post(service_api_url, json.dumps(payload))
34 43
35 44 if response.status_code != 200:
36 45 raise Exception(f"Service API at {service_api_url} responded with error: {response.status_code}")
37 46
38 47 return json.loads(response.content)['result']
General Comments 0
You need to be logged in to leave comments. Login now