piphone.py 21.3 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
from operator import itemgetter
16 17

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

import ari
23

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

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

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

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

54 55 56 57
running = False
ws = None

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

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
79
                , 'url': obj.url
80 81 82
                , 'history': obj.history
                , 'owner': obj.owner }
        else:
83
            return json.JSONEncoder.default(self, obj)
84

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 111 112 113 114 115 116 117
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)
            assert auth_token['api'] == request.params['api']
            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
118
            bottle_logger.exception(e)
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
            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
136 137 138 139 140 141 142 143 144 145 146 147
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')
148 149 150 151

    # We're checking if we're blacklisted
    db = sqlite3.connect(config['piphone']['db'])
    bl_re = None
152
    for blacklist in db.execute('SELECT pattern, reason FROM blacklist').fetchall():
153 154 155 156 157 158 159
        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)
160
        if groups is not None:
161
            # We matched, so we're blacklisted
162 163
            groupdict = groups.groupdict()
            raise ValueError('{} is blacklisted. Reason: {}', (number, list(groupdict)[0],))
okhin's avatar
okhin committed
164 165
    return number

okhin's avatar
okhin committed
166
@asyncio.coroutine
okhin's avatar
okhin committed
167
def listen(db):
168 169 170 171 172
    '''
    Start listening on the websocket
    '''
    global running
    global ws
173
    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
