chiark / gitweb /
chpwd, subcommand.py: Only show global options in admin context help.
[chopwood] / service.py
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
26 from __future__ import with_statement
27
28 import os as OS
29 import re as RX
30 import subprocess as SUB
31
32 from auto import HOME
33 import backend as B
34 import cgi as CGI
35 import config as CONF; CFG = CONF.CFG
36 import hash as H
37 import 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
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.
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
63 UnknownUser = B.UnknownUser
64
65 class 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
75 class BasicService (object):
76   """
77   A simple base class for services.
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.
82   """
83
84   def __init__(me, friendly, name = None, manage_pwent_p = True,
85                *args, **kw):
86     super(BasicService, me).__init__(*args)
87     me.name = name
88     me.friendly = friendly
89     me.manage_pwent_p = manage_pwent_p
90     me.meta = kw
91
92 ###--------------------------------------------------------------------------
93 ### Local services.
94
95 class 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
136   def remove(me):
137     """Service protocol: remove the user's password entry."""
138     me._rec.remove()
139
140 class 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
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
183 CONF.export('LocalService')
184
185 ###--------------------------------------------------------------------------
186 ### Remote services.
187
188 class 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
204   def _run(me, cmd, input = None, state = None):
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
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
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.
227     with U.timeout(30, "waiting for remote service %s" % me._describe()):
228       proc = SUB.Popen(me._mkcmd(cmd, state),
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
269   def _run_noout(me, cmd, input = None, state = None):
270     """Like `_run', but expect no output."""
271     for _ in me._run(cmd, input, state):
272       raise U.ExpectedError, (500, 'Unexpected output from remote service')
273
274 class 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
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
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     """
310     super(SSHRemoteService, me).__init__(name = name, *args, **kw)
311     me._remote = remote
312
313   def _describe(me):
314     """Description of the remote service."""
315     return "`%s' via SSH to `%s'" % (me.name, me._remote),
316
317   def _mkcmd(me, cmd, state):
318     """Format a command for SSH.  Mainly escaping arguments."""
319     return ['ssh', me._remote, ' '.join(map(CGI.urlencode, cmd))]
320
321   def setpasswd(me, user, passwd):
322     """Service protocol: set the USER's password to PASSWD."""
323     me._run_noout(['set', me.name, user], passwd + '\n')
324
325   def clearpasswd(me, user):
326     """Service protocol: clear the USER's password."""
327     me._run_noout(['clear', me.name, user])
328
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
337 CONF.export('SSHRemoteService')
338
339 class CommandRemoteService (BasicRemoteService):
340   """
341   A remote service transported over a standard Unix command.
342
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:
349
350   `%u'          the user's name
351   `%f'          a user record field (list-valued)
352   `%%'          a single `%' character
353
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
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
369         ['echo', 'ERR', '500', 'unsupported command:']
370
371   to reject other commands.
372   """
373
374   R_PAT = RX.compile('%(.)')
375
376   def __init__(me,
377                default = ['echo', 'ERR', '500', 'unimplemented command:'],
378                opmap = {}, *args, **kw):
379     """Initialize the command remote service."""
380     super(CommandRemoteService, me).__init__(*args, **kw)
381     me._default = default
382     me._opmap = opmap
383
384   def _describe(me):
385     """Description of the remote service."""
386     return "`%s' command service (%s)" % (me.name, ' '.join(me._default))
387
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     """
396
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
442
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
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.
451     """
452     try:
453       cmd = me._opmap[op]
454     except KeyError:
455       cmd = me._default + [op] + reduce(lambda x, y: x + y,
456                                         [v for k, v in args], [])
457       map = None
458     else:
459       map = dict(args)
460     return func(cmd, input = input, state = map)
461
462   def setpasswd(me, user, passwd):
463     """Service protocol: set the USER's password to PASSWD."""
464     me._dispatch(me._run_noout, 'set', [('u', [user])],
465                  input = passwd + '\n')
466
467   def clearpasswd(me, user):
468     """Service protocol: clear the USER's password."""
469     me._dispatch(me._run_noout, 'clear', [('u', [user])])
470
471   def mkpwent(me, user, passwd, fields):
472     """Service protocol: create a record for USER."""
473     me._dispatch(me._run_noout, 'mkpwent', [('u', [user]), ('f', fields)],
474                  input = passwd + '\n')
475
476   def rmpwent(me, user):
477     """Service protocol: delete the record for USER."""
478     me._dispatch(me._run_noout, 'rmpwent', [('u', [user])])
479
480 CONF.export('CommandRemoteService')
481
482 ###--------------------------------------------------------------------------
483 ### Services registry.
484
485 ## The registry of services.
486 SERVICES = {}
487 CONF.export('SERVICES')
488
489 ## Set some default configuration.
490 CONF.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
500 def 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')
507   for name, svc in SERVICES.iteritems():
508     if svc.name is None: svc.name = name
509
510 ###----- That's all, folks --------------------------------------------------