#!/bin/sh
# =============================================================================
# fix-lan-via-vpn.sh  --  QualityNOC LAN-via-VPN remediation for Teltonika
# -----------------------------------------------------------------------------
# Standalone remediation for routers whose WireGuard tunnel is UP and healthy
# but whose LAN traffic is NOT routed through the tunnel.
#
# WHEN TO USE THIS
#   The full provisioner (qualitynoc-vpn-provision.sh v1.4.0+) already sets up
#   LAN-via-VPN policy routing, BUT only for the interface it manages
#   (`wg_qualitynoc`). Routers that were provisioned by hand, or before v1.4.0,
#   have a WG interface with a custom name (e.g. `UKSAT`) and NO policy routing.
#   On those, `route_allowed_ips=0` (correct, to protect RMS) leaves the LAN
#   with no route to the tunnel -> LAN clients keep egressing via WAN.
#
#   This script applies the same table-100 policy route + `qnoc_vpn` firewall
#   zone as the provisioner, but against ANY interface name, persistently via
#   UCI (survives reboots / reconnects -- no hotplug needed).
#
# WHAT IT DOES (all idempotent)
#   1. persistent_keepalive=25 on the peer(s) (stops NAT-idle tunnels dying).
#   2. `config rule`  : src <LAN_SUBNET> -> lookup <TABLE>, RECONCILED --
#      if a rule for <TABLE> already exists with a different src (because the
#      LAN subnet was changed, e.g. 192.168.1.1 -> 192.168.25.1) it is
#      rewritten in place, and any leftover duplicates are removed. Re-running
#      after a LAN renumber is therefore safe and self-healing.
#   3. `config route` : default via <WG_IFACE> in table <TABLE>.
#   4. Firewall zone `qnoc_vpn` (masq + mtu_fix) with lan -> qnoc_vpn forward,
#      and the WG interface added to the zone device list.
#   5. Restart network + firewall, then print verification commands.
#
# It does NOT set route_allowed_ips=1 (that would capture RMS/SSH return path
# and break Remote Access -- see LESSONS.md #3).
#
# USAGE
#   ./fix-lan-via-vpn.sh [-i WG_IFACE] [-s LAN_SUBNET] [-t TABLE] [-K]
#     -i  WireGuard interface name   (default: auto-detect from UCI)
#     -s  LAN source subnet (CIDR)   (default: auto-detect from network.lan)
#     -t  routing table number       (default: 100)
#     -K  keep extra rules that already point at TABLE instead of removing
#         them. Only needed if you deliberately policy-route more than one
#         source subnet into the same table (e.g. a second VLAN).
#
#   Examples:
#     ./fix-lan-via-vpn.sh                 # auto-detect everything
#     ./fix-lan-via-vpn.sh -i UKSAT        # force the interface
#     ./fix-lan-via-vpn.sh -i UKSAT -s 192.168.25.0/24 -t 100
#
#   After renumbering the LAN (e.g. to 192.168.25.1), just re-run with no
#   arguments: the new subnet is auto-detected and the existing rule is
#   rewritten to match it.
#
# Safe to run over SSH: the network restart briefly drops interfaces, so if you
# are connected THROUGH the tunnel you may need to reconnect. Prefer running
# from the LAN side or via an RMS ad-hoc task.
# =============================================================================

set -u

LOG_TAG="qualitynoc-vpn"
WG_IFACE=""
LAN_SUBNET=""
TABLE="100"
KEEP_EXTRA="0"

log()  { logger -t "$LOG_TAG" -- "$*" 2>/dev/null; echo "[${LOG_TAG}] $*"; }
die()  { echo "[${LOG_TAG}] ERROR: $*" >&2; exit 1; }
warn() { echo "[${LOG_TAG}] WARN: $*" >&2; }

# ---- args -------------------------------------------------------------------
while [ $# -gt 0 ]; do
    case "$1" in
        -i) WG_IFACE="${2:-}"; shift 2 ;;
        -s) LAN_SUBNET="${2:-}"; shift 2 ;;
        -t) TABLE="${2:-}"; shift 2 ;;
        -K) KEEP_EXTRA="1"; shift 1 ;;
        -h|--help) sed -n '2,52p' "$0"; exit 0 ;;
        *) die "Unknown argument: $1 (try -h)" ;;
    esac
done

command -v uci >/dev/null 2>&1 || die "uci not found -- is this a RUTOS/OpenWrt device?"

# ---- auto-detect WG interface ----------------------------------------------
if [ -z "$WG_IFACE" ]; then
    # First interface with proto=wireguard in UCI.
    WG_IFACE=$(uci show network 2>/dev/null \
        | sed -n "s/^network\.\([^.]*\)\.proto='wireguard'\$/\1/p" \
        | head -n1)
    [ -z "$WG_IFACE" ] && die "No WireGuard interface found in UCI. Pass one with -i NAME."
    log "Auto-detected WireGuard interface: ${WG_IFACE}"
fi

uci -q get "network.${WG_IFACE}" >/dev/null 2>&1 \
    || die "network.${WG_IFACE} does not exist. Check the name with: uci show network | grep wireguard"

