cmdctl.py.in 17.5 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
Likun Zhang's avatar
Likun Zhang committed
31 32 33 34 35
import http.server
import urllib.parse
import json
import re
import ssl, socket
36
import isc
Likun Zhang's avatar
Likun Zhang committed
37 38 39 40
import pprint
import select
import csv
import random
41 42 43
import time
import signal
from optparse import OptionParser, OptionValueError
Likun Zhang's avatar
Likun Zhang committed
44 45 46 47 48 49
from hashlib import sha1
try:
    import threading
except ImportError:
    import dummy_threading as threading

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

# 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"
58
    SYSCONF_PATH = os.environ["B10_FROM_SOURCE"] + "/src/bin/cmdctl"
59 60 61 62
else:
    PREFIX = "@prefix@"
    DATAROOTDIR = "@datarootdir@"
    SPECFILE_PATH = "@datadir@/@PACKAGE@".replace("${datarootdir}", DATAROOTDIR).replace("${prefix}", PREFIX)
63
    SYSCONF_PATH = "@sysconfdir@/@PACKAGE@".replace("${prefix}", PREFIX)
64
SPECFILE_LOCATION = SPECFILE_PATH + "/cmdctl.spec"
65 66 67
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
68 69
        
class SecureHTTPRequestHandler(http.server.BaseHTTPRequestHandler):
70 71
    '''https connection request handler.
    Currently only GET and POST are supported.
Likun Zhang's avatar
Likun Zhang committed
72

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

    def do_GET(self):
76
        '''The client should send its session id in header with 
77 78
        the name 'cookie'
        '''
79 80 81 82 83
        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()
84
            else:
85 86 87 88
                rcode, reply = http.client.UNAUTHORIZED, ["please login"]
        else:
            rcode = http.client.BAD_REQUEST

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

93 94 95 96 97 98 99 100 101
    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):
102 103 104 105 106 107 108 109 110 111
        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
112 113 114 115 116 117 118 119 120

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

Likun Zhang's avatar
Likun Zhang committed
143

144 145 146 147 148
    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:
149
            self.server.save_user_session_id(self.session_id)
150 151 152 153 154
            return http.client.OK, ["login success "]
        else:
            return http.client.UNAUTHORIZED, error_info

    def _check_user_name_and_pwd(self):
155
        '''Check user name and its password '''
156 157 158
        length = self.headers.get('Content-Length')
        if not length:
            return False, ["invalid username or password"]     
159 160 161 162 163 164

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

165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182
        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):
183
        '''Handle all the post request from client. '''
184
        mod, cmd = self._parse_request_path()
185 186 187
        if (not mod) or (not cmd):
            return http.client.BAD_REQUEST, ['malformed url']

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

197
        rcode, reply = self.server.send_command_to_module(mod, cmd, param)
198
        print('b10-cmdctl finish send message \'%s\' to module %s' % (cmd, mod))
199 200 201 202 203 204 205 206 207 208
        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
209
class CommandControl():
210 211 212 213
    '''Get all modules' config data/specification from configmanager.
    receive command from client and resend it to proper module.
    '''

Likun Zhang's avatar
Likun Zhang committed
214
    def __init__(self):
215
        self.cc = isc.cc.Session()
Likun Zhang's avatar
Likun Zhang committed
216
        self.cc.group_subscribe('Cmd-Ctrld')
217
        #self.cc.group_subscribe('Boss', 'Cmd-Ctrld')
Likun Zhang's avatar
Likun Zhang committed
218 219 220 221
        self.command_spec = self.get_cmd_specification()
        self.config_spec = self.get_data_specification()
        self.config_data = self.get_config_data()

222 223 224 225 226 227
    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
228
    def get_cmd_specification(self): 
229 230
        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
231 232

    def get_config_data(self):
233
        '''Get config data for all modules from configmanager '''
234 235
        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
236 237

    def update_config_data(self, module_name, command_name):
238
        '''Get lastest config data for all modules from configmanager '''
239
        if module_name == 'ConfigManager' and command_name == isc.config.ccsession.COMMAND_SET_CONFIG:
Likun Zhang's avatar
Likun Zhang committed
240 241 242
            self.config_data = self.get_config_data()

    def get_data_specification(self):
243 244
        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
245 246

    def handle_recv_msg(self):
247
        '''Handle received message, if 'shutdown' is received, return False'''
Likun Zhang's avatar
Likun Zhang committed
248
        (message, env) = self.cc.group_recvmsg(True)
249 250 251 252 253 254 255 256
        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
257
            (message, env) = self.cc.group_recvmsg(True)
258
            command, arg = isc.config.ccsession.parse_command(message)
Likun Zhang's avatar
Likun Zhang committed
259 260 261
        
        return True
    
262 263 264 265 266 267 268 269 270 271
    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.
        '''

272 273 274 275
        # core module ConfigManager does not have a specification file
        if module_name == 'ConfigManager':
            return self.send_command(module_name, command_name, params)

276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
        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)

291
    def send_command(self, module_name, command_name, params = None):
292
        '''Send the command from bindctl to proper module. '''
293 294
        
        errstr = 'no error'
295
        print('b10-cmdctl send command \'%s\' to %s' %(command_name, module_name))
Likun Zhang's avatar
Likun Zhang committed
296
        try:
297
            msg = isc.config.ccsession.create_command(command_name, params)
Likun Zhang's avatar
Likun Zhang committed
298
            self.cc.group_sendmsg(msg, module_name)
