#!/bin/sh

# Recover the critical VPN VM after an ISP /64 change and dynamically
# seed active VLAN 10 privacy addresses learned from firewall states.
#
# Public template: review every setting below before installation. In
# particular, replace the documentation IPv4 address and both IID placeholders.

WAN_IF="vtnet0"
LAN_IF="vtnet1"
VLAN10_IF="vtnet2"
VM_IPV4="192.0.2.13"
VM_IID="REPLACE_WITH_VPN_VM_INTERFACE_ID"
ONT_IID="REPLACE_WITH_ONT_LAN_INTERFACE_ID"
PF_GLOBAL="ndp_proxy_global"
PF_LAN="ndp_proxy_lan"
STATE_DIR="/var/db/ipv6-ndp-watchdog"
STATE_FILE="${STATE_DIR}/prefix"
VLAN10_STATE="${STATE_DIR}/vlan10-seeded"
LOCK_DIR="/var/run/ipv6-ndp-watchdog.lock"
INTERVAL="30"
TAG="ipv6-ndp-watchdog"

alias_added=0
proxy_stopped=0
locked=0
active_addr=""
wan_nodad=0

log()
{
    /usr/bin/logger -t "${TAG}" -- "$*"
}

current_prefix()
{
    /sbin/ifconfig "${WAN_IF}" inet6 2>/dev/null | /usr/bin/awk '
        $1 == "inet6" && $2 !~ /^fe80:/ && /autoconf/ &&
        !/deprecated/ && !/detached/ && !/tentative/ && !/duplicated/ {
            split($2, part, ":")
            if (part[1] != "" && part[2] != "" && part[3] != "" && part[4] != "") {
                print part[1] ":" part[2] ":" part[3] ":" part[4]
                exit
            }
        }
    '
}

make_addr()
{
    printf '%s:%s\n' "$1" "$2"
}

lan_link_local()
{
    interface_link_local "${LAN_IF}"
}

interface_link_local()
{
    target_iface="$1"
    /sbin/ifconfig "${target_iface}" inet6 2>/dev/null | /usr/bin/awk '
        $1 == "inet6" && $2 ~ /^fe80:/ { print $2; exit }
    '
}

proxy_running()
{
    /usr/sbin/service ndp-proxy-go status >/dev/null 2>&1
}

route_present()
{
    check_addr="$1"
    route_present_on "${check_addr}" "${LAN_IF}"
}

route_present_on()
{
    check_addr="$1"
    check_iface="$2"
    /usr/bin/netstat -rn -f inet6 | /usr/bin/awk -v addr="${check_addr}" -v iface="${check_iface}" '
        $1 == addr && $NF == iface { found = 1 }
        END { exit(found ? 0 : 1) }
    '
}

route_interface()
{
    check_addr="$1"
    /usr/bin/netstat -rn -f inet6 | /usr/bin/awk -v addr="${check_addr}" '
        $1 == addr { print $NF; exit }
    '
}

table_contains()
{
    table_name="$1"
    check_addr="$2"
    /sbin/pfctl -t "${table_name}" -T show 2>/dev/null | /usr/bin/awk -v addr="${check_addr}" '
        $1 == addr { found = 1 }
        END { exit(found ? 0 : 1) }
    '
}

state_healthy()
{
    check_addr="$1"
    proxy_running &&
        route_present "${check_addr}" &&
        table_contains "${PF_GLOBAL}" "${check_addr}" &&
        table_contains "${PF_LAN}" "${check_addr}"
}

write_state()
{
    state_prefix="$1"
    /bin/mkdir -p "${STATE_DIR}"
    /bin/chmod 700 "${STATE_DIR}"
    (umask 077; printf '%s\n' "${state_prefix}" > "${STATE_FILE}")
}

remove_addr_state()
{
    stale_addr="$1"
    [ -n "${stale_addr}" ] || return 0
    /sbin/route delete -inet6 "${stale_addr}" >/dev/null 2>&1 || true
    /sbin/pfctl -t "${PF_GLOBAL}" -T delete "${stale_addr}" >/dev/null 2>&1 || true
    /sbin/pfctl -t "${PF_LAN}" -T delete "${stale_addr}" >/dev/null 2>&1 || true
}

