#!/bin/sh
# DSM service control for the RocketZero tunnel client.
#
# Tiến trình mang tên "rocketcore". Đây là frp (Apache 2.0) đổi tên: một
# thương hiệu duy nhất trên mọi máy đỡ làm khách hoang mang khi thấy một cái
# tên lạ chạy trong máy hoặc hiện ra ở hộp thoại tường lửa. Phần ghi công giấy
# phép nằm ở THIRD-PARTY.md và trong gói cài.

TARGET="/var/packages/rocketzero/target"
VAR="/var/packages/rocketzero/var"
CORE="$TARGET/bin/rocketcore"
CFG="$VAR/rocketcore.toml"
PIDFILE="$VAR/rocketcore.pid"

# Máy cài từ bản cũ vẫn còn tên frpc.*; chuyển sang tên mới một lần.
migrate_names() {
    [ -f "$VAR/frpc.toml" ] && [ ! -f "$CFG" ] || return 0
    sed 's#/frpc\.log#/rocketcore.log#' "$VAR/frpc.toml" >"$CFG" &&
        chmod 600 "$CFG" && rm -f "$VAR/frpc.toml"
    [ -f "$VAR/frpc.log" ] && mv "$VAR/frpc.log" "$VAR/rocketcore.log"
    rm -f "$VAR/frpc.pid"
    return 0
}

CHILDFILE="$VAR/rocketcore.child"

# Vòng trông coi: chạy tunnel khi CÓ cấu hình, nằm chờ khi chưa có.
#
# VÌ SAO CẦN: gói nay cài xong là CHƯA kích hoạt — khách tự kích hoạt trong trang
# RocketZero. Bản trước thì "start" thất bại khi thiếu cấu hình, mà gói không
# chạy thì DSM không hiện app, và khách không có đường nào vào để kích hoạt. Vòng
# lặp này làm "start" luôn thành công, nên app luôn mở được.
#
# Nó còn bỏ luôn nhu cầu quyền root sau khi kích hoạt: trang web chỉ cần ghi cấu
# hình rồi giết tiến trình con (cùng user, làm được), vòng này tự dựng lại tunnel
# với cấu hình mới trong vài giây. Không có nó thì phải gọi synopkg restart —
# thứ đòi root mà gói không có.
# Báo về "máy này đã cài gói", kể cả khi chưa kích hoạt.
#
# VÌ SAO CẦN: máy chỉ xuất hiện ở phía máy chủ khi nó kích hoạt. Người tải về,
# cài xong rồi dừng lại — vì chưa mua mã, vì kích hoạt trượt, hay vì chưa hiểu
# phải làm gì — là hoàn toàn vô hình. Mà khoảng cách giữa "đã cài" và "đã kích
# hoạt" đúng là con số nói cho ta biết khách rụng ở đâu; không có nó thì mọi cải
# tiến luồng kích hoạt đều là đoán.
#
# Gửi thưa: một lần lúc khởi động, rồi sáu tiếng một lần. Đây là số để nhìn xu
# hướng, không phải nhịp tim — gửi dày hơn chỉ tốn pin router của khách.
#
# Máy ĐÃ kích hoạt hầu như không đi qua đây, vì "wait" bên dưới đứng im hàng giờ
# khi tunnel đang chạy. Không sao: máy đã kích hoạt tự khai model, bản DSM và bản
# gói ở mỗi lượt đăng nhập tunnel. Đường này tồn tại cho đúng những máy KHÔNG có
# tunnel — tức những máy mà nếu không có nó thì ta chẳng biết gì cả.
HELLO_EVERY=21600
HELLO_STAMP="$VAR/hello.at"

