piphone.py 21.7 KB
Newer Older
1 2 3
#!/usr/bin/env python

import sqlite3
okhin's avatar
okhin committed
4 5 6
import datetime
import uuid
import json
7 8 9
import logging
import concurrent.futures
import asyncio
10
import re
11 12
import configparser
import argparse
13 14
import os.path
import sys
15
import hashlib
16
from operator import itemgetter
17 18

import jwt
19
import websockets
okhin's avatar
okhin committed
20
from bottle import request, abort, Bottle, JSONPlugin, template, static_file, auth_basic
21
from bottle_sqlite import SQLitePlugin
22 23

import ari
24

25
arg_parser = argparse.ArgumentParser(description='Manage the SIP Backend for the piphone')
26
config = configparser.ConfigParser()
27 28

arg_parser.add_argument('-c', '--config', help="Config file")
okhin's avatar
okhin committed
29
args = arg_parser.parse_args()
30
try:
31
    logging.debug("Let's use {} as a config file".format(args.config,))
okhin's avatar
okhin committed
32
    config.read(args.config)
33 34
except AttributeError as e:
    print(e)
35 36 37 38
    try:
        if os.path.isfile('config.ini'):
            logging.debug("Let's use config.ini as a config file")
            config.read('config.ini')
okhin's avatar
okhin committed
39
        elif os.path.isfile('/etc/piphone/sip_config.ini'):
40
            logging.debug("Let's use /etc/iphone/config.ini as a config file")
okhin's avatar
okhin committed
41
            config.read('/etc/piphone/sip_config.ini')
42
        else:
okhin's avatar
okhin committed
43
            raise Exception("No configuration file found (tried ./config.ini and /etc/piphone/sip_config.ini")
44 45 46 47
    except Exception as e:
        arg_parser.print_help()
        sys.exit(1)

48
application = app = Bottle(autojson=False)
49
app.install(SQLitePlugin(dbfile=config['piphone']['db']))
50 51
app.install(JSONPlugin(json_dumps=lambda s: json.dumps(s, cls=PiphoneJSONEncoder)))

52
threads = concurrent.futures.ThreadPoolExecutor(max_workers=5)
53
loop = asyncio.get_event_loop()
54

55 56 57 58
running = False
ws = None

# Loggers
59 60
handler = logging.FileHandler(config['piphone']['log'])
verbosity = getattr(logging, config['piphone']['verbosity'].upper()) or logging.DEBUG
61 62
phone_logger = logging.getLogger('piphone')
phone_logger.addHandler(handler)
63
phone_logger.setLevel(verbosity)
64 65
ws_logger = logging.getLogger('asterisk')
ws_logger.addHandler(handler)
66
ws_logger.setLevel(verbosity)
67 68
bottle_logger = logging.getLogger('bottle')
bottle_logger.addHandler(handler)
69
bottle_logger.setLevel(verbosity)
70 71 72 73 74 75 76 77 78 79

class PiphoneJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        """
        We need to implement this to be able to JSONEncode
        """
        if isinstance(obj, Call):
            return {  'caller': obj.caller
                , 'callee': obj.callee
                , 'callid': obj.id
okhin's avatar
okhin committed
80
                , 'url': obj.url
81 82 83
                , 'history': obj.history
                , 'owner': obj.owner }
        else:
84
            return json.JSONEncoder.default(self, obj)
85

86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
def authenticated(f):
    '''
    We need a decorator to check if our query is authenticated.
    We will store an API key and SECRET in ur database, the client
    needs to have both of them.
    He must then send us a JWT token with an API claim in the payload.
    The JWT token must be encoded and signed with the SECRET. If the
    token is bad, we return a 403.
    '''
    def wrapped(db, *args, **kwargs):
        # Let's get the JWT token. It should be a params (from get or post or whatev')
        bottle_logger.debug("Authentication: {}".format([':'.join([key, request.params[key]]) for key in request.params],))
        if 'token' not in request.params:
            bottle_logger.error("No token found in the params")
            abort(401, "No token found in the query")
        # We want the api id in the params to.
        if 'api' not in request.params:
            bottle_logger.error("No api id found in the params")
            abort(401, "No api id found in the params")
        # Now, let's get the token on our side
        try:
            results = db.execute('SELECT token FROM users WHERE api = ?', (request.params['api'],)).fetchall()
            assert len(results) == 1
            token = results[0][0]
            auth_token = jwt.decode(request.params['token'], token)
