#!/usr/bin/python3
#
# linuxmuster-import-subnets
# thomas@linuxmuster.net
# 20200418
#

import ast
import constants
import datetime
import os
import re
import time
import yaml

from bs4 import BeautifulSoup
from functions import firewallApi, getFwConfig, getSetupValue, getSftp
from functions import getSubnetArray, ipMatchSubnet, isValidHostIpv4
from functions import printScript, putFwConfig, putSftp, readTextfile
from functions import sshExec, writeTextfile
from IPy import IP

# read necessary values from setup.ini and other sources
serverip = getSetupValue('serverip')
domainname = getSetupValue('domainname')
opsiip = getSetupValue('opsiip')
dockerip = getSetupValue('dockerip')
firewallip = getSetupValue('firewallip')
# get boolean value
skipfw = ast.literal_eval(getSetupValue('skipfw'))
bitmask_setup = getSetupValue('bitmask')
network_setup = getSetupValue('network')
ipnet_setup = network_setup + '/' + bitmask_setup


# template variables

# lan gateway
gw_lan_descr = 'Interface LAN Gateway'
gw_lan_xml = """
        <gateway_item>
            <interface>lan</interface>
            <gateway>@@gw_ip@@</gateway>
            <name>@@gw_lan@@</name>
            <weight>1</weight>
            <ipprotocol>inet</ipprotocol>
            <interval></interval>
            <descr>@@gw_lan_descr@@</descr>
            <avg_delay_samples></avg_delay_samples>
            <avg_loss_samples></avg_loss_samples>
            <avg_loss_delay_samples></avg_loss_delay_samples>
            <monitor_disable>1</monitor_disable>
        </gateway_item>
"""
gw_lan_xml = gw_lan_xml.replace('@@gw_lan@@', constants.GW_LAN).replace(
    '@@gw_lan_descr@@', gw_lan_descr)

# outbound nat rules
nat_rule_descr = 'Outbound NAT rule for subnet'
nat_rule_xml = """
      <rule>
        <source>
          <network>@@subnet@@</network>
        </source>
        <destination>
          <any>1</any>
        </destination>
        <descr>@@nat_rule_descr@@ @@subnet@@</descr>
        <interface>wan</interface>
        <tag/>
        <tagged/>
        <poolopts/>
        <ipprotocol>inet</ipprotocol>
        <created>
          <username>root@@@serverip@@</username>
          <time>@@timestamp@@</time>
          <description>linuxmuster-import-subnet made changes</description>
        </created>
        <target/>
        <targetip_subnet>0</targetip_subnet>
        <sourceport/>
      </rule>
"""
nat_rule_xml = nat_rule_xml.replace(
    '@@nat_rule_descr@@', nat_rule_descr).replace('@@serverip@@', serverip)


# functions begin
# update static routes in netplan configuration
def updateNetplan(subnets, remoteip=''):
    if remoteip == '':
        machine = 'server'
    elif remoteip == opsiip:
        machine = 'opsi'
    elif remoteip == dockerip:
        machine = 'docker'
    else:
        printScript('Unknown ip address, skipping netplan configuration.')
        return False
    printScript('Processing netplan configuration on ' + machine + ':')
    if machine == 'server':
        cfgfile = constants.NETCFG
    else:
        # get file per sftp
        cfgfile = '/tmp/' + os.path.basename(constants.NETCFG)
        rc = getSftp(remoteip, constants.NETCFG, cfgfile)
        if rc:
            printScript('* Fetched configuration file from remote machine.')
        else:
            printScript(
                '* Unable to fetch configuration file from remote machine!')
            return False
    # read netplan config file
    with open(cfgfile) as config:
        netcfg = yaml.safe_load(config)
    iface = str(netcfg['network']['ethernets']).split('\'')[1]
    ifcfg = netcfg['network']['ethernets'][iface]
    # first delete the old routes if there are any
    try:
        del ifcfg['routes']
        changed = True
        printScript('* Removed old routes.')
    except:
        changed = False
    # only if there are subnets beside server network
    if len(subnets) > 0:
        changed = True
        ifcfg['routes'] = []
        for item in subnets:
            subnet = item.split(':')[0]
            # tricky: concenate dict object for yaml using eval
            subroute = eval('{"to": ' + '\'' + subnet + '\'' + ', "via": ' + '\'' + servernet_router + '\'' + '}')
            ifcfg['routes'].append(subroute)
        printScript('* Added new routes for all subnets.')
    # save netcfg
    if changed:
        with open(cfgfile, 'w') as config:
            config.write(yaml.dump(netcfg, default_flow_style=False))
        if machine == 'server':
            os.system('netplan apply')
            printScript('* Applied new configuration.')
        else:
            # upload modified netcfg
            rc = putSftp(remoteip, cfgfile, constants.NETCFG)
            if rc:
                printScript('* Configuration transfered.')
            else:
                printScript('* Unable to transfer configuration!')
                return False
            rc = sshExec(remoteip, 'netplan apply')
            if rc:
                printScript('* Configuration applied.')
            else:
                printScript('* Unable to apply configuration!')
                return False
            os.unlink(cfgfile)
    # send changed configuration back and apply it
    return changed