release_action()
{
    result="$1"
    trap - EXIT INT TERM HUP

    if [ "${alias_added}" -eq 1 ] && [ -n "${active_addr}" ]; then
        /sbin/ifconfig "${WAN_IF}" inet6 "${active_addr}" delete >/dev/null 2>&1 || true
        alias_added=0
    fi
    if [ "${proxy_stopped}" -eq 1 ]; then
        /usr/sbin/service ndp-proxy-go start >/dev/null 2>&1 || true
        proxy_stopped=0
    fi
    if [ "${wan_nodad}" -eq 1 ]; then
        /sbin/ifconfig "${WAN_IF}" inet6 -no_dad >/dev/null 2>&1 || true
        wan_nodad=0
    fi
    if [ "${locked}" -eq 1 ]; then
        /bin/rmdir "${LOCK_DIR}" >/dev/null 2>&1 || true
        locked=0
    fi
    return "${result}"
}

cleanup()
{
    result=$?
    release_action "${result}"
    exit "${result}"
}

acquire_lock()
{
    if /bin/mkdir "${LOCK_DIR}" 2>/dev/null; then
        locked=1
        trap cleanup EXIT INT TERM HUP
        return 0
    fi
    return 1
}

learn_downstream()
{
    learn_addr="$1"
    source_ll="$(lan_link_local)"
    [ -n "${source_ll}" ] || return 1

    /sbin/route add -inet6 "${learn_addr}" -iface "${LAN_IF}" >/dev/null 2>&1 || true

    attempt=1
    while [ "${attempt}" -le 5 ]; do
        /sbin/ping6 -c 1 -W 2000 -S "${source_ll}" "${learn_addr}" >/dev/null 2>&1 || true
        /bin/sleep 1
        if route_present "${learn_addr}" &&
            table_contains "${PF_GLOBAL}" "${learn_addr}" &&
            table_contains "${PF_LAN}" "${learn_addr}"; then
            return 0
        fi
        attempt=$((attempt + 1))
    done
    return 1
}

fast_relearn()
{
    relearn_addr="$1"
    if ! proxy_running; then
        /usr/sbin/service ndp-proxy-go start >/dev/null 2>&1 || return 1
        /bin/sleep 3
    fi
    learn_downstream "${relearn_addr}"
}

addr_is_local()
{
    check_addr="$1"
    /sbin/ifconfig -a inet6 2>/dev/null | /usr/bin/awk -v addr="${check_addr}" '
        $1 == "inet6" && $2 == addr { found = 1 }
        END { exit(found ? 0 : 1) }
    '
}

vlan10_is_seeded()
{
    check_addr="$1"
    [ -r "${VLAN10_STATE}" ] && /usr/bin/grep -Fqx "${check_addr}" "${VLAN10_STATE}"
}

vlan10_mark_seeded()
{
    seeded_addr="$1"
    /bin/mkdir -p "${STATE_DIR}"
    /bin/chmod 700 "${STATE_DIR}"
    if ! vlan10_is_seeded "${seeded_addr}"; then
        (umask 077; printf '%s\n' "${seeded_addr}" >> "${VLAN10_STATE}")
    fi
}

vlan10_candidates()
{
    candidate_prefix="$1"
    /sbin/pfctl -ss 2>/dev/null | /usr/bin/tr ' ' '\n' | /usr/bin/sed -n \
        "s/^\\(${candidate_prefix}:[0-9A-Fa-f:]*\\)\\[[0-9][0-9]*\\]$/\\1/p" | \
        /usr/bin/sort -u | /usr/bin/head -n 64
}