299
            #TODO, it may be blocked, msqg need to add a new interface waiting in timeout.
Likun Zhang's avatar
Likun Zhang committed
300
            answer, env = self.cc.group_recvmsg(False)
301 302 303 304 305 306
            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:
307 308 309
                            return rcode, arg
                        else:
                            return rcode, {}
310 311
                    else:
                        # todo: exception
312
                        errstr = str(answer['result'][1])
313
                except isc.config.ccsession.ModuleCCSessionError as mcse:
314
                    errstr = str("Error in ccsession answer:") + str(mcse)
315
                    print(answer)
Likun Zhang's avatar
Likun Zhang committed
316
        except Exception as e:
317
            errstr = str(e)
318 319
            print(e, ':b10-cmdctl fail send command \'%s\' to %s' % (command_name, module_name))
        
320
        return 1, {'error': errstr}
Likun Zhang's avatar
Likun Zhang committed
321 322 323


class SecureHTTPServer(http.server.HTTPServer):
324 325
    '''Make the server address can be reused.'''
    allow_reuse_address = True
Likun Zhang's avatar
Likun Zhang committed
326

327 328
    def __init__(self, server_address, RequestHandlerClass, idle_timeout = 1200):
        '''idle_timeout: the max idle time for login'''
Likun Zhang's avatar
Likun Zhang committed
329
        http.server.HTTPServer.__init__(self, server_address, RequestHandlerClass)
330 331
        self.user_sessions = {}
        self.idle_timeout = idle_timeout
Likun Zhang's avatar
Likun Zhang committed
332 333 334
        self.cmdctrl = CommandControl()
        self.__is_shut_down = threading.Event()
        self.__serving = False
335 336
        self.user_infos = {}
        self._read_user_info()
Likun Zhang's avatar
Likun Zhang committed
337

338
    def _read_user_info(self):
339
        '''Read all user's name and its' password from csv file.'''
340 341 342 343 344 345 346 347 348 349 350 351
        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:
            print("Fail to read user information ", e)                
        finally:
            if csvfile:
                csvfile.close()
        
352 353 354
    def save_user_session_id(self, session_id):
        # Record user's id and login time.
        self.user_sessions[session_id] = time.time()
355
        
Likun Zhang's avatar
Likun Zhang committed
356
    def get_request(self):
357
        '''Get client request socket and wrap it in SSL context. '''
Likun Zhang's avatar
Likun Zhang committed
358 359 360 361
        newsocket, fromaddr = self.socket.accept()
        try:
            connstream = ssl.wrap_socket(newsocket,
                                     server_side = True,
362
                                     certfile = CERTIFICATE_FILE,
363
                                     keyfile = PRIVATE_KEY_FILE,
Likun Zhang's avatar
Likun Zhang committed
364 365 366
                                     ssl_version = ssl.PROTOCOL_SSLv23)
            return (connstream, fromaddr)
        except ssl.SSLError as e :
367 368 369 370
            print("cmdctl: deny client's invalid connection", e)
            self.close_request(newsocket)
            # raise socket error to finish the request
            raise socket.error
Likun Zhang's avatar
Likun Zhang committed
371
            
372

373 374 375 376
    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
377
            if id == 'command_spec':
378
               rcode, reply = http.client.OK, self.cmdctrl.command_spec
Likun Zhang's avatar
Likun Zhang committed
379
            elif id == 'config_data':
380
               rcode, reply = http.client.OK, self.cmdctrl.config_data
Likun Zhang's avatar
Likun Zhang committed
381
            elif id == 'config_spec':
382
               rcode, reply = http.client.OK, self.cmdctrl.config_spec
383
        
384
        return rcode, reply 
Likun Zhang's avatar
Likun Zhang committed
385

386
        
Likun Zhang's avatar
Likun Zhang committed
387
    def serve_forever(self, poll_interval = 0.5):
388
        '''Start cmdctl as one tcp server. '''
Likun Zhang's avatar
Likun Zhang committed
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406
        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):
407
        return self.cmdctrl.send_command_with_check(module_name, command_name, params)
Likun Zhang's avatar
Likun Zhang committed
408

409 410 411 412 413 414
httpd = None

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

416 417 418 419 420
def set_signal_handler():
    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGINT, signal_handler)

def run(addr = 'localhost', port = 8080, idle_timeout = 1200):
421
    ''' Start cmdctl as one https server. '''
422
    print("b10-cmdctl module is starting on :%s port:%d" %(addr, port))
423
    httpd = SecureHTTPServer((addr, port), SecureHTTPRequestHandler, idle_timeout)
Likun Zhang's avatar
Likun Zhang committed
424 425
    httpd.serve_forever()

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

Likun Zhang's avatar
Likun Zhang committed
456 457 458

if __name__ == '__main__':
    try:
459 460 461 462 463
        parser = OptionParser(version = __version__)
        set_cmd_options(parser)
        (options, args) = parser.parse_args()
        set_signal_handler()
        run(options.addr, options.port, options.idle_timeout)
464
    except isc.cc.SessionError as se:
465
        print("[b10-cmdctl] Error creating b10-cmdctl, "
Likun Zhang's avatar
Likun Zhang committed
466 467 468
                "is the command channel daemon running?")        
    except KeyboardInterrupt:
        print("exit http server")
469 470 471 472 473

    if httpd:
        httpd.shutdown()


Likun Zhang's avatar
Likun Zhang committed
474