### 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
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, name = None, *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
###--------------------------------------------------------------------------
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
"""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')
###--------------------------------------------------------------------------
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.
## Run the command and collect its output and status.
with U.timeout(30, "waiting for remote service %s" % me._describe()):
- proc = SUB.Popen(me._mkcmd(cmd),
+ 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)
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):
`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.
(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(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')
"""
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._set = set
- me._clear = clear
- me._map = dict(u = user)
+ me._default = default
+ me._opmap = opmap
- def _subst(me, c):
- """Return the substitution for the placeholder `%C'."""
- return me._map.get(c, c)
+ def _describe(me):
+ """Description of the remote service."""
+ return "`%s' command service (%s)" % (me.name, ' '.join(me._default))
- 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]
+ def _mkcmd(me, cmd, argmap):
+ """
+ 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.
+ """
+
+ ## 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.
+
+ 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')