processhandler.py 23.4 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
    package : configd
Ad Schellevis's avatar
Ad Schellevis committed
28 29
    function: unix domain socket process worker process
"""
30

Ad Schellevis's avatar
Ad Schellevis committed
31 32 33 34 35 36 37 38 39
import os
import subprocess
import socket
import traceback
import syslog
import threading
import ConfigParser
import glob
import time
40
import uuid
41
import shlex
42
import tempfile
43
import ph_inline_actions
44
from modules import singleton
Ad Schellevis's avatar
Ad Schellevis committed
45

46 47
__author__ = 'Ad Schellevis'

Ad Schellevis's avatar
Ad Schellevis committed
48

Ad Schellevis's avatar
Ad Schellevis committed
49 50 51 52 53 54 55 56
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
57
                    -> execute ActionHandler command using Action objects
Ad Schellevis's avatar
Ad Schellevis committed
58 59
                    <- send back result string
    """
60 61

    def __init__(self, socket_filename, config_path, config_environment=None, 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
        :return: object
        """
69 70
        if config_environment is None:
            config_environment = {}
Ad Schellevis's avatar
Ad Schellevis committed
71 72
        self.socket_filename = socket_filename
        self.config_path = config_path
73
        self.simulation_mode = simulation_mode
74
        self.config_environment = config_environment
75
        self.single_threaded = False
Ad Schellevis's avatar
Ad Schellevis committed
76 77 78 79 80 81 82

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

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

                # 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
98
                os.chmod(self.socket_filename, 0o666)
Ad Schellevis's avatar
Ad Schellevis committed
99 100 101 102 103 104 105
                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,
106
                                               action_handler=act_handler,
Ad Schellevis's avatar
Ad Schellevis committed
107
                                               simulation_mode=self.simulation_mode)
Ad Schellevis's avatar
Ad Schellevis committed
108
                    if self.single_threaded:
109 110 111
                        # run single threaded
                        cmd_thread.run()
                    else:
112
                        # run threaded
113 114
                        cmd_thread.start()

Ad Schellevis's avatar
Ad Schellevis committed
115 116
            except KeyboardInterrupt:
                # exit on <ctrl><c>
117 118 119
                if os.path.exists(self.socket_filename):
                    # cleanup, remove socket
                    os.remove(self.socket_filename)
Ad Schellevis's avatar
Ad Schellevis committed
120
                raise
121 122
            except SystemExit:
                # stop process handler on system exit
123 124 125
                if os.path.exists(self.socket_filename):
                    # cleanup on exit, remove socket
                    os.remove(self.socket_filename)
126
                return
127
            except Exception:
Ad Schellevis's avatar
Ad Schellevis committed
128 129
                # 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
130
                syslog.syslog(syslog.LOG_ERR, 'Handler died on %s' % traceback.format_exc())
Ad Schellevis's avatar
Ad Schellevis committed
131 132 133 134 135 136
                time.sleep(1)


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

Ad Schellevis's avatar
Ad Schellevis committed
138
    def __init__(self, connection, client_address, action_handler, simulation_mode=False):
Ad Schellevis's avatar
Ad Schellevis committed
139 140 141 142
        """
        :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
143
        :param simulation_mode: Emulation mode, do not start actual (script) commands
Ad Schellevis's avatar
Ad Schellevis committed
144 145 146 147 148 149 150
        :return: None
        """
        threading.Thread.__init__(self)
        self.connection = connection
        self.client_address = client_address
        self.action_handler = action_handler
        self.simulation_mode = simulation_mode
151
        self.message_uuid = uuid.uuid4()
Ad Schellevis's avatar
Ad Schellevis committed
152 153 154 155 156 157

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

        :return: None
        """
158 159
        result = ''
        exec_command = ''
Ad Schellevis's avatar
Ad Schellevis committed
160 161
        exec_action = ''
        exec_params = ''
162
        exec_in_background = False
163
        # noinspection PyBroadException
Ad Schellevis's avatar
Ad Schellevis committed
164 165 166 167
        try:
            # receive command, maximum data length is 4k... longer messages will be truncated
            data = self.connection.recv(4096)
            # map command to action
168
            data_parts = shlex.split(data)
Ad Schellevis's avatar
Ad Schellevis committed
169 170 171 172 173
            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]
