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