# Written by Arno Bakker
# see LICENSE.txt for license information
#
# The dialback-message extension serves to (1)~see if we're externally reachable
# and (2)~to tell us what our external IP adress is. When an overlay connection
# is made when we're in dialback mode, we will send a DIALBACK_REQUEST message
# over the overlay connection. The peer is then support to initiate a new
# BT connection with infohash 0x00 0x00 ... 0x01 and send a DIALBACK_REPLY over
# that connection. Those connections are referred to as ReturnConnections
#
# TODO: security problem: if malicious peer connects 7 times to us and tells
# 7 times the same bad external iP, we believe him. Sol: only use locally 
# initiated conns + IP address check (BC2 message could be used to attack
# still)
#

import sys
from time import time
from random import shuffle
from traceback import print_exc

from BitTornado.BT1.MessageID import *
from BitTornado.bencode import bencode,bdecode

from Tribler.CacheDB.CacheDBHandler import MyDBHandler,PeerDBHandler, SuperPeerDBHandler
from Tribler.NATFirewall.ReturnConnHandler import ReturnConnHandler
from Tribler.Overlay.SecureOverlay import OLPROTO_VER_THIRD
from Tribler.utilities import *
from Tribler.Dialogs.activities import *

DEBUG = False

#
# Constants
# 

REPLY_WAIT = 60 # seconds
REPLY_VALIDITY = 2*24*3600.0    # seconds

# Normally, one would allow just one majority to possibly exists. However,
# as current Buddycast has a lot of stale peer addresses, let's make
# PEERS_TO_ASK not 5 but 7.
#
PEERS_TO_AGREE = 4   # peers have to say X is my IP before I believe them
PEERS_TO_ASK   = 7   # maximum number of outstanding requests 
MAX_TRIES      = 35  # 5 times 7 peers