seed_vlan10_addr()
{
    client_addr="$1"
    client_prefix="$2"
    source_ll="$(interface_link_local "${VLAN10_IF}")"
    ont_addr="$(make_addr "${client_prefix}" "${ONT_IID}")"

    [ -n "${source_ll}" ] || return 1

    existing_iface="$(route_interface "${client_addr}")"
    if [ -n "${existing_iface}" ] && [ "${existing_iface}" != "${VLAN10_IF}" ]; then
        return 0
    fi

    /sbin/route delete -inet6 "${client_addr}" >/dev/null 2>&1 || true
    /sbin/route add -inet6 "${client_addr}" -iface "${VLAN10_IF}" >/dev/null 2>&1 || return 1
    /usr/sbin/ndp -d "${client_addr}" >/dev/null 2>&1 || true

    if ! /sbin/ping6 -c 1 -W 2000 -S "${source_ll}" "${client_addr}" >/dev/null 2>&1; then
        /sbin/route delete -inet6 "${client_addr}" >/dev/null 2>&1 || true
        return 1
    fi
    /bin/sleep 1

    /sbin/ifconfig "${WAN_IF}" inet6 no_dad >/dev/null 2>&1 || return 1
    wan_nodad=1
    /sbin/route delete -inet6 "${client_addr}" >/dev/null 2>&1 || true

    if ! /sbin/ifconfig "${WAN_IF}" inet6 "${client_addr}/128" alias >/dev/null 2>&1; then
        /sbin/ifconfig "${WAN_IF}" inet6 -no_dad >/dev/null 2>&1 || true
        wan_nodad=0
        /sbin/route add -inet6 "${client_addr}" -iface "${VLAN10_IF}" >/dev/null 2>&1 || true
        return 1
    fi
    active_addr="${client_addr}"
    alias_added=1

    seed_result=0
    /sbin/ping6 -c 2 -W 3000 -S "${client_addr}" "${ont_addr}" >/dev/null 2>&1 || seed_result=1

    /sbin/ifconfig "${WAN_IF}" inet6 "${client_addr}" delete >/dev/null 2>&1 || true
    active_addr=""
    alias_added=0
    /sbin/ifconfig "${WAN_IF}" inet6 -no_dad >/dev/null 2>&1 || true
    wan_nodad=0
    /sbin/route add -inet6 "${client_addr}" -iface "${VLAN10_IF}" >/dev/null 2>&1 || true

    if [ "${seed_result}" -ne 0 ]; then
        log "VLAN10 seed failed: ISP ONT did not accept ${client_addr}"
        return 1
    fi

    vlan10_mark_seeded "${client_addr}"
    log "VLAN10 client seeded without proxy restart: ${client_addr}"
    return 0
}

seed_vlan10_clients()
{
    client_prefix="$1"
    candidates="$(vlan10_candidates "${client_prefix}")"
    [ -n "${candidates}" ] || return 0

    for client_addr in ${candidates}; do
        [ "${client_addr}" != "$(make_addr "${client_prefix}" "${VM_IID}")" ] || continue
        addr_is_local "${client_addr}" && continue

        if vlan10_is_seeded "${client_addr}" &&
            route_present_on "${client_addr}" "${VLAN10_IF}" &&
            table_contains "${PF_GLOBAL}" "${client_addr}"; then
            continue
        fi

        if [ "${locked}" -eq 0 ]; then
            acquire_lock || return 0
        fi
        seed_vlan10_addr "${client_addr}" "${client_prefix}" || true
    done

    if [ "${locked}" -eq 1 ]; then
        release_action 0
    fi
    return 0
}

full_recovery()
{
    new_prefix="$1"
    old_prefix="$2"
    reason="$3"
    new_addr="$(make_addr "${new_prefix}" "${VM_IID}")"
    ont_addr="$(make_addr "${new_prefix}" "${ONT_IID}")"
    old_addr=""

    active_addr="${new_addr}"
    if [ -n "${old_prefix}" ]; then
        old_addr="$(make_addr "${old_prefix}" "${VM_IID}")"
    fi

    if ! /sbin/ping -c 1 -W 2000 "${VM_IPV4}" >/dev/null 2>&1; then
        log "recovery deferred (${reason}): VPN VM ${VM_IPV4} is unreachable"
        return 1
    fi

    log "recovery started (${reason}): ${old_prefix:-none} -> ${new_prefix}"

    if [ -n "${old_prefix}" ] && [ "${old_prefix}" != "${new_prefix}" ]; then
        /bin/rm -f "${VLAN10_STATE}"
    fi

    if proxy_running; then
        if ! /usr/sbin/service ndp-proxy-go stop >/dev/null 2>&1; then
            log "recovery failed: could not stop ndp-proxy-go"
            return 1
        fi
        proxy_stopped=1
    fi

    remove_addr_state "${old_addr}"
    remove_addr_state "${new_addr}"

    if ! /sbin/ifconfig "${WAN_IF}" inet6 "${new_addr}/128" alias; then
        log "recovery failed: could not create temporary WAN /128 ${new_addr}"
        return 1
    fi
    alias_added=1
    /bin/sleep 3

    if ! /sbin/ping6 -c 3 -W 3000 -S "${new_addr}" "${ont_addr}" >/dev/null 2>&1; then
        log "recovery failed: ISP ONT did not accept ${new_addr}"
        return 1
    fi

    /sbin/ifconfig "${WAN_IF}" inet6 "${new_addr}" delete >/dev/null 2>&1 || true
    alias_added=0

    if ! /usr/sbin/service ndp-proxy-go start >/dev/null 2>&1; then
        log "recovery failed: could not start ndp-proxy-go"
        return 1
    fi
    proxy_stopped=0
    /bin/sleep 3

    if ! learn_downstream "${new_addr}"; then
        log "recovery failed: NDP proxy did not learn ${new_addr} on ${LAN_IF}"
        return 1
    fi

    write_state "${new_prefix}"
    log "recovery complete: ${new_addr} is proxied through ${LAN_IF}"
    return 0
}

