#!/usr/bin/python3
#
# chhoyhopper-server.py
#
# Copyright (C) 2021 by University of Southern California
# Written by ASM Rizvi<asmrizvi@usc.edu>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License,
# version 2, as published by the Free Software Foundation.
# 
# 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, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
#


import argparse
from datetime import datetime
import dns.resolver
from enum import Enum
import hashlib
import ipaddress
import logging
import math
import netifaces
import os
import socket
import subprocess
import sys
import time

DELTA_TIME = 10 # in second
NEXT_ADDRESS_CHANGE = 60 # in second

SERVER_INTERFACE = ""
SERVER_ADDRESS = ""
UTILITY = ""

verbose = 0

class Utility(Enum):
    HTTP = 'HTTP'
    HTTPS = 'HTTPS'
    SSH = 'SSH'

class Program:
    def __init__(self):
        self.arg = self.parse_args()
        
    def parse_args(self):
        parser = argparse.ArgumentParser(description = 'Implement a moving target defense, where the server IPv6 address changes over time', epilog="""

With this program, an IPv6 service will be provided at a moving IPv6 address.
The service runs on another IP address and this program forwards 
traffic to that service IP from an IPv6 address
that depends on the current time, a shared secret, and a salt value.

By default the service address changes every minute.
We account for clock skew with a grace period of up to 60s.
       
By default, the service is ssh on prefix::f.
To overwrite, select the translated address with --to.

The key is provided in a file via --keyfile.
The key is arbitrary binary data.

By default we hop over the entire /64 on the given interface.

This daemon runs forever, changing the address regularly.

It assumes clients have general access to the Chhoyhopper services like SSH/HTTP/HTTPS. If server has default rules to filter out SSH/HTTP/HTTPS services, it is essential to give access to the corresponding service using these commands: ip6tables -I INPUT -p tcp --dport 22 -j ACCEPT (for SSH) OR ip6tables -I INPUT -p tcp --dport 80 -j ACCEPT (for HTTP) OR ip6tables -I INPUT -p tcp --dport 443 -j ACCEPT (for HTTPS). This program inserts ip6tables NAT and INPUT filter rules on top of the above mentioned rules to control the access.
NAT rules will be inserted top of the table and will translate the temporary IPv6 address to the actual server address. The INPUT filter will be at the top of the INPUT chain and will drop packets that do not have the actual server address. A packet needs to go through both NAT and INPUT chain rule to get the service. No one can reach the IPv6 server without computing the current IPv6 address. Even targeting the actual server address won't be successful. This program will also insert rules to keep the already established connections. Also, it automatically assigns IPv6 address to the interface. When the life of an address is over, it stops the service at that address and deletes the NAT rules and interface addresses.

For HTTP(s) service, Chhoyhopper runs its service at different weblinks changing every minute. These weblinks are mapped to the hopping IPv6 addresses. This program provides the dynamic DNS support to update the DNS records every minute. 

EXAMPLE: 

Running hopping for ssh on prefix::f, exporting service on
using vm18.ant.isi.edu's /64 prefix:

Opening service for hostname (default):

        chhoyhopper-server

Opening service for vm18.ant.isi.edu:

        chhoyhopper-server --address vm18.ant.isi.edu

or by IP address
        
        chhoyhopper-server --address 2001:1878:401::8009:1d15

(note that the hopping address will be anywhere in 2001:1878:401::/64,
not at this public IP address.)

changing key file (this should be shared with clients, default is ./chhoyhopper_key.bin):

        chhoyhopper-server --keyfile "/tmp/private.bin" 
        
For HTTP(S) service, maintaining dynamic DNS and running hopping service:
        
        chhoyhopper-server --address hop.ant.isi.edu --keyfile /tmp/private.bin --utility HTTPS --nameserver 2001:1878:401::8009:1d15 --dnskey  hmac-sha512:client_abc_key:generated-key==
        """)
        
        parser.add_argument('--address', '-a', default='hostname', help='Enter the service address IPv6 address or domain name.')
        
        parser.add_argument('--dnskey', '-d', default='', help='DNS key to update DNS record')

        parser.add_argument('--keyfile', '-k', default='./chhoyhopper_key.bin', help='Key file shared by the server')
        
        parser.add_argument('--nameserver', '-n', default='', help='DNS server address to update the DNS record')
        
        parser.add_argument('--salt', '-s', default='4750', help='Constant salt for generating key')
        
        parser.add_argument('--to', '-t', help='Enter the internal IPv6 server address. NAT rule will translate the dummy to this address.')

        parser.add_argument('--utility', '-u', default='SSH', help='Service: SSH / HTTP / HTTPS')
                
        parser.add_argument('--verbose', '-v', action='count', default=0)
        
        args = parser.parse_args()

        return args


