cmdctl.py.in 18.2 KB
Newer Older
Jeremy C. Reed's avatar
Jeremy C. Reed committed
1 2
#!@PYTHON@

Likun Zhang's avatar
Likun Zhang committed
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
# Copyright (C) 2010  Internet Systems Consortium.
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
# INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

18 19 20 21 22 23 24 25 26 27
''' cmdctl module is the configuration entry point for all commands from bindctl
or some other web tools client of bind10. cmdctl is pure https server which provi-
des RESTful API. When command client connecting with cmdctl, it should first login 
with legal username and password. 
    When cmdctl starting up, it will collect command specification and 
configuration specification/data of other available modules from configmanager, then
wait for receiving request from client, parse the request and resend the request to
the proper module. When getting the request result from the module, send back the 
resut to client.
'''
Likun Zhang's avatar
Likun Zhang committed
28

29
import sys; sys.path.append ('@@PYTHONPATH@@')
30
import os
31
import socketserver
Likun Zhang's avatar
Likun Zhang committed
32 33 34 35 36
import http.server
import urllib.parse
import json
import re
import ssl, socket
37
import isc
Likun Zhang's avatar
Likun Zhang committed
38 39 40 41
import pprint
import select
import csv
import random
42 43 44
import time
import signal
from optparse import OptionParser, OptionValueError
Likun Zhang's avatar
Likun Zhang committed
45 46 47 48 49 50
from hashlib import sha1
try:
    import threading
except ImportError:
    import dummy_threading as threading

51
__version__ = 'BIND10'
Likun Zhang's avatar
Likun Zhang committed
52
URL_PATTERN = re.compile('/([\w]+)(?:/([\w]+))?/?')
53 54 55 56 57 58

# If B10_FROM_SOURCE is set in the environment, we use data files
# from a directory relative to that, otherwise we use the ones
# installed on the system
if "B10_FROM_SOURCE" in os.environ:
    SPECFILE_PATH = os.environ["B10_FROM_SOURCE"] + "/src/bin/cmdctl"
59
    SYSCONF_PATH = os.environ["B10_FROM_SOURCE"] + "/src/bin/cmdctl"
60 61 62 63
else:
    PREFIX = "@prefix@"
    DATAROOTDIR = "@datarootdir@"
    SPECFILE_PATH = "@datadir@/@PACKAGE@".replace("${datarootdir}", DATAROOTDIR).replace("${prefix}", PREFIX)
64
    SYSCONF_PATH = "@sysconfdir@/@PACKAGE@".replace("${prefix}", PREFIX)
65
SPECFILE_LOCATION = SPECFILE_PATH + "/cmdctl.spec"
66 67 68
USER_INFO_FILE = SYSCONF_PATH + "/cmdctl-accounts.csv"
PRIVATE_KEY_FILE = SYSCONF_PATH + "/cmdctl-keyfile.pem"
CERTIFICATE_FILE = SYSCONF_PATH + "/cmdctl-certfile.pem"
Likun Zhang's avatar
Likun Zhang committed
69 70
        
class SecureHTTPRequestHandler(http.server.BaseHTTPRequestHandler):
71 72
    '''https connection request handler.
    Currently only GET and POST are supported.
Likun Zhang's avatar
Likun Zhang committed
73

74
    '''
Likun Zhang's avatar
Likun Zhang committed
75 76

    def do_GET(self):
77
        '''The client should send its session id in header with 
78 79
        the name 'cookie'
        '''
80 81 82 83 84
        self.session_id = self.headers.get('cookie')
        rcode, reply = http.client.OK, []        
        if self._is_session_valid():
            if self._is_user_logged_in():
                rcode, reply = self._handle_get_request()
85
            else:
86 87 88 89
                rcode, reply = http.client.UNAUTHORIZED, ["please login"]
        else:
            rcode = http.client.BAD_REQUEST

90
        self.send_response(rcode)
Likun Zhang's avatar
Likun Zhang committed
91
        self.end_headers()
92
        self.wfile.write(json.dumps(reply).encode())
Likun Zhang's avatar
Likun Zhang committed
93