say_hello() {
    [ -r "$TARGET/sysinfo.sh" ] || return 0
    _now=$(date +%s 2>/dev/null) || return 0
    _last=$(cat "$HELLO_STAMP" 2>/dev/null | tr -cd '0-9')
    [ -n "$_last" ] && [ $((_now - _last)) -lt "$HELLO_EVERY" ] && return 0

    TL_VAR="$VAR"; export TL_VAR
    . "$TARGET/sysinfo.sh"
    _ver=""
    if [ -r "$TARGET/version.sh" ]; then
        # version.sh đọc $SYNOPKG_PKGVER, biến chỉ DSM đặt lúc cài; ở đây nó rỗng
        # và với set -u thì hàm chết. Script này không bật set -u, nhưng cứ giữ
        # nếp phòng thân cho lần sau ai đó bật.
        . "$TARGET/version.sh" 2>/dev/null
        _ver=$(tl_pkg_ver 2>/dev/null || echo "")
    fi
    _id=$(cat "$VAR/linkid.txt" 2>/dev/null | tr -cd 'a-z0-9-')
    _api=$(cat "$VAR/api.txt" 2>/dev/null | tr -cd 'A-Za-z0-9:/._-')
    [ -n "$_api" ] || _api="https://link.nashub.vn"

    # -m 8: không để một máy chủ chậm giữ chân vòng trông coi. Mọi lỗi đều nuốt —
    # đếm số liệu không bao giờ được làm hỏng việc chính là giữ tunnel sống.
    curl -s -m 8 -o /dev/null -X POST "$_api/api/v1/hello" \
        -H 'Content-Type: application/json' \
        -d "{\"iid\":\"$(tl_iid)\",\"ver\":\"$_ver\",\"model\":\"$(tl_model)\",\"dsm\":\"$(tl_dsm)\",\"id\":\"$_id\"}" \
        2>/dev/null
    echo "$_now" >"$HELLO_STAMP" 2>/dev/null
    return 0
}

# Khách đổi cổng DSM SAU khi kích hoạt thì cấu hình tunnel thành lạc hậu, và cái
# hỏng không tự lộ ra: frpc vẫn nối tốt tới máy chủ, chỉ có điều nó chuyển byte
# vào một cổng không còn ai nghe. Khách thấy "máy bạn đang offline" mãi trong khi
# máy đang bật, và phía máy chủ không phân biệt được với một NAS đã tắt.
#
# Nên phải dò lại theo nhịp, không thể chỉ dò một lần lúc kích hoạt. Và phải chạy
# ở tiến trình riêng: vòng canh chính đứng chờ ở "wait" suốt thời gian tunnel còn
# sống, có thể là hàng tuần, nên nó không có nhịp nào để tự kiểm.
# TL_PORT_EVERY chỉ để bộ kiểm rút ngắn nhịp; trên máy khách nó rỗng và nhịp là
# 30 phút. Không dùng nhịp ngắn ngoài thực địa: mỗi lượt là một lần gõ curl vào
# DSM, và đây là tiến trình chạy suốt đời máy.
PORT_EVERY="${TL_PORT_EVERY:-1800}"
WATCHFILE="$VAR/portwatch.pid"

# Cổng đang ghi trong khối proxy "dsm". Không dùng grep: tệp có ba dòng localPort
# (dsm, smb-p2p, smb-relay) và hai dòng sau là 445 — sửa nhầm thì mất ổ đĩa mạng.
dsm_port_in_cfg() {
    awk '
        /^\[\[proxies\]\]/ { inblk = 0 }
        inblk && /^localPort = [0-9]+$/ { sub(/^localPort = /, ""); print; exit }
        /^name = "dsm"$/ { inblk = 1 }
    ' "$CFG" 2>/dev/null
}