111
            assert hashlib.sha256(auth_token['api'].encode()).hexdigest() == request.params['api']
112 113 114 115 116 117 118
            for key in auth_token:
                request.params[key] = auth_token[key]
        except (jwt.exceptions.InvalidTokenError, AssertionError) as e:
            bottle_logger.error("Access refused")
            bottle_logger.exception(e)
            abort(403, e)
        except Exception as e:
okhin's avatar
okhin committed
119
            bottle_logger.exception(e)
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
            abort(500, e)
        return f(db, *args, **kwargs)
    return wrapped

def connected(f):
    '''
    This is a decorator used to check if we are connected to a websocket or not. If we're not
    We're returning 500 error.
    '''
    def wrapped(db, *args, **kwargs):
        if isinstance(ws, websockets.client.WebSocketClientProtocol):
            if ws.open == True:
                return f(db, *args, **kwargs)
        ws_logger.error("Websocket connection is closed")
        abort(500, "Websocket isn't running")
    return wrapped

okhin's avatar
okhin committed
137 138 139 140 141 142 143 144 145 146 147 148
def sanitize_phonenumber(number):
    """
    This function is used to sanitize a phone number.
    If it starts with a +, it will be removed and replaced by 00.
    Any chars who do notbelong to [0-9] will be stripped.
    If the number doesn't starts with 00, a TypeError will be raised
    """
    if number[0] == '+':
        number = '00' + number[1:]
    number = ''.join([c for c in number if c in '0123456789'])
    if not number.startswith('00'):
        raise TypeError('{} is not a valid international number, it should start with 00')
149 150 151 152

    # We're checking if we're blacklisted
    db = sqlite3.connect(config['piphone']['db'])
    bl_re = None
153
    for blacklist in db.execute('SELECT pattern, reason FROM blacklist').fetchall():
154 155 156 157 158 159 160
        if bl_re == None:
            bl_re ='(?P<{reason}>^{pattern})'.format(pattern=blacklist[0],reason=blacklist[1],)
        else:
            bl_re +='|(?P<{reason}>^{pattern})'.format(pattern=blacklist[0], reason=blacklist[1],)
    if bl_re != None:
        # We have blacklisted patterns, need to chck for them
        groups = re.match(bl_re, number)
161
        if groups is not None:
162
            # We matched, so we're blacklisted
163 164
            groupdict = groups.groupdict()
            raise ValueError('{} is blacklisted. Reason: {}', (number, list(groupdict)[0],))
okhin's avatar
okhin committed
165 166
    return number

okhin's avatar
okhin committed
167
@asyncio.coroutine
okhin's avatar
okhin committed
168
def listen(db):
169 170 171 172 173
    '''
    Start listening on the websocket
    '''
    global running
    global ws
