processhandler.py 21.9 KB
Newer Older
Ad Schellevis's avatar
Ad Schellevis committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
"""
    Copyright (c) 2014 Ad Schellevis
    All rights reserved.

    Redistribution and use in source and binary forms, with or without
    modification, are permitted provided that the following conditions are met:

    1. Redistributions of source code must retain the above copyright notice,
     this list of conditions and the following disclaimer.

    2. Redistributions in binary form must reproduce the above copyright
     notice, this list of conditions and the following disclaimer in the
     documentation and/or other materials provided with the distribution.

    THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
    INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
    AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
    AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
    OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    POSSIBILITY OF SUCH DAMAGE.

    --------------------------------------------------------------------------------------
27

28
    package : configd
Ad Schellevis's avatar
Ad Schellevis committed
29 30
    function: unix domain socket process worker process
"""
31

Ad Schellevis's avatar
Ad Schellevis committed
32 33 34 35 36 37 38 39 40 41 42
__author__ = 'Ad Schellevis'

import os
import subprocess
import socket
import traceback
import syslog
import threading
import ConfigParser
import glob
import time
43
import uuid
44
import shlex
45
import tempfile
46
import ph_inline_actions
47
from modules import singleton
Ad Schellevis's avatar
Ad Schellevis committed
48

Ad Schellevis's avatar
Ad Schellevis committed
49

Ad Schellevis's avatar
Ad Schellevis committed
50 51 52 53 54 55 56 57
class Handler(object):
    """ Main handler class, opens unix domain socket and starts listening
        - New connections are handed over to a HandlerClient type object in a new thread
        - All possible actions are stored in 1 ActionHandler type object and parsed to every client for script execution

        processflow:
            Handler ( waits for client )
                -> new client is send to HandlerClient
58
                    -> execute ActionHandler command using Action objects
Ad Schellevis's avatar
Ad Schellevis committed
59 60
                    <- send back result string
    """
61
    def __init__(self, socket_filename, config_path, config_environment={}, simulation_mode=False):
Ad Schellevis's avatar
Ad Schellevis committed
62 63 64 65
        """ Constructor

        :param socket_filename: filename of unix domain socket to use
        :param config_path: location of configuration files
Ad Schellevis's avatar
Ad Schellevis committed
66
        :param simulation_mode: emulation mode, do not start actual (script) commands
Ad Schellevis's avatar
Ad Schellevis committed
67 68 69 70
        :return: object
        """
        self.socket_filename = socket_filename
        self.config_path = config_path
71
        self.simulation_mode = simulation_mode
72
        self.config_environment = config_environment
73
        self.single_threaded = False
Ad Schellevis's avatar
Ad Schellevis committed
74 75 76 77 78 79 80 81 82

    def run(self):
        """ Run process handler

        :return:
        """
        while True:
            try:
                # open action handler
83 84
                actHandler = ActionHandler(config_path=self.config_path,
                                           config_environment=self.config_environment)
Ad Schellevis's avatar
Ad Schellevis committed
85 86 87 88 89 90 91 92 93 94

                # remove previous socket ( if exists )
                try:
                    os.unlink(self.socket_filename)
                except OSError:
                    if os.path.exists(self.socket_filename):
                        raise

                sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                sock.bind(self.socket_filename)
Ad Schellevis's avatar
Ad Schellevis committed
95
                os.chmod(self.socket_filename, 0o666)
Ad Schellevis's avatar
Ad Schellevis committed
96 97 98 99 100 101 102 103 104
                sock.listen(30)
                while True:
                    # wait for a connection to arrive
                    connection, client_address = sock.accept()
                    # spawn a client connection
                    cmd_thread = HandlerClient(connection=connection,
                                               client_address=client_address,
                                               action_handler=actHandler,
                                               simulation_mode=self.simulation_mode)
Ad Schellevis's avatar
Ad Schellevis committed
105
                    if self.single_threaded:
106 107 108
                        # run single threaded
                        cmd_thread.run()
                    else:
109
                        # run threaded
110 111
                        cmd_thread.start()

Ad Schellevis's avatar
Ad Schellevis committed
112 113
            except KeyboardInterrupt:
                # exit on <ctrl><c>
114 115 116
                if os.path.exists(self.socket_filename):
                    # cleanup, remove socket
                    os.remove(self.socket_filename)
Ad Schellevis's avatar
Ad Schellevis committed
117
                raise
118 119
            except SystemExit:
                # stop process handler on system exit
120 121 122
                if os.path.exists(self.socket_filename):
                    # cleanup on exit, remove socket
                    os.remove(self.socket_filename)