def check_ipv6(n):
    try:
        socket.inet_pton(socket.AF_INET6, n)
        return True
    except socket.error:
        return False

def executeCommand(command, message):
    if verbose == 2:
        print(command)
    # exitCode = os.system(command)
    exitCode = subprocess.call(command, stderr=subprocess.DEVNULL, shell=True)
    if exitCode != 0 and verbose == 2:
         print("Command execution message: " + message)
    return exitCode

def find_interface(interface_address):
    interface_list = netifaces.interfaces()
    retIface = ""
    for iface in interface_list:
        address_entries = netifaces.ifaddresses(iface)
        for address in address_entries:
            for i in  range(len(address_entries[address])):
                addr = address_entries[address][i]['addr']
                if interface_address in addr:
                    retIface = iface
                    break
    return retIface

def sha256Val (timeNowMin, key, constant):
    result = hashlib.sha256()
    result.update(str(timeNowMin).encode())
    result.update(key)
    result.update(str(constant).encode())
    
    return result.hexdigest()[0:40]

    
def hexToIp (result, prefix):
    ipv6 = prefix

    i = 0
    while i < 13:
        ipv6 = ipv6 + result[i : i + 4]
        if i < 12:
            ipv6 = ipv6 + ":"
        i = i + 4

    return ipv6

def createNatRule(address):
    command = "ip6tables -t nat -I PREROUTING -d " + address + " -j DNAT --to-destination " + SERVER_ADDRESS
    executeCommand(command, "NAT creation")

def createBlockRule(address, prefix):

    # Blocking everything other than the translated server address.
    if verbose == 2:
        print("Checking whether we already have the ip6tables rejection rule or not...")
        
    command = "ip6tables -C INPUT -p tcp --dport 22 ! -d " + address + " -j REJECT"
    exitCode = executeCommand(command, "no rejection rule, this program is adding it")
    
    if exitCode != 0: # check whether we already have that rule
        command = "ip6tables -I INPUT -p tcp --dport 22 ! -d " + address + " -j REJECT"
        executeCommand(command, "creating block rule")
        
    # By default SSH will be blocked. HTTP and HTTPS will be blocked if the administrator wants.
    if UTILITY == "HTTP" or UTILITY == "HTTPS":
        command = "ip6tables -C INPUT -p tcp --dport 80 ! -d " + address + " -j REJECT"
        exitCode = executeCommand(command, "no rejection rule for HTTP, this program is adding it")
        if exitCode != 0: # check whether we already have that rule
            command = "ip6tables -I INPUT -p tcp --dport 80 ! -d " + address + " -j REJECT"
            executeCommand(command, "creating block rule for HTTP")
            
        command = "ip6tables -C INPUT -p tcp --dport 443 ! -d " + address + " -j REJECT"
        exitCode = executeCommand(command, "no rejection rule for HTTPS, this program is adding it")
        if exitCode != 0: # check whether we already have that rule
            command = "ip6tables -I INPUT -p tcp --dport 443 ! -d " + address + " -j REJECT"
            executeCommand(command, "creating block rule for HTTPS")
        
    else:
        if verbose == 2:
            print("block rule exists, nothing adding")
         
    # Changing the destination address if client targets the real destination address.
    if verbose == 2:
        print("Checking whether we already have the destination change rule or not...")
    command = "ip6tables -t nat -C PREROUTING -d " + SERVER_ADDRESS + " -j DNAT --to-destination " + prefix + "e"
    exitCode = executeCommand(command, "no real destination change rule, this program is adding it")
    if exitCode != 0:
        command = "ip6tables -t nat -I PREROUTING -d " + SERVER_ADDRESS + " -j DNAT --to-destination " + prefix + "e"
        executeCommand(command, "change real destination using NAT")
    else:
        if verbose == 2:
            print("NAT rule already exists to translate the actual destination")

def createInterface(address):
    if verbose >= 1:
        dt_object = datetime.fromtimestamp(time.time())
        print("at " + str(dt_object) + " accepting " + address)
    command = "ip -6 addr add " + address + " dev " + SERVER_INTERFACE
    executeCommand(command, "create interface rule exists.")
    
def createDnsEntry(hexDigest, domainName, dnsKey, generatedIp6Address, dnsServer):
    partOfName = hexDigest[0 : 64]
    fullDomainName = partOfName + "." + domainName
    
    command = "knsupdate <<EOF \nserver " + dnsServer + "\nzone " + domainName + ".\norigin " + domainName + ".\nkey " + dnsKey + "\nttl 60" + "\nadd " + fullDomainName + ". 60 AAAA " + generatedIp6Address + "\nsend" + "\nquit\nEOF"
    print("Added DNS entry for " + fullDomainName + "-> " + generatedIp6Address)
        
    executeCommand(command, "DNS entry update does not work!")
    
