Mercurial > kallithea
changeset 7775:9e1d4409d9ef
ssh: introduce 'kallithea-cli ssh-serve' command for providing actual protocol access over ssh
To be invoked from ~/.ssh/authorized_keys as
command=".../kallithea-cli ssh-serve -c .../my.ini 1007 7",... ssh-rsa AAA...=
The command is not supposed to be used directly by users, and is thus hidden.
Based on work by Ilya Beda <ir4y.ix@gmail.com> on
https://bitbucket.org/ir4y/rhodecode/commits/branch/ssh_server_support ,
also incorporating updates for gearbox by Anton Schur <tonich.sh@gmail.com>,
and further heavily refactored and rewritten by Mads Kiilerich.
author | Christian Oyarzun <oyarzun@gmail.com> |
---|---|
date | Mon, 17 Nov 2014 14:42:45 -0500 |
parents | e53f1db2b839 |
children | 8f3cf5d00d7f |
files | kallithea/bin/kallithea_cli.py kallithea/bin/kallithea_cli_ssh.py kallithea/lib/hooks.py kallithea/lib/vcs/backends/git/ssh.py kallithea/lib/vcs/backends/hg/ssh.py kallithea/lib/vcs/backends/ssh.py |
diffstat | 6 files changed, 330 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- a/kallithea/bin/kallithea_cli.py Mon Nov 17 14:42:45 2014 -0500 +++ b/kallithea/bin/kallithea_cli.py Mon Nov 17 14:42:45 2014 -0500 @@ -25,3 +25,4 @@ import kallithea.bin.kallithea_cli_index import kallithea.bin.kallithea_cli_ishell import kallithea.bin.kallithea_cli_repo +import kallithea.bin.kallithea_cli_ssh
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/kallithea/bin/kallithea_cli_ssh.py Mon Nov 17 14:42:45 2014 -0500 @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import click +import kallithea.bin.kallithea_cli_base as cli_base + +import os +import sys +import re +import logging +import shlex + +import kallithea +from kallithea.lib.utils2 import str2bool +from kallithea.lib.vcs.backends.git.ssh import GitSshHandler +from kallithea.lib.vcs.backends.hg.ssh import MercurialSshHandler + +log = logging.getLogger(__name__) + + +@cli_base.register_command(config_file_initialize_app=True, hidden=True) +@click.argument('user-id', type=click.INT, required=True) +@click.argument('key-id', type=click.INT, required=True) +def ssh_serve(user_id, key_id): + """Serve SSH repository protocol access. + + The trusted command that is invoked from .ssh/authorized_keys to serve SSH + protocol access. The access will be granted as the specified user ID, and + logged as using the specified key ID. + """ + ssh_enabled = kallithea.CONFIG.get('ssh_enabled', False) + if not str2bool(ssh_enabled): + sys.stderr.write("SSH access is disabled.\n") + return sys.exit(1) + + ssh_original_command = os.environ.get('SSH_ORIGINAL_COMMAND', '') + connection = re.search('^([\d\.]+)', os.environ.get('SSH_CONNECTION', '')) + client_ip = connection.group(1) if connection else '0.0.0.0' + log.debug('ssh-serve was invoked for SSH command %r from %s', ssh_original_command, client_ip) + + if not ssh_original_command: + if os.environ.get('SSH_CONNECTION'): + sys.stderr.write("'kallithea-cli ssh-serve' can only provide protocol access over SSH. Interactive SSH login for this user is disabled.\n") + else: + sys.stderr.write("'kallithea-cli ssh-serve' cannot be called directly. It must be specified as command in an SSH authorized_keys file.\n") + return sys.exit(1) + + try: + ssh_command_parts = shlex.split(ssh_original_command) + except ValueError as e: + sys.stderr.write('Error parsing SSH command %r: %s\n' % (ssh_original_command, e)) + sys.exit(1) + for VcsHandler in [MercurialSshHandler, GitSshHandler]: + vcs_handler = VcsHandler.make(ssh_command_parts) + if vcs_handler is not None: + vcs_handler.serve(user_id, key_id, client_ip) + assert False # serve is written so it never will terminate + + sys.stderr.write("This account can only be used for repository access. SSH command %r is not supported.\n" % ssh_original_command) + sys.exit(1)
--- a/kallithea/lib/hooks.py Mon Nov 17 14:42:45 2014 -0500 +++ b/kallithea/lib/hooks.py Mon Nov 17 14:42:45 2014 -0500 @@ -394,3 +394,11 @@ process_pushed_raw_ids(git_revs) return 0 + + +# Almost exactly like Mercurial contrib/hg-ssh: +def rejectpush(ui, **kwargs): + """Mercurial hook to be installed as pretxnopen and prepushkey for read-only repos""" + ex = get_hook_environment() + ui.warn((b"Push access to %r denied\n") % safe_str(ex.repository)) + return 1
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/kallithea/lib/vcs/backends/git/ssh.py Mon Nov 17 14:42:45 2014 -0500 @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import logging + +from kallithea.lib.hooks import log_pull_action +from kallithea.lib.utils import make_ui +from kallithea.lib.utils2 import safe_unicode, safe_str +from kallithea.lib.vcs.backends.ssh import BaseSshHandler + + +log = logging.getLogger(__name__) + + +class GitSshHandler(BaseSshHandler): + vcs_type = 'git' + + @classmethod + def make(cls, ssh_command_parts): + r""" + >>> import shlex + + >>> GitSshHandler.make(shlex.split("git-upload-pack '/foo bar'")).repo_name + u'foo bar' + >>> GitSshHandler.make(shlex.split("git-upload-pack '/foo bar'")).verb + 'git-upload-pack' + >>> GitSshHandler.make(shlex.split(" git-upload-pack /blåbærgrød ")).repo_name # might not be necessary to support no quoting ... but we can + u'bl\xe5b\xe6rgr\xf8d' + >>> GitSshHandler.make(shlex.split('''git-upload-pack "/foo'bar"''')).repo_name + u"foo'bar" + >>> GitSshHandler.make(shlex.split("git-receive-pack '/foo'")).repo_name + u'foo' + >>> GitSshHandler.make(shlex.split("git-receive-pack '/foo'")).verb + 'git-receive-pack' + + >>> GitSshHandler.make(shlex.split("/bin/git-upload-pack '/foo'")) # ssh-serve will report 'SSH command %r is not supported' + >>> GitSshHandler.make(shlex.split('''git-upload-pack /foo bar''')) # ssh-serve will report 'SSH command %r is not supported' + >>> shlex.split("git-upload-pack '/foo'bar' x") # ssh-serve will report: Error parsing SSH command "...": No closing quotation + Traceback (most recent call last): + ValueError: No closing quotation + >>> GitSshHandler.make(shlex.split('hg -R foo serve --stdio')) # not handled here + """ + if (len(ssh_command_parts) == 2 and + ssh_command_parts[0] in ['git-upload-pack', 'git-receive-pack'] and + ssh_command_parts[1].startswith('/') + ): + return cls(safe_unicode(ssh_command_parts[1][1:]), ssh_command_parts[0]) + + return None + + def __init__(self, repo_name, verb): + self.repo_name = repo_name + self.verb = verb + + def _serve(self): + if self.verb == 'git-upload-pack': # action 'pull' + # base class called set_hook_environment - action is hardcoded to 'pull' + log_pull_action(ui=make_ui(), repo=self.db_repo.scm_instance._repo) + else: # probably verb 'git-receive-pack', action 'push' + if not self.allow_push: + self.exit('Push access to %r denied' % safe_str(self.repo_name)) + # Note: push logging is handled by Git post-receive hook + + # git shell is not a real shell but use shell inspired quoting *inside* the argument. + # Per https://github.com/git/git/blob/v2.22.0/quote.c#L12 : + # The path must be "'" quoted, but "'" and "!" must exit the quoting and be "\" escaped + quoted_abspath = "'%s'" % self.db_repo.repo_full_path.replace("'", r"'\''").replace("!", r"'\!'") + newcmd = ['git', 'shell', '-c', "%s %s" % (self.verb, quoted_abspath)] + log.debug('Serving: %s', newcmd) + os.execvp(newcmd[0], newcmd) + self.exit("Failed to exec 'git' as %s" % newcmd)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/kallithea/lib/vcs/backends/hg/ssh.py Mon Nov 17 14:42:45 2014 -0500 @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import logging + +from mercurial import hg +try: + from mercurial.wireprotoserver import sshserver +except ImportError: + from mercurial.sshserver import sshserver # moved in Mercurial 4.6 (1bf5263fe5cc) + +from kallithea.lib.utils import make_ui +from kallithea.lib.utils2 import safe_unicode, safe_str +from kallithea.lib.vcs.backends.ssh import BaseSshHandler + + +log = logging.getLogger(__name__) + + +class MercurialSshHandler(BaseSshHandler): + vcs_type = 'hg' + + @classmethod + def make(cls, ssh_command_parts): + r""" + >>> import shlex + + >>> MercurialSshHandler.make(shlex.split('hg -R "foo bar" serve --stdio')).repo_name + u'foo bar' + >>> MercurialSshHandler.make(shlex.split(' hg -R blåbærgrød serve --stdio ')).repo_name + u'bl\xe5b\xe6rgr\xf8d' + >>> MercurialSshHandler.make(shlex.split('''hg -R 'foo"bar' serve --stdio''')).repo_name + u'foo"bar' + + >>> MercurialSshHandler.make(shlex.split('/bin/hg -R "foo" serve --stdio')) + >>> MercurialSshHandler.make(shlex.split('''hg -R "foo"bar" serve --stdio''')) # ssh-serve will report: Error parsing SSH command "...": invalid syntax + Traceback (most recent call last): + ValueError: No closing quotation + >>> MercurialSshHandler.make(shlex.split('git-upload-pack "/foo"')) # not handled here + """ + if ssh_command_parts[:2] == ['hg', '-R'] and ssh_command_parts[3:] == ['serve', '--stdio']: + return cls(safe_unicode(ssh_command_parts[2])) + + return None + + def __init__(self, repo_name): + self.repo_name = repo_name + + def _serve(self): + # Note: we want a repo with config based on .hg/hgrc and can thus not use self.db_repo.scm_instance._repo.ui + baseui = make_ui(repo_path=self.db_repo.repo_full_path) + if not self.allow_push: + baseui.setconfig('hooks', 'pretxnopen._ssh_reject', 'python:kallithea.lib.hooks.rejectpush') + baseui.setconfig('hooks', 'prepushkey._ssh_reject', 'python:kallithea.lib.hooks.rejectpush') + + repo = hg.repository(baseui, safe_str(self.db_repo.repo_full_path)) + log.debug("Starting Mercurial sshserver for %s", self.db_repo.repo_full_path) + sshserver(baseui, repo).serve_forever()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/kallithea/lib/vcs/backends/ssh.py Mon Nov 17 14:42:45 2014 -0500 @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +vcs.backends.ssh +~~~~~~~~~~~~~~~~~ + +SSH backend for all available SCMs +""" + +import sys +import logging + +from kallithea.model.db import Repository, User +from kallithea.lib.auth import HasPermissionAnyMiddleware, AuthUser +from kallithea.lib.utils2 import safe_str, set_hook_environment + + +log = logging.getLogger(__name__) + + +class BaseSshHandler(object): + # Protocol for setting properties: + # Set by sub class: + # vcs_type: 'hg' or 'git' + # Set by make() / __init__(): + # repo_name: requested repo name - only validated by serve() + # Set by serve() - must not be accessed before: + # db_repo: repository db object + # authuser: user that has been authenticated - like request.authuser ... which isn't used here + # allow_push: false for read-only access to the repo + + # Set defaults, in case .exit should be called early + vcs_type = None + repo_name = None + + @staticmethod + def make(ssh_command): + """Factory function. Given a command as invoked over SSH (and preserved + in SSH_ORIGINAL_COMMAND when run as authorized_keys command), return a + handler if the command looks ok, else return None. + """ + raise NotImplementedError + + def serve(self, user_id, key_id, client_ip): + """Verify basic sanity of the repository, and that the user is + valid and has access - then serve the native VCS protocol for + repository access.""" + dbuser = User.get(user_id) + if dbuser is None: + self.exit('User %r not found' % user_id) + self.authuser = AuthUser.make(dbuser=dbuser, ip_addr=client_ip) + log.info('Authorized user %s from SSH %s trusting user id %s and key id %s for %r', dbuser, client_ip, user_id, key_id, self.repo_name) + if self.authuser is None: # not ok ... but already kind of authenticated by SSH ... but not really not authorized ... + self.exit('User %s from %s cannot be authorized' % (dbuser.username, client_ip)) + + if HasPermissionAnyMiddleware('repository.write', + 'repository.admin')(self.authuser, self.repo_name): + self.allow_push = True + elif HasPermissionAnyMiddleware('repository.read')(self.authuser, self.repo_name): + self.allow_push = False + else: + self.exit('Access to %r denied' % safe_str(self.repo_name)) + + self.db_repo = Repository.get_by_repo_name(self.repo_name) + if self.db_repo is None: + self.exit("Repository '%s' not found" % self.repo_name) + assert self.db_repo.repo_name == self.repo_name + + # Set global hook environment up for 'push' actions. + # If pull actions should be served, the actual hook invocation will be + # hardcoded to 'pull' when log_pull_action is invoked (directly on Git, + # or through the Mercurial 'outgoing' hook). + # For push actions, the action in global hook environment is used (in + # handle_git_post_receive when it is called as Git post-receive hook, + # or in log_push_action through the Mercurial 'changegroup' hook). + set_hook_environment(self.authuser.username, client_ip, self.repo_name, self.vcs_type, 'push') + return self._serve() + + def _serve(self): + """Serve the native protocol for repository access.""" + raise NotImplementedError + + def exit(self, error): + log.info('abort serving %s %s: %s', self.vcs_type, self.repo_name, error) + sys.stderr.write('abort: %s\n' % error) + sys.exit(1)