94 95 96 97 98 99 100 101 102
    def _handle_get_request(self):
        '''Currently only support the following three url GET request '''
        id, module = self._parse_request_path()
        return self.server.get_reply_data_for_GET(id, module) 

    def _is_session_valid(self):
        return self.session_id 

    def _is_user_logged_in(self):
103 104 105 106 107 108 109 110 111 112
        login_time = self.server.user_sessions.get(self.session_id)
        if not login_time:
            return False
        
        idle_time = time.time() - login_time
        if idle_time > self.server.idle_timeout:
            return False
        # Update idle time
        self.server.user_sessions[self.session_id] = time.time()
        return True
113 114 115 116 117 118 119 120 121

    def _parse_request_path(self):
        '''Parse the url, the legal url should like /ldh or /ldh/ldh '''
        groups = URL_PATTERN.match(self.path) 
        if not groups:
            return (None, None)
        else:
            return (groups.group(1), groups.group(2))

Likun Zhang's avatar
Likun Zhang committed
122
    def do_POST(self):
123
        '''Process POST request. '''
124 125 126 127
        '''Process user login and send command to proper module  
        The client should send its session id in header with 
        the name 'cookie'
        '''
128
        self.session_id = self.headers.get('cookie')
129
        rcode, reply = http.client.OK, []
130
        if self._is_session_valid():
131
            if self.path == '/login':
132
                rcode, reply = self._handle_login()
133
            elif self._is_user_logged_in():
134
                rcode, reply = self._handle_post_request()
135 136
            else:
                rcode, reply = http.client.UNAUTHORIZED, ["please login"]
137 138 139
        else:
            rcode, reply = http.client.BAD_REQUEST, ["session isn't valid"]
      
Likun Zhang's avatar
Likun Zhang committed
140 141
        self.send_response(rcode)
        self.end_headers()
142 143
        self.wfile.write(json.dumps(reply).encode())

Likun Zhang's avatar
Likun Zhang committed
144

145 146 147 148 149
    def _handle_login(self):
        if self._is_user_logged_in():
            return http.client.OK, ["user has already login"]
        is_user_valid, error_info = self._check_user_name_and_pwd()
        if is_user_valid:
150
            self.server.save_user_session_id(self.session_id)
151 152 153 154 155
            return http.client.OK, ["login success "]
        else:
            return http.client.UNAUTHORIZED, error_info

    def _check_user_name_and_pwd(self):
156
        '''Check user name and its password '''
157
        length = self.headers.get('Content-Length')
158

159 160
        if not length:
            return False, ["invalid username or password"]     
161 162 163 164 165 166

        try:
            user_info = json.loads((self.rfile.read(int(length))).decode())
        except:
            return False, ["invalid username or password"]                

167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184
        user_name = user_info.get('username')
        if not user_name:
            return False, ["need user name"]
        if not self.server.user_infos.get(user_name):
            return False, ["user doesn't exist"]

        user_pwd = user_info.get('password')
        if not user_pwd:
            return False, ["need password"]
        local_info = self.server.user_infos.get(user_name)
        pwd_hashval = sha1((user_pwd + local_info[1]).encode())
        if pwd_hashval.hexdigest() != local_info[0]:
            return False, ["password doesn't match"] 

        return True, None
   

    def _handle_post_request(self):
185
        '''Handle all the post request from client. '''
186
        mod, cmd = self._parse_request_path()
187 188 189
        if (not mod) or (not cmd):
            return http.client.BAD_REQUEST, ['malformed url']

190 191 192
        param = None
        len = self.headers.get('Content-Length')
        if len:
193 194 195 196 197
            try:
                post_str = str(self.rfile.read(int(len)).decode())
                param = json.loads(post_str)
            except:
                pass
198

199
        rcode, reply = self.server.send_command_to_module(mod, cmd, param)
200 201 202
        if self.server._verbose:
            print('[b10-cmdctl] Finish send message \'%s\' to module %s' % (cmd, mod))

203 204 205 206 207 208 209 210 211 212
        ret = http.client.OK
        if rcode != 0:
            ret = http.client.BAD_REQUEST
        return ret, reply
    
    def log_request(self, code='-', size='-'):
        '''Rewrite the log request function, log nothing.'''
        pass


Likun Zhang's avatar
Likun Zhang committed
213
class CommandControl():
214 215 216 217
    '''Get all modules' config data/specification from configmanager.
    receive command from client and resend it to proper module.
    '''

