"""External TURN allocation and bidirectional UDP relay probe; never prints credentials."""
import argparse
import hashlib
import hmac
import json
from pathlib import Path
import secrets
import socket
import ssl
import struct
import time

COOKIE = 0x2112A442


def attribute(kind, data):
    return struct.pack('!HH', kind, len(data)) + data + b'\0' * (-len(data) % 4)


def packet(kind, attrs=b'', key=None):
    transaction = secrets.token_bytes(12)
    header = struct.pack('!HHI12s', kind, len(attrs) + (24 if key else 0), COOKIE, transaction)
    body = header + attrs
    if key:
        body += attribute(0x0008, hmac.new(key, body, hashlib.sha1).digest())
    return body, transaction


def decode(data):
    kind, length, cookie, transaction = struct.unpack('!HHI12s', data[:20])
    if cookie != COOKIE or len(data) < 20 + length:
        raise ValueError('Invalid STUN message')
    attrs = {}
    offset = 20
    while offset < 20 + length:
        tag, size = struct.unpack('!HH', data[offset:offset + 4])
        attrs[tag] = data[offset + 4:offset + 4 + size]
        offset += 4 + size + (-size % 4)
    return kind, transaction, attrs


def address(value):
    if value[1] != 1:
        raise ValueError('IPv4 probe only')
    port = struct.unpack('!H', value[2:4])[0] ^ (COOKIE >> 16)
    ip = socket.inet_ntoa(bytes(a ^ b for a, b in zip(value[4:8], struct.pack('!I', COOKIE))))
    return ip, port


def encoded_address(endpoint):
    ip, port = endpoint
    return b'\0\1' + struct.pack('!H', port ^ (COOKIE >> 16)) + bytes(
        a ^ b for a, b in zip(socket.inet_aton(ip), struct.pack('!I', COOKIE)))


class Client:
    def __init__(self, host, transport):
        self.transport = transport
        port = 5349 if transport == 'tls' else 3478
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM if transport == 'udp' else socket.SOCK_STREAM)
        self.sock.settimeout(8)
        if transport == 'tls':
            self.sock = ssl.create_default_context().wrap_socket(self.sock, server_hostname=host)
        self.endpoint = (socket.gethostbyname(host), port)
        if transport == 'udp':
            self.sock.bind(('0.0.0.0', 0))
        else:
            self.sock.connect((host, port))
        self.auth = b''
        self.key = None

    def read(self):
        if self.transport == 'udp':
            data, source = self.sock.recvfrom(65536)
            if source != self.endpoint:
                raise ValueError('Unexpected TURN response source')
            return data
        def exact(size):
            data = b''
            while len(data) < size:
                part = self.sock.recv(size - len(data))
                if not part:
                    raise ValueError('TURN stream closed')
                data += part
            return data
        header = exact(20)
        return header + exact(struct.unpack('!H', header[2:4])[0])

    def send(self, message):
        if self.transport == 'udp':
            self.sock.sendto(message, self.endpoint)
        else:
            self.sock.sendall(message)

    def request(self, kind, attrs=b'', authenticated=True):
        message, transaction = packet(kind, attrs + (self.auth if authenticated else b''), self.key if authenticated else None)
        self.send(message)
        response = self.read()
        response_kind, response_transaction, values = decode(response)
        if transaction != response_transaction:
            raise ValueError('TURN transaction mismatch')
        return response_kind, values

    def allocate(self, username, credential):
        requested = attribute(0x0019, b'\x11\0\0\0')
        kind, values = self.request(0x0003, requested, authenticated=False)
        if kind != 0x0113 or 0x0014 not in values or 0x0015 not in values:
            raise ValueError('TURN auth challenge missing')
        realm = values[0x0014]
        self.auth = attribute(0x0006, username.encode()) + attribute(0x0014, realm) + attribute(0x0015, values[0x0015])
        self.key = hashlib.md5(username.encode() + b':' + realm + b':' + credential.encode()).digest()
        kind, values = self.request(0x0003, requested)
        if kind != 0x0103 or 0x0016 not in values:
            raise ValueError('Authenticated allocation rejected')
        return address(values[0x0016])

    def close(self):
        try:
            if self.key:
                self.request(0x0004, attribute(0x000d, struct.pack('!I', 0)))
        except (OSError, ValueError):
            pass
        self.sock.close()


def probe(credentials):
    host, username, password = credentials['host'], credentials['username'], credentials['credential']
    proof = {'host': host, 'tested_at': int(time.time())}
    for transport in ['udp', 'tcp', 'tls']:
        client = None
        try:
            client = Client(host, transport)
            relay = client.allocate(username, password)
            proof[transport] = True
            if transport == 'udp':
                echo = Client(host, 'udp')
                try:
                    kind, values = echo.request(0x0001, authenticated=False)
                    if kind != 0x0101:
                        raise ValueError('STUN mapping failed')
                    peer = address(values[0x0020])
                    kind, _ = client.request(0x0008, attribute(0x0012, encoded_address(peer)))
                    if kind != 0x0108:
                        raise ValueError('TURN peer permission failed')
                    payload = secrets.token_bytes(32)
                    message, _ = packet(0x0016, attribute(0x0012, encoded_address(peer)) + attribute(0x0013, payload))
                    client.send(message)
                    incoming, source = echo.sock.recvfrom(65536)
                    if incoming != payload:
                        raise ValueError('Relayed payload mismatch')
                    echo.sock.sendto(payload, relay)
                    kind, _, values = decode(client.read())
                    if kind != 0x0017 or values.get(0x0013) != payload:
                        raise ValueError('Return relay payload mismatch')
                    proof['bidirectional_udp'] = True
                finally:
                    echo.sock.close()
        except (OSError, ValueError) as error:
            proof[transport] = False
            proof[transport + '_error'] = type(error).__name__ + ': ' + str(error)
        finally:
            if client:
                client.close()
    return proof


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--credentials', type=Path, required=True)
    parser.add_argument('--output', type=Path, required=True)
    args = parser.parse_args()
    result = probe(json.loads(args.credentials.read_text()))
    args.output.write_text(json.dumps(result, indent=2) + '\n')
    print(json.dumps(result))
    raise SystemExit(0 if all(result.get(key) for key in ['udp', 'tcp', 'tls', 'bidirectional_udp']) else 1)