174
    ws_logger.debug('Connecting to websocket: {}'.format(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password'])))
okhin's avatar
okhin committed
175
    ws = yield from websockets.connect(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(
okhin's avatar
okhin committed
176
        config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password']))
177 178 179
    ws_logger.debug('Websocket connected: {}'.format(type(ws)))
    while running == True:
        try:
okhin's avatar
okhin committed
180
            event = yield from ws.recv()
181
            # Let's call the applications function
okhin's avatar
okhin committed
182
            yield from dispatch(json.loads(event), db)
183 184 185 186 187 188 189 190 191 192 193
        except websockets.exceptions.ConnectionClosed as e:
            ws_logger.warning("Connexion closed")
            ws_logger.exception(e)
            running = False
            ws.close()
            return
        except Exception as e:
            ws_logger.exception(e)
            continue
    ws.close()

okhin's avatar
okhin committed
194
@asyncio.coroutine
okhin's avatar
okhin committed
195
def dispatch(event, db):
196 197 198 199 200 201 202 203 204
    """
    Let's work on our events. Parse them and do request on the ARI API. Event is
    a dict loaded from JSON.
    """
    ws_logger.debug('Event received: {}'.format(event,))
    # Let's get the call ID, the call id isthe channel id minus the last -part.
    if 'channel' not in event:
        return
    call_id = re.sub('-\d+$', '', event['channel']['id'])
okhin's avatar
okhin committed
205
    call = Call.load(call_id, db)
206 207
    call.event_handler(event)

208
class Call(object):
okhin's avatar
okhin committed
209
    """
210
    This Class is used to manage operations on a call, to print it and dump it.
okhin's avatar
okhin committed
211
    """
212
    history = []
213
    actions = {'Created': 'call_caller'
214
        , 'ChannelStateChange': 'change'
215
        , 'ChannelDtmfReceived': 'dtmf'
216
        , 'ChannelDestroyed': 'hangup'
217
        , 'ChannelHangupRequest': 'hangup'}
218

219
    def __init__(self, caller, callee, owner, callid=None, db=None):
okhin's avatar
okhin committed
220 221 222 223
        try:
            self.caller = caller
            self.callee = callee
            self.owner = owner
224
            if callid == None:
225
                self.id = str(uuid.uuid4())
226 227
            else:
                self.id = callid
okhin's avatar
okhin committed
228
            self.db = db
okhin's avatar
okhin committed
229
        except Exception as e:
230
            phone_logger.exception(e)
okhin's avatar
okhin committed
231 232
            raise e

okhin's avatar
okhin committed
233
    @property
okhin's avatar
okhin committed
234 235 236
    def url(self):
        return ''.join(['/calls/', self.id])

okhin's avatar
okhin committed
237
    @property
238 239 240 241
    def state(self):
        sort = sorted(self.history, reverse=True, key=itemgetter(1))
        return sort[0][0]

okhin's avatar
okhin committed
242 243
    @classmethod
    def load(cls, callid, db):
okhin's avatar
okhin committed
244
        phone_logger.debug("Loading call {} from db {}".format(callid, db,))
okhin's avatar
okhin committed
245
        try:
246
            results = db.execute('SELECT caller, callee, owner, callid, history FROM calls WHERE callid = ?;', (callid,))
okhin's avatar
okhin committed
247
            result = results.fetchone()
248
            assert len(result) == 5
okhin's avatar
okhin committed
249
            object = cls(result[0], result[1], result[2], result[3], db=db)
okhin's avatar
okhin committed
250 251
            object.history = json.loads(result[4])
            return object
252
        except Exception as e:
253
            phone_logger.exception(e)
254
            raise e
okhin's avatar
okhin committed
255

256
    def update(self, new_state):
257
        '''
258
        Let's update the state of the call. new_state is a tuple in the form (newstate, timestamp,)
259
        '''
260
        phone_logger.debug("Got a new state: {}".format(new_state,))
261 262
        self.history.append(new_state)
        self.save()
263 264 265 266 267 268

    def event_handler(self, event):
        '''
        There's a new event related to our call
        '''
        state = event['type']
269
        if state in self.actions:
270 271 272 273
            try:
                getattr(self, self.actions[state])(event=event)
            except Exception as e:
                raise e
274

275 276 277 278 279 280 281 282
    def hangup(self, event):
        '''
        There's a call which has lost connection. Probably someone who hanged up the call.
        We need to check if the other side is still up, which can be done by checking
        the bridge item (it is in the channels part of the bridge object) and see if there's
        more than one channel.
        If there's more than one, then we need to send a hangup to the other side and then delete
        our channel, if not we need to delete ourselves and then delete the bridge.
283
        We might also be in a case where no channel has been created … or we're still on moh.
284 285
        '''
        bridge_id = '-'.join(event['channel']['id'].split('-')[:-1])
286 287 288
        self.update((':'.join([event['channel']['state']
            , event['channel']['id'].split('-')[-1]])
            , event['timestamp']))
289 290
        try:
            bridge = ari.Bridge(config['asterisk'], bridge_id, 'mixed')
291
            results = json.loads(bridge.status())
292 293 294 295 296 297
        except:
            # Not in a bridge yet. Our channel has been destroyed.
            # That or we're in moh.
            phone_logger.info('Channel destroyed {}'.format(event['channel']['id']))
            return

298 299 300 301 302 303 304 305 306 307 308
        if len(results['channels']) == 0:
            # We were the last channel standing
            phone_logger.info('Deleting bridge {}'.format(bridge_id))
            bridge.delete()
        else:
            # There's at least one channel standing
            for channel in results['channels']:
                chan = ari.Channel(config['asterisk'], channel)
                phone_logger.info('Hanging up channel {}'.format(channel))
                chan.delete()

309 310 311 312 313
    def dtmf(self, event):
        '''
        We received a DTMF sequence
        '''
        try:
okhin's avatar
okhin committed
314
            assert self.state.startswith('Up')
315
            # The only thing we want to do is to call the callee if we press 1
316 317 318
            if event['digit'] != '1':
                return
            # Now, we're connectig the other side
319
            # We need to originate a call to the other side
320
            phone_logger.info('Will now connect {} to {}'.format(self.caller, self.callee,))
321
            endpoint = 'SIP/' + sanitize_phonenumber(self.callee) + '@' + config['asterisk']['sip-context']
okhin's avatar
okhin committed
322
            channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.callee))
323
            channel.originate(endpoint)
324
        except AssertionError as e:
okhin's avatar
okhin committed
325
            phone_logger.error("Received a DTMF sequence out le being in  a '{}' state, ignoring: {}".format(self.state, event['digit']))
326 327 328 329
            raise e
        except ValueError as e:
            phone_logger.error("Incorrect number: {}".format(e.message,))
            raise e
330

331 332 333 334
    def change(self, event):
        '''
        Let's change the state of the call
        '''
okhin's avatar
okhin committed
335
        # First we need to check if it's really a change ie, if the new state is not the previous one
336
        self.update((':'.join([event['channel']['state']
okhin's avatar
okhin committed
337
            , event['channel']['id'].split('-')[-1]])
338
            , event['timestamp'],))
339
        phone_logger.info("New state for call {}: {}".format(event['channel']['id'], event['channel']['state']))
340 341
        # We now need to take action according to our new state
        if event['channel']['state'] == 'Up':
342
            # Are we the caller or the callee?
343
            if event['channel']['id'].endswith(sanitize_phonenumber(self.callee)):
344
                # We are the callee
okhin's avatar
okhin committed
345
                # Step 1 create a bridge
okhin's avatar
okhin committed
346
                bridge = ari.Bridge(config['asterisk'], self.id, 'mixing')
347
                phone_logger.debug("Creating a bridges to connect {} to {}".format(self.caller, self.callee,))
348 349 350 351
                try:
                    bridge.create()
                except Exception as e:
                    raise e
352 353 354
                # Step 2, stop playing moh to the channel
                channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.caller))
                channel.stopMoh()
