chiark / gitweb /
agpl.py: Exclude the root directory from listers.
[chopwood] / chpwd
CommitLineData
a2916c06
MW
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
26from __future__ import with_statement
27
28import contextlib as CTX
29import optparse as OP
30import os as OS; ENV = OS.environ
31import shlex as SL
32import sys as SYS
33
34from auto import HOME, VERSION
35import cgi as CGI
36import cmdutil as CU
37import config as CONF; CFG = CONF.CFG
38import dbmaint as D
39import httpauth as HA
40import output as O; OUT = O.OUT
71d74dcf 41import service as S
a2916c06
MW
42import subcommand as SC
43import util as U
44
45for i in ['admin', 'cgi', 'remote', 'user']:
46 __import__('cmd-' + i)
47
48###--------------------------------------------------------------------------
49### Parsing command-line options.
50
51## Command-line options parser.
52OPTPARSE = 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 = """\
58Manage all of those annoying passwords.
59
60This is free software, and you can redistribute it and/or modify it
61under the terms of the GNU Affero General Public License
62<http://www.gnu.org/licenses/agpl-3.0.html>. For a `.tar.gz' file
63of the source code, use the `source' command.
64""")
65
66OPTS = None
67
68## Set up the global options.
69for 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',
2a875c57
MW
75 'default': ENV.get('CHPWD_CONFIG',
76 OS.path.join(HOME, 'chpwd.conf')),
a2916c06 77 'help': 'read configuration from FILE.' }),
bb623e8f
MW
78 ('-s', '--ssl', {
79 'dest': 'sslp', 'action': 'store_true',
80 'help': 'pretend CGI connection is carried over SSL/TLS' }),
a2916c06
MW
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'.
90CGI.SPECIAL['%act'] = None
91CGI.SPECIAL['%nonce'] = None
ba8f1b92 92CGI.SPECIAL['%user'] = None
a2916c06
MW
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.
97def 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
106def 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
ba8f1b92
MW
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
a2916c06
MW
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
ba8f1b92
MW
180 if CU.USER != expuser: raise U.ExpectedError, (401, 'User mismatch')
181 CGI.STATE.kw['user'] = CU.USER
a2916c06
MW
182
183 ## Invoke the subcommand handler.
184 c.cgi(CGI.PARAM, CGI.PATH[i:])
185
186###--------------------------------------------------------------------------
187### Main dispatch.
188
189@CTX.contextmanager
190def 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
201if __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'])
71d74dcf 233 if args: raise U.ExpectedError, (500, 'Unexpected arguments via SSH')
a2916c06
MW
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'])
71d74dcf 243 S.SERVICES['master'].find(CU.USER)
a2916c06
MW
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()):
71d74dcf
MW
256 OPTPARSE.dispatch('remote', map(CGI.urldecode, cmd))
257 except U.ExpectedError, e:
a2916c06
MW
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():
71d74dcf 267 raise U.ExpectedError, (400, "Unabled to determine SSH context")
a2916c06
MW
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)
bb623e8f 278 CGI.SSLP = OPTS.sslp
a2916c06
MW
279 ctx = OPTS.context
280 if OPTS.user:
281 CU.set_user(OPTS.user)
ea3f041b 282 CGI.STATE.kw['user'] = OPTS.user
a2916c06
MW
283 if ctx is None: ctx = 'userv'
284 else:
285 D.opendb()
286 if ctx is None: ctx = 'admin'
287 with OUT.redirect_to(O.FileOutput()):
288 OPTPARSE.dispatch(ctx, args)
289
290###----- That's all, folks --------------------------------------------------