#!/bin/sh
# ============================================================================
#  NikoVPN — Hysteria2 установщик для OpenWRT (apk + opkg, mipsle-sf и др.)
#
#  sh hy2.sh install     установка + автоподбор рабочего сервера
#  sh hy2.sh update      обновить список серверов из API
#  sh hy2.sh switch      вручную переключить на следующий сервер
#  sh hy2.sh status      статус
#  sh hy2.sh uninstall   удаление
#
#  Креды: /root/creds (login:password). Сервера тянутся из NikoVPN API.
#  Фейловер: если сервер не отвечает (timeout) — автоматом берётся следующий.
# ============================================================================

set -u

BASE="https://f.n1ko.dev/nikovpn_openwrt"
API_HOST="vpnd.n1ko.dev/api/v1"
CREDS_FILE="/root/creds"
CONFIG_FILE="/root/hy2-config.yaml"
SERVERS_FILE="/root/hy2-servers.txt"
STATE_FILE="/root/hy2.state"
PREFS_FILE="/root/hy2.prefs"
INIT_SCRIPT="/etc/init.d/hy2-proxy"

have() { command -v "$1" >/dev/null 2>&1; }
log()  { echo "[*] $*"; }
warn() { echo "[!] $*"; }
die()  { echo "[ERROR] $*" >&2; exit 1; }

# ------------------------------- helpers -----------------------------------

urlencode() {
    printf '%s' "$1" | sed \
        -e 's/%/%25/g' -e 's/ /%20/g' -e 's/!/%21/g' -e 's/"/%22/g' \
        -e 's/#/%23/g' -e 's/\$/%24/g' -e 's/&/%26/g' -e "s/'/%27/g" \
        -e 's/(/%28/g' -e 's/)/%29/g' -e 's/\*/%2A/g' -e 's/+/%2B/g' \
        -e 's/,/%2C/g' -e 's/:/%3A/g' -e 's/;/%3B/g' -e 's/=/%3D/g' \
        -e 's/?/%3F/g' -e 's/@/%40/g'
}

urldecode() {
    printf '%b' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/%\([0-9A-Fa-f][0-9A-Fa-f]\)/\\x\1/g')"
}

get_param() {  # $1=name -> $P
    P=""
    local _q="$QUERY"
    while [ -n "$_q" ]; do
        local _kv="${_q%%&*}"
        [ "$_q" = "$_kv" ] && _q="" || _q="${_q#*&}"
        case "$_kv" in
            "$1="*) P="${_kv#*=}"; break ;;
        esac
    done
}

fetch() {  # $1=url $2=out ; http-fallback только для f.n1ko.dev
    local url="$1" out="$2" ok=""
    if have curl; then
        curl -fsSL --connect-timeout 10 --max-time 300 -o "$out" "$url" && ok=1
        [ -z "$ok" ] && case "$url" in https://f.n1ko.dev/*)
            curl -fsSL --max-time 300 -o "$out" "http://${url#https://}" && ok=1 ;; esac
    else
        wget -q -T 60 --no-check-certificate -O "$out" "$url" && ok=1
        [ -z "$ok" ] && case "$url" in https://f.n1ko.dev/*)
            wget -q -T 60 -O "$out" "http://${url#https://}" && ok=1 ;; esac
    fi
    [ -n "$ok" ]
}

read_creds() {
    [ -f "$CREDS_FILE" ] || die "не найден $CREDS_FILE — запишите туда 'login:password'"
    CRED=$(sed -n '1{s/\r$//;s/^[[:space:]]*//;s/[[:space:]]*$//;p}' "$CREDS_FILE")
    [ -n "$CRED" ] || die "$CREDS_FILE пуст"
    case "$CRED" in *:*) ;; *) die "в $CREDS_FILE нет ':' — формат login:password" ;; esac
    UUSER=$(urlencode "${CRED%%:*}")
    UPASS=$(urlencode "${CRED#*:}")
}

api_get() {  # $1=path $2=out ; auth через userinfo URL
    local path="$1" out="$2"
    local url="https://${UUSER}:${UPASS}@${API_HOST}${path}"
    if have curl; then
        curl -fsSL --connect-timeout 10 --max-time 30 -o "$out" "$url" || return 1
    else
        wget -q -T 30 --no-check-certificate -O "$out" "$url" || return 1
    fi
    [ -s "$out" ]
}

