#!/bin/bash

# Copyright (c) 2022-2024 SUSE LLC
#
# This file is part of cloud-netconfig.
#
# cloud-netconfig is free software: you can redistribute it 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.
#
# cloud-netconfig 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 cloud-netconfig.  If not, see <http://www.gnu.org/licenses/

PROGNAME="cloud-netconfig"
SCRIPTDIR=/usr/libexec/cloud-netconfig
STATEDIR=/run/wicked
ADDRDIR=/run/cloud-netconfig
LOCKFILE=/run/cloud-netconfig/lock
LOGLEVEL=2

. ${SCRIPTDIR}/functions.cloud-netconfig

LOGGER_OPTS="-t $PROGNAME"
tty -s && LOGGER_OPTS="$LOGGER_OPTS -s"

# -------------------------------------------------------------------
# log debug message
#
debug()
{
    test $LOGLEVEL -lt 3 && return
    logger $LOGGER_OPTS -p debug "$*"
}

# -------------------------------------------------------------------
# log warning message
#
warn()
{
    test $LOGLEVEL -lt 1 && return
    logger $LOGGER_OPTS -p warn "$*"
}

# -------------------------------------------------------------------
# log info message
#
log()
{
    test $LOGLEVEL -lt 2 && return
    logger $LOGGER_OPTS "$*"
}

# -------------------------------------------------------------------
# set log level depending on network system config
#
set_loglevel()
{
    netsys=$(get_network_service)

    case "$netsys" in
       "wicked")
            if [[ -f /etc/sysconfig/network/config ]]; then
                get_variable DEBUG /etc/sysconfig/network/config
                test "$DEBUG" == "yes" && LOGLEVEL=3
            fi
            ;;
       "NetworkManager")
           local ll=$(nmcli g logging | grep 'DISPATCH' | cut -f1 -d' ')
           case "$ll" in
               OFF)
                   LOGLEVEL=0
                   ;;
               WARN|ERR)
                   LOGLEVEL=1
                   ;;
               INFO)
                   LOGLEVEL=2
                   ;;
               DEBUG|TRACE)
                   LOGLEVEL=3
                   ;;
           esac
           ;;
    esac
}

# -------------------------------------------------------------------
# get variable from file
#
function get_variable()
{
    local line
    while read line; do
        eval $line
    done < <(grep "^[[:space:]]*$1=" $2 2>/dev/null)
}

# -------------------------------------------------------------------
# print network service in use
#
get_network_service()
{
    IFS="=."
    NET_SERVICE=(`systemctl --no-pager -p Id show network.service`)
    echo "${NET_SERVICE[1]}"
}

# -------------------------------------------------------------------
# print relevant information from wicked lease info
#
get_wicked_lease_info()
{
    local iface="$1" cfg="${STATEDIR}/leaseinfo.${iface}.dhcp.ipv4"
    if [ -e "$cfg" ]; then
        grep -E '^IPADDR|^NETWORK|^NETMASK|^BROADCAST|^GATEWAYS|^ROUTES' $cfg
    else
        return 1
    fi
}

# -------------------------------------------------------------------
# print network info from given IPv4 address and netmask
#
calculate_network()
{
    local addr=$1 netmask=$2
    test -z "$addr" -o -z "$netmask" && return 1
    IFS=. read -r i1 i2 i3 i4 <<< "$addr"
    IFS=. read -r m1 m2 m3 m4 <<< "$netmask"
    printf "%d.%d.%d.%d\n" "$((i1 & m1))" "$((i2 & m2))" "$((i3 & m3))" "$((i4 & m4))"
}


# -------------------------------------------------------------------
# get relevant information from NetworkManager, map to expected
# variable names and print them
#
get_nm_lease_info()
{
    local iface="$1"
    test -z `type -p nmcli` && { warn "nmcli not found, cannot configure interface" ; return 1 ; }
    local ipaddr network netmask broadcast gateways prefixlen
    ipaddr=`nmcli -f DHCP4 -t d show $iface | grep ':ip_address' | cut -d= -f2 | tr -d ' '`
    netmask=`nmcli -f DHCP4 -t d show $iface | grep ':subnet_mask' | cut -d= -f2 | tr -d ' '`
    broadcast=`nmcli -f DHCP4 -t d show $iface | grep ':broadcast_address' | cut -d= -f2 | tr -d ' '`
    gateways=`nmcli -f DHCP4 -t d show $iface | grep ':routers' | cut -d= -f2 | tr ',' ' '`
    echo "IPADDR=$ipaddr"
    echo -n "NETWORK="
    calculate_network $ipaddr $netmask
    echo "NETMASK=$netmask"
    echo "BROADCAST=$broadcast"
    echo "GATEWAYS=($gateways)"
}