set_dsm_port_in_cfg() {
    _new="$1"
    _tmp="$CFG.new.$$"
    : >"$_tmp" 2>/dev/null || return 1
    chmod 600 "$_tmp" 2>/dev/null
    awk -v p="$_new" '
        /^\[\[proxies\]\]/ { inblk = 0 }
        inblk && /^localPort = [0-9]+$/ { print "localPort = " p; inblk = 0; next }
        { print }
        /^name = "dsm"$/ { inblk = 1 }
    ' "$CFG" >>"$_tmp" 2>/dev/null || { rm -f "$_tmp"; return 1; }

    # Xem lại tệp mới TRƯỚC khi thay: ghi ra một cấu hình cụt thì tunnel không bao
    # giờ lên lại được, và lúc đó phải có người tới tận nơi. Ba dấu hiệu phải còn
    # nguyên — token đăng nhập, số khối proxy, và đúng cổng vừa đặt.
    grep -q '^metadatas.token' "$_tmp" 2>/dev/null || { rm -f "$_tmp"; return 1; }
    _a=$(grep -c '^\[\[proxies\]\]' "$CFG" 2>/dev/null)
    _b=$(grep -c '^\[\[proxies\]\]' "$_tmp" 2>/dev/null)
    [ "$_a" = "$_b" ] || { rm -f "$_tmp"; return 1; }
    _c=$(awk '
        /^\[\[proxies\]\]/ { inblk = 0 }
        inblk && /^localPort = [0-9]+$/ { sub(/^localPort = /, ""); print; exit }
        /^name = "dsm"$/ { inblk = 1 }
    ' "$_tmp" 2>/dev/null)
    [ "$_c" = "$_new" ] || { rm -f "$_tmp"; return 1; }

    mv -f "$_tmp" "$CFG" 2>/dev/null || { rm -f "$_tmp"; return 1; }
    return 0
}

watch_dsm_port() {
    [ -r "$TARGET/sysinfo.sh" ] || return 0
    TL_VAR="$VAR"; export TL_VAR
    . "$TARGET/sysinfo.sh"
    # Dò NGAY một lượt rồi mới ngủ, không ngủ trước. Khách đổi cổng trong lúc gói
    # đang tắt là chuyện thường — họ tắt gói đi để sửa mạng rồi bật lại. Ngủ trước
    # thì nửa tiếng đầu sau khi bật máy vẫn hỏng, mà đó đúng là lúc khách đang
    # ngồi xem nó có chạy không.
    _first=1
    while :; do
        [ "$_first" = 1 ] || sleep "$PORT_EVERY"
        _first=0
        [ -s "$CFG" ] || continue
        _want=$(tl_dsm_port 2>/dev/null)
        case "$_want" in ''|*[!0-9]*) continue ;; esac
        _have=$(dsm_port_in_cfg)
        # Rỗng nghĩa là cấu hình không có khối dsm như mong đợi — đừng đoán, để yên.
        [ -n "$_have" ] || continue
        [ "$_want" = "$_have" ] && continue
        set_dsm_port_in_cfg "$_want" || continue
        # Giết tiến trình con để vòng canh chính nối lại bằng cấu hình mới. Không
        # tự chạy lại ở đây: một chỗ khởi động tunnel là đủ, hai chỗ thì có ngày
        # hai bản frpc cùng chạy và tranh nhau một subdomain.
        #
        # Thử ba nhịp vì lượt dò đầu tiên chạy song song với lúc gói đang khởi
        # động: nếu bắn ngay thì pidfile có thể chưa được ghi, cú kill rơi vào chỗ
        # trống, và tunnel cứ thế chạy bằng cấu hình cũ tới nửa tiếng sau.
        _n=0
        while [ "$_n" -lt 3 ]; do
            _pid=$(cat "$CHILDFILE" 2>/dev/null | tr -cd '0-9')
            if [ -n "$_pid" ]; then
                kill "$_pid" 2>/dev/null
                break
            fi
            _n=$((_n + 1))
            sleep 2
        done
    done
}

supervise() {
    while :; do
        say_hello 2>/dev/null || :
        if [ -s "$CFG" ] && grep -q '^metadatas.token' "$CFG" 2>/dev/null; then
            "$CORE" -c "$CFG" >>"$VAR/rocketcore.out" 2>&1 &
            echo $! >"$CHILDFILE"
            wait $! 2>/dev/null
            rm -f "$CHILDFILE"
        fi
        # Nhịp nghỉ này là cái phanh: tunnel hỏng ngay khi vừa chạy (cấu hình sai,
        # mất mạng) thì không có nó vòng lặp sẽ quay tít và ngốn hết CPU của NAS.
        sleep 3
    done
}

start_daemon() {
    migrate_names
    if status_daemon >/dev/null 2>&1; then
        echo "Đang chạy rồi"
        return 0
    fi
    supervise >/dev/null 2>&1 &
    echo $! >"$PIDFILE"
    watch_dsm_port >/dev/null 2>&1 &
    echo $! >"$WATCHFILE"
    sleep 1
    status_daemon >/dev/null 2>&1
}

