#!/usr/bin/env python2
"""
dynamicEndpoints.py: make cjdns reliably connect to remote nodes with dynamic IP
addresses, identified by a DNS name.

Requires a config file with a section for each dynamic-IP node, like this:

    [lhjs0njqtvh1z4p2922bbyp2mksmyzf5lb63kvs3ppy78y1dj130.k]
    hostname: verge.info.tm
    port: 6324
    password: ns6vn00hw0buhrtc4wbk8sv230

The section name (in square brackets) is the public key of the node. Then the
hostname, port, and peering password for the node are given.

By default, this program looks up the current Internet IP of each node defined
in the config file, and add that node at that IP to the local cjdns instance.
Unless the --noWait option is given, or the $nowait environment variable is
true, the program then continues running, waiting for cjdns to log messages
about those peers being unresponsive and updating the peers' Internet IP
addresses as needed.

If cjdns dies while the program is monitoring for messages, the program will
hang indefinitely.

Requires that the $HOME/.cjdnsadmin file be correctly set up. See
cjdnsadminmaker.py if that is not the case.

"""

# You may redistribute this program and/or modify it under the terms of
# the GNU General Public License as published by the Free Software Foundation,
# either version 3 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

from cjdnsadmin.cjdnsadmin import connectWithAdminInfo;
from cjdnsadmin.publicToIp6 import PublicToIp6_convert;
from cjdnsadmin.bencode import *
import sys
import socket, re
import select
import time
import os
import pwd
import grp
import logging
import argparse
import atexit
import ConfigParser

# This holds a regex that matches the message we get from the roiuter when it
# sees an unresponsive peer.
IS_UNRESPONSIVE = re.compile(
    "Pinging unresponsive peer \\[(.*\\.k)\\] lag \\[.*\\]")

# Make sure that it works
assert(IS_UNRESPONSIVE.match("Pinging unresponsive peer " +
    "[6fmmn3qurcjg6d8hplq1rrcsspfhvm1900s13f3p5bv2bb4f4mm0.k] lag [207147]"))

# What file do these messages come from? TODO: don't depend so tightly on the
# other end of the codebase. Use the API to watch peers.
MESSAGE_FILE = "InterfaceController.c"
MESSAGE_LINE = 0 # All lines


class Node(object):
    """
    Represents a remote peer. A remoter peer has:

        - A hostname to repeatedly look up
        - A port to connect to
        - A password to connect with
        - A public key to authenticate the remote peer with
        - A last known Internet IP address.

    """
    __slots__ = ("host","port","password","key","lastAddr")
    def __init__(self,host,port,password,key):
        self.host = host
        self.port = port
        self.password = password
        self.key = key
        self.lastAddr = None