# -------------------------------------------------------------------
# print relevant lease information from used network system
#
get_lease_info()
{
    local iface="$1"
    test -z "$iface" && return 1
    netsys=`get_network_service`
    case "$netsys" in
        "wicked")
            get_wicked_lease_info "$iface"
            ;;
        "NetworkManager")
            get_nm_lease_info "$iface"
            ;;
        "*")
            return 1
            ;;
    esac
}


# -------------------------------------------------------------------
# return all IPv4 addresses currently configured on given interface
#
get_ipv4_addresses_from_interface()
{
    local idx type addr iface="$1"
    test -z "$iface" && return 1
    ip -o -4 addr show dev $iface | while read -r idx iface_r type addr rest ; do
        echo -n "${addr} "
    done
}

# -------------------------------------------------------------------
# return all IPv6 addresses currently configured on given interface
#
get_ipv6_addresses_from_interface()
{
    local idx iface_r scope_lit scope type addr rest iface="$1"
    test -z "$iface" && return 1
    ip -o -6 addr show dev $iface | \
    while read -r idx iface_r type addr scope_lit scope rest ; do
        # skip anything that is not scope global
        if [[ "$scope" == "global" ]]; then
            echo -n "${addr} "
        fi
    done
}

# -------------------------------------------------------------------
# compare arrays and return the ones that are in the first but not
# the second
#
get_extra_elements()
{
    local actual=(${!1}) target=(${!2})
    local elem_a elem_t
    for elem_a in ${actual[@]} ; do
        found=0
        for elem_t in ${target[@]} ; do
            if [ "$elem_a" == "$elem_t" ]; then
                found=1
                break
            fi
        done
        test $found = 0 && echo -n "$elem_a "
    done
}

# -------------------------------------------------------------------
# check wether $2 is an element of array $1
#
is_element()
{
    local array=(${!1}) item=${2}
    local elem
    for elem in ${array[@]} ; do
        if [[ $elem == $item ]]; then
            return 0
        fi
    done
    return 1
}

# -------------------------------------------------------------------
# get IPv4 addresses from log dir
#
get_cn_assigned_addrs()
{
    local iface="$1"
    local addr_type="$2"
    test -z "$iface" && return 1

    if [[ -e ${ADDRDIR}/${addr_type}${iface} ]]; then
        echo $(cat ${ADDRDIR}/${addr_type}${iface})
    fi
}

# -------------------------------------------------------------------
# get link status for interface
#
get_link_status()
{
    local iface="$1"
    test -z "$iface" && return 1

    ip -o -br link show dev $iface | awk '{ print $2 }'
}

# -------------------------------------------------------------------
# add address to log
#
add_addr_to_log()
{
    local iface="$1"
    local addr="$2"
    local addr_type="$3"

    test -z "$iface" -o -z "$addr" && return 1
    addr_log_file="${ADDRDIR}/${addr_type}${iface}"
    if [[ -e ${addr_log_file} ]]; then
        grep -q -x "${addr}" "${addr_log_file}" || echo "${addr}" >> "${addr_log_file}"
    else
        mkdir -p "${ADDRDIR}"
        echo "${addr}" > "${addr_log_file}"
    fi
}

# -------------------------------------------------------------------
# remove address from log
#
remove_addr_from_log()
{
    local iface="$1"
    local addr="$2"
    local addr_type="$3"

    test -z "$iface" -o -z "$addr" && return 1
    addr_log_file="${ADDRDIR}/${addr_type}${iface}"
    if [[ -e ${addr_log_file} ]]; then
        addr_log_file_tmp=$(mktemp ${ADDRDIR}/${iface}.XXXXXXXX)
        if [ $? -ne 0 ]; then
            log "could not create temp file, not removing address from log"
        else
            grep -v -x "${addr}" "${addr_log_file}" >> "${addr_log_file_tmp}"
            mv "${addr_log_file_tmp}" "${addr_log_file}"
        fi
    fi
}