state_get() {  # $1=key $2=default
    local v=""
    [ -f "$STATE_FILE" ] && v="$(sed -n "s/^$1='\(.*\)'/\1/p" "$STATE_FILE" | head -1)"
    printf '%s' "${v:-$2}"
}

state_set() {  # $1=key $2=val (перезаписывает ключ в state)
    touch "$STATE_FILE"
    sed -i "\|^$1='|d" "$STATE_FILE" 2>/dev/null || true
    echo "$1='$2'" >> "$STATE_FILE"
}

# ------------------------------- architecture -------------------------------

detect_arch() {
    local da=""
    if [ -r /etc/openwrt_release ]; then
        # shellcheck disable=SC1091
        . /etc/openwrt_release 2>/dev/null
        da="$DISTRIB_ARCH"
    fi
    if [ -z "$da" ] && have apk; then
        da=$(apk --print-arch 2>/dev/null)
    fi
    case "$da" in
        mipsel*|mips_24kc|24kec*) HY_ARCH="mipsle-sf"; IPT_ARCH="mipsel" ;;
        *) die "архитектура '${da:-?}' не поддерживается (есть только mipsle-sf). Напиши автору." ;;
    esac
}

# ------------------------------- link parsing -------------------------------

parse_hysteria_link() {  # $1=link
    LINK="$1"
    local rest authhost hostport
    rest="${LINK#hysteria2://}"
    FRAG=""
    case "$rest" in *\#*) FRAG="${rest##*\#}"; rest="${rest%%\#*}" ;; esac
    QUERY=""
    case "$rest" in *\?*) QUERY="${rest#*\?}"; rest="${rest%%\?*}" ;; esac
    authhost="$rest"
    AUTH="${authhost%%@*}"
    hostport="${authhost#*@}"
    hostport="${hostport%/}"
    PORT="${hostport##*:}"
    HOST="${hostport%:*}"
    AUTH=$(urldecode "$AUTH")
    HOST=$(urldecode "$HOST")
    get_param sni;           SNI="$P";    [ -n "$SNI" ]    || SNI="$HOST"
    get_param insecure;      INSECURE="$P"
    get_param obfs;          OBFS="$P"
    get_param obfs-password; OBFSPASS="$P"
    get_param port;          PORTRANGE="$P"
    OBFSPASS=$(urldecode "$OBFSPASS")
}

# ------------------------------- config gen ---------------------------------

write_hysteria_config() {
    local ins="false"
    [ "$INSECURE" = "1" ] && ins="true"
    {
        echo "auth: ${AUTH}"
        echo "tls:"
        echo "  sni: ${SNI}"
        echo "  insecure: ${ins}"
        if [ -n "$PORTRANGE" ]; then
            echo "server: ${HOST}:${PORTRANGE}"
            echo "transport:"
            echo "  udp:"
            echo "    minHopInterval: 20s"
            echo "    maxHopInterval: 40s"
        else
            echo "server: ${HOST}:${PORT}"
        fi
        echo "quic:"
        echo "  maxIdleTimeout: 60s"
        echo "  keepAlivePeriod: 15s"
        echo "socks5:"
        echo "  listen: 127.0.0.1:1080"
        echo "http:"
        echo "  listen: 127.0.0.1:8080"
        if [ -n "$OBFS" ]; then
            echo "obfs:"
            echo "  type: ${OBFS}"
            echo "  ${OBFS}:"
            echo "    password: ${OBFSPASS}"
        fi
    } > "$CONFIG_FILE"
}

# ------------------------------- servers ------------------------------------

count_servers() { grep -c '^hysteria2://' "$SERVERS_FILE" 2>/dev/null || echo 0; }

server_line() {  # $1=idx (с 1)
    grep '^hysteria2://' "$SERVERS_FILE" | sed -n "${1}p"
}

select_server_idx() {  # $1=idx : парсит ссылку и пишет конфиг
    local line
    line=$(server_line "$1")
    [ -n "$line" ] || return 1
    parse_hysteria_link "$line"
    write_hysteria_config
    state_set CUR_IDX "$1"
    log "Сервер #$1: $HOST:$PORT${PORTRANGE:+ (hop $PORTRANGE)} (${FRAG:-?})"
}

# ------------------------------- binaries -----------------------------------