174
    ws = yield from websockets.connect(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(
okhin's avatar
okhin committed
175
        config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password']))
176 177 178
    ws_logger.debug('Websocket connected: {}'.format(type(ws)))
    while running == True:
        try:
okhin's avatar
okhin committed
179
            event = yield from ws.recv()
180
            # Let's call the applications function
okhin's avatar
okhin committed
181
            yield from dispatch(json.loads(event), db)
182 183 184 185 186 187 188 189 190 191 192
        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
193
@asyncio.coroutine
okhin's avatar
okhin committed
194
def dispatch(event, db):
195 196 197 198 199 200 201 202 203
    """
    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
204
    call = Call.load(call_id, db)
205 206
    call.event_handler(event)

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

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

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

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

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

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

    def event_handler(self, event):
        '''
        There's a new event related to our call
        '''
        state = event['type']
268
        if state in self.actions:
269
            getattr(self, self.actions[state])(event=event)
270

271 272 273 274 275 276 277 278
    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.
279
        We might also be in a case where no channel has been created … or we're still on moh.
280 281
        '''
        bridge_id = '-'.join(event['channel']['id'].split('-')[:-1])
282 283 284
        self.update((':'.join([event['channel']['state']
            , event['channel']['id'].split('-')[-1]])
            , event['timestamp']))
285 286
        try:
            bridge = ari.Bridge(config['asterisk'], bridge_id, 'mixed')
287
            results = json.loads(bridge.status())
288 289 290 291 292 293
        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

294 295 296 297 298 299 300 301 302 303 304
        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()

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

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

    def call_caller(self, event):
362
        '''
okhin's avatar
okhin committed
363 364
        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.
365

okhin's avatar
okhin committed
366
        The bridge needed to connect the calls together will be created later.
367
        '''
368
        self.update((':'.join([event['type']
okhin's avatar
okhin committed
369
            , event['channel']['id'].split('-')[-1]])
370
            , event['timestamp'],))
371
        # Now, let's create the channel
372
        try:
okhin's avatar
okhin committed
373 374
            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
375
            channel.originate(endpoint)
376
        except Exception as e:
377
            phone_logger.exception(e)
378
            raise e
379 380

    def save(self):
381 382 383
        '''
        Save the Call to database.
        '''
384
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
385 386 387 388 389 390
        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
391
            bottle_logger.exception(e)
392
            phone_logger.exception(e)
393
            raise e
394

okhin's avatar
okhin committed
395
def start(db):
396 397
    global running
    running = True
398
    threads.submit(app.run, server='paste')
okhin's avatar
okhin committed
399
    loop.run_until_complete(listen(db))
400

401 402
def stop():
    global running
403
    running = False
404 405 406
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
407
    sys.exit(0)
okhin's avatar
okhin committed
408

409
@app.get('/calls/<callid>')
410
@app.get('/calls/')
411
@authenticated
412
@connected
413
def calls(db, callid=None):
okhin's avatar
okhin committed
414 415 416 417
    """
    Return the list of calls associated to the connected user.
    The call has a status, caller, callee and history (status change+timestamp)
    """
418
    bottle_logger.debug("GET {}".format(request.fullpath))
419 420 421 422 423 424
    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)
425
                bottle_logger.debug("Call fetched: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
426 427 428 429
                calls.append(call)
            head = {'call': request.fullpath, 'user': request.params['api'], 'hits': len(calls)}
            return {'head': head, 'data': calls}
        except Exception as e:
430
            bottle_logger.exception(e)
431 432 433 434 435 436
            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()
437
        bottle_logger.debug("Found {} results: {}".format(len(rows), rows))
438 439
        assert len(rows) == 1
        call = Call.load(callid, db)
okhin's avatar
okhin committed
440
        head = {'call': call.url, 'user': request.params['api'], 'hits': 1}
441 442
        return {'head': head, 'data': call}
    except AssertionError as e:
443 444
        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']))
445 446
        abort(403, "You do not have the authorization to get this call")
    except Exception as e:
447 448
        bottle_logger.debug("Exception catched: {}".format(e,))
        bottle_logger.error("Call not found {}".format(callid,))
449 450 451 452
        abort(404, "This call does not exist")

@app.post('/calls/')
@app.post('/calls/<callid>')
453
@authenticated
454
@connected
455
def originate(db, callid=None):
456
    bottle_logger.debug("POST {}".format(request.fullpath))
457 458
    try:
        if callid is not None:
okhin's avatar
okhin committed
459
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], callid=callid, db=db)
460 461
        else:
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], db=db)
462
        bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
463
        call.event_handler({'type': 'Created', 'timestamp': datetime.datetime.now().isoformat(), 'channel': {'id': 'Init'}})
okhin's avatar
okhin committed
464 465
        call.save()
        head = {'call': call.url
466 467
            , 'user': request.params['api']
            , 'hits': 1}
468 469
        return {'header': head, 'data': call}
    except Exception as e:
470 471
        bottle_logger.debug("Missing params : {}".format([p for p in request.params],))
        bottle_logger.exception(e)
472
        abort(400, "Missing or incorrect fields, the call cannot be processed")
473

474 475 476 477 478 479 480 481
@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
482
def login_admin(user, password):
483
    db = sqlite3.connect(config['piphone']['db'])
484
    user = db.execute('SELECT api, token, admin FROM users where api = ?', (user,))
485
    user = user.fetchone()
okhin's avatar
okhin committed
486 487 488 489 490 491 492 493 494 495 496 497
    if user is None:
        # user does not exist
        return False
    if password != user[1]:
        # password does not match
        return False
    if user[2] == 0:
        # User is not admin
        return False
    return True


498
@app.get('/admin')
okhin's avatar
okhin committed
499
@auth_basic(login_admin)
500 501
def little_admin():
    db = sqlite3.connect(config['piphone']['db'])
502
    # Get the list of all users
503
    users = db.execute('SELECT api, token, admin FROM users').fetchall()
504
    # Get the list of all blacklist patterns
okhin's avatar
okhin committed
505
    blacklisted = db.execute('SELECT pattern, reason FROM blacklist').fetchall()
506
    return template('index', users=users, blacklists=blacklisted)
507 508

@app.post('/admin')
okhin's avatar
okhin committed
509
@auth_basic(login_admin)
510 511
def medium_admin():
    db = sqlite3.connect(config['piphone']['db'])
512
    api = request.forms.get('api')
513
    token = request.forms.get('api_token')
514 515
    admin = request.forms.get('admin')
    action = request.forms.get('action')
516 517
    pattern = request.forms.get('pattern')
    reason = request.forms.get('reason')
518 519 520 521 522 523 524 525 526 527

    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()
528 529 530 531 532 533
    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()
534 535

    users = db.execute('SELECT api, token, admin FROM users').fetchall()
okhin's avatar
okhin committed
536
    blacklisted = db.execute('SELECT pattern, reason FROM blacklist').fetchall()
537
    return template('index', users=users, blacklists=blacklisted)
538 539


540
if __name__ == '__main__':
541 542
    db = sqlite3.connect(config['piphone']['db'])
    phone_logger.info("Starting the piphone SIP backend")
543
    try:
okhin's avatar
okhin committed
544
        start(db)
545
    except (KeyboardInterrupt, SystemExit):
546
        stop()