218 219
    def __init__(self, verbose = False):
        self._verbose = verbose
220
        self.cc = isc.cc.Session()
Likun Zhang's avatar
Likun Zhang committed
221 222 223 224 225
        self.cc.group_subscribe('Cmd-Ctrld')
        self.command_spec = self.get_cmd_specification()
        self.config_spec = self.get_data_specification()
        self.config_data = self.get_config_data()

226 227 228 229 230 231
    def _parse_command_result(self, rcode, reply):
        '''Ignore the error reason when command rcode isn't 0, '''
        if rcode != 0:
            return {}
        return reply

Likun Zhang's avatar
Likun Zhang committed
232
    def get_cmd_specification(self): 
233 234
        rcode, reply = self.send_command('ConfigManager', isc.config.ccsession.COMMAND_GET_COMMANDS_SPEC)
        return self._parse_command_result(rcode, reply)
Likun Zhang's avatar
Likun Zhang committed
235 236

    def get_config_data(self):
237
        '''Get config data for all modules from configmanager '''
238 239
        rcode, reply = self.send_command('ConfigManager', isc.config.ccsession.COMMAND_GET_CONFIG)
        return self._parse_command_result(rcode, reply)
Likun Zhang's avatar
Likun Zhang committed
240

241

Likun Zhang's avatar
Likun Zhang committed
242
    def update_config_data(self, module_name, command_name):
243
        '''Get lastest config data for all modules from configmanager '''
244
        if module_name == 'ConfigManager' and command_name == isc.config.ccsession.COMMAND_SET_CONFIG:
Likun Zhang's avatar
Likun Zhang committed
245 246 247
            self.config_data = self.get_config_data()

    def get_data_specification(self):
248 249
        rcode, reply = self.send_command('ConfigManager', isc.config.ccsession.COMMAND_GET_MODULE_SPEC)
        return self._parse_command_result(rcode, reply)
Likun Zhang's avatar
Likun Zhang committed
250 251

    def handle_recv_msg(self):
252
        '''Handle received message, if 'shutdown' is received, return False'''
Likun Zhang's avatar
Likun Zhang committed
253
        (message, env) = self.cc.group_recvmsg(True)
254 255 256 257 258 259 260 261
        command, arg = isc.config.ccsession.parse_command(message)
        while command:
            if command == isc.config.ccsession.COMMAND_COMMANDS_UPDATE:
                self.command_spec[arg[0]] = arg[1]
            elif command == isc.config.ccsession.COMMAND_SPECIFICATION_UPDATE:
                self.config_spec[arg[0]] = arg[1]
            elif command == "shutdown":
                return False
Likun Zhang's avatar
Likun Zhang committed
262
            (message, env) = self.cc.group_recvmsg(True)
263
            command, arg = isc.config.ccsession.parse_command(message)
Likun Zhang's avatar
Likun Zhang committed
264 265 266
        
        return True
    
267 268 269 270 271 272 273 274 275 276
    def send_command_with_check(self, module_name, command_name, params = None):
        '''Before send the command to modules, check if module_name, command_name
        parameters are legal according the spec file of the module.
        Return rcode, dict.
        rcode = 0: dict is the correct returned value.
        rcode > 0: dict is : { 'error' : 'error reason' }

        TODO. add check for parameters.
        '''

277 278 279 280
        # core module ConfigManager does not have a specification file
        if module_name == 'ConfigManager':
            return self.send_command(module_name, command_name, params)

281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
        if module_name not in self.command_spec.keys():
            return 1, {'error' : 'unknown module'}

        cmd_valid = False
        commands = self.command_spec[module_name]
        for cmd in commands:
            if cmd['command_name'] == command_name:
                cmd_valid = True
                break

        if not cmd_valid:
            return 1, {'error' : 'unknown command'}

        return self.send_command(module_name, command_name, params)

296
    def send_command(self, module_name, command_name, params = None):
297
        '''Send the command from bindctl to proper module. '''
298 299
        
        errstr = 'no error'
300 301
        if self._verbose:
            self.log_info('[b10-cmdctl] send command \'%s\' to %s\n' %(command_name, module_name))