# update opsi network configuration with routes
def updateOpsi(subnets, opsiip, ipnet_setup):
    printScript('Processing opsi network configuration:')
    # first delete routes on opsi, create script and invoke it on opsi server
    remotefile = '/etc/network/if-up.d/999staticroutes'
    content = """
#!/bin/sh
[ -e @@remotefile@@ ] || exit 0
sed -i 's|route add|route del|g' @@remotefile@@
chmod +x @@remotefile@@
@@remotefile@@
rm -f @@remotefile@@
"""
    content = content.replace('@@remotefile@@', remotefile)
    localfile = constants.CACHEDIR + '/opsi_staticroutes'
    remotescript = '/tmp/delroutes'
    try:
        writeTextfile(localfile, content, 'w')
        putSftp(opsiip, localfile, remotescript)
        sshExec(opsiip, 'chmod +x ' + remotescript)
        sshExec(opsiip, remotescript)
        sshExec(opsiip, 'rm -f ' + remotescript)
        printScript('* Removed old routes.')
    except:
        printScript('* Unable to remove old routes!')
        return False
    # return if no subnets were defined
    if len(subnets) == 0:
        return True
    # create new route script if subnets were defined
    content = '#!/bin/sh\n#\n# linuxmuster.net\n# static routes for vlans\n\n'
    try:
        for item in subnets:
            s = item.split(':')[0]
            # skip server network
            if s == ipnet_setup:
                continue
            content = content + 'route add -net ' + s + ' gw ' + servernet_router + '\n'
            writeTextfile(localfile, content, 'w')
        printScript('* Created new routes script.')
    except:
        printScript('* Unable to create routes script!')
        return False
    # transfer and invoke it
    try:
        putSftp(opsiip, localfile, remotefile)
        sshExec(opsiip, 'chmod +x ' + remotefile)
        sshExec(opsiip, remotefile)
        printScript('* Script transfered and applied.')
    except:
        printScript('* Unable to transfer and apply script!.')
        return False
    os.unlink(localfile)
    return True


# update vlan gateway on firewall
def updateFwGw(servernet_router, content):
    soup = BeautifulSoup(content, 'lxml')
    # get all gateways
    gateways = soup.findAll('gateways')[0]
    soup = BeautifulSoup(str(gateways), 'lxml')
    # remove old lan gateway from gateways
    gw_array = []
    for gw_item in soup.findAll('gateway_item'):
        if gw_lan_descr not in str(gw_item):
            gw_array.append(gw_item)
    # append new lan gateway
    gw_array.append(gw_lan_xml.replace('@@gw_ip@@', servernet_router))
    # create gateways xml code
    gateways_xml = '<gateways>'
    for gw_item in gw_array:
        gateways_xml = gateways_xml + str(gw_item)
    gateways_xml = gateways_xml + '\n' + '</gateways>'
    content = re.sub(r'<gateways>.*?</gateways>',
                     gateways_xml, content, flags=re.S)
    return True, content


