chiark / gitweb /
agpl.py: Fix up symbolic links between directories being dumped.
[chopwood] / chpwd
1 #! /usr/bin/python
2 ###
3 ### Password management
4 ###
5 ### (c) 2012 Mark Wooding
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of Chopwood: a password-changing service.
11 ###
12 ### Chopwood is free software; you can redistribute it and/or modify
13 ### it under the terms of the GNU Affero General Public License as
14 ### published by the Free Software Foundation; either version 3 of the
15 ### License, or (at your option) any later version.
16 ###
17 ### Chopwood is distributed in the hope that it will be useful,
18 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
19 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 ### GNU Affero General Public License for more details.
21 ###
22 ### You should have received a copy of the GNU Affero General Public
23 ### License along with Chopwood; if not, see
24 ### <http://www.gnu.org/licenses/>.
25
26 from __future__ import with_statement
27
28 import contextlib as CTX
29 import optparse as OP
30 import os as OS; ENV = OS.environ
31 import shlex as SL
32 import sys as SYS
33
34 from auto import HOME, VERSION
35 import cgi as CGI
36 import cmdutil as CU
37 import config as CONF; CFG = CONF.CFG
38 import dbmaint as D
39 import httpauth as HA
40 import output as O; OUT = O.OUT
41 import service as S
42 import subcommand as SC
43 import util as U
44
45 for i in ['admin', 'cgi', 'remote', 'user']:
46   __import__('cmd-' + i)
47
48 ###--------------------------------------------------------------------------
49 ### Parsing command-line options.
50
51 ## Command-line options parser.
52 OPTPARSE = SC.SubcommandOptionParser(
53   usage = '%prog SUBCOMMAND [ARGS ...]',
54   version = '%%prog, verion %s' % VERSION,
55   contexts = ['admin', 'userv', 'remote', 'cgi', 'cgi-query', 'cgi-noauth'],
56   commands = SC.COMMANDS,
57   description = """\
58 Manage all of those annoying passwords.
59
60 This is free software, and you can redistribute it and/or modify it
61 under the terms of the GNU Affero General Public License
62 <http://www.gnu.org/licenses/agpl-3.0.html>.  For a `.tar.gz' file
63 of the source code, use the `source' command.
64 """)
65
66 OPTS = None
67
68 ## Set up the global options.
69 for short, long, props in [
70   ('-c', '--context', {
71     'metavar': 'CONTEXT', 'dest': 'context', 'default': None,
72     'help': 'run commands with the given CONTEXT' }),
73   ('-f', '--config-file', {
74     'metavar': 'FILE', 'dest': 'config',
75     'default': ENV.get('CHPWD_CONFIG',
76                        OS.path.join(HOME, 'chpwd.conf')),
77     'help': 'read configuration from FILE.' }),
78   ('-s', '--ssl', {
79     'dest': 'sslp', 'action': 'store_true',
80     'help': 'pretend CGI connection is carried over SSL/TLS' }),
81   ('-u', '--user', {
82     'metavar': 'USER', 'dest': 'user', 'default': None,
83     'help': "impersonate USER, and default context to `userv'." })]:
84   OPTPARSE.add_option(short, long, **props)
85
86 ###--------------------------------------------------------------------------
87 ### CGI dispatch.
88
89 ## The special variables, to be picked out by `cgiparse'.
90 CGI.SPECIAL['%act'] = None
91 CGI.SPECIAL['%nonce'] = None
92 CGI.SPECIAL['%user'] = None
93
94 ## We don't want to parse arguments until we've settled on a context; but
95 ## issuing redirects in the early setup phase fails because we don't know
96 ## the script name.  So package the setup here.
97 def cgi_setup(ctx = 'cgi-noauth'):
98   global OPTS
99   if OPTS: return
100   OPTPARSE.context = ctx
101   OPTS, args = OPTPARSE.parse_args()
102   if args: raise U.ExpectedError, (500, 'Unexpected arguments to CGI')
103   CONF.loadconfig(OPTS.config)
104   D.opendb()
105
106 def dispatch_cgi():
107   """Examine the CGI request and invoke the appropriate command."""
108
109   ## Start by picking apart the request.
110   CGI.cgiparse()
111
112   ## We'll be taking items off the trailing path.
113   i, np = 0, len(CGI.PATH)
114
115   ## Sometimes, we want to run several actions out of the same form, so the
116   ## subcommand name needs to be in the query string.  We use the special
117   ## variable `%act' for this.  If it's not set, then we use the first elment
118   ## of the path.
119   act = CGI.SPECIAL['%act']
120   if act is None:
121     if i >= np:
122       cgi_setup()
123       CGI.redirect(CGI.action('login'))
124       return
125     act = CGI.PATH[i]
126     i += 1
127
128   ## Figure out which context we're meant to be operating in, according to
129   ## the requested action.  Unknown actions result in an error here; known
130   ## actions where we don't have enough authorization send the user back to
131   ## the login page.
132   for ctx in ['cgi-noauth', 'cgi-query', 'cgi']:
133     try:
134       c = OPTPARSE.lookup_subcommand(act, exactp = True, context = ctx)
135     except U.ExpectedError, e:
136       if e.code != 404: raise
137     else:
138       break
139   else:
140     raise e
141
142   ## Parse the command line, and load configuration.
143   cgi_setup(ctx)
144
145   ## Check whether we have enough authorization.  There's always enough for
146   ## `cgi-noauth'.
147   if ctx != 'cgi-noauth':
148
149     ## The next part of the URL should be the user name, so that caches don't
150     ## cross things over.
151     expuser = CGI.SPECIAL['%user']
152     if expuser is None:
153       if i >= np: raise U.ExpectedError, (404, 'Missing user name')
154       expuser = CGI.PATH[i]
155       i += 1
156
157     ## If there's no token cookie, then we have to bail.
158     try: token = CGI.COOKIE['chpwd-token']
159     except KeyError:
160       CGI.redirect(CGI.action('login', why = 'NOAUTH'))
161       return
162
163     ## If we only want read-only access, then the cookie is good enough.
164     ## Otherwise we must check that a nonce was supplied, and that it is
165     ## correct.
166     if ctx == 'cgi-query':
167       nonce = None
168     else:
169       nonce = CGI.SPECIAL['%nonce']
170       if not nonce:
171         CGI.redirect(CGI.action('login', why = 'NONONCE'))
172         return
173
174     ## Verify the token and nonce.
175     try:
176       CU.USER = HA.check_auth(token, nonce)
177     except HA.AuthenticationFailed, e:
178       CGI.redirect(CGI.action('login', why = e.why))
179       return
180     if CU.USER != expuser: raise U.ExpectedError, (401, 'User mismatch')
181     CGI.STATE.kw['user'] = CU.USER
182
183   ## Invoke the subcommand handler.
184   c.cgi(CGI.PARAM, CGI.PATH[i:])
185
186 ###--------------------------------------------------------------------------
187 ### Main dispatch.
188
189 @CTX.contextmanager
190 def cli_errors():
191   """Catch expected errors and report them in the traditional Unix style."""
192   try:
193     yield None
194   except U.ExpectedError, e:
195     SYS.stderr.write('%s: %s\n' % (OS.path.basename(SYS.argv[0]), e.msg))
196     if 400 <= e.code < 500: SYS.exit(1)
197     else: SYS.exit(2)
198
199 ### Main dispatch.
200
201 if __name__ == '__main__':
202
203   if 'REQUEST_METHOD' in ENV:
204     ## This looks like a CGI request.  The heavy lifting for authentication
205     ## over HTTP is done in `dispatch_cgi'.
206
207     with OUT.redirect_to(CGI.HTTPOutput()):
208       with CGI.cgi_errors(cgi_setup): dispatch_cgi()
209
210   elif 'USERV_SERVICE' in ENV:
211     ## This is a Userv request.  The caller's user name is helpfully in the
212     ## `USERV_USER' environment variable.
213
214     with cli_errors():
215       OPTS, args = OPTPARSE.parse_args()
216       CONF.loadconfig(OPTS.config)
217       try: CU.set_user(ENV['USERV_USER'])
218       except KeyError: raise ExpectedError, (500, 'USERV_USER unset')
219       with OUT.redirect_to(O.FileOutput()):
220         OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args)
221
222   elif 'SSH_ORIGINAL_COMMAND' in ENV:
223     ## This looks like an SSH request; but we present two different
224     ## interfaces over SSH.  We must distinguish them -- carefully: they have
225     ## very different error-reporting conventions.
226
227     def ssh_setup():
228       """Extract and parse the client's request from where SSH left it."""
229       global OPTS
230       OPTS, args = OPTPARSE.parse_args()
231       CONF.loadconfig(OPTS.config)
232       cmd = SL.split(ENV['SSH_ORIGINAL_COMMAND'])
233       if args: raise U.ExpectedError, (500, 'Unexpected arguments via SSH')
234       return cmd
235
236     if 'CHPWD_SSH_USER' in ENV:
237       ## Setting `CHPWD_SSH_USER' to a user name is the administrator's way
238       ## of telling us that this is a user request, so treat it like Userv.
239
240       with cli_errors():
241         cmd = ssh_setup()
242         CU.set_user(ENV['CHPWD_SSH_USER'])
243         S.SERVICES['master'].find(CU.USER)
244         with OUT.redirect_to(O.FileOutput()):
245           OPTPARSE.dispatch('userv', cmd)
246
247     elif 'CHPWD_SSH_MASTER' in ENV:
248       ## Setting `CHPWD_SSH_MASTER' to anything tells us that the client is
249       ## making a remote-service request.  We must turn on the protocol
250       ## decoration machinery, but don't need to -- mustn't, indeed -- set up
251       ## a user.
252
253       try:
254         cmd = ssh_setup()
255         with OUT.redirect_to(O.RemoteOutput()):
256           OPTPARSE.dispatch('remote', map(CGI.urldecode, cmd))
257       except U.ExpectedError, e:
258         print 'ERR', e.code, e.msg
259       else:
260         print 'OK'
261
262     else:
263       ## There's probably some strange botch in the `.ssh/authorized_keys'
264       ## file, but we can't do much about it from here.
265
266       with cli_errors():
267         raise U.ExpectedError, (400, "Unabled to determine SSH context")
268
269   else:
270     ## Plain old command line, apparently.  We default to administration
271     ## commands, but allow any kind, since this is useful for debugging, and
272     ## this isn't a security problem since our caller is just as privileged
273     ## as we are.
274
275     with cli_errors():
276       OPTS, args = OPTPARSE.parse_args()
277       CONF.loadconfig(OPTS.config)
278       CGI.SSLP = OPTS.sslp
279       ctx = OPTS.context
280       if OPTS.user:
281         CU.set_user(OPTS.user)
282         CGI.STATE.kw['user'] = OPTS.user
283         if ctx is None: ctx = 'userv'
284       else:
285         D.opendb()
286         if ctx is None: ctx = 'admin'
287       with OUT.redirect_to(O.FileOutput()):
288         OPTPARSE.dispatch(ctx, args)
289
290 ###----- That's all, folks --------------------------------------------------