class DynamicEndpointWatcher(object):
    """
    Encapsulates all the stuff we need to actually keep an eye on our remote
    nodes and see if they change IPs. When a node with a dynamic IP is
    unresponsive, we look up its IP address and tell cjdns to go connect to it.
    """

    def __init__(self, cjdns, configuration):
        """
        Set up a new DynamicEndpointWatcher operating on the given CJDNS admin
        connection, using the specified ConfigParser parsed configuration.

        """

        # Keep the cjdns admin connection
        self.cjdns = cjdns

        # Holds a dict from public key string to Node object for the remote
        # peer, for all known nodes.
        self.nodes = dict()

        # Holds a dict from public key to Node object for those nodes which are
        # unresponsive.
        self.unresponsive = dict()

        # Add nodes from the given ConfigParser parser.
        for section in configuration.sections():
            # Each section is named with a node key, and contains a
            # hostname, port, and password.
            peerHostname = configuration.get(section, "hostname")
            peerPort = configuration.get(section, "port")
            peerPassword = configuration.get(section, "password")

            # Add the node
            self.addNode(peerHostname, peerPort, peerPassword, section)

        # Add all the nodes we're supposed to watch.
        for node in self.nodes.values():
            self.lookup(node)
        logging.info("{} peers added!".format(len(self.nodes)))
        # Holds a cjdns log message subscription to messages about unresponsive
        # nodes.
        self.sub = self.cjdns.AdminLog_subscribe(MESSAGE_LINE, MESSAGE_FILE,
            'DEBUG')

        if self.sub['error'] == 'none':
            # We successfully subscribed to messages.
            
            # When we die, try to unsubscribe
            atexit.register(self.stop)
            
        else:
            logging.error(self.sub)

    def run(self):
        """
        Run forever, monitoring the peers we are responsible for.
        """

        logging.info("Watching for unresponsive peers")

        # Watch for any messages from our log message subscription.
        self.recieve(self.sub['txid'])
        
    def stop(self):
        """
        Unsubscribe from the admin log and close the connection to cjdns because
        we are shutting down the program. If we don't do this, cjdns might
        crash. If we do do it, cjdns might still crash.
        """

        # Unsubscribe cleanly
        logging.info("Unsubscribing from stream {}".format(
            self.sub['streamId']))
        unsub =  self.cjdns.AdminLog_unsubscribe(self.sub['streamId'])
        if unsub['error'] != 'none':
            logging.error(unsub['error'])
        
        # Close the connection
        logging.info("Closing admin connection")
        self.cjdns.disconnect()


    def addNode(self, host, port, password, key):
        """
        Add a new remote peer with the given hostname, port, password, and
        public key. Does not automatically try to connect to the remote node.

        """

        self.nodes[key] = Node(host, port, password, key)

    def lookup(self, node):
        """
        Look up the current IP address for the given Node object, and tell the
        cjdns router to try to connect to it.

        """


        try:

            # Use AF_INET here to make sure we don't get an IPv6 address and try
            # to connect to it when the cjdns UDPInterface is using only IPv4.
            # TODO: Make cjdns bind its UDPInterface to IPv6 as well as IPv4.
            for info in socket.getaddrinfo(node.host,node.port,
                                           socket.AF_INET,socket.SOCK_DGRAM):

                # For every IP address the node has in DNS, with the port we
                # wanted attached...



                # Save the address we get in a field in the node.
                sockaddr = info[-1]
                node.lastAddr = sockaddr

                # Grab the IP:port string
                sockaddr = sockaddr[0] + ":" + str(sockaddr[1])

                # Announce we are going to connect
                logging.info("Connecting to {} at {}".format(
                    PublicToIp6_convert(node.key), sockaddr))

                # Tell CJDNS to begin a UDPInterface connection to the given
                # IP:port, with the given public key and password. Always use
                # the 0th UDPInterface, which is the default.
                reply = self.cjdns.UDPInterface_beginConnection(
                    password=node.password, publicKey=node.key,
                    address=sockaddr)

                if reply["error"] != "none":
                    # The router didn't like our request. Complain.
                    logging.error(
                        "Router refuses to connect to remote peer. {}".format(
                        reply["error"]))

                    # Maybe try the next address?
                    break

                # Mark this node as no longer unresponsive
                try: del self.unresponsive[node.key]
                except KeyError: pass

                # Don't try any more addresses. Stop after the first.
                return

        except socket.gaierror as e:
            # The lookup failed at the OS level. Did we put in a bad hostname?
            logging.error("Could not resolve DNS name {}: {}".format(
                node.host, e))

        # If we get here, we found no addresses that worked.
        logging.error("No working addresses found for node {}".format(
            PublicToIp6_convert(node.key)))


    def doLog(self, message):
        """
        Process a log line from cjdns to see if it indicates that a peer we are
        responsible for is unresponsive.

        """

        logging.debug(message)

        # Short-circuit messages that start with the wrong l;etter and can't
        # possibly match.
        if message[0]!='P': return;

        # Test plausible messages against the regex
        p = IS_UNRESPONSIVE.match(message)

        # If they don't match, ignore them.
        if not p: return

        # Otherwise, get the key of the unresponsive node from the regex match
        # group.
        downKey = p.group(1)

        # And get the nodfe for that key
        node = self.nodes.get(downKey,None)

        if not node:
            # Complain we aren't responsible for that node.
            logging.debug("Unmonitored neighbor {} is down".format(
                PublicToIp6_convert(downKey)))
            return

        # Otherwise, announce we are doing our job.
        logging.warning("Monitored neighbor {} is down.".format(
             PublicToIp6_convert(downKey)))

        # If we are responsible for it, register it as unresponsive.
        self.unresponsive[downKey] = node

        # Go get its address and try reconnecting.
        self.lookup(node)

    def recieve(self, txid):
        """
        Loop forever porcessing messages from the cjdns router. Takes a txid
        pointing to the subscription to such messages.

        """

        while True:
            # Repeatedly get and process log messages.
            self.doLog(self.cjdns.getMessage(txid)["message"])

def main(argv):
    """
    Run the program.
    """

    # Set up logging. See the logging module docs.
    logging.basicConfig(format="[%(asctime)s] %(levelname)s: %(message)s",
        level=logging.INFO)

    # Parse command-line arguments. Make sure to give our docstring as program
    # help.
    parser = argparse.ArgumentParser(description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter)

    parser.add_argument("configFile", type=argparse.FileType("r"),
        help="configuration file of hosts to read")
    parser.add_argument("--noWait", action="store_true",
        help="look up dynamic peers once and exit")
    parser.add_argument("--adminInfo",
        help="use this file to load the cjdns admin password")

    # Parse all the command-line arguments
    options = parser.parse_args(argv[1:])

    while True:
        try:
            # Connect to the router, using the specified admin info file, if
            # given.
            cjdns = connectWithAdminInfo(path=options.adminInfo)
            break
        except socket.error:
            # Connection probably refused. Retry in a bit
            logging.error("Error connecting to cjdns. Retrying in 1 minute...")
            time.sleep(60)
            
    # Drop root if we have it. We can't do it before we load the admin info
    # file, for the use case where that file is root-only.
    try:

        # Switch group to nogroup
        os.setgid(grp.getgrnam("nogroup").gr_gid)
        # Switch user to nobody
        os.setuid(pwd.getpwnam("nobody").pw_uid)

        # Announce we dropped privs
        logging.info("Dropped privileges: running as {}:{}".format(
            pwd.getpwuid(os.getuid())[0], grp.getgrgid(os.getgid())[0]))
    except (OSError,KeyError):
        # Complain we couldn't drop privs right
        logging.warning("Could not drop privileges: running as {}:{}".format(
            pwd.getpwuid(os.getuid())[0], grp.getgrgid(os.getgid())[0]))



    # Now we can load the config file. It is now required.

    # Maker a new parser to parse the config file
    parsedConfig = ConfigParser.SafeConfigParser()

    # Be case sensitive
    parsedConfig.optionxform = str

    # Read the config from the file
    parsedConfig.readfp(options.configFile)

    # Make a new watcher on the cjdroute connection, with the config from the
    # config file. This automatically looks up all the peers and tries to
    # connect to them once.
    watcher = DynamicEndpointWatcher(cjdns, parsedConfig)

    if options.noWait or os.environ.get('nowait', False):
        # We're not supposed to wait. Quit while we're ahead
        sys.exit(0)
    else:
        # Monitor for unresponsive nodes. This will loop until cjdns restarts,
        # at which point it will throw an exception.
        watcher.run()



if __name__ == "__main__":
    # Run our main method
    sys.exit(main(sys.argv))