174 175 176
                if exec_command[0] == "&":
                    # set run in background
                    exec_in_background = True
177
                    exec_command = exec_command[1:]
Ad Schellevis's avatar
Ad Schellevis committed
178 179 180 181
                if len(data_parts) > 1:
                    exec_action = data_parts[1]
                else:
                    exec_action = None
182
                if len(data_parts) > 2:
Ad Schellevis's avatar
Ad Schellevis committed
183 184 185 186
                    exec_params = data_parts[2:]
                else:
                    exec_params = None

187 188 189 190 191 192
                # 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
193
                # execute requested action
194
                if self.simulation_mode:
195
                    self.action_handler.show_action(exec_command, exec_action, exec_params, self.message_uuid)
196
                    result = 'OK'
Ad Schellevis's avatar
Ad Schellevis committed
197
                else:
198
                    result = self.action_handler.execute(exec_command, exec_action, exec_params, self.message_uuid)
Ad Schellevis's avatar
Ad Schellevis committed
199

200 201 202 203 204 205 206 207 208
                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]))
209 210

            # send end of stream characters
211
            if not exec_in_background:
Ad Schellevis's avatar
Ad Schellevis committed
212
                self.connection.sendall("%c%c%c" % (chr(0), chr(0), chr(0)))
213 214 215
        except SystemExit:
            # ignore system exit related errors
            pass
216
        except Exception:
Ad Schellevis's avatar
Ad Schellevis committed
217
            print (traceback.format_exc())
218 219 220 221 222 223 224 225 226 227
            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()
                                                                                                )
            )
Ad Schellevis's avatar
Ad Schellevis committed
228
        finally:
229 230
            if not exec_in_background:
                self.connection.close()
Ad Schellevis's avatar
Ad Schellevis committed
231

232

233
@singleton
Ad Schellevis's avatar
Ad Schellevis committed
234
class ActionHandler(object):
Ad Schellevis's avatar
Ad Schellevis committed
235
    """ Start/stop services and functions using configuration data defined in conf/actions_<topic>.conf
Ad Schellevis's avatar
Ad Schellevis committed
236
    """
237

238
    def __init__(self, config_path=None, config_environment=None):
Ad Schellevis's avatar
Ad Schellevis committed
239 240 241
        """ Initialize action handler to start system functions

        :param config_path: full path of configuration data
242
        :param config_environment: environment to use (if possible)
Ad Schellevis's avatar
Ad Schellevis committed
243 244
        :return:
        """
245 246 247 248 249 250 251
        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'):
252
            self.action_map = {}
253
            self.load_config()
Ad Schellevis's avatar
Ad Schellevis committed
254 255 256 257 258 259

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

        :return: None
        """
Ad Schellevis's avatar
Ad Schellevis committed
260 261
        for config_filename in glob.glob('%s/actions_*.conf' % self.config_path) \
                + glob.glob('%s/actions.d/actions_*.conf' % self.config_path):
262 263 264
            # 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
265
            if topic_name not in self.action_map:
266
                self.action_map[topic_name] = {}
267

Ad Schellevis's avatar
Ad Schellevis committed
268
            # traverse config directory and open all filenames starting with actions_
269
            cnf = ConfigParser.RawConfigParser()
Ad Schellevis's avatar
Ad Schellevis committed
270 271 272
            cnf.read(config_filename)
            for section in cnf.sections():
                # map configuration data on object
273
                action_obj = Action(config_environment=self.config_environment)
Ad Schellevis's avatar
Ad Schellevis committed
274
                for act_prop in cnf.items(section):
Ad Schellevis's avatar
Ad Schellevis committed
275
                    setattr(action_obj, act_prop[0], act_prop[1])
Ad Schellevis's avatar
Ad Schellevis committed
276 277 278 279

                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
280
                        if alias not in self.action_map[topic_name]:
281 282
                            self.action_map[topic_name][alias] = {}
                        self.action_map[topic_name][alias][section.split('.')[1]] = action_obj
Ad Schellevis's avatar
Ad Schellevis committed
283 284
                else:
                    for alias in section.split('|'):
285
                        self.action_map[topic_name][alias] = action_obj
Ad Schellevis's avatar
Ad Schellevis committed
286

287
    def list_actions(self, attributes=None):
288
        """ list all available actions