def deleteDnsEntry(hexDigest, domainName, dnsKey, generatedIp6Address, dnsServer):
    partOfName = hexDigest[0 : 64]
    fullDomainName = partOfName + "." + domainName
    
    command = "knsupdate <<EOF \nserver " + dnsServer + "\nzone " + domainName + ".\norigin " + domainName + ".\nkey " + dnsKey + "\nttl 60" + "\ndel " + fullDomainName + ". 60 AAAA " + generatedIp6Address + "\nsend" + "\nquit\nEOF"
    #print(command)
        
    executeCommand(command, "DNS entry deletion does not work!")

def deleteInterfaceAndNat(address):
    # check whether the NAT rule is used or not by parsing the ip6tables NAT list.
    tempResult = subprocess.run(['sudo ip6tables -t nat -L PREROUTING -nv | head -n 5'], stdout=subprocess.PIPE, shell = True).stdout.decode('utf-8')
    tempResultLines = tempResult.split('\n')
    for tempResultLine in tempResultLines:
        # Checking the table initials.
        if "Chain" in tempResultLine or "pkts" in tempResultLine:
            continue
        tempVals = tempResultLine.split()
        # This value 7 indicates the number of space separated values from the ip6tables NAT output. Tested with ip6tables v1.4.21.
        if len(tempVals) < 7:
            continue
        destVal = tempVals[7]
        if socket.inet_pton(socket.AF_INET6, address) == socket.inet_pton(socket.AF_INET6, destVal):
            tempVal = int(tempVals[0])
            # If the NAT rule was not used, no one established a new connection. So, delete the interface IPv6 address.
            if tempVal == 0:
                command = "ip -6 addr del " + address + " dev " + SERVER_INTERFACE
                executeCommand(command, "delete interface rule does not exist")
                break

    # Always delete the NAT rule.
    command = "ip6tables -t nat -D PREROUTING -d " + address + " -j DNAT --to-destination " + SERVER_ADDRESS
    executeCommand(command, "delete NAT rule does not exist")

def initRules(key, currentTime, constant, prefix, domainName, dnsKey, dnsServer):
    createInterface(SERVER_ADDRESS)
    createBlockRule(SERVER_ADDRESS, prefix)
    
    timeNowMin = math.floor((currentTime + DELTA_TIME) / NEXT_ADDRESS_CHANGE)

    exitCode = executeCommand("ip6tables -t nat -C PREROUTING -m state --state ESTABLISHED,RELATED -j ACCEPT", "no NAT rule for keeping established connections, this program is adding it.")
    if exitCode != 0:
        exitCode = executeCommand("ip6tables -t nat -A PREROUTING -m state --state ESTABLISHED,RELATED -j ACCEPT", "inserting NAT rule for established connection did not work")
    else:
        if verbose == 2:
            print("rule for established connection exists, nothing adding")
    
    # Rule for keeping existing connections   
    exitCode = executeCommand("ip6tables -C INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT", "no rule for keeping established connections, this program is adding it.")
    if exitCode != 0:
        exitCode = executeCommand("ip6tables -I PREROUTING -m state --state ESTABLISHED,RELATED -j ACCEPT", "inserting rule for established connection did not work")
    else:
        if verbose == 2:
            print("rule for established connection exists, nothing adding")

    hexDigest = sha256Val(timeNowMin, key, constant)
    generatedIp6Address = hexToIp(hexDigest, prefix)
    logging.info("TIME: " + str(timeNowMin) + ": initial rules " + generatedIp6Address)
    createNatRule(generatedIp6Address)
    createInterface(generatedIp6Address)
    if UTILITY == "HTTP" or UTILITY == "HTTPS":
        createDnsEntry(hexDigest, domainName, dnsKey, generatedIp6Address, dnsServer)

    hexDigest = sha256Val(timeNowMin - 1, key, constant)
    generatedIp6Address = hexToIp(hexDigest, prefix)
    createNatRule(generatedIp6Address)
    createInterface(generatedIp6Address)
    if UTILITY == "HTTP" or UTILITY == "HTTPS":
        createDnsEntry(hexDigest, domainName, dnsKey, generatedIp6Address, dnsServer)

