(Ab)using Samba and inotify to implement simple menu of privilegedactions [Part 3: Basic Implementation]

Okay, so I got it working; but more as the first-generation system that I sketched out in my design notes. Ie. one trigger maps to one action, and there is no separation between objects and actions. When I set it up and gave it to my client to try out, she send me a text message with some feedback; it said "That's cool!", I'm happy. I dare say this will get a (little) more polished in subsequent deployments; it would be good to separate the configuration from the application logic.

Here is what it looks like in action; note that this is done over CIFS, so the reactivity of the interface will depend on whether Samba on the server, and you CIFS client, handles update notifications. For example, on my aging RHEL 6 GNOME 2 desktop, it does not (I have to hit refresh repeatedly); but I gather from my client's Mac, it does. You can see what it looks like from Windows from this tiny screencast I made:

In this deployment, the user is running as a particular user ('YOUR_USER') in the init script; this will set the runtime permissions of the deleventd process, and set the filesystem permissions of the triggers and logs. Make sure that the Samba rights match (using the 'force user' and 'valid users' Samba directives can be useful).

Since we're often talking about administrative commands, we use 'sudo' to run the whitelisted commands. You'll want a configuration similar to the following, changing any instances of YOUR_USER and YOUR_HOSTNAME appropriately:

Defaults:YOUR_USER          !requiretty,visiblepw,!lecture
YOUR_USER YOUR_HOSTNAME=NOPASSWD:/sbin/service httpd stop, /sbin/service tomcat6 stop, /sbin/service tomcat6 start, /sbin/service httpd start

Enough talking, here's the code. I should probably stick it up on Github or such...

