7e590b59b9b641bc6d5f1079ea21b51aba932525
[gitosis.git] / gitosis / serve.py
1 """
2 Enforce git-shell to only serve allowed by access control policy.
3 directory. The client should refer to them without any extra directory
4 prefix. Repository names are forced to match ALLOW_RE.
5 """
6
7 import logging
8
9 import sys, os, re
10
11 from gitosis import access
12 from gitosis import repository
13 from gitosis import gitweb
14 from gitosis import gitdaemon
15 from gitosis import app
16 from gitosis import util
17
18 log = logging.getLogger('gitosis.serve')
19
20 ALLOW_RE = re.compile("^'/*(?P<path>[a-zA-Z0-9][a-zA-Z0-9@._-]*(/[a-zA-Z0-9][a-zA-Z0-9@._-]*)*)'$")
21
22 COMMANDS_READONLY = [
23     'git-upload-pack',
24     'git upload-pack',
25     ]
26
27 COMMANDS_WRITE = [
28     'git-receive-pack',
29     'git receive-pack',
30     ]
31
32 class ServingError(Exception):
33     """Serving error"""
34
35     def __str__(self):
36         return '%s' % self.__doc__
37
38 class CommandMayNotContainNewlineError(ServingError):
39     """Command may not contain newline"""
40
41 class UnknownCommandError(ServingError):
42     """Unknown command denied"""
43
44 class UnsafeArgumentsError(ServingError):
45     """Arguments to command look dangerous"""
46
47 class AccessDenied(ServingError):
48     """Access denied to repository"""
49
50 class WriteAccessDenied(AccessDenied):
51     """Repository write access denied"""
52
53 class ReadAccessDenied(AccessDenied):
54     """Repository read access denied"""
55
56 def serve(
57     cfg,
58     user,
59     command,
60     ):
61     if '\n' in command:
62         raise CommandMayNotContainNewlineError()
63
64     try:
65         verb, args = command.split(None, 1)
66     except ValueError:
67         # all known "git-foo" commands take one argument; improve
68         # if/when needed
69         raise UnknownCommandError()
70
71     if verb == 'git':
72         try:
73             subverb, args = args.split(None, 1)
74         except ValueError:
75             # all known "git foo" commands take one argument; improve
76             # if/when needed
77             raise UnknownCommandError()
78         verb = '%s %s' % (verb, subverb)
79
80     if (verb not in COMMANDS_WRITE
81         and verb not in COMMANDS_READONLY):
82         raise UnknownCommandError()
83
84     match = ALLOW_RE.match(args)
85     if match is None:
86         raise UnsafeArgumentsError()
87
88     path = match.group('path')
89
90     # write access is always sufficient
91     newpath = access.haveAccess(
92         config=cfg,
93         user=user,
94         mode='writable',
95         path=path)
96
97     if newpath is None:
98         # didn't have write access; try once more with the popular
99         # misspelling
100         newpath = access.haveAccess(
101             config=cfg,
102             user=user,
103             mode='writeable',
104             path=path)
105         if newpath is not None:
106             log.warning(
107                 'Repository %r config has typo "writeable", '
108                 +'should be "writable"',
109                 path,
110                 )
111
112     if newpath is None:
113         # didn't have write access
114
115         newpath = access.haveAccess(
116             config=cfg,
117             user=user,
118             mode='readonly',
119             path=path)
120
121         if newpath is None:
122             raise ReadAccessDenied()
123
124         if verb in COMMANDS_WRITE:
125             # didn't have write access and tried to write
126             raise WriteAccessDenied()
127
128     (topdir, relpath) = newpath
129     assert not relpath.endswith('.git'), \
130            'git extension should have been stripped: %r' % relpath
131     repopath = '%s.git' % relpath
132     fullpath = os.path.join(topdir, repopath)
133     if not os.path.exists(fullpath):
134         # it doesn't exist on the filesystem, but the configuration
135         # refers to it, we're serving a write request, and the user is
136         # authorized to do that: create the repository on the fly
137
138         # create leading directories
139         p = topdir
140         for segment in repopath.split(os.sep)[:-1]:
141             p = os.path.join(p, segment)
142             util.mkdir(p, 0751)
143
144         repository.init(path=fullpath)
145         gitweb.set_descriptions(
146             config=cfg,
147             )
148         generated = util.getGeneratedFilesDir(config=cfg)
149         gitweb.generate_project_list(
150             config=cfg,
151             path=os.path.join(generated, 'projects.list'),
152             )
153         gitdaemon.set_export_ok(
154             config=cfg,
155             )
156
157     # put the verb back together with the new path
158     newcmd = "%(verb)s '%(path)s'" % dict(
159         verb=verb,
160         path=fullpath,
161         )
162     return newcmd
163
164 class Main(app.App):
165     def create_parser(self):
166         parser = super(Main, self).create_parser()
167         parser.set_usage('%prog [OPTS] USER')
168         parser.set_description(
169             'Allow restricted git operations under DIR')
170         return parser
171
172     def handle_args(self, parser, cfg, options, args):
173         try:
174             (user,) = args
175         except ValueError:
176             parser.error('Missing argument USER.')
177
178         main_log = logging.getLogger('gitosis.serve.main')
179         os.umask(0022)
180
181         cmd = os.environ.get('SSH_ORIGINAL_COMMAND', None)
182         if cmd is None:
183             main_log.error('Need SSH_ORIGINAL_COMMAND in environment.')
184             sys.exit(1)
185
186         main_log.debug('Got command %(cmd)r' % dict(
187             cmd=cmd,
188             ))
189
190         os.chdir(os.path.expanduser('~'))
191
192         try:
193             newcmd = serve(
194                 cfg=cfg,
195                 user=user,
196                 command=cmd,
197                 )
198         except ServingError, e:
199             main_log.error('%s', e)
200             sys.exit(1)
201
202         main_log.debug('Serving %s', newcmd)
203         os.execvp('git', ['git', 'shell', '-c', newcmd])
204         main_log.error('Cannot execute git-shell.')
205         sys.exit(1)