# ---- auto-detect LAN subnet -------------------------------------------------
if [ -z "$LAN_SUBNET" ]; then
    lan_ip=$(uci -q get network.lan.ipaddr 2>/dev/null || echo "")
    lan_mask=$(uci -q get network.lan.netmask 2>/dev/null || echo "255.255.255.0")
    if [ -n "$lan_ip" ]; then
        # Derive network address + CIDR prefix from ip/netmask (busybox-safe).
        oIFS="$IFS"; IFS='.'
        set -- $lan_ip;  i1=$1; i2=$2; i3=$3; i4=$4
        set -- $lan_mask; m1=$1; m2=$2; m3=$3; m4=$4
        IFS="$oIFS"
        n1=$((i1 & m1)); n2=$((i2 & m2)); n3=$((i3 & m3)); n4=$((i4 & m4))
        # prefix length = popcount of the mask
        prefix=0
        for o in $m1 $m2 $m3 $m4; do
            while [ "$o" -gt 0 ]; do
                prefix=$((prefix + (o & 1)))
                o=$((o >> 1))
            done
        done
        LAN_SUBNET="${n1}.${n2}.${n3}.${n4}/${prefix}"
        log "Auto-detected LAN subnet: ${LAN_SUBNET}"
    else
        LAN_SUBNET="192.168.1.0/24"
        warn "Could not read network.lan.ipaddr; defaulting LAN subnet to ${LAN_SUBNET}"
    fi
fi

log "Remediating LAN-via-VPN: iface=${WG_IFACE} subnet=${LAN_SUBNET} table=${TABLE}"

# ---- 1. persistent_keepalive on the peer(s) --------------------------------
# Peer sections are of type wireguard_<WG_IFACE>. Set keepalive if missing.
i=0
while uci -q get "network.@wireguard_${WG_IFACE}[$i]" >/dev/null 2>&1; do
    ka=$(uci -q get "network.@wireguard_${WG_IFACE}[$i].persistent_keepalive" 2>/dev/null || echo "")
    if [ -z "$ka" ] || [ "$ka" = "0" ]; then
        uci set "network.@wireguard_${WG_IFACE}[$i].persistent_keepalive=25"
        log "Set persistent_keepalive=25 on peer #$i"
    fi
    i=$((i + 1))
    [ "$i" -gt 64 ] && break
done

# ---- 2. policy rule: LAN source -> custom table (reconciling) --------------
# We reconcile rather than blindly append. The rule stores the LAN CIDR
# literally, so if the LAN subnet changes (e.g. 192.168.1.0/24 ->
# 192.168.25.0/24) the old rule matches nothing and LAN clients silently fall
# back to the main table -> out the WAN, bypassing the tunnel. A plain
# "add if absent" check would leave that dead rule sitting next to the new one.
# So: find every rule pointing at our table, rewrite the first one to the
# current subnet, and drop the leftovers.
rule_idxs=""
i=0
while uci -q get "network.@rule[$i]" >/dev/null 2>&1; do
    rl=$(uci -q get "network.@rule[$i].lookup" 2>/dev/null || echo "")
    [ "$rl" = "$TABLE" ] && rule_idxs="${rule_idxs}${i} "
    i=$((i + 1))
    [ "$i" -gt 128 ] && break
done

if [ -z "$rule_idxs" ]; then
    r=$(uci add network rule)
    uci set "network.${r}.src=${LAN_SUBNET}"
    uci set "network.${r}.lookup=${TABLE}"
    uci set "network.${r}.priority=30000"
    log "Added ip rule: from ${LAN_SUBNET} lookup ${TABLE}"
else
    keep=""
    dupes=""
    for idx in $rule_idxs; do
        if [ -z "$keep" ]; then keep="$idx"; continue; fi
        # Prepend, so $dupes ends up in descending index order.
        dupes="${idx} ${dupes}"
    done

    cur=$(uci -q get "network.@rule[${keep}].src" 2>/dev/null || echo "")
    if [ "$cur" = "$LAN_SUBNET" ]; then
        log "ip rule already correct (from ${LAN_SUBNET} lookup ${TABLE})"
    else
        uci set "network.@rule[${keep}].src=${LAN_SUBNET}"
        log "Reconciled ip rule #${keep}: src '${cur:-<unset>}' -> '${LAN_SUBNET}'"
    fi
    uci set "network.@rule[${keep}].priority=30000"

    # Delete highest index first so the remaining indices do not shift.
    if [ -n "$dupes" ] && [ "$KEEP_EXTRA" = "0" ]; then
        for idx in $dupes; do
            old=$(uci -q get "network.@rule[${idx}].src" 2>/dev/null || echo "")
            if uci delete "network.@rule[${idx}]" 2>/dev/null; then
                log "Removed stale ip rule #${idx} (src '${old:-<unset>}' lookup ${TABLE})"
            fi
        done
    elif [ -n "$dupes" ]; then
        warn "Kept extra rule(s) with lookup ${TABLE} as requested (-K): ${dupes}"
    fi
fi