289
        :param attributes:
290 291
        :return: dict
        """
292 293
        if attributes is None:
            attributes = []
294 295 296
        result = {}
        for command in self.action_map:
            for action in self.action_map[command]:
297 298 299 300
                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]:
301
                        cmd = '%s %s %s' % (command, action, subAction)
302 303 304 305 306 307 308
                        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:
309
                    cmd = '%s %s' % (command, action)
310 311 312 313 314 315
                    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] = ''
316 317 318

        return result

319
    def find_action(self, command, action, parameters):
Ad Schellevis's avatar
Ad Schellevis committed
320 321 322 323 324 325 326 327
        """ 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
328 329
        if command in self.action_map:
            if action in self.action_map[command]:
Ad Schellevis's avatar
Ad Schellevis committed
330
                if type(self.action_map[command][action]) == dict:
Ad Schellevis's avatar
Ad Schellevis committed
331 332
                    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
333
                        # 3 level action (  "interface linkup start" for example )
Ad Schellevis's avatar
Ad Schellevis committed
334
                        if isinstance(self.action_map[command][action][parameters[0]], Action):
Ad Schellevis's avatar
Ad Schellevis committed
335
                            action_obj = self.action_map[command][action][parameters[0]]
336
                            action_obj.set_parameter_start_pos(1)
Ad Schellevis's avatar
Ad Schellevis committed
337
                elif isinstance(self.action_map[command][action], Action):
Ad Schellevis's avatar
Ad Schellevis committed
338 339 340 341
                    action_obj = self.action_map[command][action]

        return action_obj

342
    def execute(self, command, action, parameters, message_uuid):
Ad Schellevis's avatar
Ad Schellevis committed
343 344 345 346 347
        """ 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
348
        :param message_uuid: message unique id
Ad Schellevis's avatar
Ad Schellevis committed
349 350 351
        :return: OK on success, else error code
        """
        action_params = []
352
        action_obj = self.find_action(command, action, parameters)
Ad Schellevis's avatar
Ad Schellevis committed
353

354
        if action_obj is not None:
355 356
            if parameters is not None and len(parameters) > action_obj.get_parameter_start_pos():
                action_params = parameters[action_obj.get_parameter_start_pos():]
Ad Schellevis's avatar
Ad Schellevis committed
357

358
            return '%s\n' % action_obj.execute(action_params, message_uuid)
Ad Schellevis's avatar
Ad Schellevis committed
359 360 361

        return 'Action not found\n'

362
    def show_action(self, command, action, parameters, message_uuid):
Ad Schellevis's avatar
Ad Schellevis committed
363
        """ debug/simulation mode: show action information
364 365 366 367 368
        :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
369
        """
370
        action_obj = self.find_action(command, action, parameters)
Ad Schellevis's avatar
Ad Schellevis committed
371
        print ('---------------------------------------------------------------------')
Ad Schellevis's avatar
Ad Schellevis committed
372
        print ('execute %s.%s with parameters : %s ' % (command, action, parameters))
373
        print ('action object %s (%s) %s' % (action_obj, action_obj.command, message_uuid))
Ad Schellevis's avatar
Ad Schellevis committed
374
        print ('---------------------------------------------------------------------')
375

Ad Schellevis's avatar
Ad Schellevis committed
376

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

382
    def __init__(self, config_environment):
383
        """ setup default properties
384
        :param config_environment: environment to use
385 386
        :return:
        """
387
        self.config_environment = config_environment