Likun Zhang's avatar
Likun Zhang committed
302
        try:
303
            msg = isc.config.ccsession.create_command(command_name, params)
304
            seq = self.cc.group_sendmsg(msg, module_name)
305
            #TODO, it may be blocked, msqg need to add a new interface waiting in timeout.
306
            answer, env = self.cc.group_recvmsg(False, seq)
307 308 309 310 311 312
            if answer:
                try:
                    rcode, arg = isc.config.ccsession.parse_answer(answer)
                    if rcode == 0:
                        self.update_config_data(module_name, command_name)
                        if arg != None:
313 314 315
                            return rcode, arg
                        else:
                            return rcode, {}
316 317
                    else:
                        # todo: exception
318
                        errstr = str(answer['result'][1])
319
                except isc.config.ccsession.ModuleCCSessionError as mcse:
320
                    errstr = str("Error in ccsession answer:") + str(mcse)
321
                    self.log_info(answer)
Likun Zhang's avatar
Likun Zhang committed
322
        except Exception as e:
323
            errstr = str(e)
324
            self.log_info('\'%s\':[b10-cmdctl] fail send command \'%s\' to %s\n' % (e, command_name, module_name))
325
        
326
        return 1, {'error': errstr}
327 328 329
    
    def log_info(self, msg):
        sys.stdout.write(msg)
Likun Zhang's avatar
Likun Zhang committed
330

331
class SecureHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
332 333
    '''Make the server address can be reused.'''
    allow_reuse_address = True
Likun Zhang's avatar
Likun Zhang committed
334

335
    def __init__(self, server_address, RequestHandlerClass, idle_timeout = 1200, verbose = False):
336
        '''idle_timeout: the max idle time for login'''
Likun Zhang's avatar
Likun Zhang committed
337
        http.server.HTTPServer.__init__(self, server_address, RequestHandlerClass)
338 339
        self.user_sessions = {}
        self.idle_timeout = idle_timeout
Likun Zhang's avatar
Likun Zhang committed
340 341 342
        self.cmdctrl = CommandControl()
        self.__is_shut_down = threading.Event()
        self.__serving = False
343
        self._verbose = verbose
344 345
        self.user_infos = {}
        self._read_user_info()
Likun Zhang's avatar
Likun Zhang committed
346

347
    def _read_user_info(self):
348
        '''Read all user's name and its' password from csv file.'''
349 350 351 352 353 354 355
        csvfile = None
        try:
            csvfile = open(USER_INFO_FILE)
            reader = csv.reader(csvfile)
            for row in reader:
                self.user_infos[row[0]] = [row[1], row[2]]
        except Exception as e:
356
            self.log_info('[b10-cmdctl] Fail to read user information :\'%s\'\n' % e)                
357 358 359 360
        finally:
            if csvfile:
                csvfile.close()
        
361 362 363
    def save_user_session_id(self, session_id):
        # Record user's id and login time.
        self.user_sessions[session_id] = time.time()
364
        
Likun Zhang's avatar
Likun Zhang committed
365
    def get_request(self):
366
        '''Get client request socket and wrap it in SSL context. '''
Likun Zhang's avatar
Likun Zhang committed
367 368 369 370
        newsocket, fromaddr = self.socket.accept()
        try:
            connstream = ssl.wrap_socket(newsocket,
                                     server_side = True,
371
                                     certfile = CERTIFICATE_FILE,
372
                                     keyfile = PRIVATE_KEY_FILE,
Likun Zhang's avatar
Likun Zhang committed
373 374 375
                                     ssl_version = ssl.PROTOCOL_SSLv23)
            return (connstream, fromaddr)
        except ssl.SSLError as e :
376
            self.log_info('[b10-cmdctl] deny client\'s invalid connection:\'%s\'\n' % e)
377 378 379
            self.close_request(newsocket)
            # raise socket error to finish the request
            raise socket.error
Likun Zhang's avatar
Likun Zhang committed
380
            
381

382 383 384 385
    def get_reply_data_for_GET(self, id, module):
        '''Currently only support the following three url GET request '''
        rcode, reply = http.client.NO_CONTENT, []        
        if not module:
Likun Zhang's avatar
Likun Zhang committed
386
            if id == 'command_spec':