123
                return
Ad Schellevis's avatar
Ad Schellevis committed
124 125 126
            except:
                # something went wrong... send traceback to syslog, restart listener (wait for a short time)
                print (traceback.format_exc())
Ad Schellevis's avatar
Ad Schellevis committed
127
                syslog.syslog(syslog.LOG_ERR, 'Handler died on %s' % traceback.format_exc())
Ad Schellevis's avatar
Ad Schellevis committed
128 129 130 131 132 133
                time.sleep(1)


class HandlerClient(threading.Thread):
    """ Handle commands via specified socket connection
    """
Ad Schellevis's avatar
Ad Schellevis committed
134
    def __init__(self, connection, client_address, action_handler, simulation_mode=False):
Ad Schellevis's avatar
Ad Schellevis committed
135 136 137 138
        """
        :param connection: socket connection object
        :param client_address: client address ( from socket accept )
        :param action_handler: action handler object
Ad Schellevis's avatar
Ad Schellevis committed
139
        :param simulation_mode: Emulation mode, do not start actual (script) commands
Ad Schellevis's avatar
Ad Schellevis committed
140 141 142 143 144 145 146
        :return: None
        """
        threading.Thread.__init__(self)
        self.connection = connection
        self.client_address = client_address
        self.action_handler = action_handler
        self.simulation_mode = simulation_mode
147
        self.message_uuid = uuid.uuid4()
Ad Schellevis's avatar
Ad Schellevis committed
148 149 150 151 152 153

    def run(self):
        """ handle single action ( read data, execute command, send response )

        :return: None
        """
154 155
        result = ''
        exec_command = ''
Ad Schellevis's avatar
Ad Schellevis committed
156 157
        exec_action = ''
        exec_params = ''
158
        exec_in_background = False
Ad Schellevis's avatar
Ad Schellevis committed
159 160 161 162
        try:
            # receive command, maximum data length is 4k... longer messages will be truncated
            data = self.connection.recv(4096)
            # map command to action
163
            data_parts = shlex.split(data)
Ad Schellevis's avatar
Ad Schellevis committed
164 165 166 167 168
            if len(data_parts) == 0 or len(data_parts[0]) == 0:
                # no data found
                self.connection.sendall('no data\n')
            else:
                exec_command = data_parts[0]
169 170 171
                if exec_command[0] == "&":
                    # set run in background
                    exec_in_background = True
172
                    exec_command = exec_command[1:]
Ad Schellevis's avatar
Ad Schellevis committed
173 174 175 176
                if len(data_parts) > 1:
                    exec_action = data_parts[1]
                else:
                    exec_action = None
177
                if len(data_parts) > 2:
Ad Schellevis's avatar
Ad Schellevis committed
178 179 180 181
                    exec_params = data_parts[2:]
                else:
                    exec_params = None

182 183 184 185 186 187
                # when running in background, return this message uuid and detach socket
                if exec_in_background:
                    result = self.message_uuid
                    self.connection.sendall('%s\n%c%c%c' % (result, chr(0), chr(0), chr(0)))
                    self.connection.close()

Ad Schellevis's avatar
Ad Schellevis committed
188
                # execute requested action
189
                if self.simulation_mode:
190
                    self.action_handler.showAction(exec_command, exec_action, exec_params, self.message_uuid)
191
                    result = 'OK'
Ad Schellevis's avatar
Ad Schellevis committed
192
                else:
193
                    result = self.action_handler.execute(exec_command, exec_action, exec_params, self.message_uuid)
Ad Schellevis's avatar
Ad Schellevis committed
194

195 196 197 198 199 200 201 202 203
                if not exec_in_background:
                    # send response back to client( including trailing enter )
                    self.connection.sendall('%s\n' % result)
                else:
                    # log response
                    syslog.syslog(syslog.LOG_INFO, "message %s [%s.%s] returned %s " % (self.message_uuid,
                                                                                        exec_command,
                                                                                        exec_action,
                                                                                        unicode(result)[:100]))
204 205

            # send end of stream characters
206
            if not exec_in_background:
Ad Schellevis's avatar
Ad Schellevis committed
207
                self.connection.sendall("%c%c%c" % (chr(0), chr(0), chr(0)))
208 209 210
        except SystemExit:
            # ignore system exit related errors
            pass
Ad Schellevis's avatar
Ad Schellevis committed
211 212
        except:
            print (traceback.format_exc())