#!/usr/bin/env python
import pyinotify
import os
import time
from datetime import datetime
from threading import Timer
import shlex
import subprocess
import re
import threading
trigger_directory = '/var/local/deleventd/triggers/'
log_directory = '/var/local/deleventd/logs/'
class Trigger(object):
    '''A Trigger is a file, which when deleted, causes an action to be run.
    Triggers have a state, such as 'ready', 'running', etc. which are presented as filename
    components in square brackets. Only 'ready' triggers will run when deleted, and a new
    filename will be created reflecting the current state. After a state of 'completed' or
    'failed' has been achieved, after a brief timeout it will change back to 'ready'.
    Triggers and the captured output are stored in separate directories. Triggers should
    be the only thing in the trigger directory.
    The use of triggers as a user-interface relies on inotify to learn when something
    is deleted. Note: ensure you actually delete, not move to trash.
    A trigger doesn't manage its execution; that is the job of the TriggerSet, which
    manages the files, runs the jobs, and collects the output.'''
    valid_statuses = ['running', 'completed', 'failed', 'ready']
    def parse_filename(classname, filename):
        '''Given the filename (no directory), split out any moniker and the name.'''
        matches = re.match('^([a-zA-Z0-9][a-zA-Z0-9_]*)-\[([a-zA-Z0-9]+)\]$', filename)
        if matches is not None:             name = matches.group(1)
            status = matches.group(2)
            return {'name': name, 'status': status}         matches = re.match('^[a-zA-Z0-9]+$', filename)         if matches is not None:             status = None             name = matches.group(0)             return {'name': name, 'status': status}         else:             return None       def __init__(self, directory, name, command):         self.name = name         self.args = shlex.split(command)         self._directory = directory         self._status = 'ready'         self._lock = threading.Lock()         self._mask_level = 0         self.update_filesystem()     def __repr__(self):         return '%s(%r)' % (self.__class__, self.__dict__)     def render(self, as_state=None):         '''Return filename component of the object.'''         if as_state is not None:             return '%s-[%s]' % (self.name, as_state)         elif self._status is not None:             return '%s-[%s]' % (self.name, self._status)         else:             raise "No status stored for %r" % (self)     def status(self, status = None):         '''Get or set status. Status is one of running, completed, failed, ready.'''         self.acquire()         try:             return self._status_unsafe(status)         finally:             self.release()     def _status_unsafe(self, status = None):         if status is None:             return self._status         elif status == self._status:             return         elif status in self.valid_statuses:             self._status = status             self._update_filesystem_unsafe()         else:             raise "Requested status %r is not a valid status" % (status)     def update_filesystem(self):         '''Change the files to reflect the new state.'''         self.acquire()         try:             self._update_filesystem_unsafe()         finally:             self.release()     def _update_filesystem_unsafe(self):         '''For internal use only when the lock has already been obtained.'''         # Ignore any deletion events for this trigger         self._mask_unsafe()         for state in self.valid_statuses:             if state == self._status:                 continue             potential_filename = os.path.join(self._directory, self.render(as_state=state))             if os.path.isfile(potential_filename):                 os.remove(potential_filename)         # Now create the correct one         open(os.path.join(self._directory, self.render()), 'w').close()         # And potentially allow events to be processed         self._unmask_unsafe()     def acquire(self):         self._lock.acquire()     def release(self):         self._lock.release()     def mask(self):         self.acquire()         try:             self._mask_unsafe()         finally:             self.release()     def _mask_unsafe(self):         self._mask_level += 1     def unmask(self):         self.acquire()         try:             self._unmask_unsafe()         finally:             self.release()     def _unmask_unsafe(self):         self._mask_level -= 1         if self._mask_level < 0:             self._mask_level = 0               def masked(self):         self.acquire()         try:             return self._mask_level > 0         finally:             self.release()     def mask_and_set_status(self, status):         self.acquire()         self._mask_unsafe()         try:             self._status_unsafe(status)         finally:             self._unmask_unsafe()             self.release()   class TriggerSet(object):     '''A TriggerSet is a collection of Triggers and looks after their event handling.'''     class EventHandler(pyinotify.ProcessEvent):         def __init__(self, trigger_set):             super(TriggerSet.EventHandler, self).__init__()             self.trigger_set = trigger_set                       def process_IN_DELETE(self, event):             trigger_parse = Trigger.parse_filename(os.path.basename(event.pathname))             trigger = self.trigger_set.triggers[trigger_parse['name']]             if trigger_parse['status'] != 'ready':                 return             if trigger.masked():                 pass             else:                 trigger.mask()                 trigger.status('running')                 output_filename = "%s/%s.log" % (self.trigger_set.log_directory, trigger.name)                 output_fh = open(output_filename, 'a')                 output_fh.write('\n%s|%s|Trigger executing|%s\n' % (datetime.isoformat(datetime.now()), trigger.name, trigger.args))                 output_fh.flush()                 process = subprocess.Popen(trigger.args, close_fds=True, cwd='/', stdin=None, stdout=output_fh, stderr=subprocess.STDOUT)                 return_code = process.wait()                 output_fh.write('\n%s|%s|Trigger returned|%d\n' % (datetime.isoformat(datetime.now()), trigger.name, return_code))                 output_fh.close()                 if return_code == 0:                     trigger.status('completed')                 else:                     trigger.status('failed')                 trigger.unmask()                 Timer(5.0, trigger.mask_and_set_status, ['ready']).start()         def __repr__(self):             return '%s(%r)' % (self.__class__, self.__dict__)     def __init__(self, trigger_directory, log_directory):         '''Set up the Trigger machinery, but don't run it yet.'''         self.trigger_directory = trigger_directory         self.log_directory = log_directory         self.triggers = {}         self.mask = pyinotify.IN_DELETE         self.watch_manager = pyinotify.WatchManager()         self.handler = self.EventHandler(self)         self.notifier = pyinotify.Notifier(self.watch_manager, self.handler)         self.wdd = self.watch_manager.add_watch(self.trigger_directory, self.mask, rec=True)     def __repr__(self):         return '%s(%r)' % (self.__class__, self.__dict__)     def add_trigger(self, name, command):         '''Create a new trigger and add it to the event handler.'''         self.triggers[name] = Trigger(self.trigger_directory, name, command)     def loop(self):         '''Run forever; this is a blocking method.'''         self.notifier.loop()   ts = TriggerSet(trigger_directory, log_directory) ts.add_trigger('stop_application_stack', r''' /bin/bash -c '/usr/bin/sudo /sbin/service httpd stop; /usr/bin/sudo /sbin/service tomcat6 stop' ''') ts.add_trigger('start_application_stack', r''' /bin/bash -c '/usr/bin/sudo /sbin/service tomcat6 start; /usr/bin/sudo /sbin/service httpd start' ''') ts.loop() print 'Ending'

Here's a simple SysV init-script.

# deleventd        Startup script for Deletion-Triggered Events
# chkconfig: - 85 15
# description: Allows you to run custom scripts when monitored
#              trigger files are deleted.
# processname: deleventd
# config: /usr/local/sbin/deleventd
# Provides: deleventd
# Required-Start: $local_fs
# Required-Stop: $local_fs
# Short-Description: Start and stop Deletion-Triggered Events
# Description: Allows you to run custom scripts when monitored
#              trigger files are deleted.
# Source function library.
. /etc/rc.d/init.d/functions
# Start in the C locale by default.
export LANG=${HTTPD_LANG-"C"}
start() {
        echo $"Starting $prog"
        nohup runuser -c /usr/local/sbin/deleventd YOUR_USER 2>&1 | logger -t deleventd &
        return 0
stop() {
    echo $"Stopping $prog"
        kill $(ps -u YOUR_USER -o pid,command --no-header | awk '/python \/usr\/local\/sbin\/deleventd/ { print $1 }')
    return 0
# See how we were called.
case "$1" in
    echo $"Usage: $prog {start|stop}"
exit $RETVAL


Popular posts from this blog

ORA-12170: TNS:Connect timeout — resolved

Getting MySQL server to run with SSL

From DNS Packet Capture to analysis in Kibana