def addNewAddress(key, currentTime, constant, prefix, domainName, dnsKey, dnsServer):
    timeNowMin = math.floor((currentTime + DELTA_TIME) / NEXT_ADDRESS_CHANGE)

    hexDigest = sha256Val(timeNowMin, key, constant)
    generatedIp6Address = hexToIp(hexDigest, prefix)
    logging.info("TIME: " + str(timeNowMin) + ": rules adding " + generatedIp6Address)

    createNatRule(generatedIp6Address)
    createInterface(generatedIp6Address)
    if UTILITY == "HTTP" or UTILITY == "HTTPS":
        createDnsEntry(hexDigest, domainName, dnsKey, generatedIp6Address, dnsServer)

def deleteOldAddress(key, currentTime, constant, prefix, domainName, dnsKey, dnsServer):
    timeNowMin = math.floor((currentTime + DELTA_TIME) / NEXT_ADDRESS_CHANGE)

    hexDigest = sha256Val(timeNowMin - 2, key, constant)
    generatedIp6Address = hexToIp(hexDigest, prefix)
    if verbose >= 1:
        dt_object = datetime.fromtimestamp(time.time())
        print("at " + str(dt_object) + " dropping " + generatedIp6Address)
    logging.info("TIME: " + str(timeNowMin) + ": rules deleting " + generatedIp6Address)
    deleteInterfaceAndNat(generatedIp6Address)
    if UTILITY == "HTTP" or UTILITY == "HTTPS":
        deleteDnsEntry(hexDigest, domainName, dnsKey, generatedIp6Address, dnsServer)

def resolveDnsAndGettingAddress(domainName):
    try:
        result = dns.resolver.query(domainName, 'AAAA')
        if len(result) > 0:
            IP = result[0]
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        exit("Domain name does not exist! Resolving DNS failed for \"" + domainName + "\". Please enter a valid domain name." )
    return IP

def readKey(keyFile):
    if os.path.exists(keyFile):
        f = open(keyFile, 'rb')
        key = f.read()
        f.close()
        return key
    else:
        exit("Key file not found! As a server admin, please generate a binary key file! This program searches for the key in the /usr/local/var/ directory or the directory you provided with the --keyfile option.")


def main():
    if os.geteuid() != 0:
        exit("You need to have root privileges to run this script.")

    logging.basicConfig(filename='/tmp/server.log',level=logging.DEBUG)
    
    global SERVER_ADDRESS
    global SERVER_INTERFACE
    global UTILITY
    global verbose
    
    args = Program()

    IP = args.arg.address
    serverAddress = args.arg.to
    keyFile = args.arg.keyfile
    dnsServer = args.arg.nameserver
    UTILITY = args.arg.utility
    dnsKey = args.arg.dnskey
    constant = args.arg.salt
    verbose = args.arg.verbose
    domainName = ""
    
    try:
        Utility(UTILITY)
    except ValueError:
        error("Utility not supported! Try SSH/HTTP/HTTPS!")
    
    if check_ipv6(IP) is False:
        if IP == "hostname":
            IP = os.uname()[1]
            
            # print("Hostname: " + IP)
        domainName = IP
        IP = str(resolveDnsAndGettingAddress(domainName))
    
    if UTILITY == "HTTP" or UTILITY == "HTTPS":
        if domainName == "":
            exit("You need to give a domain name!")
        if dnsKey == "":
            exit("You need to give a DNS key value for this service!")

    try:
        ipaddress.ip_address(IP)
    except ValueError:
        exit("Please enter a valid IPv6 address or domain.")
    keyVal = readKey(keyFile)

    SERVER_INTERFACE = find_interface(IP)
    
    prefix = str(ipaddress.ip_network(IP).supernet(new_prefix=64))
    prefix = prefix[0:prefix.index('/')]

    if str(serverAddress) == "None":
        SERVER_ADDRESS = prefix + "f"
    else:
        SERVER_ADDRESS = serverAddress

    print("chhoyhopper-server on clear: " + IP)
    print("Internal server at: " + SERVER_ADDRESS)
    
    currentTime = time.time()
    initRules(keyVal, currentTime, constant, prefix, domainName, dnsKey, dnsServer)
    TIMER = NEXT_ADDRESS_CHANGE - ((currentTime + DELTA_TIME - 2) % NEXT_ADDRESS_CHANGE)
    logging.info(str(currentTime) + "\t" + str(TIMER) + ": timer set for the first time")

    while 1:
        time.sleep(TIMER)
        currentTime = time.time()
        logging.info(str(currentTime) + " sleep done, write new rules.")
        addNewAddress(keyVal, currentTime, constant, prefix, domainName, dnsKey, dnsServer)
        deleteOldAddress(keyVal, currentTime, constant, prefix, domainName, dnsKey, dnsServer)
        TIMER = NEXT_ADDRESS_CHANGE - ((currentTime + DELTA_TIME - 2) % NEXT_ADDRESS_CHANGE)


if __name__ == "__main__":
    main()
    sys.exit(0)