Ad Schellevis's avatar
Ad Schellevis committed
213
            syslog.syslog(syslog.LOG_ERR,
214 215 216 217 218 219
                          'unable to sendback response [%s] for [%s][%s][%s] {%s}, message was %s' % (result,
                                                                                                      exec_command,
                                                                                                      exec_action,
                                                                                                      exec_params,
                                                                                                      self.message_uuid,
                                                                                                      traceback.format_exc()))
Ad Schellevis's avatar
Ad Schellevis committed
220
        finally:
221 222
            if not exec_in_background:
                self.connection.close()
Ad Schellevis's avatar
Ad Schellevis committed
223

224
@singleton
Ad Schellevis's avatar
Ad Schellevis committed
225
class ActionHandler(object):
Ad Schellevis's avatar
Ad Schellevis committed
226
    """ Start/stop services and functions using configuration data defined in conf/actions_<topic>.conf
Ad Schellevis's avatar
Ad Schellevis committed
227
    """
228
    def __init__(self, config_path=None, config_environment=None):
Ad Schellevis's avatar
Ad Schellevis committed
229 230 231
        """ Initialize action handler to start system functions

        :param config_path: full path of configuration data
232
        :param config_environment: environment to use (if possible)
Ad Schellevis's avatar
Ad Schellevis committed
233 234
        :return:
        """
235 236 237 238 239 240 241 242
        if config_path is not None:
            self.config_path = config_path
        if config_environment is not None:
            self.config_environment = config_environment

        # try to load data on initial start
        if not hasattr(self, 'action_map'):
            self.load_config()
Ad Schellevis's avatar
Ad Schellevis committed
243 244 245 246 247 248 249 250

    def load_config(self):
        """ load action configuration from config files into local dictionary

        :return: None
        """

        self.action_map = {}
Ad Schellevis's avatar
Ad Schellevis committed
251 252
        for config_filename in glob.glob('%s/actions_*.conf' % self.config_path) \
                + glob.glob('%s/actions.d/actions_*.conf' % self.config_path):
253 254 255
            # this topic's name (service, filter, template, etc)
            # make sure there's an action map index for this topic
            topic_name = config_filename.split('actions_')[-1].split('.')[0]
Ad Schellevis's avatar
Ad Schellevis committed
256
            if topic_name not in self.action_map:
257
                self.action_map[topic_name] = {}
258

Ad Schellevis's avatar
Ad Schellevis committed
259
            # traverse config directory and open all filenames starting with actions_
260
            cnf = ConfigParser.RawConfigParser()
Ad Schellevis's avatar
Ad Schellevis committed
261 262 263
            cnf.read(config_filename)
            for section in cnf.sections():
                # map configuration data on object
264
                action_obj = Action(config_environment = self.config_environment)
Ad Schellevis's avatar
Ad Schellevis committed
265
                for act_prop in cnf.items(section):
Ad Schellevis's avatar
Ad Schellevis committed
266
                    setattr(action_obj, act_prop[0], act_prop[1])
Ad Schellevis's avatar
Ad Schellevis committed
267 268 269 270

                if section.find('.') > -1:
                    # at this moment we only support 2 levels of actions ( 3 if you count topic as well )
                    for alias in section.split('.')[0].split('|'):
Ad Schellevis's avatar
Ad Schellevis committed
271
                        if alias not in self.action_map[topic_name]:
272 273
                            self.action_map[topic_name][alias] = {}
                        self.action_map[topic_name][alias][section.split('.')[1]] = action_obj
Ad Schellevis's avatar
Ad Schellevis committed
274 275
                else:
                    for alias in section.split('|'):
276
                        self.action_map[topic_name][alias] = action_obj
Ad Schellevis's avatar
Ad Schellevis committed
277

278 279 280 281 282 283 284
    def listActions(self, attributes=[]):
        """ list all available actions
        :return: dict
        """
        result = {}
        for command in self.action_map:
            for action in self.action_map[command]:
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303
                if type(self.action_map[command][action]) == dict:
                    # parse second level actions
                    # TODO: nesting actions may be better to solve recursive in here and in load_config part
                    for subAction in self.action_map[command][action]:
                        cmd='%s %s %s'%(command,action,subAction)
                        result[cmd] = {}
                        for actAttr in attributes:
                            if hasattr(self.action_map[command][action][subAction], actAttr):
                                result[cmd][actAttr] = getattr(self.action_map[command][action][subAction], actAttr)
                            else:
                                result[cmd][actAttr] = ''
                else:
                    cmd='%s %s'%(command,action)
                    result[cmd] = {}
                    for actAttr in attributes:
                        if hasattr(self.action_map[command][action], actAttr):
                            result[cmd][actAttr] = getattr(self.action_map[command][action], actAttr)
                        else:
                            result[cmd][actAttr] = ''