388 389 390 391 392 393
        self.command = None
        self.parameters = None
        self.type = None
        self.message = None
        self._parameter_start_pos = 0

394
    def set_parameter_start_pos(self, pos):
395 396 397 398 399 400 401
        """

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

402
    def get_parameter_start_pos(self):
403 404 405 406 407
        """ 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

408
    def execute(self, parameters, message_uuid):
409 410 411
        """ execute an action

        :param parameters: list of parameters
412
        :param message_uuid: unique message id
413 414
        :return:
        """
415
        # send-out syslog message
416
        if self.message is not None:
417
            log_message = '[%s] ' % message_uuid
418
            if self.message.count('%s') > 0 and parameters is not None and len(parameters) > 0:
419
                log_message = log_message + self.message % tuple(parameters[0:self.message.count('%s')])
420
            else:
421
                log_message = log_message + self.message.replace("%s", "")
422
            syslog.syslog(syslog.LOG_NOTICE, log_message)
423

424
        # validate input
425
        if self.type is None:
426
            # no action type, nothing to do here
427
            return 'No action type'
Ad Schellevis's avatar
Ad Schellevis committed
428
        elif self.type.lower() in ('script', 'script_output'):
429
            # script type commands, basic script type only uses exit statuses, script_output sends back stdout data.
430
            if self.command is None:
431
                # no command supplied, exit
432
                syslog.syslog(syslog.LOG_ERR, '[%s] returned "No command"' % message_uuid)
433 434
                return 'No command'

435 436
            # build script command to execute, shared for both types
            script_command = self.command
437
            if self.parameters is not None and type(self.parameters) == str and len(parameters) > 0:
Ad Schellevis's avatar
Ad Schellevis committed
438
                script_command = '%s %s' % (script_command, self.parameters)
439
                if script_command.find('%s') > -1:
440 441
                    # use command execution parameters in action parameter template
                    # use quotes on parameters to prevent code injection
442
                    if script_command.count('%s') > len(parameters):
443
                        # script command accepts more parameters then given, fill with empty parameters
444
                        for i in range(script_command.count('%s') - len(parameters)):
445 446 447 448
                            parameters.append("")
                    elif len(parameters) > script_command.count('%s'):
                        # parameters then expected, fail execution
                        return 'Parameter mismatch'
449 450

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

455
                    script_command = script_command % tuple(map(lambda x: '"' + x.replace('"', '\\"') + '"',
456 457 458 459
                                                                parameters[0:script_command.count('%s')]))
            if self.type.lower() == 'script':
                # execute script type command
                try:
460
                    exit_status = subprocess.call(script_command, env=self.config_environment, shell=True)
461
                    # send response
462
                    if exit_status == 0:
463
                        return 'OK'
Ad Schellevis's avatar
Ad Schellevis committed
464
                    else:
465
                        syslog.syslog(syslog.LOG_ERR, '[%s] returned exit status %d' % (message_uuid, exit_status))
Ad Schellevis's avatar
Ad Schellevis committed
466
                        return 'Error (%d)' % exit_status
467 468 469 470
                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()))
471 472 473
                    return 'Execute error'
            elif self.type.lower() == 'script_output':
                try:
474 475 476
                    with tempfile.NamedTemporaryFile() as error_stream:
                        with tempfile.NamedTemporaryFile() as output_stream:
                            subprocess.check_call(script_command, env=self.config_environment, shell=True,
477
                                                  stdout=output_stream, stderr=error_stream)
478 479 480 481 482
                            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:
483 484 485
                                syslog.syslog(syslog.LOG_ERR,
                                              '[%s] Script action stderr returned "%s"' %
                                              (message_uuid, script_error_output.strip()[:255])
486 487
                                              )
                            return script_output
488 489 490 491
                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()))
492
                    return 'Execute error'
493 494 495

            # fallback should never get here
            return "type error"
496 497 498 499 500 501 502 503 504
        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
505
                return ph_inline_actions.execute(self, inline_act_parameters)
506

507 508 509 510
            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()))
511 512 513
                return 'Execute error'

        return 'Unknown action type'