# ---- 3. default route via WG in the custom table (idempotent) --------------
route_exists=0
i=0
while uci -q get "network.@route[$i]" >/dev/null 2>&1; do
    ri=$(uci -q get "network.@route[$i].interface" 2>/dev/null || echo "")
    rt=$(uci -q get "network.@route[$i].table" 2>/dev/null || echo "")
    rtg=$(uci -q get "network.@route[$i].target" 2>/dev/null || echo "")
    if [ "$ri" = "$WG_IFACE" ] && [ "$rt" = "$TABLE" ] && [ "$rtg" = "0.0.0.0/0" ]; then
        route_exists=1; break
    fi
    i=$((i + 1))
done
if [ "$route_exists" = "0" ]; then
    ro=$(uci add network route)
    uci set "network.${ro}.interface=${WG_IFACE}"
    uci set "network.${ro}.target=0.0.0.0/0"
    uci set "network.${ro}.table=${TABLE}"
    log "Added default route via ${WG_IFACE} in table ${TABLE}"
else
    log "table ${TABLE} default route already present -- skipping"
fi

uci commit network

# ---- 4. firewall zone qnoc_vpn (same convention as the provisioner) --------
# Migrate legacy >11-char zone name if it slipped in from an old deploy.
if uci -q get firewall.qualitynoc_vpn >/dev/null 2>&1; then
    log "Removing legacy firewall zone 'qualitynoc_vpn' (renamed to 'qnoc_vpn')"
    uci delete firewall.qualitynoc_vpn
    i=0
    while uci -q get "firewall.@forwarding[$i]" >/dev/null 2>&1; do
        d=$(uci -q get "firewall.@forwarding[$i].dest" 2>/dev/null || echo "")
        [ "$d" = "qualitynoc_vpn" ] && uci set "firewall.@forwarding[$i].dest=qnoc_vpn"
        i=$((i + 1))
    done
fi

if ! uci -q get firewall.qnoc_vpn >/dev/null 2>&1; then
    log "Creating firewall zone 'qnoc_vpn'"
    uci set firewall.qnoc_vpn=zone
    uci set firewall.qnoc_vpn.name=qnoc_vpn
    uci set firewall.qnoc_vpn.input=REJECT
    uci set firewall.qnoc_vpn.output=ACCEPT
    uci set firewall.qnoc_vpn.forward=REJECT
    uci set firewall.qnoc_vpn.masq=1
    uci set firewall.qnoc_vpn.mtu_fix=1
fi

# Ensure the WG interface is in the zone device list (idempotent).
cur_devs=$(uci -q get firewall.qnoc_vpn.device 2>/dev/null || echo "")
if ! echo "$cur_devs" | grep -qw "$WG_IFACE"; then
    uci add_list "firewall.qnoc_vpn.device=${WG_IFACE}"
    log "Added ${WG_IFACE} to firewall zone qnoc_vpn device list"
fi

# lan -> qnoc_vpn forwarding (idempotent).
fw_exists=0
i=0
while uci -q get "firewall.@forwarding[$i]" >/dev/null 2>&1; do
    s=$(uci -q get "firewall.@forwarding[$i].src" 2>/dev/null || echo "")
    d=$(uci -q get "firewall.@forwarding[$i].dest" 2>/dev/null || echo "")
    if [ "$s" = "lan" ] && [ "$d" = "qnoc_vpn" ]; then fw_exists=1; break; fi
    i=$((i + 1))
done
if [ "$fw_exists" = "0" ]; then
    fw=$(uci add firewall forwarding)
    uci set "firewall.${fw}.src=lan"
    uci set "firewall.${fw}.dest=qnoc_vpn"
    log "Added firewall forwarding lan -> qnoc_vpn"
fi

uci commit firewall

# ---- 5. apply + verify ------------------------------------------------------
log "Restarting network and firewall to apply changes"
/etc/init.d/network restart  || warn "network restart returned non-zero"
/etc/init.d/firewall restart || warn "firewall restart returned non-zero"

# Give netifd a moment to bring the interface + routes back.
sleep 5

echo ""
echo "==================== VERIFICATION ===================="
echo "-- ip rule (expect: 30000 from ${LAN_SUBNET} lookup ${TABLE}) --"
ip rule show | grep -E "lookup ${TABLE}" || echo "  (!) rule not active yet"
echo "-- route table ${TABLE} (expect: default dev ${WG_IFACE}) --"
ip route show table "${TABLE}" 2>/dev/null || echo "  (!) table ${TABLE} empty"
echo ""
echo "From the router, confirm the two egress paths differ:"
echo "  curl -s https://api.ipify.org ; echo                      # WAN public IP"
echo "  curl -s --interface ${WG_IFACE} https://api.ipify.org ; echo   # peer/VPN public IP"
echo "Simulate a LAN client (source = router LAN IP, matches the rule):"
echo "  curl -s --interface br-lan https://api.ipify.org ; echo   # should match the VPN IP"
echo "======================================================"
echo ""
log "Remediation complete. LAN (${LAN_SUBNET}) now routes via ${WG_IFACE}; router management stays on WAN."