# -------------------------------------------------------------------
# copy routes from default table to given table and remove routes
# that do not exist in default table (if any)
#
update_routing_tables()
{
    local ipv="$1"
    local iface="$2"
    local dest_table="$3"
    local gw_table="$4"

    test -z "$gw_table" && return 1

    # copy destination specific routes from default table
    ip $ipv route show dev $iface | grep -v "^default" | while read route  ; do
        # expires arg can't be used as is, drop it
        route=$(echo $route | sed -e 's/expires [^ ]*//')
        debug "creating/replacing route $route dev $iface table $dest_table"
        ip $ipv route replace $route dev $iface table $dest_table
    done

    # copy gateway route(s) from default table
    ip $ipv route show default dev $iface | while read route ; do
        route=$(echo $route | sed -e 's/expires [^ ]*//')
        debug "creating/replacing route $route dev $iface table $gw_table"
        ip $ipv route replace $route dev $iface table $gw_table
    done

    # check if there are any leftover routes and delete them
    ip $ipv route show all table $dest_table | grep -v "^default" | while read route; do
        ip_out="$(ip $ipv route show $route)"
        if [[ -z "$ip_out" ]]; then
            debug "deleting obsolete route $route from table $dest_table"
            ip $ipv route del $route table $dest_table
        fi
    done
}

# -------------------------------------------------------------------
# copy routes from default table to given table and remove routes
# that do not exist in default table (if any)
#
update_routing_policies()
{
    local ipv="$1"
    local iface="$2"
    local dest_table="$3"
    local gw_table="$4"
    local -n addrs=$5

    # update routing policies so connections from addresses on
    # secondary interfaces are routed via those
    # also include IP rangers
    local found prio from ip rest
    for addr in ${addrs[@]} ; do
        found=0
        while read -r prio from ip rest ; do
            if [[ "${addr%/32}" == "$ip" ]]; then
                found=1
                break
            fi
        done < <(ip $ipv rule show)
        if [[ $found == 0 ]]; then
            debug "creating policy rule for src address ${addr%/32}"
            ip $ipv rule add from ${addr%/32} priority $dest_table lookup $dest_table
            ip $ipv rule add from ${addr%/32} priority $gw_table lookup $gw_table
        fi
    done

    # create main (w/o default) lookup rule, if necessary
    if [[ -z "$(ip $ipv rule show prio 30399)" ]]; then
        ip $ipv rule add from all table main prio 30399 suppress_prefixlength 0
    fi
}