# update subnet nat rules on firewall
def updateFwNat(subnets, ipnet_setup, serverip, content):
    # create array with all nat rules
    soup = BeautifulSoup(content, 'lxml')
    out_nat = soup.findAll('outbound')[0]
    soup = BeautifulSoup(str(out_nat), 'lxml')
    # remove old subnet rules from array
    nat_rules = []
    for item in soup.findAll('rule'):
        if nat_rule_descr not in str(item):
            nat_rules.append(item)
    # add new subnet rules to array
    for item in subnets:
        subnet = item.split(':')[0]
        # skip servernet
        if subnet == ipnet_setup:
            continue
        timestamp = str(datetime.datetime.now(
            datetime.timezone.utc).timestamp())
        nat_rule = nat_rule_xml.replace('@@subnet@@', subnet)
        nat_rule = nat_rule.replace('@@timestamp@@', timestamp)
        nat_rules.append(nat_rule)
    # create nat rules xml code
    nat_xml = '\n<outbound>\n<mode>hybrid</mode>\n'
    for nat_rule in nat_rules:
        nat_xml = nat_xml + str(nat_rule)
    nat_xml = nat_xml + '\n</outbound>'
    # replace code in config content
    content = re.sub(r'<outbound>.*?</outbound>', nat_xml, content, flags=re.S)
    return True, content


# download, modify and upload firewall config
def updateFw(subnets, firewallip, ipnet_setup, serverip, servernet_router, gw_lan_xml):
    # first get config.xml
    if not getFwConfig(firewallip):
        return False
    # load configfile
    rc, content = readTextfile(constants.FWCONFLOCAL)
    if not rc:
        return rc
    changed = False
    # add vlan gateway to firewall
    rc, content = updateFwGw(servernet_router, content)
    if rc:
        changed = rc
    # add subnet nat rules to firewall
    rc, content = updateFwNat(subnets, ipnet_setup, serverip, content)
    if rc:
        changed = rc
    if changed:
        # write changed config
        if writeTextfile(constants.FWCONFLOCAL, content, 'w'):
            printScript('* Saved changed config.')
        else:
            printScript('* Unable to save configfile!')
            return False
        if not putFwConfig(firewallip):
            return False
    return changed


# add single route
def addFwRoute(subnet):
    try:
        payload = '{"route": {"network": "' + subnet + '", "gateway": "' + \
            constants.GW_LAN + '", "descr": "Route for subnet ' + \
            subnet + '", "disabled": "0"}}'
        res = firewallApi('post', '/routes/routes/addroute', payload)
        printScript('* Added route for subnet ' + subnet + '.')
        return True
    except:
        printScript('* Unable to add route for subnet ' + subnet + '!')
        return False


# delete route on firewall by uuid
def delFwRoute(uuid, subnet):
    try:
        rc = firewallApi('post', '/routes/routes/delroute/' + uuid)
        printScript('* Route ' + uuid + ' - ' + subnet + ' deleted.')
        return True
    except:
        printScript('* Unable to delete route ' + uuid + ' - ' + subnet + '!')
        return False


# update firewall routes
def updateFwRoutes(subnets, ipnet_setup, servernet_router):
    printScript('Updating subnet routing on firewall:')
    try:
        routes = firewallApi('get', '/routes/routes/searchroute')
        staticroutes_nr = len(routes['rows'])
        printScript('* Got ' + str(staticroutes_nr) + ' routes.')
    except:
        printScript('* Unable to get routes.')
        return False
    # iterate through firewall routes and delete them if necessary
    changed = False
    gateway_orig = constants.GW_LAN + ' - ' + servernet_router
    if staticroutes_nr > 0:
        count = 0
        while (count < staticroutes_nr):
            uuid = routes['rows'][count]['uuid']
            subnet = routes['rows'][count]['network']
            gateway = routes['rows'][count]['gateway']
            # delete not compliant routes
            if (subnet not in str(subnets) and gateway == gateway_orig) or (subnet in str(subnets) and gateway != gateway_orig):
                delFwRoute(uuid, subnet)
                printScript('* Route ' + subnet + ' deleted.')
                changed = True
            count += 1
    # get changed routes
    if changed:
        routes = firewallApi('get', '/routes/routes/searchroute')
    # find and collect routes to be added
    for subnet in subnets:
        # extract subnet from string
        s = subnet.split(':')[0]
        # skip server network
        if s == ipnet_setup:
            continue
        if s not in str(routes):
            rc = addFwRoute(s)
            if rc:
                changed = rc
    return changed
