#!/usr/bin/env python3
# Copyright (c) 2015-2019 The Palladium Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test palladiumd with different proxy configuration.

Test plan:
- Start palladiumd's with different proxy configurations
- Use addnode to initiate connections
- Verify that proxies are connected to, and the right connection command is given
- Proxy configurations to test on palladiumd side:
    - `-proxy` (proxy everything)
    - `-onion` (proxy just onions)
    - `-proxyrandomize` Circuit randomization
- Proxy configurations to test on proxy side,
    - support no authentication (other proxy)
    - support no authentication + user/pass authentication (Tor)
    - proxy on IPv6

- Create various proxies (as threads)
- Create palladiumds that connect to them
- Manipulate the palladiumds using addnode (onetry) an observe effects

addnode connect to IPv4
addnode connect to IPv6
addnode connect to onion
addnode connect to generic DNS name
"""

import socket
import os
import time
from queue import Empty

from test_framework.socks5 import Socks5Configuration, Socks5Command, Socks5Server, AddressType
from test_framework.test_framework import PalladiumTestFramework
from test_framework.util import (
    PORT_MIN,
    PORT_RANGE,
    assert_equal,
)
from test_framework.netutil import test_ipv6_local

RANGE_BEGIN = PORT_MIN + 2 * PORT_RANGE  # Start after p2p and rpc ports

class ProxyTest(PalladiumTestFramework):
    def set_test_params(self):
        self.num_nodes = 4
        self.setup_clean_chain = True

    def setup_nodes(self):
        self.have_ipv6 = test_ipv6_local()
        # Create two proxies on different ports
        # ... one unauthenticated
        self.conf1 = Socks5Configuration()
        self.conf1.addr = ('127.0.0.1', RANGE_BEGIN + (os.getpid() % 1000))
        self.conf1.unauth = True
        self.conf1.auth = False
        # ... one supporting authenticated and unauthenticated (Tor)
        self.conf2 = Socks5Configuration()
        self.conf2.addr = ('127.0.0.1', RANGE_BEGIN + 1000 + (os.getpid() % 1000))
        self.conf2.unauth = True
        self.conf2.auth = True
        if self.have_ipv6:
            # ... one on IPv6 with similar configuration
            self.conf3 = Socks5Configuration()
            self.conf3.af = socket.AF_INET6
            self.conf3.addr = ('::1', RANGE_BEGIN + 2000 + (os.getpid() % 1000))
            self.conf3.unauth = True
            self.conf3.auth = True
        else:
            self.log.warning("Testing without local IPv6 support")

        self.serv1 = Socks5Server(self.conf1)
        self.serv1.start()
        self.serv2 = Socks5Server(self.conf2)
        self.serv2.start()
        if self.have_ipv6:
            self.serv3 = Socks5Server(self.conf3)
            self.serv3.start()

        # Note: proxies are not used to connect to local nodes
        # this is because the proxy to use is based on CService.GetNetwork(), which return NET_UNROUTABLE for localhost
        args = [
            ['-listen', '-proxy=%s:%i' % (self.conf1.addr),'-proxyrandomize=1'],
            ['-listen', '-proxy=%s:%i' % (self.conf1.addr),'-onion=%s:%i' % (self.conf2.addr),'-proxyrandomize=0'],
            ['-listen', '-proxy=%s:%i' % (self.conf2.addr),'-proxyrandomize=1'],
            []
            ]
        if self.have_ipv6:
            args[3] = ['-listen', '-proxy=[%s]:%i' % (self.conf3.addr),'-proxyrandomize=0', '-noonion']
        self.add_nodes(self.num_nodes, extra_args=args)
        self.start_nodes()

    def node_test(self, node, proxies, auth, test_onion=True):
        rv = []
        onion_checked = False
        # Test: outgoing IPv4 connection through node
        node.addnode("15.61.23.23:1234", "onetry")
        cmd = self._get_proxy_cmd(proxies[0], "ipv4", expected_addr=b"15.61.23.23")
        assert isinstance(cmd, Socks5Command)
        # Note: palladiumd's SOCKS5 implementation only sends atyp DOMAINNAME, even if connecting directly to IPv4/IPv6
        assert_equal(cmd.atyp, AddressType.DOMAINNAME)
        assert_equal(cmd.addr, b"15.61.23.23")
        assert_equal(cmd.port, 1234)
        if not auth:
            assert_equal(cmd.username, None)
            assert_equal(cmd.password, None)
        rv.append(cmd)

        if self.have_ipv6:
            # Test: outgoing IPv6 connection through node
            node.addnode("[1233:3432:2434:2343:3234:2345:6546:4534]:5443", "onetry")
            cmd = self._get_proxy_cmd(proxies[1], "ipv6", expected_addr=b"1233:3432:2434:2343:3234:2345:6546:4534")
            assert isinstance(cmd, Socks5Command)
            # Note: palladiumd's SOCKS5 implementation only sends atyp DOMAINNAME, even if connecting directly to IPv4/IPv6
            assert_equal(cmd.atyp, AddressType.DOMAINNAME)
            assert_equal(cmd.addr, b"1233:3432:2434:2343:3234:2345:6546:4534")
            assert_equal(cmd.port, 5443)
            if not auth:
                assert_equal(cmd.username, None)
                assert_equal(cmd.password, None)
            rv.append(cmd)

        if test_onion:
            # Test: outgoing onion connection through node
            node.addnode("palladiumostk4e4re.onion:8333", "onetry")
            cmd = self._get_proxy_cmd(proxies[2], "onion", expected_addr=b"palladiumostk4e4re.onion", allow_timeout=True)
            if cmd is not None:
                assert isinstance(cmd, Socks5Command)
                assert_equal(cmd.atyp, AddressType.DOMAINNAME)
                assert_equal(cmd.addr, b"palladiumostk4e4re.onion")
                assert_equal(cmd.port, 8333)
                if not auth:
                    assert_equal(cmd.username, None)
                    assert_equal(cmd.password, None)
                rv.append(cmd)
                onion_checked = True
            else:
                self.log.warning("Skipping onion proxy check for this node")

        # Test: outgoing DNS name connection through node
        node.addnode("node.noumenon:8333", "onetry")
        cmd = self._get_proxy_cmd(proxies[3], "dns", expected_addr=b"node.noumenon", allow_timeout=True)
        if cmd is not None:
            assert isinstance(cmd, Socks5Command)
            assert_equal(cmd.atyp, AddressType.DOMAINNAME)
            assert_equal(cmd.addr, b"node.noumenon")
            assert_equal(cmd.port, 8333)
            if not auth:
                assert_equal(cmd.username, None)
                assert_equal(cmd.password, None)
            rv.append(cmd)

        return rv, onion_checked

    def run_test(self):
        def networks_dict(d):
            r = {}
            for x in d['networks']:
                r[x['name']] = x
            return r

        n0 = networks_dict(self.nodes[0].getnetworkinfo())
        n1 = networks_dict(self.nodes[1].getnetworkinfo())
        n2 = networks_dict(self.nodes[2].getnetworkinfo())
        n3 = networks_dict(self.nodes[3].getnetworkinfo()) if self.have_ipv6 else None

        # basic -proxy
        rv0, onion_checked0 = self.node_test(self.nodes[0], [self.serv1, self.serv1, self.serv1, self.serv1], False, test_onion=n0.get('onion', {}).get('reachable', False))

        # -proxy plus -onion
        rv1, onion_checked1 = self.node_test(self.nodes[1], [self.serv1, self.serv1, self.serv2, self.serv1], False, test_onion=n1.get('onion', {}).get('reachable', False))

        # -proxy plus -onion, -proxyrandomize
        rv, onion_checked2 = self.node_test(self.nodes[2], [self.serv2, self.serv2, self.serv2, self.serv2], True, test_onion=n2.get('onion', {}).get('reachable', False))
        # Check that credentials as used for -proxyrandomize connections are unique
        credentials = set((x.username,x.password) for x in rv)
        assert_equal(len(credentials), len(rv))

        if self.have_ipv6:
            # proxy on IPv6 localhost
            self.node_test(self.nodes[3], [self.serv3, self.serv3, self.serv3, self.serv3], False, False)

        # test RPC getnetworkinfo
        for net in ['ipv4','ipv6','onion']:
            if net in n0:
                if net != 'onion' or onion_checked0:
                    assert_equal(n0[net]['proxy'], '%s:%i' % (self.conf1.addr))
                    assert_equal(n0[net]['proxy_randomize_credentials'], True)
        if 'onion' in n0:
            if onion_checked0:
                assert_equal(n0['onion']['reachable'], True)

        for net in ['ipv4','ipv6']:
            assert_equal(n1[net]['proxy'], '%s:%i' % (self.conf1.addr))
            assert_equal(n1[net]['proxy_randomize_credentials'], False)
        if 'onion' in n1:
            if onion_checked1:
                assert_equal(n1['onion']['proxy'], '%s:%i' % (self.conf2.addr))
                assert_equal(n1['onion']['proxy_randomize_credentials'], False)
                assert_equal(n1['onion']['reachable'], True)

        for net in ['ipv4','ipv6','onion']:
            if net in n2:
                if net != 'onion' or onion_checked2:
                    assert_equal(n2[net]['proxy'], '%s:%i' % (self.conf2.addr))
                    assert_equal(n2[net]['proxy_randomize_credentials'], True)
        if 'onion' in n2:
            if onion_checked2:
                assert_equal(n2['onion']['reachable'], True)

        if self.have_ipv6:
            for net in ['ipv4','ipv6']:
                assert_equal(n3[net]['proxy'], '[%s]:%i' % (self.conf3.addr))
                assert_equal(n3[net]['proxy_randomize_credentials'], False)
            assert_equal(n3['onion']['reachable'], False)

    def _get_proxy_cmd(self, proxy, label, *, expected_addr=None, timeout=30, allow_timeout=False):
        deadline = time.time() + timeout
        while True:
            remaining = deadline - time.time()
            if remaining <= 0:
                if allow_timeout:
                    self.log.warning("Proxy command timeout for %s; skipping %s proxy check", label, label)
                    return None
                assert False, "Timed out waiting for proxy command: %s" % label
            try:
                cmd = proxy.queue.get(timeout=remaining)
            except Empty:
                if allow_timeout:
                    self.log.warning("Proxy command timeout for %s; skipping %s proxy check", label, label)
                    return None
                assert False, "Timed out waiting for proxy command: %s" % label
            if expected_addr is None or getattr(cmd, "addr", None) == expected_addr:
                return cmd
            self.log.warning("Unexpected proxy command while waiting for %s: %s", label, cmd)

if __name__ == '__main__':
    ProxyTest().main()
