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