cmdctl.py.in 15.6 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 58 59 60 61 62 63

# 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"
else:
    PREFIX = "@prefix@"
    DATAROOTDIR = "@datarootdir@"
    SPECFILE_PATH = "@datadir@/@PACKAGE@".replace("${datarootdir}", DATAROOTDIR).replace("${prefix}", PREFIX)
SPECFILE_LOCATION = SPECFILE_PATH + "/cmdctl.spec"
USER_INFO_FILE = SPECFILE_PATH + "/passwd.csv"
64
CERTIFICATE_FILE = SPECFILE_PATH + "/b10-cmdctl.pem"
Likun Zhang's avatar
Likun Zhang committed
65 66
        
class SecureHTTPRequestHandler(http.server.BaseHTTPRequestHandler):
67 68
    '''https connection request handler.
    Currently only GET and POST are supported.
Likun Zhang's avatar
Likun Zhang committed
69

70
    '''
Likun Zhang's avatar
Likun Zhang committed
71 72

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

86
        self.send_response(rcode)
Likun Zhang's avatar
Likun Zhang committed
87
        self.end_headers()
88
        self.wfile.write(json.dumps(reply).encode())
Likun Zhang's avatar
Likun Zhang committed
89

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

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

Likun Zhang's avatar
Likun Zhang committed
140

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

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

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

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

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

194 195 196 197
        reply = self.server.send_command_to_module(mod, cmd, param)
        print('b10-cmdctl finish send message \'%s\' to module %s' % (cmd, mod))
        # TODO, need set proper rcode
        return http.client.OK, reply
