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