355
                # Step 3, connect everyone
okhin's avatar
okhin committed
356
                channels = ",".join([self.id + '-' + sanitize_phonenumber(self.caller), event['channel']['id']])
357
                phone_logger.debug("Moving channels ({}) to the created bridge: {}".format(channels, bridge.name,))
358
                bridge.addChannel(channels)
359
                phone_logger.info("Call now fully connected: {} <-> {}".format(self.caller, self.callee))
360
                return
361 362
            # Call is being picked up, we want to play a song
            try:
okhin's avatar
okhin committed
363
                channel = ari.Channel(config['asterisk'], event['channel']['id'])
364
                channel.startMoh(config['moh']['class'])
365
            except Exception as e:
366
                phone_logger.exception(e)
367
                raise e
368 369

    def call_caller(self, event):
370
        '''
okhin's avatar
okhin committed
371 372
        Let's call the caller. It's a simple originate. We will also check if the MoH bridge is ready,
        because it will be used to store people in it waiting for the callee to pick up the phone.
373

okhin's avatar
okhin committed
374
        The bridge needed to connect the calls together will be created later.
375
        '''
376
        self.update((':'.join([event['type']
okhin's avatar
okhin committed
377
            , event['channel']['id'].split('-')[-1]])
378
            , event['timestamp'],))
