processhandler.py 23.4 KB
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513
"""
    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.

    --------------------------------------------------------------------------------------
    package : configd
    function: unix domain socket process worker process
"""

import os
import subprocess
import socket
import traceback
import syslog
import threading
import ConfigParser
import glob
import time
import uuid
import shlex
import tempfile
import ph_inline_actions
from modules import singleton

__author__ = 'Ad Schellevis'


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
                    -> execute ActionHandler command using Action objects
                    <- send back result string
    """

    def __init__(self, socket_filename, config_path, config_environment=None, simulation_mode=False):
        """ Constructor

        :param socket_filename: filename of unix domain socket to use
        :param config_path: location of configuration files
        :param simulation_mode: emulation mode, do not start actual (script) commands
        :return: object
        """
        if config_environment is None:
            config_environment = {}
        self.socket_filename = socket_filename
        self.config_path = config_path
        self.simulation_mode = simulation_mode
        self.config_environment = config_environment
        self.single_threaded = False

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

        :return:
        """
        while True:
            # noinspection PyBroadException
            try:
                # open action handler
                act_handler = ActionHandler(config_path=self.config_path,
                                            config_environment=self.config_environment)

                # 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)
                os.chmod(self.socket_filename, 0o666)
                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=act_handler,
                                               simulation_mode=self.simulation_mode)
                    if self.single_threaded:
                        # run single threaded
                        cmd_thread.run()
                    else:
                        # run threaded
                        cmd_thread.start()

            except KeyboardInterrupt:
                # exit on <ctrl><c>
                if os.path.exists(self.socket_filename):
                    # cleanup, remove socket
                    os.remove(self.socket_filename)
                raise
            except SystemExit:
                # stop process handler on system exit
                if os.path.exists(self.socket_filename):
                    # cleanup on exit, remove socket
                    os.remove(self.socket_filename)
                return
            except Exception:
                # something went wrong... send traceback to syslog, restart listener (wait for a short time)
                print (traceback.format_exc())
                syslog.syslog(syslog.LOG_ERR, 'Handler died on %s' % traceback.format_exc())
                time.sleep(1)


