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 --------------------------------------------------