379
        # Now, let's create the channel
380
        try:
okhin's avatar
okhin committed
381 382
            endpoint = 'SIP/' + sanitize_phonenumber(self.caller) + '@' + config['asterisk']['sip-context']
            channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.caller))
okhin's avatar
okhin committed
383
            channel.originate(endpoint)
384
        except Exception as e:
385
            phone_logger.exception(e)
386
            raise e
387 388

    def save(self):
389 390 391
        '''
        Save the Call to database.
        '''
392
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
393 394 395 396 397 398
        try:
            self.db.execute('''INSERT OR REPLACE INTO calls (caller, callee, owner, callid, history)
                VALUES (?, ?, ?, ?, ?) '''
                , (self.caller, self.callee, self.owner, self.id, json.dumps(self.history)))
            self.db.commit()
        except Exception as e:
okhin's avatar
okhin committed
399
            bottle_logger.exception(e)
400
            raise e
401

okhin's avatar
okhin committed
402
def start(db):
403 404
    global running
    running = True
405
    threads.submit(app.run, server='paste')
okhin's avatar
okhin committed
406
    loop.run_until_complete(listen(db))
407

408 409
def stop():
    global running
410
    running = False
411 412 413
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
414
    sys.exit(0)
okhin's avatar
okhin committed
415

416
@app.get('/calls/<callid>')
417
@app.get('/calls/')
418
@authenticated
419
@connected
420
def calls(db, callid=None):
okhin's avatar
okhin committed
421 422 423 424
    """
    Return the list of calls associated to the connected user.
    The call has a status, caller, callee and history (status change+timestamp)
    """
425
    bottle_logger.debug("GET {}".format(request.fullpath))
426 427 428 429 430 431
    if callid == None:
        try:
            results = db.execute('SELECT callid FROM calls WHERE owner = ?;', (request.params['api'],))
            calls = []
            for row in results.fetchall():
                call = Call.load(row[0], db)
432
                bottle_logger.debug("Call fetched: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
433 434 435 436
                calls.append(call)
            head = {'call': request.fullpath, 'user': request.params['api'], 'hits': len(calls)}
            return {'head': head, 'data': calls}
        except Exception as e:
437
            bottle_logger.exception(e)
438 439 440 441 442 443
            abort(500, "Exception")
    # We first need to check if we can access the callid we asked for
    try:
        results = db.execute('SELECT callid FROM calls WHERE owner = ? AND callid = ? ;'
                , (request.params['api'], callid,))
        rows = results.fetchall()
444
        bottle_logger.debug("Found {} results: {}".format(len(rows), rows))
445 446
        assert len(rows) == 1
        call = Call.load(callid, db)
okhin's avatar
okhin committed
447
        head = {'call': call.url, 'user': request.params['api'], 'hits': 1}
448 449
        return {'head': head, 'data': call}
    except AssertionError as e:
450 451
        bottle_logger.debug("Not exactly one results found, this is an issue")
        bottle_logger.error("Unauthorized access to call {} from user {}".format(callid, request.params['api']))
452 453
        abort(403, "You do not have the authorization to get this call")
    except Exception as e:
454 455
        bottle_logger.debug("Exception catched: {}".format(e,))
        bottle_logger.error("Call not found {}".format(callid,))
456 457 458 459
        abort(404, "This call does not exist")

@app.post('/calls/')
@app.post('/calls/<callid>')
460
@authenticated
461
@connected
462
def originate(db, callid=None):
463
    bottle_logger.debug("POST {}".format(request.fullpath))
464 465
    try:
        if callid is not None:
okhin's avatar
okhin committed
466
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], callid=callid, db=db)
467 468
        else:
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], db=db)
469
        bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
