我们团队使用 Mercurial 管理程序代码,并使用 mercurial-server 来实现SSH访问和权限管理。

我们有个项目运行在 LeanCloud,而 LeanCloud 只支持 Git 部署代码和 SSH 协议。这样就使得我们必须支持 Git 来管理这个项目的代码,同时意味着参与项目的成员权限管理也要针对 Git 重新弄一套。

mercurial-server 实现权限管理的原理是解析 SSH_ORINGIAL_COMMAND,并在执 SSH_ORIGINAL_COMMAND 之前(针对分支和文件的权限验证是在pretxnchangegroup hook 中进行的)进行权限验证的。

既然 mercurial-server 在执行 SSH_ORINGIAL_COMMAND 之前做的权限验证(暂时忽略针对文件和分支的权限验证),那么用它来管理 Git 代码库的权限理论上也是可以的。

来看看 mercruial-server 的源码结构:

├── setup.py
└── src
    ├── hg-ssh
    ├── init/
    ├── mercurialserver/
    └── refresh-auth

源码中的 hg-ssh 文件是关键

#!/usr/bin/env python
...

import sys, os, os.path
import base64
import re
from mercurialserver import config, ruleset

...
### 此处有省略

def getrepo(op, repo):
    # First canonicalise, then check the string, then the rules
    repo = repo.strip().rstrip("/")
    if len(repo) == 0:
        fail("path to repository seems to be empty")
    if repo.startswith("/"):
        fail("absolute paths are not supported")
    checkDots(repo)
    ruleset.rules.set(repo=repo)
    if not ruleset.rules.allow(op, branch=None, file=None):
        fail("access denied")
    return repo

cmd = os.environ.get('SSH_ORIGINAL_COMMAND', None)
if cmd is None:
    fail("direct logins on the hg account prohibited")
elif cmd.startswith('hg -R ') and cmd.endswith(' serve --stdio'):
    repo = getrepo("read", cmd[6:-14])
    if not os.path.isdir(repo + "/.hg"):
        fail("no such repository %s" % repo)
    dispatch.dispatch(request(['-R', repo, 'serve', '--stdio']))
elif cmd.startswith('hg init '):
    repo = getrepo("init", cmd[8:])
    if os.path.exists(repo):
        fail("%s exists" % repo)
    d = os.path.dirname(repo)
    if d != "" and not os.path.isdir(d):
        os.makedirs(d)
    dispatch.dispatch(request(['init', repo]))
else:
    fail("illegal command %r" % cmd)

从源码中我们可以看到,hg-ssh 获得了 SSH_ORIGINAL_COMMAND 并检测了是否是 Mercurial 相关命令请求,如果是则解析出请求的目标代码库,如果不是则响应该命令非法。确认是 Mercurial 相关命令以后,则使用 getrepo 函数进行处理并验证了权限。

OK, 我们清楚 mercurial-server 的工作流程以后就可以思考怎样把 Git 集成进去。

首先,我们需要搞清楚,Git 自身是怎样处理 SSH 协议请求的: Git-Internals-Transfer-Protocols

其次,我们知道在独立搭建 Git 服务端的时候,通常建立单独的 git 用户来管理,而且专门为 git 用户配置独立的shell,叫做 git-shell。从文档中我们了解到 git-shell 会处理的命令只有以下几类:

git receive-pack <argument>
git upload-pack <argument>
git upload-archive <argument>
cvs server

cvs server 这条我们暂时忽略,大多数情况下用不上。

如此,我们就清楚 Git 客户端所有请求都会通过上面的几类命令实现提交和请求。那么,是时候动手集成到 mercurial-server 中了。

修改 hg-ssh 文件

...

elif cmd.startswith('git'):

...

增加elif区间,判断 cmd 是否是 git 开头的命令。我们所有 Git 请求的处理都在这个区间中进行。

上面我们清楚 Git 只会有上面几类命令,所以为了安全,我们也只应答上面几类命令。所以增加两个变量,另外需要从 SSH_ORINGIAL_COMMAND 里面解析出代码库的路径和验证它是否安全,所以还需要一个正则表达式:

allowedGitReadCommands = [
        'git-upload-pack',
        'git upload-pack',
        'git-upload-archive',
        'git upload-archive'
        ]

allowedGitWriteCommands = [
        'git-receive-pack',
        'git receive-pack',
        ]
        
allowedGitRe = re.compile("^'/*(?P<path>[a-zA-Z0-9][a-zA-Z0-9@._-]*(/[a-zA-Z0-9][a-zA-Z0-9@._-]*)*)'$")        

接下来就是处理 Git 请求的具体逻辑了

...

elif cmd.startswith('git'):
    try:
        verb, args = cmd.split(None, 1)
    except ValueError:
        fail("Illegal command %r" % cmd)

    if verb == 'git':
        try:
            subverb, args = args.split(None, 1)
        except ValueError:
            fail("Illegal command %r" % cmd)

        verb = '%s %s' % (verb, subverb)

    if (verb not in allowedGitWriteCommands and verb not in allowedGitReadCommands):
        fail("Illegal command %r" % cmd)

    match = allowedGitRe.match(args)
    if match is None:
        fail("Unsafe command arguments.")

    path = match.group('path')
    repo = getrepo("read", path)

    newCmd = "%(verb)s '%(path)s'" % dict(
            verb=verb,
            path=os.path.join(config.getReposPath(), path))

    os.execvp('git', ['git','shell','-c',newCmd])

...

同样使用 mercurial-server 的 getrepo 来验证权限,因为 mercurial-server 的权限验证只关心请求的代码库路径和当前请求的 SSH Public Key。

这样我们就简单的实现了通过 mercurial-server 来管理 Git 代码库的权限,之后在此基础上增加对 Git 代码库中分支和文件等更细的权限控制。