run_check()
{
    prefix="$(current_prefix)"
    if [ -z "${prefix}" ]; then
        log "check deferred: no preferred SLAAC /64 on ${WAN_IF}"
        return 1
    fi

    expected_addr="$(make_addr "${prefix}" "${VM_IID}")"
    saved_prefix=""
    if [ -r "${STATE_FILE}" ]; then
        saved_prefix="$(/usr/bin/head -n 1 "${STATE_FILE}" 2>/dev/null)"
    fi

    if [ -z "${saved_prefix}" ] && state_healthy "${expected_addr}"; then
        write_state "${prefix}"
        log "initialized on healthy prefix ${prefix}; no recovery performed"
        seed_vlan10_clients "${prefix}" || true
        return 0
    fi

    if [ "${saved_prefix}" = "${prefix}" ] && state_healthy "${expected_addr}"; then
        seed_vlan10_clients "${prefix}" || true
        return 0
    fi

    acquire_lock || return 0

    if [ -n "${saved_prefix}" ] && [ "${saved_prefix}" != "${prefix}" ]; then
        full_recovery "${prefix}" "${saved_prefix}" "prefix changed"
        action_result=$?
        release_action "${action_result}"
        return "${action_result}"
    fi

    if fast_relearn "${expected_addr}" && state_healthy "${expected_addr}"; then
        write_state "${prefix}"
        log "proxy state relearned without WAN interruption: ${expected_addr}"
        release_action 0
        return 0
    fi

    full_recovery "${prefix}" "${saved_prefix}" "proxy state missing"
    action_result=$?
    release_action "${action_result}"
    return "${action_result}"
}

show_status()
{
    prefix="$(current_prefix)"
    saved_prefix=""
    [ -r "${STATE_FILE}" ] && saved_prefix="$(/usr/bin/head -n 1 "${STATE_FILE}" 2>/dev/null)"

    echo "current_prefix=${prefix:-none}"
    echo "saved_prefix=${saved_prefix:-none}"
    if [ -n "${prefix}" ]; then
        expected_addr="$(make_addr "${prefix}" "${VM_IID}")"
        echo "vpn_ipv6=${expected_addr}"
        vlan10_count=0
        if [ -r "${VLAN10_STATE}" ]; then
            vlan10_count="$(/usr/bin/grep -c "^${prefix}:" "${VLAN10_STATE}" 2>/dev/null || true)"
        fi
        echo "vlan10_seeded=${vlan10_count:-0}"
        if state_healthy "${expected_addr}"; then
            echo "status=healthy"
            return 0
        fi
    fi
    echo "status=recovery-needed"
    return 1
}

case "${1:-check}" in
    check|--check)
        run_check
        ;;
    daemon|--daemon)
        while :; do
            run_check || true
            /bin/sleep "${INTERVAL}"
        done
        ;;
    status|--status)
        show_status
        ;;
    force|--force)
        prefix="$(current_prefix)"
        [ -n "${prefix}" ] || exit 1
        saved_prefix=""
        [ -r "${STATE_FILE}" ] && saved_prefix="$(/usr/bin/head -n 1 "${STATE_FILE}" 2>/dev/null)"
        acquire_lock || exit 0
        full_recovery "${prefix}" "${saved_prefix}" "manual force"
        action_result=$?
        release_action "${action_result}"
        exit "${action_result}"
        ;;
    *)
        echo "usage: $0 [check|status|daemon|force]" >&2
        exit 64
        ;;
esac