# functions end


# iterate over subnets
printScript('linuxmuster-import-subnets')
printScript('', 'begin')
printScript('Reading setup data:')
printScript('* Server address: ' + serverip)
printScript('* Server network: ' + ipnet_setup)
printScript('Processing dhcp subnets:')
servernet_router = firewallip
subnets = []
# collect subnet data and write dhcpd's subnet.conf
subnetconf = open(constants.DHCPSUBCONF, 'w')
for row in getSubnetArray():
    try:
        ipnet = row[0]
        router = row[1]
        range1 = row[2]
        range2 = row[3]
    except:
        continue
    if ipnet[:1] == '#' or ipnet[:1] == ';' or not isValidHostIpv4(router):
        continue
    if not isValidHostIpv4(range1) or not isValidHostIpv4(range2):
        range1 = ''
        range2 = ''
    # compute network data
    try:
        n = IP(ipnet, make_net=True)
        network = IP(n).strNormal(0)
        netmask = IP(n).strNormal(2).split('/')[1]
        broadcast = IP(n).strNormal(3).split('-')[1]
    except:
        continue
    # save servernet router address for later use
    if ipnet == ipnet_setup:
        servernet_router = router
        supp_info = 'server network'
    else:
        supp_info = ''
    subnets.append(ipnet + ':' + router)
    # write subnets.conf
    printScript('* ' + ipnet)
    subnetconf.write('# Subnet ' + ipnet + ' ' + supp_info + '\n')
    subnetconf.write('subnet ' + network + ' netmask ' + netmask + ' {\n')
    subnetconf.write('  option routers ' + router + ';\n')
    subnetconf.write('  option subnet-mask ' + netmask + ';\n')
    subnetconf.write('  option broadcast-address ' + broadcast + ';\n')
    subnetconf.write('  option netbios-name-servers ' + serverip + ';\n')
    if range1 != '':
        subnetconf.write('  range ' + range1 + ' ' + range2 + ';\n')
    subnetconf.write('  option host-name pxeclient;\n')
    subnetconf.write('}\n')
subnetconf.close()

# restart dhcp service
service = 'isc-dhcp-server'
msg = 'Restarting ' + service + ' '
printScript(msg, '', False, False, True)
os.system('service ' + service + ' stop')
os.system('service ' + service + ' start')
# wait one second before service check
time.sleep(1)
rc = os.system('systemctl is-active --quiet ' + service)
if rc == 0:
    printScript(' OK!', '', True, True, False, len(msg))
else:
    printScript(' Failed!', '', True, True, False, len(msg))

os.system('systemctl restart isc-dhcp-server.service')

# update netplan config with new routes for server (localhost)
changed = updateNetplan(subnets)

# update netplan config with new routes for dockerhost if his ip matches lan subnets
if isValidHostIpv4(dockerip) and ipMatchSubnet(dockerip, 'all'):
    changed = updateNetplan(subnets, dockerip)

# create static routes for opsi
if isValidHostIpv4(opsiip):
    changed = updateOpsi(subnets, opsiip, ipnet_setup)

# update firewall
if not skipfw:
    changed = updateFw(subnets, firewallip, ipnet_setup, serverip, servernet_router, gw_lan_xml)
    if changed:
        changed = firewallApi('post', '/routes/routes/reconfigure')
        if changed:
            printScript('Applied new gateway.')
    changed = updateFwRoutes(subnets, ipnet_setup, servernet_router)
    if changed:
        changed = firewallApi('post', '/routes/routes/reconfigure')
        if changed:
            printScript('Applied new routes.')