class DialbackMsgHandler:
    
    __single = None
    
    def __init__(self):
        if DialbackMsgHandler.__single:
            raise RuntimeError, "DialbackMsgHandler is singleton"
        DialbackMsgHandler.__single = self

        self.peers_asked = {}
        self.myips = []
        self.my_db = MyDBHandler()
        self.peer_db = PeerDBHandler()
        self.superpeer_db = SuperPeerDBHandler()
        self.consensusip = None # IP address according to peers
        self.upnpip = None        # IP address as found by UPnP
        self.fromsuperpeer = False
        self.dbreach = False    # Did I get any DIALBACK_REPLY?
        self.btenginereach = False # Did BT engine get incoming connections?
        self.secoverreach = False # Did SecureOverlay get incoming connections?
        self.ntries = 0        
        self.active = False     # Need defaults for test code
        self.rawserver = None
        self.returnconnhand = ReturnConnHandler.getInstance()

    def getInstance(*args, **kw):
        if DialbackMsgHandler.__single is None:
            DialbackMsgHandler(*args, **kw)
        return DialbackMsgHandler.__single
    getInstance = staticmethod(getInstance)
        
    def register(self,secure_overlay,rawserver,launchmany,multihandler,mylistenport, max_msg_len,active,trust_superpeers,interval):
        self.secure_overlay = secure_overlay
        self.rawserver = rawserver
        self.launchmany = launchmany
        self.active = active
        self.trust_superpeers = trust_superpeers
        self.interval = interval
        self.returnconnhand.register(rawserver,multihandler,mylistenport,max_msg_len)
        self.returnconnhand.register_conns_callback(self.handleReturnConnConnection)
        self.returnconnhand.register_recv_callback(self.handleReturnConnMessage)
        self.returnconnhand.start_listening()

    #
    # Called from OverlayApps to signal there is an overlay-connection,
    # see if we should ask it to dialback
    #
    def handleSecOverlayConnection(self,exc,permid,selversion,locally_initiated):
        
        if DEBUG:
            print >> sys.stderr,"dialback: handleConnection",exc,"v",selversion,"local",locally_initiated
        if selversion < OLPROTO_VER_THIRD:
            return True
        
        if exc is not None:
            try:
                del self.peers_asked[permid]
            except:
                if DEBUG:
                    print >> sys.stderr,"dialback: handleConnection: Got error on connection that we didn't ask for dialback"
                pass
            return
        
        if not locally_initiated:
            # Arno: this means we're externally reachable    
            self.secover_says_reachable()

        if self.consensusip is None:
            self.ntries += 1
            if self.ntries >= MAX_TRIES:
                if DEBUG:
                    print >> sys.stderr,"dialback: tried too many times, giving up"
                return True
            
            if self.dbreach or self.btenginereach or self.secoverreach:
                self.launchmany.set_activity(ACT_GET_EXT_IP_FROM_PEERS)
            else:
                self.launchmany.set_activity(ACT_REACHABLE)

            # Also do this when the connection is not locally initiated.
            # That tells us that we're connectable, but it doesn't tell us
            # our external IP address.
            self.attempt_request_dialback(permid)
        return True
            
    def attempt_request_dialback(self,permid):
        if DEBUG:
            print >> sys.stderr,"dialback: attempt dialback request",show_permid_short(permid)
                    
        dns = self.get_dns_from_peerdb(permid)
        ipinuse = False

        # 1. Remove peers we asked but didn't succeed in connecting back 
        threshold = time()-REPLY_WAIT
        newdict = {}
        for permid2,peerrec in self.peers_asked.iteritems():
            if peerrec['reqtime'] >= threshold:
                newdict[permid2] = peerrec
            if peerrec['dns'][0] == dns[0]:
                ipinuse = True
        self.peers_asked = newdict

        # 2. Already asked?
        if permid in self.peers_asked or ipinuse or len(self.peers_asked) >= PEERS_TO_ASK:
            # ipinuse protects a little against attacker that want us to believe
            # we have a certain IP address.
            if DEBUG:
                pipa = permid in self.peers_asked
                lpa = len(self.peers_asked)
                print >> sys.stderr,"dialback: No request made to",show_permid_short(permid),"already asked",pipa,"IP in use",ipinuse,"nasked",lpa

            return
        dns = self.get_dns_from_peerdb(permid)
        
        # 3. Ask him to dialback
        peerrec = {'dns':dns,'reqtime':time()}
        self.peers_asked[permid] = peerrec
        self.secure_overlay.connect(permid,self.request_connect_callback)
    
    def request_connect_callback(self,exc,dns,permid,selversion):
        if exc is None:
            if selversion >= OLPROTO_VER_THIRD:
                self.secure_overlay.send(permid, DIALBACK_REQUEST+'',self.request_send_callback)
            elif DEBUG:
                print >> sys.stderr,"dialback: DIALBACK_REQUEST: peer speaks old protocol, weird",show_permid_short(permid)
        elif DEBUG:
            print >> sys.stderr,"dialback: DIALBACK_REQUEST: error connecting to",show_permid_short(permid),exc


    def request_send_callback(self,exc,permid):
        if exc is not None:
            if DEBUG:
                print >> sys.stderr,"dialback: DIALBACK_REQUEST error sending to",show_permid_short(permid),exc
            pass

    #
    # Handle incoming DIALBACK_REQUEST messages
    #
    def handleSecOverlayMessage(self,permid,selversion,message):
        
        t = message[0]
        
        if t == DIALBACK_REQUEST:
            if DEBUG:
                print >> sys.stderr,"dialback: Got DIALBACK_REQUEST",len(message),show_permid_short(permid)
            return self.process_dialback_request(permid, message, selversion)
        else:
            if DEBUG:
                print >> sys.stderr,"dialback: UNKNOWN OVERLAY MESSAGE", ord(t)
            return False


    def process_dialback_request(self,permid,message,selversion):
        # 1. Check
        if len(message) != 1:
            if DEBUG:
                print >> sys.stderr,"dialback: DIALBACK_REQUEST: message too big"
            return False

        # 2. Retrieve peer's IP address
        dns = self.get_dns_from_peerdb(permid)

        # 3. Send back reply
        self.returnconnhand.connect_dns(dns,self.returnconn_reply_connect_callback)

        # 4. Message processed OK, don't know about sending of reply though
        return True

    def returnconn_reply_connect_callback(self,exc,dns):
        if exc is None:
            hisip = dns[0]
            try:
                reply = bencode(hisip)
                if DEBUG:
                    print >> sys.stderr,"dialback: DIALBACK_REPLY: sending to",dns
                self.returnconnhand.send(dns, DIALBACK_REPLY+reply, self.returnconn_reply_send_callback)
            except:
                print_exc(file=sys.stderr)
                return False
        elif DEBUG:
            print >> sys.stderr,"dialback: DIALBACK_REPLY: error connecting to",dns,exc

    def returnconn_reply_send_callback(self,exc,dns):
        
        if DEBUG:
            print >> sys.stderr,"dialback: DIALBACK_REPLY: send callback:",dns,exc

        
        if exc is not None:
            if DEBUG:
                print >> sys.stderr,"dialback: DIALBACK_REPLY: error sending to",dns,exc
            pass

    #
    # Receipt of connection that would carry DIALBACK_REPLY 
    #
    def handleReturnConnConnection(self,exc,dns,locally_initiated):
        if DEBUG:
            print >> sys.stderr,"dialback: DIALBACK_REPLY: Got connection from",dns,exc
        pass

    def handleReturnConnMessage(self,dns,message):
        
        t = message[0]
        
        if t == DIALBACK_REPLY:
            if DEBUG:
                print >> sys.stderr,"dialback: Got DIALBACK_REPLY",len(message),dns
            return self.process_dialback_reply(dns, message)
        else:
            if DEBUG:
                print >> sys.stderr,"dialback: UNKNOWN RETURNCONN MESSAGE", ord(t)
            return False


    def process_dialback_reply(self,dns,message):
        
        # 1. Yes, we're reachable, now just matter of determining ext IP
        self.dbreach = True
        
        # 2. Authentication: did I ask this peer?
        permid = self.permid_of_asked_peer(dns)
        if permid is None:
            if DEBUG:
                print >> sys.stderr,"dialback: DIALBACK_REPLY: Got reply from peer I didn't ask",dns
            return False

        del self.peers_asked[permid]

        # 3. See what he sent us
        try:
            myip = bdecode(message[1:])
        except:
            print_exc(file=sys.stderr)
            if DEBUG:
                print >> sys.stderr,"dialback: DIALBACK_REPLY: error becoding"
            return False
        if not isValidIP(myip):
            if DEBUG:
                print >> sys.stderr,"dialback: DIALBACK_REPLY: invalid IP"
            return False


        # 4. See if superpeer, then we're done, trusted source 
        if self.trust_superpeers:
            superpeers = self.superpeer_db.getSuperPeerList()
            if permid in superpeers:
                if DEBUG:
                    print >> sys.stderr,"dialback: DIALBACK_REPLY: superpeer said my IP address is",myip,"setting it to that"
                self.consensusip = myip
                self.fromsuperpeer = True
        else:
            # 5. Ordinary peer, just add his opinion
            self.myips.append([myip,time()])
            if DEBUG:
                print >> sys.stderr,"dialback: DIALBACK_REPLY: peer said I have IP address",myip
    
            # 6. Remove stale opinions
            newlist = []
            threshold = time()-REPLY_VALIDITY
            for pair in self.myips:
                if pair[1] >= threshold:
                    newlist.append(pair)
            self.myips = newlist
            
            # 7. See if we have X peers that agree
            opinions = {}
            for pair in self.myips:
                ip = pair[0]
                if not (ip in opinions):
                    opinions[ip] = 1
                else:
                    opinions[ip] += 1
    
            for o in opinions:
                if opinions[o] >= PEERS_TO_AGREE:
                    # We have a quorum
                    if self.consensusip is None:
                        self.consensusip = o
                        if DEBUG:
                            print >> sys.stderr,"dialback: DIALBACK_REPLY: Got consensus on my IP address being",self.consensusip
                    else:
                        # Hmmmm... more than one consensus
                        pass

        # 8. Change IP address if different
        if self.consensusip is not None:
            old_ext_ip = self.my_db.getMyIP()
            if old_ext_ip != self.consensusip:
                if DEBUG:
                    print >> sys.stderr,"dialback: DIALBACK_REPLY: I think my IP address is",old_ext_ip,"others say",self.consensusip,", setting it to latter"
                self.my_db.put('ip',self.consensusip)

        # 9. Notify GUI that we are connectable
        self.launchmany.reachable_network_callback()

        # 10. We're done and no longer need the return connection, so
        # call close explicitly
        self.returnconnhand.close(dns)
        return True


    #
    # Information from other modules
    #

    def btengine_says_reachable(self):
        """ Called by GUI thread! """
        if self.rawserver is not None:
            self.rawserver.add_task(self.btengine_network_callback,0)
    
    def btengine_network_callback(self):
        """ Called by network thread """
        self.launchmany.reachable_network_callback()
        self.btenginereach = True

    def secover_says_reachable(self):
        """ Called by GUI thread! """
        if self.rawserver is not None:
            self.rawserver.add_task(self.secover_network_callback,0)
    
    def secover_network_callback(self):
        """ Called by network thread """
        self.launchmany.reachable_network_callback()
        self.secoverreach = True

    def isConnectable(self):
        """ Called by network thread """
        return self.dbreach or self.btenginereach or self.secoverreach

    def upnp_got_ext_ip(self,ip):
        self.upnpip = ip
        

    #
    # Internal methods
    #
    def get_dns_from_peerdb(self,permid):
        dns = None
        peer = self.peer_db.getPeer(permid)
        if peer:
            ip = self.to_real_ip(peer['ip'])
            dns = (ip, int(peer['port']))
        return dns

    def to_real_ip(self,hostname_or_ip):
        """ If it's a hostname convert it to IP address first """
        ip = None
        try:
            ip = gethostbyname(hostname_or_ip)
        except:
            pass
        return ip

        
    def permid_of_asked_peer(self,dns):
        for permid,peerrec in self.peers_asked.iteritems():
            if peerrec['dns'] == dns:
                # Yes, we asked this peer
                return permid
        return None