198 199
            
   
Likun Zhang's avatar
Likun Zhang committed
200
class CommandControl():
201 202 203 204
    '''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
205
    def __init__(self):
206
        self.cc = isc.cc.Session()
Likun Zhang's avatar
Likun Zhang committed
207
        self.cc.group_subscribe('Cmd-Ctrld')
208
        #self.cc.group_subscribe('Boss', 'Cmd-Ctrld')
Likun Zhang's avatar
Likun Zhang committed
209 210 211 212 213
        self.command_spec = self.get_cmd_specification()
        self.config_spec = self.get_data_specification()
        self.config_data = self.get_config_data()

    def get_cmd_specification(self): 
214
        return self.send_command('ConfigManager', isc.config.ccsession.COMMAND_GET_COMMANDS_SPEC)
Likun Zhang's avatar
Likun Zhang committed
215 216

    def get_config_data(self):
217
        '''Get config data for all modules from configmanager '''
218
        return self.send_command('ConfigManager', isc.config.ccsession.COMMAND_GET_CONFIG)
Likun Zhang's avatar
Likun Zhang committed
219 220

    def update_config_data(self, module_name, command_name):
221
        '''Get lastest config data for all modules from configmanager '''
222
        if module_name == 'ConfigManager' and command_name == isc.config.ccsession.COMMAND_SET_CONFIG:
Likun Zhang's avatar
Likun Zhang committed
223 224 225
            self.config_data = self.get_config_data()

    def get_data_specification(self):
226
        return self.send_command('ConfigManager', isc.config.ccsession.COMMAND_GET_MODULE_SPEC)
Likun Zhang's avatar
Likun Zhang committed
227 228

    def handle_recv_msg(self):
229
        '''Handle received message, if 'shutdown' is received, return False'''
Likun Zhang's avatar
Likun Zhang committed
230
        (message, env) = self.cc.group_recvmsg(True)
231 232 233 234 235 236 237 238
        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
239
            (message, env) = self.cc.group_recvmsg(True)
240
            command, arg = isc.config.ccsession.parse_command(message)
Likun Zhang's avatar
Likun Zhang committed
241 242 243
        
        return True
    
244
    def send_command(self, module_name, command_name, params = None):
245 246
        '''Send the command from bindctl to proper module. '''
        reply = {}
247
        print('b10-cmdctl send command \'%s\' to %s' %(command_name, module_name))
Likun Zhang's avatar
Likun Zhang committed
248
        try:
249
            msg = isc.config.ccsession.create_command(command_name, params)
Likun Zhang's avatar
Likun Zhang committed
250
            self.cc.group_sendmsg(msg, module_name)
251
            #TODO, it may be blocked, msqg need to add a new interface waiting in timeout.
Likun Zhang's avatar
Likun Zhang committed
252
            answer, env = self.cc.group_recvmsg(False)
253 254 255 256 257 258 259 260 261 262 263 264 265 266
            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:
                            return arg
                    else:
                        # todo: exception
                        print("Error: " + str(answer['result'][1]))
                        return {}
                except isc.config.ccsession.ModuleCCSessionError as mcse:
                    print("Error in ccsession answer: %s" % str(mcse))
                    print(answer)
Likun Zhang's avatar
Likun Zhang committed
267
        except Exception as e:
268 269 270
            print(e, ':b10-cmdctl fail send command \'%s\' to %s' % (command_name, module_name))
        
        return reply
Likun Zhang's avatar
Likun Zhang committed
271 272 273


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

277 278
    def __init__(self, server_address, RequestHandlerClass, idle_timeout = 1200):
        '''idle_timeout: the max idle time for login'''
Likun Zhang's avatar
Likun Zhang committed
279
        http.server.HTTPServer.__init__(self, server_address, RequestHandlerClass)
280 281
        self.user_sessions = {}
        self.idle_timeout = idle_timeout
Likun Zhang's avatar
Likun Zhang committed
282 283 284
        self.cmdctrl = CommandControl()
        self.__is_shut_down = threading.Event()
        self.__serving = False
285 286
        self.user_infos = {}
        self._read_user_info()
Likun Zhang's avatar
Likun Zhang committed
287

288
    def _read_user_info(self):
289
        '''Read all user's name and its' password from csv file.'''
290 291 292 293 294 295 296 297 298 299 300 301
        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()
        
302 303 304
    def save_user_session_id(self, session_id):
        # Record user's id and login time.
        self.user_sessions[session_id] = time.time()
305
        
Likun Zhang's avatar
Likun Zhang committed
306
    def get_request(self):
307
        '''Get client request socket and wrap it in SSL context. '''
Likun Zhang's avatar
Likun Zhang committed
308 309 310 311
        newsocket, fromaddr = self.socket.accept()
        try:
            connstream = ssl.wrap_socket(newsocket,
                                     server_side = True,
312 313
                                     certfile = CERTIFICATE_FILE,
                                     keyfile = CERTIFICATE_FILE,
Likun Zhang's avatar
Likun Zhang committed
314 315 316
                                     ssl_version = ssl.PROTOCOL_SSLv23)
            return (connstream, fromaddr)
        except ssl.SSLError as e :
317 318 319 320
            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
321
            
322

323 324 325 326
    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
327
            if id == 'command_spec':
328
               rcode, reply = http.client.OK, self.cmdctrl.command_spec
Likun Zhang's avatar
Likun Zhang committed
329
            elif id == 'config_data':
330
               rcode, reply = http.client.OK, self.cmdctrl.config_data
Likun Zhang's avatar
Likun Zhang committed
331
            elif id == 'config_spec':
332
               rcode, reply = http.client.OK, self.cmdctrl.config_spec
333
        
334
        return rcode, reply 
Likun Zhang's avatar
Likun Zhang committed
335

336
        
Likun Zhang's avatar
Likun Zhang committed
337
    def serve_forever(self, poll_interval = 0.5):
338
        '''Start cmdctl as one tcp server. '''
Likun Zhang's avatar
Likun Zhang committed
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
        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):
        return self.cmdctrl.send_command(module_name, command_name, params)

359 360 361 362 363 364
httpd = None

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

366 367 368 369 370
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):
371
    ''' Start cmdctl as one https server. '''
372
    print("b10-cmdctl module is starting on :%s port:%d" %(addr, port))
373
    httpd = SecureHTTPServer((addr, port), SecureHTTPRequestHandler, idle_timeout)
Likun Zhang's avatar
Likun Zhang committed
374 375
    httpd.serve_forever()

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
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
406 407 408

if __name__ == '__main__':
    try:
409 410 411 412 413
        parser = OptionParser(version = __version__)
        set_cmd_options(parser)
        (options, args) = parser.parse_args()
        set_signal_handler()
        run(options.addr, options.port, options.idle_timeout)
414
    except isc.cc.SessionError as se:
415
        print("[b10-cmdctl] Error creating b10-cmdctl, "
Likun Zhang's avatar
Likun Zhang committed
416 417 418
                "is the command channel daemon running?")        
    except KeyboardInterrupt:
        print("exit http server")
419 420 421 422 423

    if httpd:
        httpd.shutdown()


Likun Zhang's avatar
Likun Zhang committed
424