free_kb() { df -k "$1" 2>/dev/null | awk 'NR==2{print $4}'; }

install_binaries() {
    local dest="/usr/bin"
    if [ "$(free_kb /usr/bin)" -lt 20480 ]; then
        warn "на /usr/bin меньше 20МБ — бинари в /tmp (пропадут после ребута, докачаются сами)"
        dest="/tmp"
    fi
    mkdir -p "$dest"
    local h="$dest/hysteria" i="$dest/ipt2socks"
    if [ -x "$h" ] && [ -x "$i" ]; then
        log "Бинари уже на месте: $dest"
        state_set BIN_PATH "$dest"
        return 0
    fi
    log "Скачиваю hysteria ($HY_ARCH) ..."
    fetch "$BASE/bin/hysteria-$HY_ARCH.gz" "/tmp/hysteria.gz" || die "не удалось скачать hysteria"
    gzip -dc "/tmp/hysteria.gz" > "$h" || die "повреждённый архив hysteria"
    rm -f "/tmp/hysteria.gz"; chmod +x "$h"
    log "Скачиваю ipt2socks ($IPT_ARCH) ..."
    fetch "$BASE/bin/ipt2socks-$IPT_ARCH.gz" "/tmp/ipt2socks.gz" || die "не удалось скачать ipt2socks"
    gzip -dc "/tmp/ipt2socks.gz" > "$i" || die "повреждённый архив ipt2socks"
    rm -f "/tmp/ipt2socks.gz"; chmod +x "$i"
    state_set BIN_PATH "$dest"
    log "Бинари установлены: $dest"
}

# ------------------------------- init script --------------------------------

write_init() {
    cat > "$INIT_SCRIPT" <<'INIE'
#!/bin/sh /etc/rc.common
# NikoVPN hy2-proxy — тонкая обёртка, вся логика в /root/hy2.sh
START=99
STOP=10

start() {
    sh /root/hy2.sh __start >/dev/null 2>&1
}
stop() {
    sh /root/hy2.sh __stop >/dev/null 2>&1
}
boot() {
    sleep 10
    sh /root/hy2.sh __start >/dev/null 2>&1
}
INIE
    chmod +x "$INIT_SCRIPT"
}

# ------------------------------- service ------------------------------------

stop_processes() {
    nft delete table inet transparent_proxy 2>/dev/null
    rm -f /var/run/ipt2socks.pid
    killall -9 ipt2socks 2>/dev/null
    killall -9 hysteria 2>/dev/null
}

start_ipt2socks_nft() {
    local redir=12300
    killall -9 ipt2socks 2>/dev/null
    "$(state_get BIN_PATH /usr/bin)/ipt2socks" -b 0.0.0.0 -l "$redir" \
        -s 127.0.0.1 -p 1080 -R -c 256 >/tmp/ipt2socks.log 2>&1 &
    sleep 2
    pidof ipt2socks >/dev/null 2>&1 || { warn "ipt2socks failed"; return 1; }
    nft delete table inet transparent_proxy 2>/dev/null
    nft add table inet transparent_proxy || return 1
    nft add chain inet transparent_proxy prerouting '{ type nat hook prerouting priority -100; }' || return 1
    nft add rule inet transparent_proxy prerouting ip daddr '{ 192.168.0.0/16, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12 }' return || return 1
    nft add rule inet transparent_proxy prerouting tcp dport '{ 80, 443 }' redirect to ":$redir" || return 1
    return 0
}

hysteria_healthy() {
    pidof hysteria >/dev/null 2>&1 || return 1
    # свежий лог не должен содержать фатальных ошибок соединения
    if grep -qiE 'no connection|recent network activity|failed to initialize|cannot resolve' /tmp/hysteria.log 2>/dev/null; then
        return 1
    fi
    return 0
}

try_current_server() {
    : > /tmp/hysteria.log
    killall -9 hysteria 2>/dev/null
    "$(state_get BIN_PATH /usr/bin)/hysteria" -c "$CONFIG_FILE" >/tmp/hysteria.log 2>&1 &
    sleep 6
    hysteria_healthy
}

