Fix some stupid unknown-variable errors.
[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 if ctx is None: ctx = 'userv'
283 else:
284 D.opendb()
285 if ctx is None: ctx = 'admin'
286 with OUT.redirect_to(O.FileOutput()):
287 OPTPARSE.dispatch(ctx, args)
288
289 ###----- That's all, folks --------------------------------------------------