class HandlerClient(threading.Thread):
    """ Handle commands via specified socket connection
    """

    def __init__(self, connection, client_address, action_handler, simulation_mode=False):
        """
        :param connection: socket connection object
        :param client_address: client address ( from socket accept )
        :param action_handler: action handler object
        :param simulation_mode: Emulation mode, do not start actual (script) commands
        :return: None
        """
        threading.Thread.__init__(self)
        self.connection = connection
        self.client_address = client_address
        self.action_handler = action_handler
        self.simulation_mode = simulation_mode
        self.message_uuid = uuid.uuid4()

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

        :return: None
        """
        result = ''
        exec_command = ''
        exec_action = ''
        exec_params = ''
        exec_in_background = False
        # noinspection PyBroadException
        try:
            # receive command, maximum data length is 4k... longer messages will be truncated
            data = self.connection.recv(4096)
            # map command to action
            data_parts = shlex.split(data)
            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]
                if exec_command[0] == "&":
                    # set run in background
                    exec_in_background = True
                    exec_command = exec_command[1:]
                if len(data_parts) > 1:
                    exec_action = data_parts[1]
                else:
                    exec_action = None
                if len(data_parts) > 2:
                    exec_params = data_parts[2:]
                else:
                    exec_params = None

                # 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()

                # execute requested action
                if self.simulation_mode:
                    self.action_handler.show_action(exec_command, exec_action, exec_params, self.message_uuid)
                    result = 'OK'
                else:
                    result = self.action_handler.execute(exec_command, exec_action, exec_params, self.message_uuid)

                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]))

            # send end of stream characters
            if not exec_in_background:
                self.connection.sendall("%c%c%c" % (chr(0), chr(0), chr(0)))
        except SystemExit:
            # ignore system exit related errors
            pass
        except Exception:
            print (traceback.format_exc())
            syslog.syslog(
                    syslog.LOG_ERR,
                    '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()
                                                                                                )
            )
        finally:
            if not exec_in_background:
                self.connection.close()


@singleton
class ActionHandler(object):
    """ Start/stop services and functions using configuration data defined in conf/actions_<topic>.conf
    """

    def __init__(self, config_path=None, config_environment=None):
        """ Initialize action handler to start system functions

        :param config_path: full path of configuration data
        :param config_environment: environment to use (if possible)
        :return:
        """
        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.action_map = {}
            self.load_config()

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

        :return: None
        """
        for config_filename in glob.glob('%s/actions_*.conf' % self.config_path) \
                + glob.glob('%s/actions.d/actions_*.conf' % self.config_path):
            # 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]
            if topic_name not in self.action_map:
                self.action_map[topic_name] = {}

            # traverse config directory and open all filenames starting with actions_
            cnf = ConfigParser.RawConfigParser()
            cnf.read(config_filename)
            for section in cnf.sections():
                # map configuration data on object
                action_obj = Action(config_environment=self.config_environment)
                for act_prop in cnf.items(section):
                    setattr(action_obj, act_prop[0], act_prop[1])

                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('|'):
                        if alias not in self.action_map[topic_name]:
                            self.action_map[topic_name][alias] = {}
                        self.action_map[topic_name][alias][section.split('.')[1]] = action_obj
                else:
                    for alias in section.split('|'):
                        self.action_map[topic_name][alias] = action_obj

    def list_actions(self, attributes=None):
        """ list all available actions
        :param attributes:
        :return: dict
        """
        if attributes is None:
            attributes = []
        result = {}
        for command in self.action_map:
            for action in self.action_map[command]:
                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] = ''

        return result

    def find_action(self, command, action, parameters):
        """ 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
        if command in self.action_map:
            if action in self.action_map[command]:
                if type(self.action_map[command][action]) == dict:
                    if parameters is not None and len(parameters) > 0 \
                            and parameters[0] in self.action_map[command][action]:
                        # 3 level action (  "interface linkup start" for example )
                        if isinstance(self.action_map[command][action][parameters[0]], Action):
                            action_obj = self.action_map[command][action][parameters[0]]
                            action_obj.set_parameter_start_pos(1)
                elif isinstance(self.action_map[command][action], Action):
                    action_obj = self.action_map[command][action]

        return action_obj

    def execute(self, command, action, parameters, message_uuid):
        """ 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
        :param message_uuid: message unique id
        :return: OK on success, else error code
        """
        action_params = []
        action_obj = self.find_action(command, action, parameters)

        if action_obj is not None:
            if parameters is not None and len(parameters) > action_obj.get_parameter_start_pos():
                action_params = parameters[action_obj.get_parameter_start_pos():]

            return '%s\n' % action_obj.execute(action_params, message_uuid)

        return 'Action not found\n'

    def show_action(self, command, action, parameters, message_uuid):
        """ debug/simulation mode: show action information
        :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
        """
        action_obj = self.find_action(command, action, parameters)
        print ('---------------------------------------------------------------------')
        print ('execute %s.%s with parameters : %s ' % (command, action, parameters))
        print ('action object %s (%s) %s' % (action_obj, action_obj.command, message_uuid))
        print ('---------------------------------------------------------------------')


class Action(object):
    """ Action class,  handles actual (system) calls.
    set command, parameters (template) type and log message
    """

    def __init__(self, config_environment):
        """ setup default properties
        :param config_environment: environment to use
        :return:
        """
        self.config_environment = config_environment
        self.command = None
        self.parameters = None
        self.type = None
        self.message = None
        self._parameter_start_pos = 0

    def set_parameter_start_pos(self, pos):
        """

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

    def get_parameter_start_pos(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

    def execute(self, parameters, message_uuid):
        """ execute an action

        :param parameters: list of parameters
        :param message_uuid: unique message id
        :return:
        """
        # send-out syslog message
        if self.message is not None:
            log_message = '[%s] ' % message_uuid
            if self.message.count('%s') > 0 and parameters is not None and len(parameters) > 0:
                log_message = log_message + self.message % tuple(parameters[0:self.message.count('%s')])
            else:
                log_message = log_message + self.message.replace("%s", "")
            syslog.syslog(syslog.LOG_NOTICE, log_message)

        # validate input
        if self.type is None:
            # no action type, nothing to do here
            return 'No action type'
        elif self.type.lower() in ('script', 'script_output'):
            # script type commands, basic script type only uses exit statuses, script_output sends back stdout data.
            if self.command is None:
                # no command supplied, exit
                syslog.syslog(syslog.LOG_ERR, '[%s] returned "No command"' % message_uuid)
                return 'No command'

            # build script command to execute, shared for both types
            script_command = self.command
            if self.parameters is not None and type(self.parameters) == str and len(parameters) > 0:
                script_command = '%s %s' % (script_command, self.parameters)
                if script_command.find('%s') > -1:
                    # use command execution parameters in action parameter template
                    # use quotes on parameters to prevent code injection
                    if script_command.count('%s') > len(parameters):
                        # script command accepts more parameters then given, fill with empty parameters
                        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'

                    # 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)

                    script_command = script_command % tuple(map(lambda x: '"' + x.replace('"', '\\"') + '"',
                                                                parameters[0:script_command.count('%s')]))
            if self.type.lower() == 'script':
                # execute script type command
                try:
                    exit_status = subprocess.call(script_command, env=self.config_environment, shell=True)
                    # send response
                    if exit_status == 0:
                        return 'OK'
                    else:
                        syslog.syslog(syslog.LOG_ERR, '[%s] returned exit status %d' % (message_uuid, exit_status))
                        return 'Error (%d)' % exit_status
                except Exception as script_exception:
                    syslog.syslog(syslog.LOG_ERR, '[%s] Script action failed with %s at %s' % (message_uuid,
                                                                                               script_exception,
                                                                                               traceback.format_exc()))
                    return 'Execute error'
            elif self.type.lower() == 'script_output':
                try:
                    with tempfile.NamedTemporaryFile() as error_stream:
                        with tempfile.NamedTemporaryFile() as output_stream:
                            subprocess.check_call(script_command, env=self.config_environment, shell=True,
                                                  stdout=output_stream, stderr=error_stream)
                            output_stream.seek(0)
                            error_stream.seek(0)
                            script_output = output_stream.read()
                            script_error_output = error_stream.read()
                            if len(script_error_output) > 0:
                                syslog.syslog(syslog.LOG_ERR,
                                              '[%s] Script action stderr returned "%s"' %
                                              (message_uuid, script_error_output.strip()[:255])
                                              )
                            return script_output
                except Exception as script_exception:
                    syslog.syslog(syslog.LOG_ERR, '[%s] Script action failed with %s at %s' % (message_uuid,
                                                                                               script_exception,
                                                                                               traceback.format_exc()))
                    return 'Execute error'

            # fallback should never get here
            return "type error"
        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 = ''

                return ph_inline_actions.execute(self, inline_act_parameters)

            except Exception as inline_exception:
                syslog.syslog(syslog.LOG_ERR, '[%s] Inline action failed with %s at %s' % (message_uuid,
                                                                                           inline_exception,
                                                                                           traceback.format_exc()))
                return 'Execute error'

        return 'Unknown action type'