stop_daemon() {
    # Giết vòng trông coi TRƯỚC, không thì nó thấy con chết và dựng lại ngay.
    if [ -f "$PIDFILE" ]; then
        kill "$(cat "$PIDFILE")" 2>/dev/null
        rm -f "$PIDFILE"
    fi
    # Bộ canh cổng cũng phải chết trước con, không thì nó thấy con chết rồi đi
    # giết một tiến trình đã không còn — vô hại, nhưng nó còn sống sau khi khách
    # bấm Stop thì gói coi như chưa dừng.
    if [ -f "$WATCHFILE" ]; then
        kill "$(cat "$WATCHFILE")" 2>/dev/null
        rm -f "$WATCHFILE"
    fi
    if [ -f "$CHILDFILE" ]; then
        kill "$(cat "$CHILDFILE")" 2>/dev/null
        rm -f "$CHILDFILE"
    fi
    # Chắc tay: tiến trình có thể đã được chạy lại ngoài pidfile. Dọn cả tên cũ
    # cho máy vừa nâng cấp từ bản trước.
    killall rocketcore 2>/dev/null
    killall frpc 2>/dev/null
    return 0
}

status_daemon() {
    [ -f "$PIDFILE" ] || return 1
    kill -0 "$(cat "$PIDFILE")" 2>/dev/null
}

# Tunnel có đang thông không — hỏi chính cái log của frp.
#
# Tiến trình còn sống không có nghĩa là khách vào được NAS: frp vẫn chạy và vẫn
# thử kết nối lại trong khi đường ra Internet đã đứt hàng giờ.
#
# Cách đọc: khi tunnel thông, frp im lặng (không ghi gì thêm); khi đứt, nó ghi
# lỗi và "try to reconnect" liên tục. Nên ta đếm số lần hỏng KỂ TỪ lần thành
# công gần nhất — số đó vừa cho biết đang đứt hay không, vừa cho biết đứt lâu
# chưa, mà không phải mò đọc dấu thời gian trong log (định dạng đổi theo phiên
# bản frp, và date trên DSM là busybox nên không phân tích được cho chắc).
#
# Không có log hoặc log không có dòng nào đáng kể thì coi như đang thông: thà
# báo nhầm là chạy còn hơn báo hỏng một cái tunnel đang tốt rồi khách bấm khởi
# động lại làm đứt thật.
LOGF="$VAR/rocketcore.log"; [ -f "$LOGF" ] || LOGF="$VAR/frpc.log"
RETRY_LIMIT=12

tunnel_fails() {
    [ -s "$LOGF" ] || { echo 0; return 0; }
    tail -300 "$LOGF" 2>/dev/null | awk '
        /start proxy success|login to server success/ { n = 0; next }
        /login to server error|login to server failed|connect to server error|session shutdown|try to reconnect|i\/o deadline reached|dial tcp/ { n = n + 1 }
        END { print n + 0 }'
}

case "$1" in
    start)   start_daemon ;;
    stop)    stop_daemon ;;
    restart) stop_daemon; sleep 1; start_daemon ;;
    status)
        if ! status_daemon; then echo "stopped"; exit 3; fi
        # DSM chỉ hiểu hai câu trả lời: 0 = đang chạy, 3 = đã dừng. Không có ô
        # nào cho "tiến trình sống nhưng tunnel đứt", nên phải chọn một bên.
        #
        # Chọn thế này: mới đứt, đang thử lại thì vẫn trả 0. Mạng nhà khách rớt
        # dăm giây là chuyện thường ngày; báo dừng ngay sẽ làm Package Center
        # nhấp nháy và có thể kéo theo một vòng dừng-chạy-dừng vô ích, trong khi
        # frp tự nối lại được mà không cần ai đụng vào.
        #
        # Chỉ khi đã thử lại hàng chục lần mà vẫn không vào nổi máy chủ — đứt
        # kéo dài tính bằng nhiều phút chứ không phải vài giây — mới trả 3. Tới
        # mức đó thì khởi động lại đúng là việc nên làm, và quan trọng hơn:
        # khách mở Package Center ra là thấy máy đang hỏng, thay vì thấy chữ
        # "Running" màu xanh trong khi không ai vào được NAS.
        FAILS=$(tunnel_fails)
        if [ "${FAILS:-0}" -ge "$RETRY_LIMIT" ]; then
            echo "stopped (tunnel mất kết nối, đã thử lại $FAILS lần)"
            exit 3
        fi
        if [ "${FAILS:-0}" -gt 0 ]; then
            echo "running (đang kết nối lại, lần thứ $FAILS)"
        else
            echo "running"
        fi
        exit 0
        ;;
    log)     echo "$VAR/rocketcore.log" ;;
    *)       echo "usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac
