chiark / gitweb /
Found in crybaby's working tree.
[chopwood] / service.py
CommitLineData
a2916c06
MW
1### -*-python-*-
2###
3### Services
4###
5### (c) 2013 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 os as OS
29import re as RX
30import subprocess as SUB
31
32from auto import HOME
33import backend as B
ac377b4f 34import cgi as CGI
a2916c06
MW
35import config as CONF; CFG = CONF.CFG
36import hash as H
37import util as U
38
39###--------------------------------------------------------------------------
40### Protocol.
41###
42### A service is a thing for which a user might have an account, with a login
82d4f64b
MW
43### name and password. The service protocol is fairly straightforward: there
44### are methods corresponding to the various low-level operations which can
45### be performed on services. Services also present `friendly' names, used
46### by the user interface.
a2916c06
MW
47###
48### A service may be local or remote. Local services are implemented in
49### terms of a backend and hashing scheme. Information about a particular
50### user of a service is maintained in an `account' object which keeps track
51### of the backend record and hashing scheme; the service protocol operations
52### are handed off to the account. Accounts provide additional protocol for
53### clients which are willing to restrict themselves to the use of local
54### services.
55###
56### A remote service doesn't have local knowledge of the password database:
57### instead, it simply sends commands corresponding to the service protocol
58### operations to some external service which is expected to act on them.
59### The implementation here uses SSH, and the remote end is expected to be
60### provided by another instance of `chpwd', but that needn't be the case:
61### the protocol is very simple.
62
63UnknownUser = B.UnknownUser
64
65class IncorrectPassword (Exception):
66 """
67 A failed password check is reported via an exception.
68
69 This is /not/ an `ExpectedError', since we anticipate that whoever called
70 `check' will have made their own arrangements to deal with the failure in
71 some more useful way.
72 """
73 pass
74
75class BasicService (object):
76 """
77 A simple base class for services.
82d4f64b
MW
78
79 The `manage_pwent_p' flag indicates whether administration commands should
80 attempt to add or remove password entries in the corresponding database
81 when users are added or removed.
a2916c06
MW
82 """
83
82d4f64b
MW
84 def __init__(me, friendly, name = None, manage_pwent_p = True,
85 *args, **kw):
a2916c06 86 super(BasicService, me).__init__(*args)
788a729c 87 me.name = name
a2916c06 88 me.friendly = friendly
82d4f64b 89 me.manage_pwent_p = manage_pwent_p
a2916c06
MW
90 me.meta = kw
91
92###--------------------------------------------------------------------------
93### Local services.
94
95class Account (object):
96 """
97 An account represents information about a user of a particular service.
98
99 From here, we can implement the service protocol operations, and also check
100 passwords.
101
102 Users are expected to acquire account objects via the `lookup' method of a
103 `LocalService' or similar.
104 """
105
106 def __init__(me, svc, rec):
107 """
108 Create a new account, for the service SVC, holding the user record REC.
109 """
110 me._svc = svc
111 me._rec = rec
112 me._hash = svc.hash
113
114 def check(me, passwd):
115 """
116 Check the password PASSWD against the information we have. If the
117 password is correct, return normally; otherwise, raise
118 `IncorrectPassword'.
119 """
120 if not me._hash.check(me._rec, me._rec.passwd, passwd):
121 raise IncorrectPassword
122
123 def clearpasswd(me):
124 """Service protocol: clear the user's password."""
125 if me._hash.NULL is None:
126 raise U.ExpectedError, (400, "Can't clear this password")
127 me._rec.passwd = me._hash.NULL
128 me._rec.write()
129
130 def setpasswd(me, passwd):
131 """Service protocol: set the user's password to PASSWD."""
132 passwd = me._hash.hash(me._rec, passwd)
133 me._rec.passwd = passwd
134 me._rec.write()
135
82d4f64b
MW
136 def remove(me):
137 """Service protocol: remove the user's password entry."""
138 me._rec.remove()
139
a2916c06
MW
140class LocalService (BasicService):
141 """
142 A local service has immediate knowledge of a hashing scheme and a password
143 storage backend. (Knowing connection details for a remote database server
144 is enough to qualify for being a `local' service. The important bit is
145 that the hashed passwords are exposed to us.)
146
147 The service protocol is implemented via an `Account', acquired through the
148 `find' method. Mainly for the benefit of the `Account' class, the
149 service's hashing scheme is exposed in the `hash' attribute.
150 """
151
152 def __init__(me, backend, hash, *args, **kw):
153 """
154 Create a new local service with a FRIENDLY name, using the given BACKEND
155 and HASH scheme.
156 """
157 super(LocalService, me).__init__(*args, **kw)
158 me._be = backend
159 me.hash = hash
160
161 def find(me, user):
162 """Find the named USER, returning an `Account' object."""
163 rec = me._be.lookup(user)
164 return Account(me, rec)
165
166 def setpasswd(me, user, passwd):
167 """Service protcol: set USER's password to PASSWD."""
168 me.find(user).setpasswd(passwd)
169
170 def clearpasswd(me, user):
171 """Service protocol: clear USER's password, preventing logins."""
172 me.find(user).clearpasswd()
173
82d4f64b
MW
174 def mkpwent(me, user, passwd, fields):
175 """Service protocol: create a record for USER."""
176 if me.hash.NULL is not None: passwd = me.hash.NULL
177 me._be.create(user, passwd, fields)
178
179 def rmpwent(me, user):
180 """Service protocol: delete the record for USER."""
181 me.find(user).remove()
182
a2916c06
MW
183CONF.export('LocalService')
184
185###--------------------------------------------------------------------------
186### Remote services.
187
188class BasicRemoteService (BasicService):
189 """
190 A remote service transmits the simple service protocol operations to some
191 remote system, which presumably is better able to implement them than we
192 are. This is useful if, for example, the password file isn't available to
193 us, or we don't have (or can't be allowed to have) access to the database
194 tables containing password hashes, or must synchronize updates with some
195 remote process. It can also be useful to integrate with services which
196 don't present a conventional password file.
197
198 This class provides common machinery for communicating with various kinds
199 of remote service. Specific subclasses are provided for transporting
200 requests through SSH and GNU Userv; others can be added easily in local
201 configuration.
202 """
203
c8d6d67b 204 def _run(me, cmd, input = None, state = None):
a2916c06
MW
205 """
206 This is the core of the remote service machinery. It issues a command
207 and parses the response. It will generate strings of informational
208 output from the command; error responses cause appropriate exceptions to
209 be raised.
210
c8d6d67b
MW
211 The command is determined by passing the CMD and STATE arguments to the
212 `_mkcmd' method, which a subclass must implement; it should return a list
213 of command-line arguments suitable for `subprocess.Popen'. The INPUT is
214 a string to make available on the command's stdin; if None, then no input
a2916c06
MW
215 is provided to the command. The `_describe' method must provide a
216 description of the remote service for use in timeout messages.
217
218 We expect output on stdout in a simple line-based format. The first
219 whitespace-separated token on each line is a type code: `OK' means the
220 command completed successfully; `INFO' means the rest of the line is some
221 useful (and expected) information; and `ERR' means an error occurred: the
222 next token is an HTTP integer status code, and the remainder is a
223 human-readable message.
224 """
225
226 ## Run the command and collect its output and status.
ac377b4f 227 with U.timeout(30, "waiting for remote service %s" % me._describe()):
c8d6d67b 228 proc = SUB.Popen(me._mkcmd(cmd, state),
a2916c06
MW
229 stdin = input is not None and SUB.PIPE or None,
230 stdout = SUB.PIPE, stderr = SUB.PIPE)
231 out, err = proc.communicate(input)
232 st = proc.wait()
233
234 ## If the program failed then report this: it obviously didn't work
235 ## properly.
236 if st or err:
237 raise U.ExpectedError, (
238 500, 'Remote service error: %r (rc = %d)' % (err, st))
239
240 ## Split a word off the front of a string; return the word and the
241 ## remaining string.
242 def nextword(line):
243 ww = line.split(None, 1)
244 n = len(ww)
245 if not n: return None
246 elif n == 1: return ww[0], ''
247 else: return ww
248
249 ## Work through the lines, parsing them.
250 win = False
251 for line in out.splitlines():
252 type, rest = nextword(line)
253 if type == 'ERR':
254 code, msg = nextword(rest)
255 raise U.ExpectedError, (int(code), msg)
256 elif type == 'INFO':
257 yield rest
258 elif type == 'OK':
259 win = True
260 else:
261 raise U.ExpectedError, \
262 (500, 'Incomprehensible reply from remote service: %r' % line)
263
264 ## If we didn't get any kind of verdict then something weird has
265 ## happened.
266 if not win:
267 raise U.ExpectedError, (500, 'No reply from remote service')
268
c8d6d67b 269 def _run_noout(me, cmd, input = None, state = None):
a2916c06 270 """Like `_run', but expect no output."""
c8d6d67b 271 for _ in me._run(cmd, input, state):
a2916c06
MW
272 raise U.ExpectedError, (500, 'Unexpected output from remote service')
273
274class SSHRemoteService (BasicRemoteService):
275 """
276 A remote service transported over SSH.
277
278 The remote service is given commands of the form
279
280 `set SERVICE USER'
281 Set USER's password for SERVICE to the password provided on the next
282 line of standard input.
283
284 `clear SERVICE USER'
285 Clear the USER's password for SERVICE.
286
1e83d6d9
MW
287 `mkpwent USER SERVICE [FIELDS ...]'
288 Install a record for USER in the SERVICE, supplying any other
289 necessary FIELDS in the appropriate format. The user's password is
290 provided on the next line of standard input.
291
292 `rmpwent USER SERVICE'
293 Remove USER's password record for SERVICE.
294
a2916c06
MW
295 Arguments are form-url-encoded, since SSH doesn't preserve token boundaries
296 in its argument list.
297
298 It is expected that the remote user has an `.ssh/authorized_keys' file
299 entry for us specifying a program to be run; the above commands will be
300 left available to this program in the environment variable
301 `SSH_ORIGINAL_COMMAND'.
302 """
303
304 def __init__(me, remote, name, *args, **kw):
305 """
306 Initialize an SSH remote service, contacting the SSH user REMOTE
307 (probably of the form `LOGIN@HOSTNAME') and referring to the service
308 NAME.
309 """
7789d380 310 super(SSHRemoteService, me).__init__(name = name, *args, **kw)
a2916c06 311 me._remote = remote
a2916c06
MW
312
313 def _describe(me):
314 """Description of the remote service."""
7789d380 315 return "`%s' via SSH to `%s'" % (me.name, me._remote),
a2916c06 316
7789d380 317 def _mkcmd(me, cmd, state):
a2916c06 318 """Format a command for SSH. Mainly escaping arguments."""
ac377b4f 319 return ['ssh', me._remote, ' '.join(map(CGI.urlencode, cmd))]
a2916c06
MW
320
321 def setpasswd(me, user, passwd):
322 """Service protocol: set the USER's password to PASSWD."""
7789d380 323 me._run_noout(['set', me.name, user], passwd + '\n')
a2916c06
MW
324
325 def clearpasswd(me, user):
326 """Service protocol: clear the USER's password."""
7789d380 327 me._run_noout(['clear', me.name, user])
a2916c06 328
82d4f64b
MW
329 def mkpwent(me, user, passwd, fields):
330 """Service protocol: create a record for USER."""
331 me._run_noout(['mkpwent', user, me.name] + fields, passwd + '\n')
332
333 def rmpwent(me, user):
334 """Service protocol: delete the record for USER."""
335 me._run_noout(['rmpwent', user, me.name])
336
a2916c06
MW
337CONF.export('SSHRemoteService')
338
339class CommandRemoteService (BasicRemoteService):
340 """
341 A remote service transported over a standard Unix command.
342
46eb5a38
MW
343 This is left rather generic. Two strategies are available (and can be
344 combined using appropriate configuration). A DEFAULT command list can be
345 specified, and will be invoked as `DEFAULT OP ARGS...', where OP ARGS form
346 a Chopwood remote command. Additionally, an OPMAP dictionary can be
347 provided, mapping OP names (remote command names) to command lists
348 containing `%' placeholders, as follows:
a2916c06
MW
349
350 `%u' the user's name
3351cbe5 351 `%f' a user record field (list-valued)
a2916c06 352 `%%' a single `%' character
46eb5a38 353
3351cbe5
MW
354 If a template word contains placeholders for list-valued arguments, then
355 one output word is produced for each element of each list, with the
356 rightmost placeholder varying fastest. If any list is empty then no output
357 words are produced.
358
46eb5a38
MW
359 On success, the commands should print a line `OK' to standard output, and
360 on any kind of anticipated failure, they should print `ERR' followed by an
361 HTTP status code and a message; in either case, the program should exit
362 with status zero. In disastrous cases, it's acceptable to print an error
363 message to stderr and/or exit with a nonzero status.
364
365 Configuration hint: if you can only handle some subset of the available
366 commands, then your easy approach is to set commands for the operations you
367 can handle in the OPMAP, and set the DEFAULT to something like
368
1e83d6d9 369 ['echo', 'ERR', '500', 'unsupported command:']
46eb5a38
MW
370
371 to reject other commands.
a2916c06
MW
372 """
373
374 R_PAT = RX.compile('%(.)')
375
1e83d6d9
MW
376 def __init__(me,
377 default = ['echo', 'ERR', '500', 'unimplemented command:'],
46eb5a38 378 opmap = {}, *args, **kw):
74b87214 379 """Initialize the command remote service."""
a2916c06 380 super(CommandRemoteService, me).__init__(*args, **kw)
46eb5a38
MW
381 me._default = default
382 me._opmap = opmap
a2916c06 383
99968b29
MW
384 def _describe(me):
385 """Description of the remote service."""
386 return "`%s' command service (%s)" % (me.name, ' '.join(me._default))
387
3351cbe5
MW
388 def _mkcmd(me, cmd, argmap):
389 """
390 Construct the command to be executed, by substituting placeholders.
391
392 The ARGMAP is a dictionary mapping placeholder letters to lists of
393 arguments. These are substituted cartesian-product style into the
394 command words.
395 """
a2916c06 396
3351cbe5
MW
397 ## No command map, so assume someone's already done the hard word.
398 if argmap is None: return cmd
399
400 ## Start on building a list of arguments.
401 ww = []
402
403 ## Work through each template argument in turn...
404 for w in cmd:
405
406 ## Firstly, build a list of lists. We'll then take the cartesian
407 ## product of these, and concatenate each of the results.
408 pc = []
409 last = 0
410 for m in me.R_PAT.finditer(w):
411 start, end = m.start(0), m.end(0)
412 if start > last: pc.append([w[last:start]])
413 ch = m.group(1)
414 if ch == '%':
415 pc.append(['%'])
416 else:
417 try: pc.append(argmap[m.group(1)])
418 except KeyError: raise U.ExpectedError, (
419 500, "Unknown placeholder `%%%s' in command `%s'" % (ch, cmd))
420 last = end
421 if last < len(w): pc.append([w[last:]])
422
423 ## If any of the components is empty then there's nothing to do for
424 ## this word.
425 if not all(pc): continue
426
427 ## Now do all the substitutions.
428 ii = len(pc)*[0]
429 while True:
430 ww.append(''.join(map(lambda v, i: v[i], pc, ii)))
431 i = len(ii) - 1
432 while i >= 0:
433 ii[i] += 1
434 if ii[i] < len(pc[i]): break
435 ii[i] = 0
436 i -= 1
437 else:
438 break
439
440 ## And finally we're done.
441 return ww
a2916c06 442
46eb5a38
MW
443 def _dispatch(me, func, op, args, input = None):
444 """
445 Work out how to invoke a particular command.
446
447 Invoke FUNC, which works like `_run', with appropriate arguments. The OP
448 is a remote command name; ARGS is a sequence of (C, ARG) pairs, where C
3351cbe5
MW
449 is a placeholder character and ARG is a list of string values; INPUT is
450 the text to provide to the command on standard input.
46eb5a38
MW
451 """
452 try:
453 cmd = me._opmap[op]
454 except KeyError:
3351cbe5
MW
455 cmd = me._default + [op] + reduce(lambda x, y: x + y,
456 [v for k, v in args], [])
46eb5a38
MW
457 map = None
458 else:
459 map = dict(args)
460 return func(cmd, input = input, state = map)
461
a2916c06
MW
462 def setpasswd(me, user, passwd):
463 """Service protocol: set the USER's password to PASSWD."""
3351cbe5
MW
464 me._dispatch(me._run_noout, 'set', [('u', [user])],
465 input = passwd + '\n')
a2916c06
MW
466
467 def clearpasswd(me, user):
468 """Service protocol: clear the USER's password."""
3351cbe5 469 me._dispatch(me._run_noout, 'clear', [('u', [user])])
a2916c06 470
82d4f64b
MW
471 def mkpwent(me, user, passwd, fields):
472 """Service protocol: create a record for USER."""
3351cbe5
MW
473 me._dispatch(me._run_noout, 'mkpwent', [('u', [user]), ('f', fields)],
474 input = passwd + '\n')
82d4f64b
MW
475
476 def rmpwent(me, user):
477 """Service protocol: delete the record for USER."""
3351cbe5 478 me._dispatch(me._run_noout, 'rmpwent', [('u', [user])])
82d4f64b 479
a2916c06
MW
480CONF.export('CommandRemoteService')
481
482###--------------------------------------------------------------------------
483### Services registry.
484
485## The registry of services.
486SERVICES = {}
487CONF.export('SERVICES')
488
489## Set some default configuration.
490CONF.DEFAULTS.update(
491
492 ## The master database, as a pair (MODNAME, MODARGS).
493 DB = ('sqlite3', [OS.path.join(HOME, 'chpwd.db')]),
494
495 ## The hash to use for our master password database.
496 HASH = H.CryptHash('md5'))
497
498## Post-configuration hook: add the master service.
499@CONF.hook
500def add_master_service():
501 dbmod, dbargs = CFG.DB
502 SERVICES['master'] = \
503 LocalService(B.DatabaseBackend(dbmod, dbargs,
504 'users', 'user', 'passwd'),
505 CFG.HASH,
506 friendly = 'Password changing service')
788a729c
MW
507 for name, svc in SERVICES.iteritems():
508 if svc.name is None: svc.name = name
a2916c06
MW
509
510###----- That's all, folks --------------------------------------------------