# -------------------------------------------------------------------
# configure interface with secondary IPv4 addresses configured in the
# cloud framework and set up routing policies
#
configure_interface_ipv4()
{
    local INTERFACE="$1"
    test -z "$INTERFACE" && return 1
    local IPADDR NETWORK NETMASK BROADCAST GATEWAYS
    eval `get_lease_info $INTERFACE`
    if [ -z "$IPADDR" ]; then
        warn "Could not determine address from DHCP4 lease info"
        return 1
    fi
    local HWADDR="$(cat /sys/class/net/${INTERFACE}/address)"
    if [ -z "$HWADDR" ]; then
        warn "Could not determine MAC address for $INTERFACE"
        return 1
    fi
    local ifindex="$(cat /sys/class/net/${INTERFACE}/ifindex)"
    if [ -z "$ifindex" ]; then
        warn "Could not determine interface index for $INTERFACE"
        return 1
    fi
    local dest_table="$((ifindex+30000))"
    local gw_table="$((ifindex+30400))"

    # get active and configured addresses
    local laddrs=($(get_ipv4_addresses_from_interface $INTERFACE))
    local raddrs addr addrs
    addrs=$(get_ipv4_addresses_from_metadata $HWADDR)
    if [[ $? -ne 0 ]]; then
        warn "error getting IPv4 addresses from metadata, aborting configuration of $INTERFACE"
        return
    fi
    for addr in $addrs ; do
        # validate whether element looks like an IPv4 address
        if  [[ $addr =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
            raddrs+=($addr)
        else
            warn "IPv4 address \"${addr}\" from instance metadata does not look valid, skipping"
        fi
    done

    # get differences
    local addr_to_remove=($(get_extra_elements laddrs[@] raddrs[@]))
    local addr_to_add=($(get_extra_elements raddrs[@] laddrs[@]))

    # get addresses cloud-netconfig configured
    local addr_cn=($(get_cn_assigned_addrs $INTERFACE))

    debug "active IPv4 addresses on $INTERFACE: ${laddrs[@]}"
    debug "configured IPv4 addresses for $INTERFACE: ${raddrs[@]}"
    debug "excess addresses on $INTERFACE: ${addr_to_remove[@]}"
    debug "addresses to configure on $INTERFACE: ${addr_to_add[@]}"

    for addr in ${addr_to_remove[@]} ; do
        # as a safety measure, check against the address received via DHCP and
        # refuse to remove it even if it is not in the meta data
        if [[ "${addr%/*}" == "${IPADDR%/*}" ]]; then
            debug "not removing DHCP IP address from interface"
            continue
        fi

        if is_element addr_cn[@] ${addr} ; then
            # remove old IP address
            log "removing address $addr from interface $INTERFACE"
            ip -4 addr del $addr dev $INTERFACE

            # drop routing policy rules, if any
            ip -4 rule show from ${addr%/*} | while read rule ; do
                ip -4 rule del from ${addr%/*} prio ${rule%:*}
            done

            # remove from address log
            remove_addr_from_log $INTERFACE $addr
        else
            debug "not removing address ${addr} (was not added by cloud-netconfig)"
        fi
    done
    for addr in ${addr_to_add[@]} ; do
        # add new IP address
        log "adding address $addr to interface $INTERFACE"
        ip -4 addr add $addr broadcast $BROADCAST dev $INTERFACE
        add_addr_to_log $INTERFACE $addr
    done

    local ip_ranges=$(get_ipv4_ranges_from_metadata $HWADDR)
    if [[ $? -eq 0 ]]; then
        local local_route
        for ipr in $ip_ranges; do
            local_route=$(ip r show $ipr type local table local)
            if [[ -z $local_route ]]; then
                debug "No local route for IP range $ipr, creating one"
                ip -4 r add local $ipr dev $INTERFACE table local
                add_addr_to_log $INTERFACE $ipr aliases_
            fi
        done
        # check if cloud-netconfig configured IP aliases were removed
        local cn_ip_ranges=$(get_cn_assigned_addrs $INTERFACE ranges_)
        if [[ -n $cn_ip_ranges ]]; then
            for ipr in $cn_ip_ranges ; do
                if [[ " $ip_ranges " == *" $ipr "* ]]; then
                    debug "IP range $ipr was removed, dropping local route"
                    ip -4 r del local $ipr dev $INTERFACE table local
                    remove_addr_from_log $INTERFACE $ipr ranges_
                fi
            done
        fi
    else
        warn "error getting IPv4 ranges from metadata, skipping ranges configuration on $INTERFACE"
    fi

    # If we have a single NIC configuration, skip routing policies
    if [[ $SINGLE_NIC = yes ]]; then
       return
    fi

    # To make a working default route for secondary interfaces, we
    # need a separate route table so we can send packets there
    # using routing policy. Check whether the table is there and
    # create it if not.
    if [ -z "$(ip -4 route show default dev $INTERFACE)" ]; then
        local GW GWS=()
        # we simply take the first gateway in case there is more than one
        eval GWS=\(${GATEWAYS}\)
        if [[ -n "${GWS[0]}" ]]; then
            GW="${GWS[0]}"
        else
            # GCE does not provide a gateway for secondary NICs in the
            # lease info but is available via meta data.
            GW=$(get_gateway_from_metadata $HWADDR)
            if [ -z $GW ]; then
                debug "No gateway info available for $INTERFACE, guessing"
                # we assume the gateway is the first host of the network
                local netstart=${NETWORK##*.}
                local gw_host_part=$((netstart+1))
                GW="${NETWORK%.*}.${gw_host_part}"
            fi
        fi
        if [[ -n $GW ]]; then
            debug "adding default route via $GW for $INTERFACE table $GW_TABLE"
            ip -4 route add default via $GW dev "$INTERFACE" metric $((ifindex+20000))
        else
            warn "No default route for $INTERFACE"
        fi
    fi

    # copy specific routes from the default routing table
    update_routing_tables -4 $INTERFACE $dest_table $gw_table

    local all_addrs=()
    for addr in ${raddrs[@]} ; do
        all_addrs+=("${addr%/*}")
    done
    all_addrs+=($ip_ranges)
    update_routing_policies -4 $INTERFACE $dest_table $gw_table all_addrs

    # create main (w/o default) lookup rule, if necessary
    if [[ -z "$(ip -4 rule show prio 30399)" ]]; then
        ip -4 rule add from all table main prio 30399 suppress_prefixlength 0
    fi
}

# -------------------------------------------------------------------
# set up IPv6 routing policies
#
configure_interface_ipv6()
{
    local INTERFACE="$1"
    test -z "$INTERFACE" && return 1

    local ifindex="$(cat /sys/class/net/${INTERFACE}/ifindex)"
    if [ -z "$ifindex" ]; then
        warn "Could not determine interface index for $INTERFACE"
        return 1
    fi

    local dest_table="$((ifindex+30000))"
    local gw_table="$((ifindex+30400))"

    # if necessary, create route table with default route for interface
    if [ -z "$(ip -6 route show default dev $INTERFACE table $RTABLE 2>/dev/null)" ]; then
        # it is possible that we received the DHCP response before the
        # the router advertisement; in that case, we wait up to 10 secs
        local route via GW rest counter=0
        while [[ $counter -lt 10 ]]; do
            counter=$((counter+1))
            read -r route via GW rest < <(ip -6 route show default dev $INTERFACE)
            if [[ -n "$GW" ]]; then
                break
            else
                sleep 1
            fi
        done
    fi

    update_routing_tables -6 $INTERFACE $dest_table $gw_table
    local laddrs=($(get_ipv6_addresses_from_interface $INTERFACE))
    update_routing_policies -6 $INTERFACE $dest_table $gw_table laddrs
}

# -------------------------------------------------------------------
# Check if interface has a DHCP lease. Takes interface name
# and IP version.
#
iface_has_lease()
{
    local iface="$1" ipv="$2"
    test -z "$iface" && return 1
    test "$ipv" != "4" -a "$ipv" != "6" && return 1

    local netsys=`get_network_service`
    if [ "$netsys" == "wicked" ]; then
        test -e /run/wicked/leaseinfo.${iface}.dhcp.ipv${ipv}
        return $?
    elif [ "$netsys" == "NetworkManager" ]; then
        nmcli -f DHCP${ipv} -t d show $iface | grep -q -E ':ip6?_address'
        return $?
    fi
    return 1
}

# -------------------------------------------------------------------
# check if interface is configured to be managed by cloud-netconfig
# and whether it is DHCP configured; if yes, apply settings from
# the cloud framework
#
manage_interfaceconfig()
{
    local iface="$1"
    test -z "$iface" && return 1
    local netsys=`get_network_service`
    local CLOUD_NETCONFIG_MANAGE
    if [ "$netsys" == "wicked" ]; then
        local ifcfg="/etc/sysconfig/network/ifcfg-${iface}"
        if [[ -f "${ifcfg}" ]]; then
            get_variable "CLOUD_NETCONFIG_MANAGE" "${ifcfg}"
        fi
    elif [ "$netsys" == "NetworkManager" ]; then
        get_variable "CLOUD_NETCONFIG_MANAGE" /etc/default/cloud-netconfig
    else
        debug "Unknown network service $netsys. Not handling interface configuration."
        return
    fi
    if [[ "$CLOUD_NETCONFIG_MANAGE" != "yes" ]]; then
        # do not touch interface
        debug "Not managing interface ${iface}"
        return
    fi
    linkstatus=$(get_link_status ${iface})
    if [[ $linkstatus != UP ]]; then
        debug "interface ${iface} is down"
        return
    fi
    if ! metadata_available ; then
        warn "Cannot access instance metadata, skipping interface configuration for ${iface}"
        return
    fi
    iface_has_lease $iface 4 && configure_interface_ipv4 $iface
    iface_has_lease $iface 6 && configure_interface_ipv6 $iface
}

if [ ! -d $ADDRDIR ]; then
    rm -f $ADDRDIR
    mkdir $ADDRDIR
fi

test -e "$LOCKFILE" && exit 0

trap "rm -f $LOCKFILE" EXIT
touch $LOCKFILE

set_loglevel

nics=()
for IFDIR in /sys/class/net/* ; do
    # skip virtual and bonded interfaces
    test -e $IFDIR/device || continue
    test -e $IFDIR/master && continue
    nics+=(${IFDIR##*/})
done

# set single NIC flag if appropriate
if [[ ${#nics[@]} -eq 1 ]]; then
    SINGLE_NIC=yes
else
    SINGLE_NIC=no
fi

for nic in ${nics[@]} ; do
    manage_interfaceconfig $nic
done

exit 0