387
               rcode, reply = http.client.OK, self.cmdctrl.command_spec
Likun Zhang's avatar
Likun Zhang committed
388
            elif id == 'config_data':
389
               rcode, reply = http.client.OK, self.cmdctrl.config_data
Likun Zhang's avatar
Likun Zhang committed
390
            elif id == 'config_spec':
391
               rcode, reply = http.client.OK, self.cmdctrl.config_spec
392
        
393
        return rcode, reply 
Likun Zhang's avatar
Likun Zhang committed
394

395
        
Likun Zhang's avatar
Likun Zhang committed
396
    def serve_forever(self, poll_interval = 0.5):
397
        '''Start cmdctl as one tcp server. '''
Likun Zhang's avatar
Likun Zhang committed
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415
        self.__serving = True
        self.__is_shut_down.clear()
        while self.__serving:
            if not self.cmdctrl.handle_recv_msg():
                break

            r, w, e = select.select([self], [], [], poll_interval)
            if r:
                self._handle_request_noblock()

        self.__is_shut_down.set()
    
    def shutdown(self):
        self.__serving = False
        self.__is_shut_down.wait()


    def send_command_to_module(self, module_name, command_name, params):
416
        return self.cmdctrl.send_command_with_check(module_name, command_name, params)
417 418 419
   
    def log_info(self, msg):
        sys.stdout.write(msg)
Likun Zhang's avatar
Likun Zhang committed
420

421 422 423 424 425 426
httpd = None

def signal_handler(signal, frame):
    if httpd:
        httpd.shutdown()
    sys.exit(0)
427

428 429 430 431
def set_signal_handler():
    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGINT, signal_handler)

432
def run(addr = 'localhost', port = 8080, idle_timeout = 1200, verbose = False):
433
    ''' Start cmdctl as one https server. '''
434 435 436
    if verbose:
        sys.stdout.write("[b10-cmdctl] starting on :%s port:%d\n" %(addr, port))
    httpd = SecureHTTPServer((addr, port), SecureHTTPRequestHandler, idle_timeout, verbose)
Likun Zhang's avatar
Likun Zhang committed
437 438
    httpd.serve_forever()

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
def check_port(option, opt_str, value, parser):
    if (value < 0) or (value > 65535):
        raise OptionValueError('%s requires a port number (0-65535)' % opt_str)
    parser.values.port = value

def check_addr(option, opt_str, value, parser):
    ipstr = value
    ip_family = socket.AF_INET
    if (ipstr.find(':') != -1):
        ip_family = socket.AF_INET6

    try:
        socket.inet_pton(ip_family, ipstr)
    except:
        raise OptionValueError("%s invalid ip address" % ipstr)

    parser.values.addr = value

def set_cmd_options(parser):
    parser.add_option('-p', '--port', dest = 'port', type = 'int',
            action = 'callback', callback=check_port,
            default = '8080', help = 'port cmdctl will use')

    parser.add_option('-a', '--address', dest = 'addr', type = 'string',
            action = 'callback', callback=check_addr,
            default = '127.0.0.1', help = 'IP address cmdctl will use')

    parser.add_option('-i', '--idle-timeout', dest = 'idle_timeout', type = 'int',
            default = '1200', help = 'login idle time out')

469 470 471
    parser.add_option("-v", "--verbose", dest="verbose", action="store_true", default=False,
            help="display more about what is going on")

Likun Zhang's avatar
Likun Zhang committed
472 473 474

if __name__ == '__main__':
    try:
475 476 477 478
        parser = OptionParser(version = __version__)
        set_cmd_options(parser)
        (options, args) = parser.parse_args()
        set_signal_handler()
479
        run(options.addr, options.port, options.idle_timeout, options.verbose)
480
    except isc.cc.SessionError as se:
481 482
        sys.stderr.write("[b10-cmdctl] Error creating b10-cmdctl, "
                "is the command channel daemon running?\n")        
Likun Zhang's avatar
Likun Zhang committed
483
    except KeyboardInterrupt:
484
        sys.stderr.write("[b10-cmdctl] exit http server\n")
485 486 487 488 489

    if httpd:
        httpd.shutdown()


Likun Zhang's avatar
Likun Zhang committed
490