X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~mdw/git/chopwood/blobdiff_plain/a2916c0635fec5b45ad742904db9f5769b48f53d..fb306d6efd803bb855c8a84b750c400a668e332b:/service.py diff --git a/service.py b/service.py index 5153539..b1f170c 100644 --- a/service.py +++ b/service.py @@ -31,6 +31,7 @@ import subprocess as SUB from auto import HOME import backend as B +import cgi as CGI import config as CONF; CFG = CONF.CFG import hash as H import util as U @@ -39,11 +40,10 @@ import util as U ### Protocol. ### ### A service is a thing for which a user might have an account, with a login -### name and password. The service protocol is fairly straightforward: a -### password can be set to a particular value using `setpasswd' (which -### handles details of hashing and so on), or cleared (i.e., preventing -### logins using a password) using `clearpasswd'. Services also present -### `friendly' names, used by the user interface. +### name and password. The service protocol is fairly straightforward: there +### are methods corresponding to the various low-level operations which can +### be performed on services. Services also present `friendly' names, used +### by the user interface. ### ### A service may be local or remote. Local services are implemented in ### terms of a backend and hashing scheme. Information about a particular @@ -75,11 +75,18 @@ class IncorrectPassword (Exception): class BasicService (object): """ A simple base class for services. + + The `manage_pwent_p' flag indicates whether administration commands should + attempt to add or remove password entries in the corresponding database + when users are added or removed. """ - def __init__(me, friendly, *args, **kw): + def __init__(me, friendly, name = None, manage_pwent_p = True, + *args, **kw): super(BasicService, me).__init__(*args) + me.name = name me.friendly = friendly + me.manage_pwent_p = manage_pwent_p me.meta = kw ###-------------------------------------------------------------------------- @@ -126,6 +133,10 @@ class Account (object): me._rec.passwd = passwd me._rec.write() + def remove(me): + """Service protocol: remove the user's password entry.""" + me._rec.remove() + class LocalService (BasicService): """ A local service has immediate knowledge of a hashing scheme and a password @@ -160,6 +171,15 @@ class LocalService (BasicService): """Service protocol: clear USER's password, preventing logins.""" me.find(user).clearpasswd() + def mkpwent(me, user, passwd, fields): + """Service protocol: create a record for USER.""" + if me.hash.NULL is not None: passwd = me.hash.NULL + me._be.create(user, passwd, fields) + + def rmpwent(me, user): + """Service protocol: delete the record for USER.""" + me.find(user).remove() + CONF.export('LocalService') ###-------------------------------------------------------------------------- @@ -181,17 +201,17 @@ class BasicRemoteService (BasicService): configuration. """ - def _run(me, cmd, input = None): + def _run(me, cmd, input = None, state = None): """ This is the core of the remote service machinery. It issues a command and parses the response. It will generate strings of informational output from the command; error responses cause appropriate exceptions to be raised. - The command is determined by passing the CMD argument to the `_mkcmd' - method, which a subclass must implement; it should return a list of - command-line arguments suitable for `subprocess.Popen'. The INPUT is a - string to make available on the command's stdin; if None, then no input + The command is determined by passing the CMD and STATE arguments to the + `_mkcmd' method, which a subclass must implement; it should return a list + of command-line arguments suitable for `subprocess.Popen'. The INPUT is + a string to make available on the command's stdin; if None, then no input is provided to the command. The `_describe' method must provide a description of the remote service for use in timeout messages. @@ -204,8 +224,8 @@ class BasicRemoteService (BasicService): """ ## Run the command and collect its output and status. - with timeout(30, "waiting for remote service %s" % me._describe()): - proc = SUB.Popen(me._mkcmd(cmd), + with U.timeout(30, "waiting for remote service %s" % me._describe()): + proc = SUB.Popen(me._mkcmd(cmd, state), stdin = input is not None and SUB.PIPE or None, stdout = SUB.PIPE, stderr = SUB.PIPE) out, err = proc.communicate(input) @@ -246,9 +266,9 @@ class BasicRemoteService (BasicService): if not win: raise U.ExpectedError, (500, 'No reply from remote service') - def _run_noout(me, cmd, input = None): + def _run_noout(me, cmd, input = None, state = None): """Like `_run', but expect no output.""" - for _ in me._run(cmd, input): + for _ in me._run(cmd, input, state): raise U.ExpectedError, (500, 'Unexpected output from remote service') class SSHRemoteService (BasicRemoteService): @@ -264,6 +284,14 @@ class SSHRemoteService (BasicRemoteService): `clear SERVICE USER' Clear the USER's password for SERVICE. + `mkpwent USER SERVICE [FIELDS ...]' + Install a record for USER in the SERVICE, supplying any other + necessary FIELDS in the appropriate format. The user's password is + provided on the next line of standard input. + + `rmpwent USER SERVICE' + Remove USER's password record for SERVICE. + Arguments are form-url-encoded, since SSH doesn't preserve token boundaries in its argument list. @@ -279,25 +307,32 @@ class SSHRemoteService (BasicRemoteService): (probably of the form `LOGIN@HOSTNAME') and referring to the service NAME. """ - super(SSHRemoteService, me).__init__(*args, **kw) + super(SSHRemoteService, me).__init__(name = name, *args, **kw) me._remote = remote - me._name = name def _describe(me): """Description of the remote service.""" - return "`%s' via SSH to `%s'" % (me._name, me._remote), + return "`%s' via SSH to `%s'" % (me.name, me._remote), - def _mkcmd(me, cmd): + def _mkcmd(me, cmd, state): """Format a command for SSH. Mainly escaping arguments.""" - return ['ssh', me._remote, ' '.join(map(urlencode, cmd))] + return ['ssh', me._remote, ' '.join(map(CGI.urlencode, cmd))] def setpasswd(me, user, passwd): """Service protocol: set the USER's password to PASSWD.""" - me._run_noout(['set', me._name, user], passwd + '\n') + me._run_noout(['set', me.name, user], passwd + '\n') def clearpasswd(me, user): """Service protocol: clear the USER's password.""" - me._run_noout(['clear', me._name, user]) + me._run_noout(['clear', me.name, user]) + + def mkpwent(me, user, passwd, fields): + """Service protocol: create a record for USER.""" + me._run_noout(['mkpwent', user, me.name] + fields, passwd + '\n') + + def rmpwent(me, user): + """Service protocol: delete the record for USER.""" + me._run_noout(['rmpwent', user, me.name]) CONF.export('SSHRemoteService') @@ -305,51 +340,142 @@ class CommandRemoteService (BasicRemoteService): """ A remote service transported over a standard Unix command. - This is left rather generic. We need to know some command lists SET and - CLEAR containing the relevant service names and arguments. These are - simply executed, after simple placeholder substitution. - - The SET command should read a password as its first line on stdin, and set - that as the user's new password. The CLEAR command should simply prevent - the user from logging in with a password. On success, the commands should - print a line `OK' to standard output, and on any kind of anticipated - failure, they should print `ERR' followed by an HTTP status code and a - message; in either case, the program should exit with status zero. In - disastrous cases, it's acceptable to print an error message to stderr - and/or exit with a nonzero status. - - The placeholders are as follows. + This is left rather generic. Two strategies are available (and can be + combined using appropriate configuration). A DEFAULT command list can be + specified, and will be invoked as `DEFAULT OP ARGS...', where OP ARGS form + a Chopwood remote command. Additionally, an OPMAP dictionary can be + provided, mapping OP names (remote command names) to command lists + containing `%' placeholders, as follows: `%u' the user's name + `%f' a user record field (list-valued) `%%' a single `%' character + + If a template word contains placeholders for list-valued arguments, then + one output word is produced for each element of each list, with the + rightmost placeholder varying fastest. If any list is empty then no output + words are produced. + + On success, the commands should print a line `OK' to standard output, and + on any kind of anticipated failure, they should print `ERR' followed by an + HTTP status code and a message; in either case, the program should exit + with status zero. In disastrous cases, it's acceptable to print an error + message to stderr and/or exit with a nonzero status. + + Configuration hint: if you can only handle some subset of the available + commands, then your easy approach is to set commands for the operations you + can handle in the OPMAP, and set the DEFAULT to something like + + ['echo', 'ERR', '500', 'unsupported command:'] + + to reject other commands. """ R_PAT = RX.compile('%(.)') - def __init__(me, set, clear, *args, **kw): + def __init__(me, + default = ['echo', 'ERR', '500', 'unimplemented command:'], + opmap = {}, *args, **kw): + """Initialize the command remote service.""" + super(CommandRemoteService, me).__init__(*args, **kw) + me._default = default + me._opmap = opmap + + def _describe(me): + """Description of the remote service.""" + return "`%s' command service (%s)" % (me.name, ' '.join(me._default)) + + def _mkcmd(me, cmd, argmap): """ - Initialize the command remote service. + Construct the command to be executed, by substituting placeholders. + + The ARGMAP is a dictionary mapping placeholder letters to lists of + arguments. These are substituted cartesian-product style into the + command words. """ - super(CommandRemoteService, me).__init__(*args, **kw) - me._set = set - me._clear = clear - me._map = dict(u = user) - def _subst(me, c): - """Return the substitution for the placeholder `%C'.""" - return me._map.get(c, c) + ## No command map, so assume someone's already done the hard word. + if argmap is None: return cmd + + ## Start on building a list of arguments. + ww = [] + + ## Work through each template argument in turn... + for w in cmd: + + ## Firstly, build a list of lists. We'll then take the cartesian + ## product of these, and concatenate each of the results. + pc = [] + last = 0 + for m in me.R_PAT.finditer(w): + start, end = m.start(0), m.end(0) + if start > last: pc.append([w[last:start]]) + ch = m.group(1) + if ch == '%': + pc.append(['%']) + else: + try: pc.append(argmap[m.group(1)]) + except KeyError: raise U.ExpectedError, ( + 500, "Unknown placeholder `%%%s' in command `%s'" % (ch, cmd)) + last = end + if last < len(w): pc.append([w[last:]]) + + ## If any of the components is empty then there's nothing to do for + ## this word. + if not all(pc): continue + + ## Now do all the substitutions. + ii = len(pc)*[0] + while True: + ww.append(''.join(map(lambda v, i: v[i], pc, ii))) + i = len(ii) - 1 + while i >= 0: + ii[i] += 1 + if ii[i] < len(pc[i]): break + ii[i] = 0 + i -= 1 + else: + break + + ## And finally we're done. + return ww + + def _dispatch(me, func, op, args, input = None): + """ + Work out how to invoke a particular command. - def _mkcmd(me, cmd): - """Construct the command to be executed, by substituting placeholders.""" - return [me.R_PAT.sub(lambda m: me._subst(m.group(1))) for arg in cmd] + Invoke FUNC, which works like `_run', with appropriate arguments. The OP + is a remote command name; ARGS is a sequence of (C, ARG) pairs, where C + is a placeholder character and ARG is a list of string values; INPUT is + the text to provide to the command on standard input. + """ + try: + cmd = me._opmap[op] + except KeyError: + cmd = me._default + [op] + reduce(lambda x, y: x + y, + [v for k, v in args], []) + map = None + else: + map = dict(args) + return func(cmd, input = input, state = map) def setpasswd(me, user, passwd): """Service protocol: set the USER's password to PASSWD.""" - me._run_noout(me._set, passwd + '\n') + me._dispatch(me._run_noout, 'set', [('u', [user])], + input = passwd + '\n') def clearpasswd(me, user): """Service protocol: clear the USER's password.""" - me._run_noout(me._clear) + me._dispatch(me._run_noout, 'clear', [('u', [user])]) + + def mkpwent(me, user, passwd, fields): + """Service protocol: create a record for USER.""" + me._dispatch(me._run_noout, 'mkpwent', [('u', [user]), ('f', fields)], + input = passwd + '\n') + + def rmpwent(me, user): + """Service protocol: delete the record for USER.""" + me._dispatch(me._run_noout, 'rmpwent', [('u', [user])]) CONF.export('CommandRemoteService') @@ -378,5 +504,7 @@ def add_master_service(): 'users', 'user', 'passwd'), CFG.HASH, friendly = 'Password changing service') + for name, svc in SERVICES.iteritems(): + if svc.name is None: svc.name = name ###----- That's all, folks --------------------------------------------------