304 305 306

        return result

307
    def findAction(self, command, action, parameters):
Ad Schellevis's avatar
Ad Schellevis committed
308 309 310 311 312 313 314 315
        """ find action object

        :param command: command/topic for example interface
        :param action: action to run ( for example linkup )
        :param parameters: the parameters to supply
        :return: action object or None if not found
        """
        action_obj = None
Ad Schellevis's avatar
Ad Schellevis committed
316 317
        if command in self.action_map:
            if action in self.action_map[command]:
Ad Schellevis's avatar
Ad Schellevis committed
318
                if type(self.action_map[command][action]) == dict:
Ad Schellevis's avatar
Ad Schellevis committed
319 320
                    if parameters is not None and len(parameters) > 0 \
                            and parameters[0] in self.action_map[command][action]:
Ad Schellevis's avatar
Ad Schellevis committed
321
                        # 3 level action (  "interface linkup start" for example )
Ad Schellevis's avatar
Ad Schellevis committed
322
                        if isinstance(self.action_map[command][action][parameters[0]], Action):
Ad Schellevis's avatar
Ad Schellevis committed
323 324
                            action_obj = self.action_map[command][action][parameters[0]]
                            action_obj.setParameterStartPos(1)
Ad Schellevis's avatar
Ad Schellevis committed
325
                elif isinstance(self.action_map[command][action], Action):
Ad Schellevis's avatar
Ad Schellevis committed
326 327 328 329
                    action_obj = self.action_map[command][action]

        return action_obj

330
    def execute(self, command, action, parameters, message_uuid):
Ad Schellevis's avatar
Ad Schellevis committed
331 332 333 334 335
        """ execute configuration defined action

        :param command: command/topic for example interface
        :param action: action to run ( for example linkup )
        :param parameters: the parameters to supply
336
        :param message_uuid: message unique id
Ad Schellevis's avatar
Ad Schellevis committed
337 338 339
        :return: OK on success, else error code
        """
        action_params = []
Ad Schellevis's avatar
Ad Schellevis committed
340
        action_obj = self.findAction(command, action, parameters)
Ad Schellevis's avatar
Ad Schellevis committed
341

342 343
        if action_obj is not None:
            if parameters is not None and len(parameters) > action_obj.getParameterStartPos():
Ad Schellevis's avatar
Ad Schellevis committed
344 345
                action_params = parameters[action_obj.getParameterStartPos():]

346
            return '%s\n' % action_obj.execute(action_params, message_uuid)
Ad Schellevis's avatar
Ad Schellevis committed
347 348 349

        return 'Action not found\n'

350
    def showAction(self, command, action, parameters, message_uuid):
Ad Schellevis's avatar
Ad Schellevis committed
351
        """ debug/simulation mode: show action information
352 353 354 355 356
        :param command: command/topic for example interface
        :param action: action to run ( for example linkup )
        :param parameters: the parameters to supply
        :param message_uuid: message unique id
        :return: None
Ad Schellevis's avatar
Ad Schellevis committed
357
        """
Ad Schellevis's avatar
Ad Schellevis committed
358
        action_obj = self.findAction(command, action, parameters)
Ad Schellevis's avatar
Ad Schellevis committed
359
        print ('---------------------------------------------------------------------')
Ad Schellevis's avatar
Ad Schellevis committed
360 361
        print ('execute %s.%s with parameters : %s ' % (command, action, parameters))
        print ('action object %s (%s)' % (action_obj, action_obj.command))
Ad Schellevis's avatar
Ad Schellevis committed
362
        print ('---------------------------------------------------------------------')
363

Ad Schellevis's avatar
Ad Schellevis committed
364

365 366 367 368
class Action(object):
    """ Action class,  handles actual (system) calls.
    set command, parameters (template) type and log message
    """
369
    def __init__(self, config_environment):
370
        """ setup default properties
371
        :param config_environment: environment to use
372 373
        :return:
        """
374
        self.config_environment = config_environment
375 376 377 378 379 380
        self.command = None
        self.parameters = None
        self.type = None
        self.message = None
        self._parameter_start_pos = 0

Ad Schellevis's avatar
Ad Schellevis committed
381
    def setParameterStartPos(self, pos):
382 383 384 385 386 387 388 389 390 391 392 393 394
        """

        :param pos: start position of parameter list
        :return: position
        """
        self._parameter_start_pos = pos

    def getParameterStartPos(self):
        """ getter for _parameter_start_pos
        :return: start position of parameter list ( first argument can be part of action to start )
        """
        return self._parameter_start_pos

395
    def execute(self, parameters, message_uuid):