470
        call.event_handler({'type': 'Created', 'timestamp': datetime.datetime.now().isoformat(), 'channel': {'id': 'Init'}})
okhin's avatar
okhin committed
471 472
        call.save()
        head = {'call': call.url
473 474
            , 'user': request.params['api']
            , 'hits': 1}
475
        return {'header': head, 'data': call}
476 477 478
    except ValueError as e:
        bottle_logger.exception(e)
        abort(403, "Your phone number isnt' authorized to use the piphone")
479
    except Exception as e:
480 481
        bottle_logger.debug("Missing params : {}".format([p for p in request.params],))
        bottle_logger.exception(e)
482
        abort(400, "Missing or incorrect fields, the call cannot be processed")
483

484 485 486 487 488 489 490 491
@app.get('/static/<filepath:path>')
def static_files(filepath):
    """
    take care of static files.
    should use apache/nginx instead.
    """
    return static_file(filepath, root='./views')

okhin's avatar
okhin committed
492
def login_admin(user, password):
493
    db = sqlite3.connect(config['piphone']['db'])
494
    user = db.execute('SELECT api, token, admin FROM users where api = ?', (user,))
495
    user = user.fetchone()
okhin's avatar
okhin committed
496 497 498
    if user is None:
        # user does not exist
        return False
499
    if hashlib.sha256(password.encode()).hexdigest() != user[1]:
okhin's avatar
okhin committed
500 501 502 503 504 505 506 507
        # password does not match
        return False
    if user[2] == 0:
        # User is not admin
        return False
    return True


508
@app.get('/admin')
okhin's avatar
okhin committed
509
@auth_basic(login_admin)
510 511
def little_admin():
    db = sqlite3.connect(config['piphone']['db'])
512
    # Get the list of all users
513
    users = db.execute('SELECT api, token, admin FROM users').fetchall()
514
    # Get the list of all blacklist patterns
okhin's avatar
okhin committed
515
    blacklisted = db.execute('SELECT pattern, reason FROM blacklist').fetchall()
516
    return template('index', users=users, blacklists=blacklisted)
517 518

@app.post('/admin')
okhin's avatar
okhin committed
519
@auth_basic(login_admin)
520 521
def medium_admin():
    db = sqlite3.connect(config['piphone']['db'])
522
    api = request.forms.get('api')
523
    token = hashlib.sha256(request.forms.get('api_token').encode()).hexdigest()
524 525
    admin = request.forms.get('admin')
    action = request.forms.get('action')
526 527
    pattern = request.forms.get('pattern')
    reason = request.forms.get('reason')
528 529 530 531 532 533 534 535 536 537

    if action == 'delete':
        db.execute("DELETE FROM users WHERE api = ?", (api, ))
        db.commit()
    elif action == 'add':
        db.execute("INSERT INTO users (api, token, admin) VALUES (?, ?, ?)", (api, token, admin))
        db.commit()
    elif action == 'update':
        db.execute("UPDATE users set token = ?, admin = ? where api = ?", (token, admin, api ))
        db.commit()
538 539 540 541 542 543
    elif action == 'blacklist':
        db.execute("INSERT INTO blacklist (pattern, reason) VALUES (?, ?)", (pattern, reason,))
        db.commit()
    elif action == 'whitelist':
        db.execute("DELETE FROM blacklist WHERE pattern = ?", (pattern,))
        db.commit()
544 545

    users = db.execute('SELECT api, token, admin FROM users').fetchall()
okhin's avatar
okhin committed
546
    blacklisted = db.execute('SELECT pattern, reason FROM blacklist').fetchall()
547
    return template('index', users=users, blacklists=blacklisted)
548 549


550
if __name__ == '__main__':
551 552
    db = sqlite3.connect(config['piphone']['db'])
    phone_logger.info("Starting the piphone SIP backend")
553
    try:
okhin's avatar
okhin committed
554
        start(db)
555
    except (KeyboardInterrupt, SystemExit):
556
        stop()