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