cmd___start() {
    local w=0 total idx i
    while ! ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 && [ $w -lt 30 ]; do
        sleep 2; w=$((w+1))
    done
    ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 || { log "нет интернета — пропуск запуска"; return 0; }

    stop_processes

    total=$(count_servers)
    [ "$total" -ge 1 ] || { log "нет списка серверов ($SERVERS_FILE)"; return 1; }

    idx=$(state_get CUR_IDX 1)
    for i in "" $(seq 2 "$total"); do
        select_server_idx "$idx" || { idx=$(( idx % total + 1 )); continue; }
        log "Пробую сервер #$idx ..."
        if try_current_server; then
            if start_ipt2socks_nft; then
                log "✓ Прокси активен через #$idx ($HOST:$PORT${PORTRANGE:+ hop})"
                return 0
            fi
            warn "iptables/nft не настроились на #$idx"
            stop_processes
            return 1
        fi
        warn "сервер #$idx не отвечает — пробую следующий"
        stop_processes
        idx=$(( idx % total + 1 ))
    done
    log "ни один сервер не заработал"
    return 1
}

cmd___stop() {
    stop_processes
    log "hy2-proxy остановлен"
}

# ------------------------------- commands -----------------------------------

fetch_servers() {
    read_creds
    local tmp="/tmp/hy2-links.$$"
    api_get "/config?format=links&protocol=hysteria2&multiport=1" "$tmp" \
        || die "не удалось получить список серверов из API"
    grep '^hysteria2://' "$tmp" > "$SERVERS_FILE" || die "в ответе нет hysteria2-ссылок"
    rm -f "$tmp"
    log "Серверов в списке: $(count_servers)"
}

do_install() {
    detect_arch
    fetch_servers

    # выбор: PREFER_SERVER из /root/hy2.prefs, иначе первый
    local pref="" sel=""
    [ -f "$PREFS_FILE" ] && pref="$(sed -n 's/^PREFER_SERVER=//p' "$PREFS_FILE" | head -1)"
    if [ -n "$pref" ]; then
        sel=$(grep -i "$pref" "$SERVERS_FILE" | head -1)
        [ -n "$sel" ] && grep -in "$pref" "$SERVERS_FILE" | head -1 | cut -d: -f1 > /tmp/.hy2idx
    fi

    install_binaries
    write_init

    # старт с автоперебором; если pref задан — начинаем с него
    if [ -n "$pref" ] && [ -s /tmp/.hy2idx ]; then
        state_set CUR_IDX "$(cat /tmp/.hy2idx)"
        rm -f /tmp/.hy2idx
    else
        state_set CUR_IDX 1
    fi
    cmd___start || die "ни один сервер не заработал — попробуй позже: sh $0 switch"
    log "Готово. Смена сервера: sh $0 switch"
}

do_switch() {
    local total=$(count_servers)
    [ "$total" -ge 1 ] || die "нет списка серверов — сначала install/update"
    local idx=$(state_get CUR_IDX 1)
    idx=$(( idx % total + 1 ))
    state_set CUR_IDX "$idx"
    cmd___start || die "переключение не удалось"
}

do_status() {
    echo "=== NikoVPN hy2 ==="
    [ -f "$STATE_FILE" ] && grep -E 'CUR_IDX|HY_ARCH|BIN_PATH' "$STATE_FILE"
    echo "Серверов в списке: $(count_servers)"
    pidof hysteria >/dev/null 2>&1 && echo "hysteria:  RUNNING ($(state_get CUR_IDX 1))" || echo "hysteria:  STOPPED"
    pidof ipt2socks >/dev/null 2>&1 && echo "ipt2socks: RUNNING" || echo "ipt2socks: STOPPED"
    [ -f /tmp/hysteria.log ] && tail -n 3 /tmp/hysteria.log
}

case "${1:-install}" in
    install)  do_install ;;
    update)   do_install ;;
    switch)   do_switch ;;
    status)   do_status ;;
    uninstall)
        cmd___stop
        rm -f "$INIT_SCRIPT" "$CONFIG_FILE" "$STATE_FILE" "$SERVERS_FILE" \
            /usr/bin/hysteria /usr/bin/ipt2socks /tmp/hysteria /tmp/ipt2socks
        echo "NikoVPN hy2 удалён."
        ;;
    __start)  cmd___start ;;
    __stop)   cmd___stop ;;
    restart)  cmd___stop; sleep 2; cmd___start ;;
    *) echo "usage: hy2.sh [install|update|switch|status|restart|uninstall]"; exit 1 ;;
esac