396 397 398
        """ execute an action

        :param parameters: list of parameters
399
        :param message_uuid: unique message id
400 401
        :return:
        """
402
        # send-out syslog message
403
        if self.message is not None:
404
            log_message = '[%s] ' % message_uuid
405
            if self.message.count('%s') > 0 and parameters is not None and len(parameters) > 0:
406
                log_message = log_message + self.message % tuple(parameters[0:self.message.count('%s')])
407
            else:
408
                log_message = log_message + self.message.replace("%s", "")
409
            syslog.syslog(syslog.LOG_NOTICE, log_message)
410

411
        # validate input
412
        if self.type is None:
413
            # no action type, nothing to do here
414
            return 'No action type'
Ad Schellevis's avatar
Ad Schellevis committed
415
        elif self.type.lower() in ('script', 'script_output'):
416
            # script type commands, basic script type only uses exit statuses, script_output sends back stdout data.
417
            if self.command is None:
418
                # no command supplied, exit
419
                syslog.syslog(syslog.LOG_ERR, '[%s] returned "No command"' % message_uuid)
420 421
                return 'No command'

422 423
            # build script command to execute, shared for both types
            script_command = self.command
424
            if self.parameters is not None and type(self.parameters) == str and len(parameters) > 0:
Ad Schellevis's avatar
Ad Schellevis committed
425
                script_command = '%s %s' % (script_command, self.parameters)
426
                if script_command.find('%s') > -1:
427 428
                    # use command execution parameters in action parameter template
                    # use quotes on parameters to prevent code injection
429
                    if script_command.count('%s') > len(parameters):
430
                        # script command accepts more parameters then given, fill with empty parameters
431 432 433 434 435
                        for i in range(script_command.count('%s')-len(parameters)):
                            parameters.append("")
                    elif len(parameters) > script_command.count('%s'):
                        # parameters then expected, fail execution
                        return 'Parameter mismatch'
436 437 438 439 440 441

                    # force escape of shell exploitable characters for all user parameters
                    for escape_char in ['`','$','!','(',')','|']:
                        for i in range(len(parameters[0:script_command.count('%s')])):
                            parameters[i] = parameters[i].replace(escape_char,'\\%s'%escape_char)

Ad Schellevis's avatar
Ad Schellevis committed
442
                    script_command = script_command % tuple(map(lambda x: '"'+x.replace('"', '\\"')+'"',
443 444 445 446
                                                                parameters[0:script_command.count('%s')]))
            if self.type.lower() == 'script':
                # execute script type command
                try:
447
                    exit_status = subprocess.call(script_command, env=self.config_environment, shell=True)
448
                    # send response
449
                    if exit_status == 0:
450
                        return 'OK'
Ad Schellevis's avatar
Ad Schellevis committed
451
                    else:
452
                        syslog.syslog(syslog.LOG_ERR, '[%s] returned exit status %d' % (message_uuid, exit_status))
Ad Schellevis's avatar
Ad Schellevis committed
453
                        return 'Error (%d)' % exit_status
454
                except:
455 456
                    syslog.syslog(syslog.LOG_ERR, '[%s] Script action failed at %s' % (message_uuid,
                                                                                       traceback.format_exc()))
457 458 459
                    return 'Execute error'
            elif self.type.lower() == 'script_output':
                try:
460 461 462 463 464 465
                    with tempfile.NamedTemporaryFile() as output_stream:
                        subprocess.check_call(script_command, env=self.config_environment, shell=True,
                                                              stdout=output_stream, stderr=subprocess.STDOUT)
                        output_stream.seek(0)
                        script_output = output_stream.read()
                        return script_output
466
                except:
467 468
                    syslog.syslog(syslog.LOG_ERR, '[%s] Script action failed at %s' % (message_uuid,
                                                                                       traceback.format_exc()))
469 470 471 472
                    return 'Execute error'

            # fallback should never get here
            return "type error"
473 474 475 476 477 478 479 480 481
        elif self.type.lower() == 'inline':
            # Handle inline service actions
            try:
                # match parameters, serialize to parameter string defined by action template
                if len(parameters) > 0:
                    inline_act_parameters = self.parameters % tuple(parameters)
                else:
                    inline_act_parameters = ''

Ad Schellevis's avatar
Ad Schellevis committed
482
                return ph_inline_actions.execute(self, inline_act_parameters)
483 484

            except:
485 486
                syslog.syslog(syslog.LOG_ERR, '[%s] Inline action failed at %s' % (message_uuid,
                                                                                   traceback.format_exc()))
487 488 489
                return 'Execute error'

        return 'Unknown action type'