diff --git a/antileech/Makefile b/antileech/Makefile index e59e019dd..5e0da827d 100644 --- a/antileech/Makefile +++ b/antileech/Makefile @@ -5,12 +5,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=antileech -PKG_RELEASE:=5 +PKG_RELEASE:=6 PKG_SOURCE_PROTO:=git PKG_SOURCE_DATE:=2021-12-05 PKG_SOURCE_URL:=https://github.com/persmule/amule-dlp.antiLeech.git -PKG_SOURCE_VERSION:=27f00cc98e9548777ce717f55fc760b73230b563 +PKG_SOURCE_VERSION:=886e119c87cda6220c3958459b08238300440f03 PKG_MIRROR_HASH:=skip PKG_FIXUP:=autoreconf diff --git a/clashoo/Makefile b/clashoo/Makefile index a45e540a5..ef5ee9930 100644 --- a/clashoo/Makefile +++ b/clashoo/Makefile @@ -5,13 +5,13 @@ PKG_SHORT_SHA:=e4dd968 PKG_COMMIT_DATE:=2026.10.08 PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA) -PKG_RELEASE:=7 +PKG_RELEASE:=8 PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src -PKG_SOURCE_VERSION:=aea9b179d46fdff1743a79f4e803e768acbc0711 +PKG_SOURCE_VERSION:=7c870e53cf5004a1000b5c8d9b8caba5cf889927 PKG_HASH:=skip PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA) diff --git a/clashoo/files/usr/share/clashoo/runtime/clashoo.sh b/clashoo/files/usr/share/clashoo/runtime/clashoo.sh index 32c3c777c..35d7ebfe5 100755 --- a/clashoo/files/usr/share/clashoo/runtime/clashoo.sh +++ b/clashoo/files/usr/share/clashoo/runtime/clashoo.sh @@ -153,21 +153,25 @@ resolve_via() { } curl_subscription() { - local url tmp hdr err ua extra_resolve http_code rc + local url tmp hdr err ua host ip http_code rc url="$1" tmp="$2" hdr="$3" err="$4" ua="$5" - extra_resolve="$6" + host="$6" + ip="$7" rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1 - # shellcheck disable=SC2086 + set -- + if [ -n "$host" ] && [ -n "$ip" ]; then + set -- --resolve "$host:443:$ip" --resolve "$host:80:$ip" + fi http_code="$(curl -sSL --connect-timeout 15 --max-time 60 \ --speed-time 30 --speed-limit 1 --retry 2 \ -H "User-Agent: ${ua}" -D "$hdr" -o "$tmp" \ - $extra_resolve \ + "$@" \ -w '%{http_code}' "$url" 2>"$err")" rc=$? printf '%s\n' "$http_code" @@ -176,7 +180,7 @@ curl_subscription() { download_subscription() { local url target tmp hdr err rc http_code info_line ua err_msg - local host ip dns extra + local host ip dns url="$1" target="$2" tmp="${TMP_PREFIX}.yaml" @@ -187,25 +191,27 @@ download_subscription() { [ -n "$ua" ] || ua="$DEFAULT_SUB_UA" if command -v curl >/dev/null 2>&1; then - http_code="$(curl_subscription "$url" "$tmp" "$hdr" "$err" "$ua" "")" + http_code="$(curl_subscription "$url" "$tmp" "$hdr" "$err" "$ua" "" "")" rc=$? if [ "$rc" -ne 0 ] || [ "$http_code" = "000" ]; then host="$(extract_host "$url")" + case "$host" in + ''|[-.]*|*[!A-Za-z0-9.-]*) host='' ;; + esac if [ -n "$host" ]; then for dns in 223.5.5.5 119.29.29.29 1.1.1.1 8.8.8.8; do ip="$(resolve_via "$host" "$dns")" [ -n "$ip" ] || continue log_update "DNS 回退:${host} -> ${ip} (@${dns})" - extra="--resolve ${host}:443:${ip} --resolve ${host}:80:${ip}" - http_code="$(curl_subscription "$url" "$tmp" "$hdr" "$err" "$ua" "$extra")" + http_code="$(curl_subscription "$url" "$tmp" "$hdr" "$err" "$ua" "$host" "$ip")" rc=$? [ "$rc" -eq 0 ] && [ "$http_code" = "200" ] && break done fi fi else - wget -q --tries=4 --timeout=20 \ + wget -q --timeout=20 \ --user-agent="$ua" "$url" -O "$tmp" 2>"$err" rc=$? http_code="" diff --git a/clashoo/files/usr/share/clashoo/runtime/restore.sh b/clashoo/files/usr/share/clashoo/runtime/restore.sh index 4ce19ca62..f5dd17f47 100755 --- a/clashoo/files/usr/share/clashoo/runtime/restore.sh +++ b/clashoo/files/usr/share/clashoo/runtime/restore.sh @@ -7,15 +7,21 @@ SECRET=$(uci get clashoo.config.dash_pass 2>/dev/null) LAN_IP=$(uci get network.lan.ipaddr 2>/dev/null |awk -F '/' '{print $1}' 2>/dev/null) PORT=$(uci get clashoo.config.dash_port 2>/dev/null) urlencode() { - local data if [ "$#" != 1 ]; then return 1 fi - data=$(curl -s -o /dev/null -w %{url_effective} --get --data-urlencode "$1" "") - if [ ! -z "$data" ]; then - echo "${data##/?}" - fi - return 0 + printf '%s' "$1" | hexdump -v -e '1/1 "%u "' | awk ' + { + for (i = 1; i <= NF; i++) { + b = $i + 0 + if ((b >= 48 && b <= 57) || (b >= 65 && b <= 90) || + (b >= 97 && b <= 122) || b == 45 || b == 46 || + b == 95 || b == 126) + printf "%c", b + else + printf "%%%02X", b + } + }' } cat $HISTORY_PATH |while read line do @@ -28,4 +34,4 @@ do fi done >/dev/null 2>&1 curl -m 5 --retry 2 -H "Authorization: Bearer ${SECRET}" -H "Content-Type:application/json" -X DELETE http://"$LAN_IP":"$PORT"/connections >/dev/null 2>&1 -fi \ No newline at end of file +fi diff --git a/clashoo/files/usr/share/clashoo/update/component_update.sh b/clashoo/files/usr/share/clashoo/update/component_update.sh index 2ad2eb2a3..3243fb432 100755 --- a/clashoo/files/usr/share/clashoo/update/component_update.sh +++ b/clashoo/files/usr/share/clashoo/update/component_update.sh @@ -58,8 +58,8 @@ fetch_text() { return $? fi if command -v wget >/dev/null 2>&1; then - [ -n "$proxy" ] && { http_proxy="$proxy" https_proxy="$proxy" wget -qO- --timeout="$REQUEST_TIMEOUT" --tries=1 "$url"; return $?; } - wget -qO- --timeout="$REQUEST_TIMEOUT" --tries=1 "$url" + [ -n "$proxy" ] && { http_proxy="$proxy" https_proxy="$proxy" wget -qO- --timeout="$REQUEST_TIMEOUT" "$url"; return $?; } + wget -qO- --timeout="$REQUEST_TIMEOUT" "$url" return $? fi return 127 @@ -74,10 +74,10 @@ download_file() { return $? fi if [ -n "$proxy" ]; then - http_proxy="$proxy" https_proxy="$proxy" wget -qO "$out" --timeout="$REQUEST_TIMEOUT" --tries=1 "$url" + http_proxy="$proxy" https_proxy="$proxy" wget -qO "$out" --timeout="$REQUEST_TIMEOUT" "$url" return $? fi - wget -qO "$out" --timeout="$REQUEST_TIMEOUT" --tries=1 "$url" + wget -qO "$out" --timeout="$REQUEST_TIMEOUT" "$url" } download_url() { diff --git a/clashoo/files/usr/share/clashoo/update/core_download.sh b/clashoo/files/usr/share/clashoo/update/core_download.sh index 69e0f6f06..f5aec7ac9 100755 --- a/clashoo/files/usr/share/clashoo/update/core_download.sh +++ b/clashoo/files/usr/share/clashoo/update/core_download.sh @@ -176,7 +176,7 @@ fetch_url_try() { return $? fi if command -v wget >/dev/null 2>&1; then - wget -qO- --timeout="$CONNECT_TIMEOUT" --no-check-certificate --user-agent="Clash/OpenWRT" "$url" + wget -qO- --timeout="$CONNECT_TIMEOUT" --user-agent="Clash/OpenWRT" "$url" return $? fi return 127 @@ -204,7 +204,7 @@ download_file_try() { return $? fi if command -v wget >/dev/null 2>&1; then - wget -q --timeout="$REQUEST_TIMEOUT" --tries=1 --no-check-certificate --user-agent="Clash/OpenWRT" "$url" -O "$out" + wget -q --timeout="$REQUEST_TIMEOUT" --user-agent="Clash/OpenWRT" "$url" -O "$out" return $? fi return 127 @@ -216,7 +216,7 @@ head_url_try() { curl -fsSIL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$REQUEST_TIMEOUT" -A "Clash/OpenWRT" "$url" >/dev/null 2>&1 return $? fi - wget -q --spider --timeout="$CONNECT_TIMEOUT" --no-check-certificate --user-agent="Clash/OpenWRT" "$url" + wget -q --spider --timeout="$CONNECT_TIMEOUT" --user-agent="Clash/OpenWRT" "$url" } map_mihomo_arch() { @@ -637,7 +637,7 @@ fetch_latest_tag() { if command -v curl >/dev/null 2>&1; then tag="$(curl -fsSIL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$REQUEST_TIMEOUT" -A "Clash/OpenWRT" "$u" 2>/dev/null | sed -n 's#^[Ll]ocation: .*/releases/tag/\([^[:space:]\r]*\).*#\1#p' | head -n 1)" else - tag="$(wget -S --spider --timeout="$CONNECT_TIMEOUT" --no-check-certificate --user-agent="Clash/OpenWRT" "$u" 2>&1 | sed -n 's#^ Location: .*/releases/tag/\([^[:space:]]*\).*#\1#p' | head -n 1)" + tag="$(wget -S --spider --timeout="$CONNECT_TIMEOUT" --user-agent="Clash/OpenWRT" "$u" 2>&1 | sed -n 's#^ Location: .*/releases/tag/\([^[:space:]]*\).*#\1#p' | head -n 1)" fi case "$tag" in v*[-]*) @@ -684,7 +684,7 @@ fetch_latest_tag() { if command -v curl >/dev/null 2>&1; then tag="$(curl -fsSIL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$REQUEST_TIMEOUT" -A "Clash/OpenWRT" "$u" 2>/dev/null | sed -n 's#^[Ll]ocation: .*/releases/tag/\([^[:space:][:cntrl:]]*\).*#\1#p' | head -n 1)" else - tag="$(wget -S --spider --timeout="$CONNECT_TIMEOUT" --no-check-certificate --user-agent="Clash/OpenWRT" "$u" 2>&1 | sed -n 's#^ Location: .*/releases/tag/\([^[:space:]]*\).*#\1#p' | head -n 1)" + tag="$(wget -S --spider --timeout="$CONNECT_TIMEOUT" --user-agent="Clash/OpenWRT" "$u" 2>&1 | sed -n 's#^ Location: .*/releases/tag/\([^[:space:]]*\).*#\1#p' | head -n 1)" fi [ -n "$tag" ] && { echo "$tag"; return 0; } diff --git a/clashoo/files/usr/share/clashoo/update/geoip.sh b/clashoo/files/usr/share/clashoo/update/geoip.sh index a8bc66e6a..387c634f7 100755 --- a/clashoo/files/usr/share/clashoo/update/geoip.sh +++ b/clashoo/files/usr/share/clashoo/update/geoip.sh @@ -67,7 +67,7 @@ _fetch() { fi return $? fi - wget -q --timeout=120 --no-check-certificate --user-agent="Clash/OpenWRT" "$url" -O "$target" + wget -q --timeout=120 --user-agent="Clash/OpenWRT" "$url" -O "$target" } # 多源拉取:本机代理 → 国内镜像(gh-proxy / ghfast / jsdelivr)→ 原 URL 兜底 diff --git a/clashoo/files/usr/share/clashoo/update/lgbm_update.sh b/clashoo/files/usr/share/clashoo/update/lgbm_update.sh index 6b96dbf0e..fa17a30cd 100755 --- a/clashoo/files/usr/share/clashoo/update/lgbm_update.sh +++ b/clashoo/files/usr/share/clashoo/update/lgbm_update.sh @@ -22,7 +22,7 @@ if command -v curl >/dev/null 2>&1; then curl -fL --connect-timeout 15 --max-time 120 -A "Clash/OpenWRT" "$DOWNLOAD_URL" -o "$TMP_PATH" 2>/dev/null dl_rc=$? elif command -v wget >/dev/null 2>&1; then - wget -q --timeout=30 --tries=2 --no-check-certificate -U "Clash/OpenWRT" "$DOWNLOAD_URL" -O "$TMP_PATH" 2>/dev/null + wget -q --timeout=30 -U "Clash/OpenWRT" "$DOWNLOAD_URL" -O "$TMP_PATH" 2>/dev/null dl_rc=$? else log "No curl or wget found" diff --git a/clashoo/files/usr/share/clashoo/update/panel_download.sh b/clashoo/files/usr/share/clashoo/update/panel_download.sh index 4519c4833..96c241a6b 100755 --- a/clashoo/files/usr/share/clashoo/update/panel_download.sh +++ b/clashoo/files/usr/share/clashoo/update/panel_download.sh @@ -79,13 +79,29 @@ download_zip() { --proxy "$proxy" -A "Clash/OpenWRT" "$url" -o "$ZIP_FILE"; then return 0 fi - if wget -q --timeout=60 --no-check-certificate --user-agent="Clash/OpenWRT" "$url" -O "$ZIP_FILE"; then + if wget -q --timeout=60 --user-agent="Clash/OpenWRT" "$url" -O "$ZIP_FILE"; then return 0 fi done return 1 } +validate_zip_entries() { + entries="$TMP_ROOT/entries.txt" + if command -v unzip >/dev/null 2>&1; then + unzip -Z1 "$ZIP_FILE" >"$entries" 2>/dev/null || return 1 + elif command -v bsdtar >/dev/null 2>&1; then + bsdtar -tf "$ZIP_FILE" >"$entries" 2>/dev/null || return 1 + else + return 1 + fi + [ -s "$entries" ] || return 1 + awk ' + /^\// || /^[A-Za-z]:/ || /(^|\/)\.\.(\/|$)/ || /\\/ || /\r/ { bad = 1 } + END { exit bad } + ' "$entries" +} + extract_zip() { if command -v unzip >/dev/null 2>&1; then unzip -oq "$ZIP_FILE" -d "$UNPACK_DIR" >/dev/null 2>&1 @@ -141,6 +157,12 @@ if ! download_zip; then exit 1 fi +if ! validate_zip_entries; then + set_state "error" "压缩包路径无效" + log_msg "Dashboard panel archive has unsafe entries" "面板压缩包含不安全路径" + exit 1 +fi + extract_zip extract_rc=$? if [ "$extract_rc" -ne 0 ]; then @@ -154,6 +176,12 @@ if [ "$extract_rc" -ne 0 ]; then exit 1 fi +if find "$UNPACK_DIR" ! -type d ! -type f | grep -q .; then + set_state "error" "压缩包文件类型无效" + log_msg "Dashboard panel archive has unsafe file types" "面板压缩包含不安全文件类型" + exit 1 +fi + SRC_DIR="$(find_web_root)" if [ -z "$SRC_DIR" ] || [ ! -f "$SRC_DIR/index.html" ]; then set_state "error" "文件无效" diff --git a/clashoo/files/usr/share/clashoo/update/subscription_update.sh b/clashoo/files/usr/share/clashoo/update/subscription_update.sh index 49ef064f3..60827ed41 100755 --- a/clashoo/files/usr/share/clashoo/update/subscription_update.sh +++ b/clashoo/files/usr/share/clashoo/update/subscription_update.sh @@ -188,7 +188,7 @@ download_to() { DOWNLOAD_ERROR="$(curl_error "$last_rc")" return 1 fi - wget -q --tries=4 --timeout=20 --user-agent="$ua" "$url" -O "$out" + wget -q --timeout=20 --user-agent="$ua" "$url" -O "$out" rc=$? [ "$rc" -eq 0 ] && return 0 DOWNLOAD_ERROR="网络错误(wget rc=${rc})" @@ -209,8 +209,7 @@ update_info() { } service_running() { - [ -n "${CLASHOO_SERVICE_CMD:-}" ] && return 0 - "$SERVICE_CMD" status >/dev/null 2>&1 + [ "$("$SERVICE_CMD" status 2>/dev/null)" = "running" ] } template_output_name() { diff --git a/clashoo/files/usr/share/clashoo/update/update_china_ip.sh b/clashoo/files/usr/share/clashoo/update/update_china_ip.sh index 3797e8f85..8d31b73db 100755 --- a/clashoo/files/usr/share/clashoo/update/update_china_ip.sh +++ b/clashoo/files/usr/share/clashoo/update/update_china_ip.sh @@ -9,10 +9,11 @@ TARGET_V6="${NFT_DIR}/geoip6_cn.nft" FW4_SCRIPT="${FW4_SCRIPT:-/usr/share/clashoo/net/fw4.sh}" PROC_ROOT="${PROC_ROOT:-/proc}" SINGBOX_BIN_DIR="${SINGBOX_BIN_DIR:-/usr/share/clashoo/bin}" -TMP_V4="/tmp/china_ip.txt.$$" -TMP_V6="/tmp/china_ipv6.txt.$$" -OUT_V4="/tmp/geoip_cn.nft.$$" -OUT_V6="/tmp/geoip6_cn.nft.$$" +TMP_ROOT="$(mktemp -d /tmp/clashoo_china_ip.XXXXXX)" +TMP_V4="$TMP_ROOT/china_ip.txt" +TMP_V6="$TMP_ROOT/china_ipv6.txt" +OUT_V4="$TMP_ROOT/geoip_cn.nft" +OUT_V6="$TMP_ROOT/geoip6_cn.nft" log() { printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "$LOG_FILE" @@ -30,14 +31,14 @@ download_with_fallback() { local output="$2" local ip - if curl -fsSL "$url" -o "$output"; then + if curl -fsSL --connect-timeout 10 --max-time 120 "$url" -o "$output"; then return 0 fi case "$url" in https://ispip.clang.cn/*) for ip in 182.247.248.127 103.220.64.183; do - if curl -fsSL --resolve "ispip.clang.cn:443:${ip}" "$url" -o "$output"; then + if curl -fsSL --connect-timeout 10 --max-time 120 --resolve "ispip.clang.cn:443:${ip}" "$url" -o "$output"; then log "DNS 异常,已使用 --resolve(${ip}) 回源下载" return 0 fi @@ -49,7 +50,7 @@ download_with_fallback() { } cleanup() { - rm -f "$TMP_V4" "$TMP_V6" "$OUT_V4" "$OUT_V6" + rm -rf "$TMP_ROOT" } trap cleanup EXIT INT TERM @@ -61,6 +62,21 @@ render_nft_set() { local set_type="$4" awk -v set_name="$set_name" -v set_type="$set_type" ' + function valid_cidr(value, parts, octets, i, prefix) { + if (split(value, parts, "/") != 2 || parts[2] !~ /^[0-9]+$/) + return 0 + prefix = parts[2] + 0 + if (set_type == "ipv4_addr") { + if (prefix > 32 || split(parts[1], octets, ".") != 4) + return 0 + for (i = 1; i <= 4; i++) + if (octets[i] !~ /^[0-9]+$/ || length(octets[i]) > 3 || octets[i] + 0 > 255) + return 0 + return 1 + } + return set_type == "ipv6_addr" && prefix <= 128 && + parts[1] ~ /^[0-9A-Fa-f:]+$/ && index(parts[1], ":") > 0 + } BEGIN { print "set " set_name " {" print "\ttype " set_type ";" @@ -73,6 +89,12 @@ render_nft_set() { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0) if ($0 == "") next + if (!valid_cidr($0)) { + if (!invalid) + printf "invalid CIDR at line %d\n", NR > "/dev/stderr" + invalid = 1 + next + } if (!first) print "," printf "\t\t%s", $0 @@ -83,8 +105,13 @@ render_nft_set() { print "" print "\t}" print "}" + if (invalid || first) + exit 1 } - ' "$source_file" > "$output_file" + ' "$source_file" > "$output_file" || return 1 + # nft catches malformed IPv6 forms and invalid network prefixes before replacement. + { printf 'table inet clashoo_validate {\n'; cat "$output_file"; printf '\n}\n'; } | + nft -c -f - >/dev/null 2>&1 } url4="$(uci -q get clashoo.config.china_ip_url 2>/dev/null || true)" @@ -107,7 +134,10 @@ download_with_fallback "$url4" "$TMP_V4" } changed=0 -render_nft_set "$TMP_V4" "$OUT_V4" clashoo_china ipv4_addr +if ! render_nft_set "$TMP_V4" "$OUT_V4" clashoo_china ipv4_addr; then + log '大陆 IPv4 白名单包含无效 CIDR,保留原文件' + exit 1 +fi if cmp -s "$OUT_V4" "$TARGET_V4"; then log '大陆 IPv4 白名单内容无变化' else @@ -119,7 +149,10 @@ fi if download_with_fallback "$url6" "$TMP_V6"; then if [ -s "$TMP_V6" ]; then - render_nft_set "$TMP_V6" "$OUT_V6" clashoo_china6 ipv6_addr + if ! render_nft_set "$TMP_V6" "$OUT_V6" clashoo_china6 ipv6_addr; then + log '大陆 IPv6 白名单包含无效 CIDR,保留原文件' + exit 1 + fi if cmp -s "$OUT_V6" "$TARGET_V6"; then log '大陆 IPv6 白名单内容无变化' else diff --git a/dsvpn/Makefile b/dsvpn/Makefile index 69f13fa55..8fdcc5805 100644 --- a/dsvpn/Makefile +++ b/dsvpn/Makefile @@ -14,7 +14,7 @@ PKG_SOURCE_URL:=https://github.com/Ysurac/dsvpn.git PKG_SOURCE_VERSION:=aae011339bba733d3fa4ffe8b5dd95dab2efb4ab PKG_NAME:=dsvpn PKG_VERSION:=0.1.5 -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/dsvpn/files/init b/dsvpn/files/init index a5dfc8505..bcf4d878e 100755 --- a/dsvpn/files/init +++ b/dsvpn/files/init @@ -50,7 +50,8 @@ version_over_5_4() { } start_instance() { - local enable key host port dev + local enable key host port dev mode localip remoteip externalip gateway + local prog="$PROG" local key_dir="/var/run/${PROG_NAME}" local key_file="${key_dir}/${1}.key" @@ -93,20 +94,25 @@ start_instance() { # socket at all ("Couldn't create connection: Protocol not available") # and procd just respawns it forever. Run it unwrapped instead. if version_over_5_4 && ! _omr_mptcp_globally_disabled; then - PROG="mptcpize run ${PROG}" + prog="mptcpize run ${PROG}" fi + # The arguments are positional: an empty one would shift the next ones + # into its place, dsvpn takes "auto" for each instead. The gateway is the + # route's "via": an on-link route has none, and a name can't be routed + gateway="$(ip route get "$host" 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i + 1); exit } }')" + procd_open_instance - procd_set_param command ${PROG} ${mode} \ + procd_set_param command ${prog} ${mode} \ "$key_file" \ $host \ $port \ $dev \ - ${localip:+$localip} \ - ${remoteip:+$remoteip} - [ "$mode" = "client" ] && procd_append_param command $(ip r get $host | awk '{print $3}' | tr -d "\n") - [ "$mode" = "server" ] && procd_append_param command externalip + ${localip:-auto} \ + ${remoteip:-auto} + [ "$mode" = "client" ] && procd_append_param command ${gateway:-auto} + [ "$mode" = "server" ] && procd_append_param command ${externalip:-auto} procd_set_param respawn 0 30 5 diff --git a/glorytun-udp/Makefile b/glorytun-udp/Makefile index 23b83b0c7..0c26dfb4a 100644 --- a/glorytun-udp/Makefile +++ b/glorytun-udp/Makefile @@ -13,7 +13,7 @@ PKG_SOURCE_URL:=https://github.com/ysurac/glorytun.git PKG_SOURCE_VERSION:=203c5f2223d7d5146d659b51159b2f02c2480d15 PKG_NAME:=glorytun-udp PKG_VERSION:=0.3.4 -PKG_RELEASE:=3 +PKG_RELEASE:=4 PKG_LICENSE:=BSD-2-Clause PKG_LICENSE_FILES:=LICENSE diff --git a/glorytun-udp/init b/glorytun-udp/init index 6d39b6856..a4c228c2f 100755 --- a/glorytun-udp/init +++ b/glorytun-udp/init @@ -27,11 +27,16 @@ validate_section() { 'port:port' \ 'mtuauto:bool:0' \ 'chacha:bool:0' \ + 'persist:bool:1' \ + 'kxtimeout:string:7d' \ + 'timetolerance:string:10m' \ + 'keepalive:string:25s' \ 'dev:string' } start_instance() { local enable key host port listener proto mtuauto chacha dev mode + local persist kxtimeout timetolerance keepalive local key_dir="/var/run/${PROG_NAME}" local key_file="${key_dir}/${1}.key" @@ -64,7 +69,16 @@ start_instance() { _log "starting ${PROG_NAME} ${1} instance $*" - host=$(resolveip $host) + # One address: resolveip prints all of them, which split into extra + # arguments, and none on failure, both respawned broken forever + local addr + addr="$(resolveip -4 -t 5 "$host" | head -n1)" + [ -n "$addr" ] || addr="$(resolveip -6 -t 5 "$host" | head -n1)" + [ -n "$addr" ] || { + _err "can't resolve $host" + return 1 + } + host="$addr" if [ "$mode" = "to" ]; then bind="bind from addr :: port 5000 to addr $host port $port" @@ -76,9 +90,11 @@ start_instance() { procd_set_param command ${PROG} \ ${bind} \ ${dev:+dev "$dev"} \ - keyfile "$key_file" \ - persist + keyfile "$key_file" + # LuCI's "Persist" was ignored: the device was always kept. Unset still + # means kept, as before + [ "${persist}" = "1" ] && procd_append_param command persist [ "${chacha}" = "1" ] && procd_append_param command chacha procd_set_param respawn 0 30 0 @@ -90,6 +106,23 @@ start_instance() { procd_close_instance + # kxtimeout, timetolerance and keepalive are not bind arguments: only + # the ifup hotplug set them, and a restart that keeps the device raises + # no ifup, nor does a change of them alone (procd keeps the instance). + # Set them once the tunnel answers; the delay leaves procd the time to + # replace an instance it restarts. fd 1000 is procd.sh's service lock: + # kept open, it would block every other glorytun-udp init call meanwhile + ( + sleep 2 + count=0 + while ! ${PROG} list 2>/dev/null | grep -qxF "$dev"; do + count=$((count + 1)) + [ "$count" -gt 10 ] && exit 0 + sleep 1 + done + ${PROG} set dev "$dev" kxtimeout "$kxtimeout" timetolerance "$timetolerance" keepalive "$keepalive" + ) /dev/null 2>&1 1000>&- & + #tc qdisc replace dev ${dev} root cake #ip link set $dev txqlen 100 diff --git a/glorytun/Makefile b/glorytun/Makefile index d162611da..4ed4bbb91 100755 --- a/glorytun/Makefile +++ b/glorytun/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=glorytun -PKG_RELEASE:=5 +PKG_RELEASE:=6 PKG_SOURCE_PROTO:=git PKG_SOURCE_VERSION:=95fa5ead2154adb546bcdb454c56c876e9f60bb5 PKG_SOURCE:=glorytun-$(PKG_VERSION).tar.gz diff --git a/glorytun/init b/glorytun/init index 714c52dd2..4edbb81fa 100755 --- a/glorytun/init +++ b/glorytun/init @@ -75,8 +75,10 @@ start_instance() { } key="" - if [ "$(uci -q get network.omrvpn)" != "" ]; then - uci -q set network.omrvpn.device=${dev} + # Only when it differs: the commit also takes whatever else is pending + # in the network config, and writes the flash on every start + if [ -n "$(uci -q get network.omrvpn)" ] && [ "$(uci -q get network.omrvpn.device)" != "$dev" ]; then + uci -q set network.omrvpn.device=$dev uci -q commit network fi diff --git a/gowebdav/Makefile b/gowebdav/Makefile index 302d70e27..34ef87a02 100644 --- a/gowebdav/Makefile +++ b/gowebdav/Makefile @@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gowebdav -PKG_VERSION:=5.17.0 -PKG_RELEASE:=25 +PKG_VERSION:=5.17.1 +PKG_RELEASE:=26 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/hacdias/webdav/tar.gz/v$(PKG_VERSION)? diff --git a/luci-app-adguardhome/.official_channel b/luci-app-adguardhome/.official_channel index 8ab67f2e7..49f33a8c6 100644 --- a/luci-app-adguardhome/.official_channel +++ b/luci-app-adguardhome/.official_channel @@ -1 +1 @@ -snapshot +both diff --git a/luci-app-appflow/.gitignore b/luci-app-appflow/.gitignore index cbd8b9831..f2e297d12 100644 --- a/luci-app-appflow/.gitignore +++ b/luci-app-appflow/.gitignore @@ -3,5 +3,5 @@ .DS_Store node_modules/ -# Regents review verdicts. Written into the repo being reviewed; never committed. +# Local review output; never committed. .regents/ diff --git a/luci-app-appflow/Makefile b/luci-app-appflow/Makefile index fa8f1d5ad..ec48e2035 100644 --- a/luci-app-appflow/Makefile +++ b/luci-app-appflow/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-appflow PKG_VERSION:=1.1.2 -PKG_RELEASE:=1 +PKG_RELEASE:=2 PKG_LICENSE:=Apache-2.0 PKG_LICENSE_FILES:=LICENSE PKG_MAINTAINER:=Bob diff --git a/luci-app-appflow/README.md b/luci-app-appflow/README.md index b06e5ff44..1e0a8dae2 100644 --- a/luci-app-appflow/README.md +++ b/luci-app-appflow/README.md @@ -18,7 +18,7 @@ One file, every device. The package is `noarch` — only JavaScript and ucode inside — and the same `.apk` is verified installing on both aarch64 and arm_cortex-a15 hardware. - wget https://github.com/VolanticSystems/luci-app-appflow/releases/download/v1.1.2/luci-app-appflow-1.1.2-r1.apk + wget -O luci-app-appflow-1.1.2-r1.apk https://github.com/VolanticSystems/luci-app-appflow/releases/download/v1.1.2/luci-app-appflow-1.1.2-r1.apk apk add --allow-untrusted ./luci-app-appflow-1.1.2-r1.apk /etc/init.d/appflowd enable && /etc/init.d/appflowd start diff --git a/luci-app-appflow/docs/DESIGN.md b/luci-app-appflow/docs/DESIGN.md index 989d9d074..ca8c32e04 100644 --- a/luci-app-appflow/docs/DESIGN.md +++ b/luci-app-appflow/docs/DESIGN.md @@ -135,11 +135,11 @@ participate and throttling is impossible by construction. accountable byte counts for sub-interval flows. **CORRECTED 2026-08-25. The two paragraphs below understated this, and the - review panel was right.** Once `purge_only_rescued` was instrumented and left + reviewers were right.** Once `purge_only_rescued` was instrumented and left running against ordinary traffic, it reported **13 purge-only flows out of 1,341 seen, 0.97%**. Flows purged without ever receiving a `flow_stats` DO occur on netifyd 4.4.7. Before the defensive path was added, every one of them - lost its entire byte count silently, which is exactly what three carriers + lost its entire byte count silently, which is exactly what three reviewers predicted and what this section briefly called refuted. The refutation was under-powered, not wrong-headed: a 38-flow capture at a @@ -149,7 +149,7 @@ participate and throttling is impossible by construction. rate it claims to exclude. The event-level evidence below is still accurate about what it saw; it simply did not see enough. - **Re-tested at the EVENT level 2026-08-24, after an adversarial review panel + **Re-tested at the EVENT level 2026-08-24, after an adversarial review independently reached the same prediction three times.** The 2026-08-22 result above is outcome-based: it shows the bytes landed, not why. Tapping netifyd's export socket directly for 170 s (`flow`/`flow_stats`/`flow_purge` per digest, @@ -355,7 +355,7 @@ purge: > **The rest of this subsection is HISTORICAL and describes the state before > the fix above. It is kept because the reasoning is what led to the fix, but -> nothing below is an open problem.** A review panel reading this document cold +> nothing below is an open problem.** A reviewer reading this document cold > reported it as a live double-count, having entered mid-section, so if you are > skimming, stop here and go to §3.3. @@ -582,7 +582,7 @@ Depends (from `Makefile` `LUCI_DEPENDS`): `netifyd`, `luci-base`, `conntrack.at`, i.e. on having read recently rather than on having read recently *and* found something. - **Byte conservation is now instrumented, not just asserted** (2026-08-24, - suggested by a panel juror). `status.bytes` reports `reported` (what netifyd + suggested by a reviewer). `status.bytes` reports `reported` (what netifyd said moved, before any clamp or shadow decision), `attributed` (what reached an aggregate), `shadowed` (what was deliberately dropped as a NAT twin) and `leaked` (the residual, which should be 0). Both silent byte-loss defects this @@ -602,7 +602,7 @@ Depends (from `Makefile` `LUCI_DEPENDS`): `netifyd`, `luci-base`, fixed without an established-flow dump upstream. Documented in the README rather than hidden. - ~~**reshadow() exempted the flows it existed to correct**~~, FOUND by an - adversarial panel and FIXED 2026-08-24. `reshadow()` returned early on + adversarial review and FIXED 2026-08-24. `reshadow()` returned early on `dev_key == "router"`, which is exactly how a WAN-side capture of a NAT-ed client flow is classified, because netifyd reports the post-translation identity (§3.2). The conntrack twin resolution added earlier that day went diff --git a/luci-app-appflow/htdocs/luci-static/resources/view/appflow/common.js b/luci-app-appflow/htdocs/luci-static/resources/view/appflow/common.js index 96c01b700..f1e77a0b1 100644 --- a/luci-app-appflow/htdocs/luci-static/resources/view/appflow/common.js +++ b/luci-app-appflow/htdocs/luci-static/resources/view/appflow/common.js @@ -232,7 +232,7 @@ var DEVICE_LABELS = { * normalised, and columnChart reads only p.dl/p.ul, so it drew an EMPTY framed * plot under a KPI row reporting megabytes. Worse, series.length was still 12, * so the honest "No time series available" branch never ran. - * Found by a code-review panel, 2026-08-25. No daemon field is named dl or ul, + * Found in code review, 2026-08-25. No daemon field is named dl or ul, * so the aliases are inert against real payloads. */ var A_DL = [ 'download', 'bytes_down', 'rx_bytes', 'download_bytes', 'total_download', 'dl' ], A_UL = [ 'upload', 'bytes_up', 'tx_bytes', 'upload_bytes', 'total_upload', 'ul' ], @@ -485,7 +485,7 @@ return baseclass.extend({ * bytes_down fell through to the byte counter and the Download KPI read * something like "12.4 GB/s" while nothing moved. Read with a -1 sentinel * and test presence instead, the way normTotals already does for its own - * totals. Raised by a code-review panel, 2026-08-25. + * totals. Raised in code review, 2026-08-25. * * Latent rather than live: the only caller is guarded by `summary.rates ?`, * and this daemon puts byte and rate counters together in `totals` and sends @@ -752,7 +752,7 @@ return baseclass.extend({ * A device named `constructor` then rendered style="background:undefined", * which the browser drops, leaving white tile text on a transparent * tile; in the doughnut the slice silently did not draw while its legend - * still claimed a percentage. Found by a code-review panel, 2026-08-25. */ + * still claimed a percentage. Found in code review, 2026-08-25. */ if (Object.prototype.hasOwnProperty.call(CATEGORY_SLOT, s)) return PALETTE[CATEGORY_SLOT[s]]; @@ -1257,7 +1257,7 @@ return baseclass.extend({ * `.af-donut > svg` also carried transform:rotate(-90deg) until * 2026-08-25, the two stacked, and the first slice actually began at * 9 o'clock -- so this comment asserted something the render did not - * do. Found by a code-review panel. If you re-add a CSS rotation, + * do. Found in code review. If you re-add a CSS rotation, * take this offset out. */ off = 25, ring = [ this.svg('circle', { @@ -1418,7 +1418,7 @@ return baseclass.extend({ * It failed GREEN on the one indicator whose whole job is to tell the * operator the collector is down, which is also the most common reason * the page is empty. Absent evidence is not positive evidence; unknown - * is its own state and is drawn neutral. Found by a code-review panel, + * is its own state and is drawn neutral. Found in code review, * 2026-08-25. */ known = (s.agent_connected != null) || (sock.connected != null), connected = known && (s.agent_connected !== false) && (sock.connected !== false), diff --git a/luci-app-appflow/htdocs/luci-static/resources/view/appflow/overview.js b/luci-app-appflow/htdocs/luci-static/resources/view/appflow/overview.js index 5c25ecd1e..6760696f6 100644 --- a/luci-app-appflow/htdocs/luci-static/resources/view/appflow/overview.js +++ b/luci-app-appflow/htdocs/luci-static/resources/view/appflow/overview.js @@ -300,7 +300,7 @@ return view.extend({ * ordinary config change, so that was a hair trigger. paint() now drops * the buffer only when the gap exceeds the chart's own window, which is * the case where redrawing it really would mislead. Raised by a - * code-review panel, 2026-08-25. */ + * code review, 2026-08-25. */ dom.content(this.downNode, appflow.notRunning(appflow.classifyFail(err), err)); this.liveNode.style.display = 'none'; this.downNode.style.display = ''; @@ -551,7 +551,7 @@ return view.extend({ * caption reading "top, by cumulative bytes". The fallback * looked like belt-and-braces and was actually a silent unit * mix; a row with no bytes is filtered out below, which is - * the honest outcome. Found by a code-review panel. */ + * the honest outcome. Found in code review. */ value: t.total }; }) diff --git a/luci-app-appflow/htdocs/luci-static/resources/view/appflow/statistics.js b/luci-app-appflow/htdocs/luci-static/resources/view/appflow/statistics.js index d99ead819..f1241bb3e 100644 --- a/luci-app-appflow/htdocs/luci-static/resources/view/appflow/statistics.js +++ b/luci-app-appflow/htdocs/luci-static/resources/view/appflow/statistics.js @@ -333,7 +333,7 @@ return view.extend({ * handler anywhere in this package. role=button plus tabindex puts * the row in the tab order and makes it announce as activatable; * Enter and Space are the two keys a button is required to answer. - * Raised by a code-review panel. */ + * Raised in code review. */ rows.push(E('tr', { 'class': 'tr af-row', 'title': _('Show per-device details'), @@ -530,7 +530,7 @@ return view.extend({ * active catalog says. With the English catalog that is a constant in * this file; the moment a translation is accepted it is third-party * content, which is exactly what the pending zh_Hans PR proposes. Raised - * by a review panel over that PR, which was careful to note the submitted + * by a review of that PR, which was careful to note the submitted * Chinese contains no markup: this is closing the trust distinction, not * fixing an exploit. */ ui.showModal([ _('Details') ], body, 'cbi-modal'); diff --git a/luci-app-appflow/tests/frontend-suite.js b/luci-app-appflow/tests/frontend-suite.js index 987a66f05..409a9e467 100755 --- a/luci-app-appflow/tests/frontend-suite.js +++ b/luci-app-appflow/tests/frontend-suite.js @@ -5,7 +5,7 @@ // appflow browser-half suite. // // WHY THIS FILE EXISTS. Every defect this package's frontend has had was found -// by a human or a review panel READING the code. Not one was found by a test, +// by a reviewer READING the code. Not one was found by a test, // because until now no test could run any of it. The daemon has 54 checks // against it and the JavaScript had none, which is backwards: the daemon // mostly counts bytes, while the frontend is what decides whether an operator diff --git a/luci-app-appflow/tests/hardware-suite.sh b/luci-app-appflow/tests/hardware-suite.sh index 759078c69..3f2eff334 100755 --- a/luci-app-appflow/tests/hardware-suite.sh +++ b/luci-app-appflow/tests/hardware-suite.sh @@ -155,7 +155,7 @@ test_conservation() { # A 38-flow event-socket capture had seen none, which at that rate has an # expected count of 0.4 -- so the sample was far too small to support the # "netifyd never does this" conclusion that was briefly drawn from it, and - # the review panel that reported the byte loss was right. + # the review that reported the byte loss was right. # # What is worth watching is the RATE. A sudden jump means netifyd's event # contract has shifted; zero across a busy run means this path stopped diff --git a/luci-app-appflow/tests/luci-module.js b/luci-app-appflow/tests/luci-module.js index 3d633ea2d..eb15c0831 100644 --- a/luci-app-appflow/tests/luci-module.js +++ b/luci-app-appflow/tests/luci-module.js @@ -5,7 +5,7 @@ // functions can be tested without a browser. // // WHY THIS EXISTS. Every browser-side defect this package has had was found by -// a human or a review panel reading the code. Not one was found by a test, +// a reviewer reading the code. Not one was found by a test, // because no test could run any of it: LuCI view files are not CommonJS or ES // modules, they are evaluated by LuCI's own loader with the `'require x';` // directives intercepted. So the JavaScript half of a package whose entire @@ -73,7 +73,7 @@ function stripRequires(src) { // run of a suite that asserted exactly those outputs. Loading the module a // second time with a marking translator makes the two observationally // different, and equality assertions become sound again rather than -// coincidentally true. Raised by a review panel, 2026-08-30. +// coincidentally true. Raised in review, 2026-08-30. function load(file, stubs, opts) { const translate = (opts && opts.translate) || ((s) => s); const src = fs.readFileSync(file, 'utf8'); diff --git a/luci-app-appflow/tests/protocol-suite.sh b/luci-app-appflow/tests/protocol-suite.sh index c35a4a465..1efda4dd3 100755 --- a/luci-app-appflow/tests/protocol-suite.sh +++ b/luci-app-appflow/tests/protocol-suite.sh @@ -794,7 +794,7 @@ test_hostile() { feed_and_settle || return # THIS RUNTIME CHECK BINDS NOTHING TODAY, AND THE STATIC ONE BELOW IS THE - # REAL GUARD. A review panel converged on this and it was right. + # REAL GUARD. Reviewers converged on this and they were right. # # The canary cannot be turned red by any edit that leaves appflowd doing # what it does, because appflowd never invokes a shell. The only edits that diff --git a/luci-app-clashoo/Makefile b/luci-app-clashoo/Makefile index b90cf2f51..4873fc40f 100644 --- a/luci-app-clashoo/Makefile +++ b/luci-app-clashoo/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-clashoo PKG_VERSION:=1.31.0 -PKG_RELEASE:=5 +PKG_RELEASE:=6 PKG_MAINTAINER:=kenzok8 PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) diff --git a/luci-app-clashoo/htdocs/luci-static/resources/tools/clashoo.js b/luci-app-clashoo/htdocs/luci-static/resources/tools/clashoo.js index f490247a1..3a17186e0 100644 --- a/luci-app-clashoo/htdocs/luci-static/resources/tools/clashoo.js +++ b/luci-app-clashoo/htdocs/luci-static/resources/tools/clashoo.js @@ -144,10 +144,10 @@ const callSmartFlushCache = rpc.declare({ object: 'luci.clashoo', method: const callListSingboxProfiles = rpc.declare({ object: 'luci.clashoo', method: 'list_singbox_profiles', expect: {} }); const callGetSingboxProfile = rpc.declare({ object: 'luci.clashoo', method: 'get_singbox_profile', params: ['name'], expect: {} }); const callGetSingboxProfileChunk = rpc.declare({ object: 'luci.clashoo', method: 'get_singbox_profile_chunk', params: ['name', 'index'], expect: {} }); -const callSaveSingboxProfileChunk = rpc.declare({ object: 'luci.clashoo', method: 'save_singbox_profile_chunk', params: ['name', 'content', 'index', 'total'], expect: {} }); +const callSaveSingboxProfileChunk = rpc.declare({ object: 'luci.clashoo', method: 'save_singbox_profile_chunk', params: ['name', 'content', 'index', 'total', 'upload'], expect: {} }); const callSetSingboxProfile = rpc.declare({ object: 'luci.clashoo', method: 'set_singbox_profile', params: ['name'], expect: {} }); const callDeleteSingboxProfile = rpc.declare({ object: 'luci.clashoo', method: 'delete_singbox_profile', params: ['name'], expect: {} }); -const callCreateSingboxConfig = rpc.declare({ object: 'luci.clashoo', method: 'create_singbox_config', params: ['sub_url', 'name'], expect: {} }); +const callCreateSingboxConfig = rpc.declare({ object: 'luci.clashoo', method: 'create_singbox_config', params: ['sub_url', 'name', 'converter'], expect: {} }); const callCommitConfig = rpc.declare({ object: 'luci.clashoo', method: 'commit_config', expect: {} }); const callDnsAutoSetup = rpc.declare({ object: 'luci.clashoo', method: 'dns_auto_setup', expect: {} }); const callFetchSingboxNative = rpc.declare({ object: 'luci.clashoo', method: 'fetch_singbox_native', params: ['url', 'name'], expect: {} }); @@ -292,12 +292,16 @@ return baseclass.extend({ if (!chunks.length) chunks.push(''); var total = chunks.length; var index = 0; + var upload = ''; function sendNext() { var chunk = chunks[index]; - return L.resolveDefault(callSaveSingboxProfileChunk(name, chunk, String(index), String(total)), {}).then(function (r) { + return L.resolveDefault(callSaveSingboxProfileChunk(name, chunk, String(index), String(total), upload), {}).then(function (r) { if (!r || !r.success) return { success: false, error: (r && r.error) || 'upload_failed', message: (r && (r.message || r.error)) || _('Upload failed') }; + if (index === 0 && index + 1 < total && !r.upload) + return { success: false, error: 'upload_failed', message: _('Upload failed') }; + if (r.upload) upload = r.upload; index++; return index < total ? sendNext() : r; }); @@ -307,7 +311,7 @@ return baseclass.extend({ }, setSingboxProfile: function (name) { return L.resolveDefault(callSetSingboxProfile(name), {}); }, deleteSingboxProfile: function (name) { return L.resolveDefault(callDeleteSingboxProfile(name), {}); }, - createSingboxConfig: function (url, name) { return L.resolveDefault(callCreateSingboxConfig(url, name), {}); }, + createSingboxConfig: function (url, name, converter) { return L.resolveDefault(callCreateSingboxConfig(url, name, converter || ''), {}); }, commitConfig: function () { return L.resolveDefault(callCommitConfig(), { success: false }); }, dnsAutoSetup: function () { return L.resolveDefault(callDnsAutoSetup(), { success: false }); }, fetchSingboxNative: function (url, name) { return L.resolveDefault(callFetchSingboxNative(url, name), {}); }, diff --git a/luci-app-clashoo/htdocs/luci-static/resources/view/clashoo/config.js b/luci-app-clashoo/htdocs/luci-static/resources/view/clashoo/config.js index 67882da68..719d0a4c1 100644 --- a/luci-app-clashoo/htdocs/luci-static/resources/view/clashoo/config.js +++ b/luci-app-clashoo/htdocs/luci-static/resources/view/clashoo/config.js @@ -1817,7 +1817,7 @@ return view.extend({ }); } }, _("Switch Profile")), - p.source === 'native' && p.sub_url ? E('button', { + p.source === 'native' ? E('button', { 'class': 'btn cbi-button cl-btn-sm cl-btn-sb-action', click: function (ev) { var btn = ev.currentTarget; @@ -1898,6 +1898,12 @@ return view.extend({ try { savedUa = uci.get('clashoo', 'config', 'sub_ua') || ''; } catch (e) {} // YAML 订阅转换走 mihomo 订阅流程(拉 yaml → yaml2singbox),UA 与 mihomo 共用 sub_ua var convertUaPicker = buildUaPicker(savedUa); + var converterSelect = E('select', { 'class': 'cl-sub-url', style: 'margin-top:0' }, [ + E('option', { value: '' }, _("Local conversion only")), + E('option', { value: 'kidsqq' }, 'sub.kidsqq.cn'), + E('option', { value: 'xeton' }, 'sub.xeton.dev'), + E('option', { value: 'v1mk' }, 'api.v1.mk') + ]); var genBtn, applyBtn; function setBusy(busy, activeBtn) { @@ -1925,7 +1931,7 @@ return view.extend({ .then(function () { return clearClashooDirty(); }); }).catch(function () {}); uaPromise.then(function () { - return clashoo.createSingboxConfig(url, nameInput.value.trim()); + return clashoo.createSingboxConfig(url, nameInput.value.trim(), converterSelect.value); }) .then(function (r) { /* RPC 超时被 resolveDefault 兜底成 {},r.success 是 undefined。 @@ -2050,7 +2056,10 @@ return view.extend({ E('div', { 'class': 'cl-section cl-card cl-sb-card' }, [ E('h4', {}, _("YAML Subscription Conversion")), E('div', { 'class': 'cl-form-wrap cl-fixed-600 cl-sb-form' }, [ - urlInput, nameInput, convertUaPicker.wrap, + urlInput, nameInput, convertUaPicker.wrap, converterSelect, + E('p', { 'class': 'cl-sb-note' }, + _("If local conversion fails, the selected external service receives the complete subscription URL, including its access token. Leave Local conversion only selected to keep the URL on this router.") + ), E('div', { 'class': 'cl-actions cl-sb-top-actions' }, [ genBtn, applyBtn diff --git a/luci-app-clashoo/po/zh-cn/clashoo.po b/luci-app-clashoo/po/zh-cn/clashoo.po index 6cc35a920..e3717393c 100644 --- a/luci-app-clashoo/po/zh-cn/clashoo.po +++ b/luci-app-clashoo/po/zh-cn/clashoo.po @@ -1610,3 +1610,9 @@ msgstr "只读预览;修改请编辑模板后点「更新」重新生成" msgid "Active config cannot be updated; switch to another first" msgstr "活跃配置无法更新;请先切换到其他配置" + +msgid "Local conversion only" +msgstr "仅在本机转换" + +msgid "If local conversion fails, the selected external service receives the complete subscription URL, including its access token. Leave Local conversion only selected to keep the URL on this router." +msgstr "如果本机转换失败,所选第三方服务会收到完整订阅链接,包括访问令牌。保持选择「仅在本机转换」可避免向第三方发送链接。" diff --git a/luci-app-clashoo/root/usr/share/rpcd/acl.d/luci-app-clashoo.json b/luci-app-clashoo/root/usr/share/rpcd/acl.d/luci-app-clashoo.json index 8fc522aae..74abd1773 100644 --- a/luci-app-clashoo/root/usr/share/rpcd/acl.d/luci-app-clashoo.json +++ b/luci-app-clashoo/root/usr/share/rpcd/acl.d/luci-app-clashoo.json @@ -2,6 +2,9 @@ "luci-app-clashoo": { "description": "Grant access for luci-app-clashoo", "read": { + "uci": [ "clashoo" ] + }, + "write": { "cgi-io": [ "download" ], "file": { "/tmp/clashoo-backup/export.*": [ "read" ] @@ -10,12 +13,6 @@ "ubus": { "luci.clashoo": [ "*" ] } - }, - "write": { - "uci": [ "clashoo" ], - "ubus": { - "luci.clashoo": [ "*" ] - } } } } diff --git a/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo b/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo index 3388019d5..d89be1a98 100755 --- a/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo +++ b/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo @@ -2,7 +2,7 @@ 'use strict'; -import { access, popen, glob, readfile, writefile, stat, unlink, open } from 'fs'; +import { access, popen, glob, readfile, writefile, stat, lstat, unlink, open, mkdtemp, rename, rmdir } from 'fs'; import { cursor } from 'uci'; const BINARY_PATHS = [ @@ -23,6 +23,8 @@ const SINGBOX_BIN_DIR = '/usr/share/clashoo/bin'; const SINGBOX_RUNTIME_DIR = '/var/run/clashoo/sing-box'; const SINGBOX_RUNTIME_CONFIG = SINGBOX_RUNTIME_DIR + '/config.json'; const SINGBOX_WORKDIR = SINGBOX_RUNTIME_DIR + '/work'; +const SINGBOX_UPLOAD_PREFIX = '/tmp/clashoo_sb_upload.'; +const SINGBOX_PROFILE_MAX_SIZE = 16 * 1024 * 1024; const SINGBOX_BINARY_PATHS = [ SINGBOX_BIN_DIR + '/sing-box-stable', SINGBOX_BIN_DIR + '/sing-box-alpha' @@ -1172,6 +1174,33 @@ name = name + '.yaml'; return name; } +function cleanup_singbox_upload(dir) { +unlink(dir + '/profile.json'); +unlink(dir + '/state.json'); +rmdir(dir); +} + +function cleanup_singbox_conversion(dir) { +for (let name in ['sub.url', 'sub.yaml', 'sub.hdr', 'from-yaml.json', 'yaml2sb.log', 'generated.json']) + unlink(dir + '/' + name); +rmdir(dir); +} + +function commit_singbox_profile(source, name) { +let target = SINGBOX_DIR + '/' + name; +let current = lstat(target); +if (current && current.type != 'file') return false; +let stage_dir = mkdtemp(SINGBOX_DIR + '/.clashoo_sb_save.XXXXXX'); +if (!stage_dir) return false; +let stage = stage_dir + '/profile.json'; +let ok = system('cp ' + shell_quote(source) + ' ' + shell_quote(stage) + ' >/dev/null 2>&1') == 0 && + system('chmod 600 ' + shell_quote(stage) + ' >/dev/null 2>&1') == 0 && + rename(stage, target); +if (!ok) unlink(stage); +rmdir(stage_dir); +return !!ok; +} + function file_name_from_url(url) { url = trim(url || ''); if (!url) return ''; @@ -3472,7 +3501,7 @@ system('mkdir -p ' + shell_quote(out_dir)); if (match(tpl_src, /^https?:\/\//)) { let dl_cmd = '(curl -fL --connect-timeout 15 --max-time 60 ' + shell_quote(tpl_src) + ' -o ' + shell_quote(tmp_tpl) -+ ' || wget -O ' + shell_quote(tmp_tpl) + ' ' + shell_quote(tpl_src) ++ ' || wget --timeout=60 -O ' + shell_quote(tmp_tpl) + ' ' + shell_quote(tpl_src) + ') >/dev/null 2>&1'; if (system(dl_cmd) != 0 || !access(tmp_tpl, 'r')) return { success: false, error: 'download_failed', message: '远程模板下载失败' }; @@ -3697,7 +3726,7 @@ let tmp_path = '/tmp/rewrite_remote.yaml'; system('mkdir -p ' + shell_quote(out_dir)); let dl_cmd = '(curl -fL --connect-timeout 15 --max-time 90 ' + shell_quote(url) + ' -o ' + shell_quote(tmp_path) - + ' || wget -O ' + shell_quote(tmp_path) + ' ' + shell_quote(url) + ') >/dev/null 2>&1'; + + ' || wget --timeout=90 -O ' + shell_quote(tmp_path) + ' ' + shell_quote(url) + ') >/dev/null 2>&1'; if (system(dl_cmd) != 0 || !access(tmp_path, 'r')) return { success: false, error: 'download_failed', message: '远程复写文件下载失败' }; @@ -3906,7 +3935,6 @@ for (let f in (glob(dir + '/*.json') || [])) { for (let k in meta) entry[k] = meta[k]; let url_path = f + '.url'; if (access(url_path, 'r')) { - entry.sub_url = trim(readfile(url_path) || ''); entry.source = 'native'; } else { entry.source = 'local'; @@ -3921,7 +3949,8 @@ get_singbox_profile: { args: { name: 'name' }, call: function(req) { let name = req.args?.name || ''; -if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) +if (!name || index(name, '/') >= 0 || index(name, '..') >= 0 || + !match(name, /\.json$/)) return { error: 'invalid name' }; let path = '/usr/share/clashoo/config/singbox/' + name; if (!access(path, 'r')) return { error: 'not found' }; @@ -3937,7 +3966,7 @@ call: function(req) { let name = req.args?.name || ''; let index_raw = req.args?.index || '0'; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0 || - !match(index_raw, /^[0-9]+$/)) + !match(name, /\.json$/) || !match(index_raw, /^[0-9]+$/)) return { error: 'invalid request' }; let path = SINGBOX_DIR + '/' + name; let s = stat(path); @@ -3969,65 +3998,112 @@ let content = req.args?.content || ''; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { success: false, error: 'invalid name' }; if (!content) return { success: false, error: 'empty content' }; +if (length(content) > SINGBOX_PROFILE_MAX_SIZE) + return { success: false, error: 'too_large' }; if (!match(name, /\.json$/)) name = name + '.json'; -let dir = '/usr/share/clashoo/config/singbox'; -system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); -let tmp = '/tmp/clashoo_sb_validate.json'; -writefile(tmp, content); +system('mkdir -p ' + shell_quote(SINGBOX_DIR) + ' >/dev/null 2>&1'); +let tmp_dir = mkdtemp('/tmp/clashoo_sb_save.XXXXXX'); +if (!tmp_dir) return { success: false, error: 'write_failed' }; +let tmp = tmp_dir + '/profile.json'; +if (writefile(tmp, content) === null) { + cleanup_singbox_upload(tmp_dir); + return { success: false, error: 'write_failed', message: '写入文件失败' }; +} let binary = find_binary(); if (binary && match(binary, /sing-box/)) { if (system(shell_quote(binary) + ' check -c ' + shell_quote(tmp) + ' >/dev/null 2>&1') != 0) { - system('rm -f ' + shell_quote(tmp)); + cleanup_singbox_upload(tmp_dir); return { success: false, error: 'invalid_config', message: 'sing-box 配置验证失败,请检查 JSON 格式' }; } } -system('rm -f ' + shell_quote(tmp)); -if (writefile(dir + '/' + name, content) === null) +let saved = commit_singbox_profile(tmp, name); +cleanup_singbox_upload(tmp_dir); +if (!saved) return { success: false, error: 'write_failed', message: '写入文件失败' }; return { success: true, name }; } }, save_singbox_profile_chunk: { -args: { name: 'name', content: 'content', index: 'index', total: 'total' }, +args: { name: 'name', content: 'content', index: 'index', total: 'total', upload: 'upload' }, call: function(req) { let name = req.args?.name || ''; let content = req.args?.content; -let idx = int(req.args?.index || 0); -let total = int(req.args?.total || 0); +let index_raw = req.args?.index || '0'; +let total_raw = req.args?.total || ''; +let upload = req.args?.upload || ''; if (!name || index(name, '/') >= 0 || index(name, '..') >= 0) return { success: false, error: 'invalid name' }; if (!match(name, /\.json$/)) name = name + '.json'; -if (type(content) != 'string') return { success: false, error: 'invalid content' }; -if (total < 1 || idx < 0 || idx >= total) return { success: false, error: 'invalid chunk' }; +if (type(content) != 'string' || length(content) > 131072) + return { success: false, error: 'invalid content' }; +if (!match(index_raw, /^[0-9]+$/) || !match(total_raw, /^[0-9]+$/)) + return { success: false, error: 'invalid chunk' }; +let idx = int(index_raw); +let total = int(total_raw); +if (total < 1 || total > 700 || idx < 0 || idx >= total) + return { success: false, error: 'invalid chunk' }; if (total == 1 && !content) return { success: false, error: 'empty content' }; -let dir = '/usr/share/clashoo/config/singbox'; -system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); -let tmp = '/tmp/clashoo_sb_upload_' + name; +system('mkdir -p ' + shell_quote(SINGBOX_DIR) + ' >/dev/null 2>&1'); +let tmp_dir; +let state; if (idx == 0) { - if (writefile(tmp, content) === null) + if (upload) return { success: false, error: 'invalid upload' }; + tmp_dir = mkdtemp(SINGBOX_UPLOAD_PREFIX + 'XXXXXX'); + if (!tmp_dir) return { success: false, error: 'write_failed' }; + upload = substr(tmp_dir, length('/tmp/')); + state = { name, total, next: 1, created: time() }; + if (writefile(tmp_dir + '/state.json', sprintf('%J', state)) === null || + writefile(tmp_dir + '/profile.json', content) === null) { + cleanup_singbox_upload(tmp_dir); return { success: false, error: 'write_failed', message: '写入文件失败' }; + } } else { - let fp = open(tmp, 'a'); + if (type(upload) != 'string' || !match(upload, /^clashoo_sb_upload\.[A-Za-z0-9]{6}$/)) + return { success: false, error: 'invalid upload' }; + tmp_dir = '/tmp/' + upload; + let info = lstat(tmp_dir); + if (!info || info.type != 'directory' || info.uid != 0) + return { success: false, error: 'invalid upload' }; + try { state = json(readfile(tmp_dir + '/state.json')); } catch (e) {} + if (!state || state.name != name || state.total != total || state.next != idx || + time() - state.created > 600) + return { success: false, error: 'invalid upload' }; + let previous = lstat(tmp_dir + '/profile.json'); + if (!previous || previous.type != 'file' || previous.uid != 0 || + previous.size + length(content) > SINGBOX_PROFILE_MAX_SIZE) + return { success: false, error: 'too_large' }; + let fp = open(tmp_dir + '/profile.json', 'a'); if (!fp) return { success: false, error: 'write_failed', message: '打开文件失败' }; let ok = fp.write(content); fp.close(); if (ok === null) return { success: false, error: 'write_failed', message: '写入文件失败' }; } -if (idx + 1 < total) - return { success: true, name, index: idx, total, complete: false }; +let tmp = tmp_dir + '/profile.json'; +let written = lstat(tmp); +if (!written || written.type != 'file' || written.size > SINGBOX_PROFILE_MAX_SIZE) { + cleanup_singbox_upload(tmp_dir); + return { success: false, error: 'too_large' }; +} +if (idx + 1 < total) { + state.next = idx + 1; + if (writefile(tmp_dir + '/state.json', sprintf('%J', state)) === null) { + cleanup_singbox_upload(tmp_dir); + return { success: false, error: 'write_failed' }; + } + return { success: true, name, index: idx, total, complete: false, upload }; +} let binary = find_binary(); if (binary && match(binary, /sing-box/)) { if (system(shell_quote(binary) + ' check -c ' + shell_quote(tmp) + ' >/dev/null 2>&1') != 0) { - system('rm -f ' + shell_quote(tmp)); + cleanup_singbox_upload(tmp_dir); return { success: false, error: 'invalid_config', message: 'sing-box 配置验证失败,请检查 JSON 格式' }; } } -if (system('cp -f ' + shell_quote(tmp) + ' ' + shell_quote(dir + '/' + name) + ' >/dev/null 2>&1') != 0) { - system('rm -f ' + shell_quote(tmp)); +let saved = commit_singbox_profile(tmp, name); +cleanup_singbox_upload(tmp_dir); +if (!saved) return { success: false, error: 'write_failed', message: '写入文件失败' }; -} -system('rm -f ' + shell_quote(tmp)); return { success: true, name, index: idx, total, complete: true }; } }, @@ -4223,10 +4299,11 @@ return { success: true, changes: ['deprecated fields removed/updated'] }; }, create_singbox_config: { -args: { sub_url: 'sub_url', name: 'name' }, +args: { sub_url: 'sub_url', name: 'name', converter: 'converter' }, call: function(req) { let sub_url = trim(req.args?.sub_url || ''); let name = trim(req.args?.name || ''); +let converter = req.args?.converter || ''; // dashboard 字段(port/secret)由 normalize_singbox_config.uc 在启动时强制覆盖为 UCI 值, // 这里写入仅作占位,无实际生效途径,因此直接读 UCI 即可。 let secret = uci_get('clashoo', 'config', 'dash_pass') || ''; @@ -4234,6 +4311,8 @@ let ext_port = uci_get('clashoo', 'config', 'dash_port') || '9090'; if (!sub_url || !match(sub_url, /^https?:\/\//)) return { success: false, error: 'invalid_url', message: '请输入有效的订阅链接' }; +if (converter != '' && converter != 'kidsqq' && converter != 'xeton' && converter != 'v1mk') + return { success: false, error: 'invalid_converter' }; if (!name) name = 'singbox'; name = replace(name, /\.json$/, ''); @@ -4241,14 +4320,15 @@ name = replace(name, /[^A-Za-z0-9_.\u4e00-\u9fa5-]/g, '-'); name = replace(name, /-+/g, '-'); name = replace(name, /^[-.]|[-.]$/g, '') || 'singbox'; -let dir = '/usr/share/clashoo/config/singbox'; +let dir = SINGBOX_DIR; system('mkdir -p ' + shell_quote(dir) + ' >/dev/null 2>&1'); +let work_dir = mkdtemp('/tmp/clashoo_sb_convert.XXXXXX'); +if (!work_dir) return { success: false, error: 'write_failed', message: '无法创建临时目录' }; let final_name = name + '.json'; -// Write sub_url to temp file to avoid shell injection in curl call -let tmp_url = '/tmp/.clashoo-sb-url'; -writefile(tmp_url, sub_url); +let tmp_url = work_dir + '/sub.url'; +let tmp_hdr = work_dir + '/sub.hdr'; let config_obj = null; let downloaded_yaml = ''; @@ -4283,7 +4363,8 @@ if (!prefer_template) { let raw0 = p0 ? trim(p0.read('all')) : ''; if (p0) p0.close(); if (length(raw0) > 50 && substr(raw0, 0, 1) === '{') { - let obj = json(raw0); + let obj = null; + try { obj = json(raw0); } catch (e) {} if (has_proxy_outbounds(obj)) { config_obj = obj; break; @@ -4299,11 +4380,9 @@ if (!config_obj && system('command -v ucode >/dev/null 2>&1') == 0 && access('/usr/share/clashoo/lib/templates/default.json', 'r')) { let yaml_src = ''; if (!(yaml_src && access(yaml_src, 'r') && match(yaml_src, /\.(yaml|yml)$/))) { - let tmp_yaml = '/tmp/clashoo_sb_sub.yaml'; - let tmp_hdr = '/tmp/clashoo_sb_sub.hdr'; - system('rm -f ' + shell_quote(tmp_hdr) + ' >/dev/null 2>&1'); + let tmp_yaml = work_dir + '/sub.yaml'; let conv_ua = uci_get('clashoo', 'config', 'sub_ua') || 'clash.meta'; - let dl_cmd = 'curl -fsSL --connect-timeout 8 --max-time 20 -D ' + shell_quote(tmp_hdr) + ' -A ' + shell_quote(conv_ua) + ' ' + shell_quote(sub_url) + ' -o ' + shell_quote(tmp_yaml) + ' >/dev/null 2>&1 || wget -q --user-agent=' + shell_quote(conv_ua) + ' -O ' + shell_quote(tmp_yaml) + ' ' + shell_quote(sub_url) + ' >/dev/null 2>&1'; + let dl_cmd = 'curl -fsSL --connect-timeout 8 --max-time 20 -D ' + shell_quote(tmp_hdr) + ' -A ' + shell_quote(conv_ua) + ' ' + shell_quote(sub_url) + ' -o ' + shell_quote(tmp_yaml) + ' >/dev/null 2>&1 || wget -q --timeout=20 --user-agent=' + shell_quote(conv_ua) + ' -O ' + shell_quote(tmp_yaml) + ' ' + shell_quote(sub_url) + ' >/dev/null 2>&1'; if (system(dl_cmd) == 0 && access(tmp_yaml, 'r')) { yaml_src = tmp_yaml; downloaded_yaml = tmp_yaml; @@ -4311,48 +4390,47 @@ if (!config_obj && system('command -v ucode >/dev/null 2>&1') == 0 && } if (yaml_src && access(yaml_src, 'r')) { - let tmp_json = '/tmp/clashoo_sb_from_yaml.json'; + let tmp_json = work_dir + '/from-yaml.json'; let cv_cmd = 'ucode /usr/share/clashoo/lib/yaml2singbox.uc ' + shell_quote(yaml_src) + ' ' + shell_quote('/usr/share/clashoo/lib/templates/default.json') + ' ' - + shell_quote(tmp_json) + ' >/tmp/clashoo_yaml2sb.log 2>&1'; + + shell_quote(tmp_json) + ' >' + shell_quote(work_dir + '/yaml2sb.log') + ' 2>&1'; if (system(cv_cmd) == 0 && access(tmp_json, 'r')) { let raw3 = readfile(tmp_json); - let obj3 = raw3 ? json(raw3) : null; + let obj3 = null; + try { obj3 = raw3 ? json(raw3) : null; } catch (e) {} if (has_proxy_outbounds(obj3)) config_obj = obj3; } - system('rm -f ' + shell_quote(tmp_json)); + unlink(tmp_json); } } -// Step 3: 本地 yaml2singbox 失败再走外部 subconverter(base64 ss/vmess 链接列表 -// yaml2singbox 不识别,要靠外部转换器)。每个 max-time 12s,最坏 36s 兜底。 -if (!config_obj) { - let converters = [ - ['https://sub.kidsqq.cn/singbox?selectedRules=%5B%22Location%3ACN%22%2C%22Private%22%2C%22Non-China%22%2C%22Github%22%2C%22Google%22%2C%22Youtube%22%2C%22AI%2BServices%22%2C%22Telegram%22%2C%22Ad%2BBlock%22%2C%22Streaming%22%2C%22Apple%22%2C%22Social%2BMedia%22%5D&customRules=%5B%5D&group_by_country=true', 'config'], - ['https://sub.xeton.dev/sub?target=singbox', 'url'], - ['https://api.v1.mk/sub?target=singbox', 'url'], - ]; - for (let cv in converters) { +// Step 3: 仅用户明确选择外部转换器时才把完整订阅链接交给该服务。 +if (!config_obj && converter) { + let converters = { + kidsqq: ['https://sub.kidsqq.cn/singbox?selectedRules=%5B%22Location%3ACN%22%2C%22Private%22%2C%22Non-China%22%2C%22Github%22%2C%22Google%22%2C%22Youtube%22%2C%22AI%2BServices%22%2C%22Telegram%22%2C%22Ad%2BBlock%22%2C%22Streaming%22%2C%22Apple%22%2C%22Social%2BMedia%22%5D&customRules=%5B%5D&group_by_country=true', 'config'], + xeton: ['https://sub.xeton.dev/sub?target=singbox', 'url'], + v1mk: ['https://api.v1.mk/sub?target=singbox', 'url'] + }; + let cv = converters[converter]; + if (writefile(tmp_url, sub_url) !== null) { let base = cv[0], param = cv[1]; let p = popen('curl -sL --max-time 12 -G --data-urlencode ' + shell_quote(param + '@' + tmp_url) + ' ' + shell_quote(base) + ' 2>/dev/null'); let raw = p ? trim(p.read('all')) : ''; if (p) p.close(); - if (length(raw) < 50 || substr(raw, 0, 1) !== '{') continue; - let obj = json(raw); - if (has_proxy_outbounds(obj)) { - config_obj = obj; - break; + if (length(raw) >= 50 && substr(raw, 0, 1) === '{') { + let obj = null; + try { obj = json(raw); } catch (e) {} + if (has_proxy_outbounds(obj)) config_obj = obj; } } } -system('rm -f ' + shell_quote(tmp_url)); -if (downloaded_yaml) - system('rm -f ' + shell_quote(downloaded_yaml)); +unlink(tmp_url); +if (downloaded_yaml) unlink(downloaded_yaml); @@ -4421,7 +4499,6 @@ if (config_obj) { '{\n' + ' "log": { "level": "info", "timestamp": true },\n' + ' "_note": "subconverter unavailable - replace outbounds with your proxies",\n' + -' "_sub_url": "' + je(sub_url) + '",\n' + ' "dns": {\n' + ' "servers": [\n' + ' { "tag": "remote", "type": "https", "server": "1.1.1.1" },\n' + @@ -4463,11 +4540,15 @@ if (config_obj) { } let path = dir + '/' + final_name; -if (writefile(path, content) === null) +let generated = work_dir + '/generated.json'; +if (writefile(generated, content) === null || + !commit_singbox_profile(generated, final_name)) { + cleanup_singbox_conversion(work_dir); return { success: false, error: 'write_failed', message: '写入配置文件失败' }; +} // Save Subscription-Userinfo from captured HTTP headers as .json.info -let sb_hdr_file = '/tmp/clashoo_sb_sub.hdr'; +let sb_hdr_file = tmp_hdr; if (access(sb_hdr_file, 'r')) { let hdr_raw = readfile(sb_hdr_file) || ''; for (let line in split(hdr_raw, '\n')) { @@ -4478,8 +4559,8 @@ if (access(sb_hdr_file, 'r')) { break; } } - system('rm -f ' + shell_quote(sb_hdr_file) + ' >/dev/null 2>&1'); } +cleanup_singbox_conversion(work_dir); let msg = from_converter ? '配置已从订阅生成: ' + final_name diff --git a/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/node.js b/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/node.js index 8d93872f7..13ec8e395 100644 --- a/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/node.js +++ b/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/node.js @@ -1043,25 +1043,25 @@ return view.extend({ so.depends('type', 'easytier'); so.modalonly = true; - so = ss.taboption('field_general', form.Value, 'easytier_network_name', _('Network name')); + so = ss.taboption('field_general', form.Value, 'easytier_network_name', _('Room name')); so.rmempty = false; so.depends('type', 'easytier'); so.modalonly = true; - so = ss.taboption('field_general', form.Value, 'easytier_network_secret', _('Network secret')); + so = ss.taboption('field_general', form.Value, 'easytier_network_secret', _('Room secret')); + so.password = true; so.depends('type', 'easytier'); so.modalonly = true; - so = ss.taboption('field_general', form.Flag, 'easytier_private_mode', _('Private network'), + so = ss.taboption('field_general', form.Flag, 'easytier_private_mode', _('Private room'), _('Only nodes using the same %s and %s as this network are permitted to perform a handshake or relay traffic through this node.') - .format(_('Network name'), _('Network secret'))); + .format(_('Room name'), _('Room secret'))); so.default = so.disabled; so.depends('type', 'easytier'); so.modalonly = true; so = ss.taboption('field_general', form.Value, 'easytier_hostname', _('Hostname')); so.datatype = 'hostname'; - so.placeholder = 'mihomo'; so.depends('type', 'easytier'); so.modalonly = true; diff --git a/luci-app-fchomo/po/templates/fchomo.pot b/luci-app-fchomo/po/templates/fchomo.pot index bcfedeb4a..1a1f22b3b 100644 --- a/luci-app-fchomo/po/templates/fchomo.pot +++ b/luci-app-fchomo/po/templates/fchomo.pot @@ -2255,12 +2255,12 @@ msgstr "" #: htdocs/luci-static/resources/view/fchomo/node.js:1046 #: htdocs/luci-static/resources/view/fchomo/node.js:1057 -msgid "Network name" +msgid "Room name" msgstr "" #: htdocs/luci-static/resources/view/fchomo/node.js:1051 #: htdocs/luci-static/resources/view/fchomo/node.js:1057 -msgid "Network secret" +msgid "Room secret" msgstr "" #: htdocs/luci-static/resources/fchomo/listeners.js:662 @@ -2682,7 +2682,7 @@ msgid "Private key" msgstr "" #: htdocs/luci-static/resources/view/fchomo/node.js:1055 -msgid "Private network" +msgid "Private room" msgstr "" #: htdocs/luci-static/resources/fchomo.js:346 diff --git a/luci-app-fchomo/po/zh_Hans/fchomo.po b/luci-app-fchomo/po/zh_Hans/fchomo.po index a9ef287e0..b16847df9 100644 --- a/luci-app-fchomo/po/zh_Hans/fchomo.po +++ b/luci-app-fchomo/po/zh_Hans/fchomo.po @@ -2287,13 +2287,13 @@ msgstr "" #: htdocs/luci-static/resources/view/fchomo/node.js:1046 #: htdocs/luci-static/resources/view/fchomo/node.js:1057 -msgid "Network name" -msgstr "网络名称" +msgid "Room name" +msgstr "房间名称" #: htdocs/luci-static/resources/view/fchomo/node.js:1051 #: htdocs/luci-static/resources/view/fchomo/node.js:1057 -msgid "Network secret" -msgstr "网络密码" +msgid "Room secret" +msgstr "房间密码" #: htdocs/luci-static/resources/fchomo/listeners.js:662 msgid "Network type" @@ -2717,8 +2717,8 @@ msgid "Private key" msgstr "私钥" #: htdocs/luci-static/resources/view/fchomo/node.js:1055 -msgid "Private network" -msgstr "私密网络" +msgid "Private room" +msgstr "私密房间" #: htdocs/luci-static/resources/fchomo.js:346 msgid "Process" diff --git a/luci-app-fchomo/po/zh_Hant/fchomo.po b/luci-app-fchomo/po/zh_Hant/fchomo.po index 7ecfc4bba..e2fc32db8 100644 --- a/luci-app-fchomo/po/zh_Hant/fchomo.po +++ b/luci-app-fchomo/po/zh_Hant/fchomo.po @@ -2287,13 +2287,13 @@ msgstr "" #: htdocs/luci-static/resources/view/fchomo/node.js:1046 #: htdocs/luci-static/resources/view/fchomo/node.js:1057 -msgid "Network name" -msgstr "網路名稱" +msgid "Room name" +msgstr "房間名稱" #: htdocs/luci-static/resources/view/fchomo/node.js:1051 #: htdocs/luci-static/resources/view/fchomo/node.js:1057 -msgid "Network secret" -msgstr "網路密碼" +msgid "Room secret" +msgstr "房間密碼" #: htdocs/luci-static/resources/fchomo/listeners.js:662 msgid "Network type" @@ -2717,8 +2717,8 @@ msgid "Private key" msgstr "私鑰" #: htdocs/luci-static/resources/view/fchomo/node.js:1055 -msgid "Private network" -msgstr "私密網路" +msgid "Private room" +msgstr "私密房間" #: htdocs/luci-static/resources/fchomo.js:346 msgid "Process" diff --git a/luci-app-fchomo/root/usr/share/fchomo/generate_client.uc b/luci-app-fchomo/root/usr/share/fchomo/generate_client.uc index 67e6f66f1..107e9c181 100644 --- a/luci-app-fchomo/root/usr/share/fchomo/generate_client.uc +++ b/luci-app-fchomo/root/usr/share/fchomo/generate_client.uc @@ -522,7 +522,7 @@ uci.foreach(uciconf, ucinode, (cfg) => { uuid: cfg.vmess_uuid || cfg.uuid, cipher: cfg.vmess_chipher || cfg.shadowsocks_chipher, password: cfg.shadowsocks_password || cfg.password, - hostname: cfg.tailscale_hostname || cfg.easytier_hostname, + hostname: cfg.tailscale_hostname || cfg.easytier_hostname || ubus.call('system', 'board')?.hostname, headers: cfg.headers ? json(cfg.headers) : null, network: cfg.zerotier_network_id || cfg.masque_network || null, "state-dir": (cfg.type in ['zerotier', 'tailscale', 'easytier']) ? `${HM_DIR}/${ucinode}/${cfg['.name']}` : null, diff --git a/luci-app-glorytun-udp/htdocs/luci-static/resources/view/services/glorytun-udp.js b/luci-app-glorytun-udp/htdocs/luci-static/resources/view/services/glorytun-udp.js index b9e0681fb..b6b1a32e2 100644 --- a/luci-app-glorytun-udp/htdocs/luci-static/resources/view/services/glorytun-udp.js +++ b/luci-app-glorytun-udp/htdocs/luci-static/resources/view/services/glorytun-udp.js @@ -73,6 +73,7 @@ return L.view.extend({ o.rmempty = false; o = s.taboption('advanced', form.Flag, 'persist', _('Persist'), _('Keep the tunnel device after exiting')); + o.default = o.enabled; o.modalonly = true; o = s.taboption('advanced', form.Flag, 'chacha', _('chacha'), _('Force fallback cipher')); diff --git a/luci-app-glorytun-udp/root/etc/hotplug.d/iface/30-glorytun-udp b/luci-app-glorytun-udp/root/etc/hotplug.d/iface/30-glorytun-udp index 1ccf10b8c..78aa5a65d 100644 --- a/luci-app-glorytun-udp/root/etc/hotplug.d/iface/30-glorytun-udp +++ b/luci-app-glorytun-udp/root/etc/hotplug.d/iface/30-glorytun-udp @@ -24,10 +24,17 @@ set_default() { config_get timetolerance "$config" timetolerance "10m" config_get keepalive "$config" keepalive "25s" [ "$remoteip" != "" ] && [ "$localip" != "" ] && ifconfig $DEVICE $localip pointopoint $remoteip up - while [ -z "$(glorytun-udp list | grep $iface)" ]; do - sleep 1 - done - glorytun-udp set dev $iface kxtimeout $kxtimeout timetolerance $timetolerance keepalive $keepalive + # Hotplug scripts run one at a time: wait for glorytun in the + # background, and not forever, or every later event waits too + ( + count=0 + while ! glorytun-udp list 2>/dev/null | grep -qxF "$iface"; do + count=$((count + 1)) + [ "$count" -gt 10 ] && exit 0 + sleep 1 + done + glorytun-udp set dev "$iface" kxtimeout "$kxtimeout" timetolerance "$timetolerance" keepalive "$keepalive" + ) /dev/null 2>&1 & } } diff --git a/luci-app-iperf/luasrc/controller/iperf.lua b/luci-app-iperf/luasrc/controller/iperf.lua index 41b962e4b..407d61430 100644 --- a/luci-app-iperf/luasrc/controller/iperf.lua +++ b/luci-app-iperf/luasrc/controller/iperf.lua @@ -20,9 +20,7 @@ function run_test(server,proto,mode,updown,omit,parallel,transmit,bitrate) end local addr = uci:get("iperf",server,"host") local ports = uci:get("iperf",server,"ports") - local user = uci:get("iperf",server,"user") or "" local password = uci:get("iperf",server,"password") or "" - local key = uci:get("iperf",server,"key") or "" if not addr or not ports then luci.http.write_json({ error = "Invalid server" }) return @@ -34,12 +32,9 @@ function run_test(server,proto,mode,updown,omit,parallel,transmit,bitrate) if not bitrate:match("^[0-9]+[KMG]?$") then bitrate = "1M" end + -- With credentials omr-iperf runs the test: it writes the key to its own + -- private temp file and adds --username/--rsa-public-key-path itself local options = {} - if user ~= "" and password ~= "" and key ~= "" then - luci.sys.call("printf %s " .. ut.shellquote(key) .. " | base64 -d > /tmp/iperf.pem") - options[#options + 1] = "--username " .. ut.shellquote(user) - options[#options + 1] = "--rsa-public-key-path /tmp/iperf.pem" - end if mode == "udp" then options[#options + 1] = "-u -b " .. ut.shellquote(bitrate) end @@ -64,7 +59,7 @@ function run_test(server,proto,mode,updown,omit,parallel,transmit,bitrate) local port = t[ math.random( #t ) ] options = table.concat(options, " ") if password ~= "" then - iperf = io.popen("omr-iperf %s -P %s -%s -O %s -t %s -J -Z %s" % {ut.shellquote(server),parallel,ipv,omit,transmit,options}) + iperf = io.popen("omr-iperf --server %s -P %s -%s -O %s -t %s -J -Z %s" % {ut.shellquote(server),parallel,ipv,omit,transmit,options}) else iperf = io.popen("iperf3 -c %s -P %s -%s -p %s -O %s -t %s -J -Z %s" % {ut.shellquote(addr),parallel,ipv,port,omit,transmit,options}) end diff --git a/luci-app-mail/root/etc/init.d/msmtp b/luci-app-mail/root/etc/init.d/msmtp index 509268028..b112c3bce 100755 --- a/luci-app-mail/root/etc/init.d/msmtp +++ b/luci-app-mail/root/etc/init.d/msmtp @@ -17,46 +17,63 @@ validate_section() { 'port:port:25' } +# msmtp takes the rest of the line as the argument and strips one pair of +# enclosing double quotes, nothing else: quoting keeps leading/trailing +# spaces and a value that is itself quoted; a newline would start a new +# command, so it is dropped. +msmtp_arg() { + printf '"%s"' "$(printf '%s' "$1" | tr -d '\r\n')" +} + config_account() { - local server tls tls_starttls from user password + local server tls tls_starttls from user password port validate_section "${1}" || { - _err "validation failed" + logger -t msmtp "${1}: validation failed" return 1 } - cat > /tmp/msmtp.tmp <<-EOF + cat > "$msmtp_tmp" <<-EOF account default host $server port $port EOF [ -n "$user" ] && [ -n "$password" ] && { - cat >> /tmp/msmtp.tmp <<-EOF + cat >> "$msmtp_tmp" <<-EOF auth on - user $user - password $password + user $(msmtp_arg "$user") + password $(msmtp_arg "$password") EOF } - [ -n "$from" ] && echo "from $from" >> /tmp/msmtp.tmp + [ -n "$from" ] && echo "from $(msmtp_arg "$from")" >> "$msmtp_tmp" [ "$tls" = "1" ] && { - cat >> /tmp/msmtp.tmp <<-EOF + cat >> "$msmtp_tmp" <<-EOF tls_trust_file /etc/ssl/certs/ca-certificates.crt tls on EOF } || { - echo 'tls off' >> /tmp/msmtp.tmp + echo 'tls off' >> "$msmtp_tmp" } [ "$tls_starttls" = "1" ] && { - echo 'tls_starttls on' >> /tmp/msmtp.tmp + echo 'tls_starttls on' >> "$msmtp_tmp" } || { - echo 'tls_starttls off' >> /tmp/msmtp.tmp + echo 'tls_starttls off' >> "$msmtp_tmp" } + msmtp_written=1 } start_service() { + local msmtp_tmp msmtp_written=0 + # The file holds the SMTP password: created 0600 next to the target so + # the mv is a rename, never readable by others even for a moment + msmtp_tmp="$(umask 077; mktemp /etc/msmtprc.XXXXXX)" || return 1 config_load mail config_foreach config_account smtp - [ -f "/tmp/msmtp.tmp" ] && mv /tmp/msmtp.tmp /etc/msmtprc + if [ "$msmtp_written" = "1" ]; then + mv "$msmtp_tmp" /etc/msmtprc + else + rm -f "$msmtp_tmp" + fi } service_triggers() { diff --git a/luci-app-mail/root/etc/uci-defaults/4101-mail b/luci-app-mail/root/etc/uci-defaults/4101-mail index 8037cd9ab..40d754bc2 100644 --- a/luci-app-mail/root/etc/uci-defaults/4101-mail +++ b/luci-app-mail/root/etc/uci-defaults/4101-mail @@ -10,7 +10,7 @@ EEOF uci -q batch <<-EOF >/dev/null delete ucitrack.@mail[-1] add ucitrack mail - set ucitrack.@mail[-1].init=mail + set ucitrack.@mail[-1].init=msmtp commit ucitrack EOF exit 0 \ No newline at end of file diff --git a/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js b/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js index 59a754e61..c5620aa8e 100644 --- a/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js +++ b/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js @@ -43,6 +43,15 @@ return L.view.extend({ // otherwise keep showing the selected one. var activeScheduler = String(res[2] || '').trim(); + // Compared as numbers, not as a float of the version string: "6.6" + // read as 6.6 is above 6.18. + var kernelVersion = String(boardinfo.kernel || '').split('.'); + var kernelMajor = parseInt(kernelVersion[0], 10) || 0; + var kernelMinor = parseInt(kernelVersion[1], 10) || 0; + function kernelAtLeast(major, minor) { + return kernelMajor > major || (kernelMajor == major && kernelMinor >= minor); + } + function normalizeSchedulerValue(value) { if (value == null) return value; @@ -170,7 +179,7 @@ return L.view.extend({ if (activeScheduler && selected && selected != activeScheduler) L.dom.append(widget, E('div', { 'class': 'cbi-value-description' }, [ - E('strong', {}, _('The kernel uses the %s scheduler: %s could not be enabled, see the system log.').format(activeScheduler, selected)) + E('strong', {}, [ _('The kernel uses the %s scheduler: %s could not be enabled, see the system log.').format(activeScheduler, selected) ]) ])); return widget; @@ -293,8 +302,10 @@ return L.view.extend({ o.depends("mptcp_pm_type","1"); o.retain = true; + // The kernel takes 8 at most for both limits, and one value above + // it made "ip mptcp limits set" fail for both o = s.option(form.Value, "mptcp_subflows", _("Max subflows"),_("specifies the maximum number of additional subflows allowed for each MPTCP connection")); - o.datatype = "uinteger"; + o.datatype = "range(0,8)"; o.rmempty = false; o.default = 3; @@ -304,7 +315,7 @@ return L.view.extend({ o.default = 4; o = s.option(form.Value, "mptcp_add_addr_accepted", _("Max add address"),_("specifies the maximum number of ADD_ADDR (add address) suboptions accepted for each MPTCP connection")); - o.datatype = "uinteger"; + o.datatype = "range(0,8)"; o.rmempty = false; o.default = 1; @@ -312,18 +323,18 @@ return L.view.extend({ o.datatype = "uinteger"; o.rmempty = false; o.default = 120; - if (parseFloat(boardinfo.kernel.substring(0,4)) >= 6.18) { + if (kernelAtLeast(6, 18)) { o = s.option(form.Value, "mptcp_blackhole_timeout", _("Blackhole timeout"),_("Initial time period in second to disable MPTCP on active MPTCP sockets when a MPTCP firewall blackhole issue happens. This time period will grow exponentially when more blackhole issues get detected right after MPTCP is re-enabled and will reset to the initial value when the blackhole issue goes away.")); o.datatype = "uinteger"; o.rmempty = false; - o.default = 3600; + o.default = 0; o = s.option(form.Value, "mptcp_close_timeout", _("Close timeout"),_("Set the make-after-break timeout: in absence of any close or shutdown syscall, MPTCP sockets will maintain the status unchanged for such time, after the last subflow removal, before moving to TCP_CLOSE.")); o.datatype = "uinteger"; o.rmempty = false; o.default = 60; - o = s.option(form.Value, "mptcp_syn_retrans_before_tcp_fallback", _("Control message timeout"),_("The number of SYN + MP_CAPABLE retransmissions before falling back to TCP, i.e. dropping the MPTCP options.")); + o = s.option(form.Value, "mptcp_syn_retrans_before_tcp_fallback", _("SYN retransmissions before TCP fallback"),_("The number of SYN + MP_CAPABLE retransmissions before falling back to TCP, i.e. dropping the MPTCP options.")); o.datatype = "uinteger"; o.rmempty = false; o.default = 2; diff --git a/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js b/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js index 24b8a35c3..dc00a70c6 100644 --- a/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js +++ b/luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js @@ -341,7 +341,7 @@ return view.extend({ E('a', { 'href': '#', 'click': function(ev) { ev.preventDefault(); self._switchTab(tab, allTabs); - }}, tab.label) + }}, [ tab.label ]) ); li.id = 'bwtab-' + tab.name; tabmenu.appendChild(li); @@ -432,7 +432,7 @@ return view.extend({ var sid = self._domSafeId(iface.name); var c = self._ifaceColor(iface.name); cells.push(E('div', { 'class': 'mptcp-stat' }, [ - E('span', { 'class': 'mptcp-stat__value', 'style': 'color:' + c + ';font-weight:700;font-size:11px;' }, iface.label) + E('span', { 'class': 'mptcp-stat__value', 'style': 'color:' + c + ';font-weight:700;font-size:11px;' }, [ iface.label ]) ])); ['_cur', '_avg', '_peak'].forEach(function(suf) { cells.push(E('div', { 'class': 'mptcp-stat' }, [ @@ -576,7 +576,7 @@ return view.extend({ var j = self._xToIndex(svgX, series.length); if (j < 0) return; var c = st.cards[itf]; - lines.push('' + (c ? c.label : itf) + ': ' + self._bwLabel(series[j] || 0, false)); + lines.push('' + '%h'.format(c ? c.label : itf) + ': ' + self._bwLabel(series[j] || 0, false)); }); return lines.length ? lines.join('
') : null; }); @@ -591,7 +591,7 @@ return view.extend({ var j = self._xToIndex(svgX, series.length); if (j < 0) return; var c = st.cards[itf]; - lines.push('' + (c ? c.label : itf) + ': ' + self._bwLabel(series[j] || 0, false)); + lines.push('' + '%h'.format(c ? c.label : itf) + ': ' + self._bwLabel(series[j] || 0, false)); }); return lines.length ? lines.join('
') : null; }); @@ -628,10 +628,10 @@ return view.extend({ var card = E('div', { 'class': 'mptcp-wan-card' }, [ E('div', { 'class': 'mptcp-wan-card__header' }, [ E('div', {}, [ - E('div', { 'class': 'mptcp-wan-card__title' }, tab.label), - E('div', { 'class': 'mptcp-wan-card__meta' }, tab.device || '') + E('div', { 'class': 'mptcp-wan-card__title' }, [ tab.label ]), + E('div', { 'class': 'mptcp-wan-card__meta' }, [ tab.device || '' ]) ]), - E('span', { 'class': 'mptcp-wan-card__badge', 'style': badgeStyle }, tab.name) + E('span', { 'class': 'mptcp-wan-card__badge', 'style': badgeStyle }, [ tab.name ]) ]), E('div', { 'class': 'mptcp-chart-block' }, [ E('div', { 'class': 'mptcp-chart-block__title' }, _('Inbound traffic')), diff --git a/luci-app-mptcp/po/de/mptcp.po b/luci-app-mptcp/po/de/mptcp.po index da08a81dc..743f0ca95 100644 --- a/luci-app-mptcp/po/de/mptcp.po +++ b/luci-app-mptcp/po/de/mptcp.po @@ -970,6 +970,10 @@ msgstr "Als veraltet markierte Subflows (mögliches Blackhole)" msgid "Subflows removed" msgstr "Entfernte Subflows" +#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:337 +msgid "SYN retransmissions before TCP fallback" +msgstr "SYN-Wiederholungen vor dem TCP-Fallback" + #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_check.js:40 msgid "Test" msgstr "Test" diff --git a/luci-app-mptcp/po/fr/mptcp.po b/luci-app-mptcp/po/fr/mptcp.po index 282db9232..17764c228 100644 --- a/luci-app-mptcp/po/fr/mptcp.po +++ b/luci-app-mptcp/po/fr/mptcp.po @@ -967,6 +967,10 @@ msgstr "Sous-flux marqués périmés (trou noir possible)" msgid "Subflows removed" msgstr "Sous-flux supprimés" +#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:337 +msgid "SYN retransmissions before TCP fallback" +msgstr "Retransmissions SYN avant le repli sur TCP" + #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_check.js:40 msgid "Test" msgstr "Essai" diff --git a/luci-app-mptcp/po/it/mptcp.po b/luci-app-mptcp/po/it/mptcp.po index b1fccd8ac..7337f05af 100644 --- a/luci-app-mptcp/po/it/mptcp.po +++ b/luci-app-mptcp/po/it/mptcp.po @@ -970,6 +970,10 @@ msgstr "Sottoflussi contrassegnati come obsoleti (possibile buco nero)" msgid "Subflows removed" msgstr "Sottoflussi rimossi" +#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:337 +msgid "SYN retransmissions before TCP fallback" +msgstr "Ritrasmissioni SYN prima del fallback a TCP" + #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_check.js:40 msgid "Test" msgstr "Test" diff --git a/luci-app-mptcp/po/oc/mptcp.po b/luci-app-mptcp/po/oc/mptcp.po index 5bb4e4e07..3f60d7b01 100644 --- a/luci-app-mptcp/po/oc/mptcp.po +++ b/luci-app-mptcp/po/oc/mptcp.po @@ -966,6 +966,10 @@ msgstr "Sosfluxes marcats coma perimits (trauc negre possible)" msgid "Subflows removed" msgstr "Sosfluxes suprimits" +#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:337 +msgid "SYN retransmissions before TCP fallback" +msgstr "Retransmissions SYN abans lo repli sus TCP" + #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_check.js:40 msgid "Test" msgstr "Pròva" diff --git a/luci-app-mptcp/po/ru/mptcp.po b/luci-app-mptcp/po/ru/mptcp.po index 160e15794..41ea85a51 100644 --- a/luci-app-mptcp/po/ru/mptcp.po +++ b/luci-app-mptcp/po/ru/mptcp.po @@ -962,6 +962,10 @@ msgstr "Подпотоки, помеченные как устаревшие (в msgid "Subflows removed" msgstr "Удалённые подпотоки" +#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:337 +msgid "SYN retransmissions before TCP fallback" +msgstr "Повторы SYN перед откатом на TCP" + #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_check.js:40 msgid "Test" msgstr "Тест" diff --git a/luci-app-mptcp/po/templates/mptcp.pot b/luci-app-mptcp/po/templates/mptcp.pot index 12c90cba3..d9000b316 100644 --- a/luci-app-mptcp/po/templates/mptcp.pot +++ b/luci-app-mptcp/po/templates/mptcp.pot @@ -914,6 +914,10 @@ msgstr "" msgid "Subflows removed" msgstr "" +#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:337 +msgid "SYN retransmissions before TCP fallback" +msgstr "" + #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_check.js:40 msgid "Test" msgstr "" diff --git a/luci-app-mptcp/po/zh_Hans/mptcp.po b/luci-app-mptcp/po/zh_Hans/mptcp.po index c5b68bcfe..ee1585d8e 100644 --- a/luci-app-mptcp/po/zh_Hans/mptcp.po +++ b/luci-app-mptcp/po/zh_Hans/mptcp.po @@ -928,6 +928,10 @@ msgstr "被标记为过期的子流(可能存在黑洞)" msgid "Subflows removed" msgstr "已移除的子流" +#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:337 +msgid "SYN retransmissions before TCP fallback" +msgstr "回退到 TCP 前的 SYN 重传次数" + #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_check.js:40 msgid "Test" msgstr "测试" diff --git a/luci-app-mptcp/root/usr/libexec/rpcd/luci.mptcp b/luci-app-mptcp/root/usr/libexec/rpcd/luci.mptcp index bc59189f9..6f7e4a91a 100755 --- a/luci-app-mptcp/root/usr/libexec/rpcd/luci.mptcp +++ b/luci-app-mptcp/root/usr/libexec/rpcd/luci.mptcp @@ -563,6 +563,52 @@ end -- RPC methods -- ------------------------------------------------------------------------- +-- What set_settings / set_interface_settings accept for each field. They +-- wrote any string: the init script builds a file path from mptcp_scheduler +-- ("../" in it registered an arbitrary .o as the scheduler), passes the +-- limits to "ip mptcp limits set" (a value above 8 made it fail for both) +-- and the rest to sysctl. +local function valid_uint(v, max) + local n = tonumber(v) + return v:match("^%d+$") ~= nil and n ~= nil and (max == nil or n <= max) +end + +local function valid_name(v) + return v:match("^[%w_]+$") ~= nil +end + +local setting_checks = { + mptcp_checksum = function(v) return v == "0" or v == "1" end, + mptcp_debug = function(v) return v == "0" or v == "1" end, + mptcp_path_manager = valid_name, + -- stored with or without the "mptcp_" prefix and ".o" suffix of its object + mptcp_scheduler = function(v) return valid_name((v:gsub("%.o$", ""))) end, + mptcp_syn_retries = valid_uint, + mptcp_version = function(v) return v == "0" or v == "1" end, + congestion = valid_name, + mptcp_pm_type = function(v) return v == "0" or v == "1" end, + mptcp_disable_initial_config = function(v) return v == "0" or v == "1" end, + mptcp_force_multipath = function(v) return v == "0" or v == "1" end, + mptcpd_enable = function(v) return v == "enable" or v == "disable" end, + mptcp_subflows = function(v) return valid_uint(v, 8) end, + mptcp_stale_loss_cnt = valid_uint, + mptcp_add_addr_accepted = function(v) return valid_uint(v, 8) end, + mptcp_add_addr_timeout = valid_uint, + mptcp_blackhole_timeout = valid_uint, + mptcp_close_timeout = valid_uint, + mptcp_syn_retrans_before_tcp_fallback = valid_uint, + mptcp_fullmesh_num_subflows = valid_uint, + mptcp_fullmesh_create_on_err = function(v) return v == "0" or v == "1" end, + mptcp_ndiffports_num_subflows = valid_uint, + mptcp_rr_cwnd_limited = function(v) return v == "Y" or v == "N" end, + mptcp_rr_num_segments = valid_uint, + -- list entries: plugin / path manager file names and flag names + mptcpd_path_manager = function(v) return v:match("^[%w_.%-]+$") ~= nil end, + mptcpd_plugins = function(v) return v:match("^[%w_.%-]+$") ~= nil end, + mptcpd_addr_flags = valid_name, + mptcpd_notify_flags = valid_name, +} + local methods = { -- ----------------------------------------------------------------------- @@ -653,6 +699,23 @@ local methods = { "mptcpd_path_manager", "mptcpd_plugins", "mptcpd_addr_flags", "mptcpd_notify_flags" } + -- Every value is checked before anything is written, so a + -- rejected call leaves the config as it was + for _, field in ipairs(string_fields) do + if args[field] ~= nil and args[field] ~= "" and + not setting_checks[field](args[field]) then + return { error = "Invalid value for " .. field } + end + end + for _, field in ipairs(list_fields) do + if type(args[field]) == "table" then + for _, v in ipairs(args[field]) do + if type(v) ~= "string" or not setting_checks[field](v) then + return { error = "Invalid value for " .. field } + end + end + end + end for _, field in ipairs(string_fields) do if args[field] ~= nil and args[field] ~= "" then uci:set("network", "globals", field, args[field]) @@ -715,8 +778,17 @@ local methods = { if not section or section[".type"] ~= "interface" then return { error = "Interface not found" } end - if args.multipath ~= nil and args.multipath ~= "" then - uci:set("network", iface, "multipath", args.multipath) + local mp = args.multipath or "" + if mp ~= "" and mp ~= "on" and mp ~= "off" and mp ~= "master" and + mp ~= "backup" then + return { error = "Invalid value for multipath" } + end + if (args.multipath_weight or "") ~= "" and + not valid_uint(args.multipath_weight) then + return { error = "Invalid value for multipath_weight" } + end + if mp ~= "" then + uci:set("network", iface, "multipath", mp) end if args.multipath_weight ~= nil and args.multipath_weight ~= "" then uci:set("network", iface, "multipath_weight", args.multipath_weight) diff --git a/luci-app-nikki/Makefile b/luci-app-nikki/Makefile index 0b5841e39..1a1823189 100644 --- a/luci-app-nikki/Makefile +++ b/luci-app-nikki/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk -PKG_VERSION:=1.26.1 -PKG_RELEASE:=9 +PKG_VERSION:=1.26.2 +PKG_RELEASE:=10 LUCI_TITLE:=LuCI Support for nikki LUCI_DEPENDS:=+luci-base +nikki diff --git a/luci-app-nikki/htdocs/luci-static/resources/view/nikki/mixin.js b/luci-app-nikki/htdocs/luci-static/resources/view/nikki/mixin.js index 943b7c010..b30010f2d 100644 --- a/luci-app-nikki/htdocs/luci-static/resources/view/nikki/mixin.js +++ b/luci-app-nikki/htdocs/luci-static/resources/view/nikki/mixin.js @@ -201,6 +201,7 @@ return view.extend({ o.value('system', 'System'); o.value('gvisor', 'gVisor'); o.value('mixed', 'Mixed'); + o.value('mips', 'Mips'); o = s.taboption('tun', form.Value, 'tun_mtu', _('MTU')); o.datatype = 'uinteger'; diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index ffa7441c0..3f8636805 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.306 -PKG_RELEASE:=20 +PKG_VERSION:=0.1.309 +PKG_RELEASE:=21 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-packet-capture/root/usr/libexec/packet_capture b/luci-app-packet-capture/root/usr/libexec/packet_capture index 42e4a390a..c06fb6e0e 100755 --- a/luci-app-packet-capture/root/usr/libexec/packet_capture +++ b/luci-app-packet-capture/root/usr/libexec/packet_capture @@ -16,8 +16,10 @@ local filterfile = workdir .. "/tcpdump_filter" local pcapbase = workdir .. "/capture.pcap" local outputfile = "/tmp/capture.pcap" +-- Empty is valid: "Resolve domains" alone drops the default -n and leaves no +-- argument at all, and an error() here left the pidfile behind. local function validate_args(value) - if type(value) ~= "string" or value == "" then + if type(value) ~= "string" then return false end local tokens = {} diff --git a/luci-app-packet-capture/root/usr/libexec/packet_capture_start b/luci-app-packet-capture/root/usr/libexec/packet_capture_start index 8694532ab..e6ce79119 100755 --- a/luci-app-packet-capture/root/usr/libexec/packet_capture_start +++ b/luci-app-packet-capture/root/usr/libexec/packet_capture_start @@ -8,8 +8,14 @@ FILTERFILE="$WORKDIR/tcpdump_filter" PCAPFILE="$WORKDIR/capture.pcap" if [ -f "$PIDFILE" ]; then - echo "error: Packet capture is running" - exit 1 + # a helper that died on an error never removed its pidfile + pid=$(cat "$PIDFILE" 2>/dev/null) + case "$pid" in ''|*[!0-9]*) pid="" ;; esac + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && grep -sq packet_capture "/proc/$pid/cmdline"; then + echo "error: Packet capture is running" + exit 1 + fi + rm -f "$PIDFILE" fi mkdir -p "$WORKDIR" @@ -84,6 +90,6 @@ fi (/usr/libexec/packet_capture "$args" "$duration")& -echo $! > /var/run/packet_capture.pid +echo $! > "$PIDFILE" exit 0 diff --git a/luci-app-packet-capture/root/usr/libexec/packet_capture_stop b/luci-app-packet-capture/root/usr/libexec/packet_capture_stop index bce650346..34fb62c1a 100755 --- a/luci-app-packet-capture/root/usr/libexec/packet_capture_stop +++ b/luci-app-packet-capture/root/usr/libexec/packet_capture_stop @@ -1,9 +1,16 @@ #!/bin/sh -pid=$(cat /var/run/packet_capture.pid) +PIDFILE="/var/run/packet_capture.pid" + +pid=$(cat "$PIDFILE" 2>/dev/null) +case "$pid" in ''|*[!0-9]*) pid="" ;; esac if [ -n "$pid" ] && grep -sq packet_capture "/proc/$pid/cmdline"; then ppid=$(pgrep -P $pid) - kill -TERM $ppid + [ -n "$ppid" ] && kill -TERM $ppid +else + # Nothing runs it any more (the helper died on an error before removing + # it): a stale pidfile made every later start answer "running". + rm -f "$PIDFILE" fi exit 0 diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index 9e17b1ceb..5ca7a57bc 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.10.4 -PKG_RELEASE:=325 +PKG_RELEASE:=326 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/controller/passwall.lua b/luci-app-passwall/luasrc/controller/passwall.lua index ae4ad8f87..3c61294f6 100644 --- a/luci-app-passwall/luasrc/controller/passwall.lua +++ b/luci-app-passwall/luasrc/controller/passwall.lua @@ -458,35 +458,36 @@ function connect_status() local e = {} e.use_time = "" local url = http.formvalue("url") + if not url or url == "" then + http_write_json(e) + return + end local aliyun = string.find(url, "aliyun") local chn_list = uci_get("@global[0]", "chn_list") or "direct" local gfw_list = uci_get("@global[0]", "use_gfw_list") or "1" local proxy_mode = uci_get("@global[0]", "tcp_proxy_mode") or "proxy" local localhost_proxy = uci_get("@global[0]", "localhost_proxy") or "1" local socks_server = (localhost_proxy == "0") and api.get_cache_var("GLOBAL_SOCKS_server") or "" - url = "-w %{http_code}:%{time_pretransfer} " .. url + local curl_args = "-w '%{http_code}:%{time_pretransfer}:%{time_starttransfer}' " .. util.shellquote(url) if socks_server and socks_server ~= "" then if (chn_list == "proxy" and gfw_list == "0" and proxy_mode ~= "proxy" and aliyun ~= nil) or (chn_list == "0" and gfw_list == "0" and proxy_mode == "proxy") then -- 中国列表+阿里 or 全局 - url = "-x socks5h://" .. socks_server .. " " .. url + curl_args = "-x " .. util.shellquote("socks5h://" .. socks_server) .. " " .. curl_args elseif aliyun == nil then -- 其他代理模式+阿里以外网站 - url = "-x socks5h://" .. socks_server .. " " .. url + curl_args = "-x " .. util.shellquote("socks5h://" .. socks_server) .. " " .. curl_args end end - local result = luci.sys.exec('/usr/bin/curl --connect-timeout 3 --max-time 5 -o /dev/null -I -sk ' .. url) - local code = tonumber(luci.sys.exec("echo -n '" .. result .. "' | awk -F ':' '{print $1}'") or "0") - if code ~= 0 then - local use_time_str = luci.sys.exec("echo -n '" .. result .. "' | awk -F ':' '{print $2}'") - local use_time = tonumber(use_time_str) - if use_time then - if use_time_str:find("%.") then - e.use_time = string.format("%.2f", use_time * 1000) - else - e.use_time = string.format("%.2f", use_time / 1000) - end - e.ping_type = "curl" - end + local result = luci.sys.exec('curl_result=$(/usr/bin/curl --connect-timeout 3 --max-time 5 -o /dev/null -I -sk ' .. curl_args .. '); [ $? -eq 0 ] && printf "%s" "$curl_result"') + local code, pretransfer, starttransfer = result:match("^%s*(%d+):([%d%.]+):([%d%.]+)%s*$") + code, pretransfer, starttransfer = tonumber(code), tonumber(pretransfer), tonumber(starttransfer) + if code and code > 0 then e.http_code = code end + -- A completed HTTP response, including 4xx/5xx, confirms homepage connectivity. + if code and code >= 200 and code < 600 and pretransfer and pretransfer >= 0 and starttransfer and starttransfer > 0 and starttransfer >= pretransfer then + -- HTTP request latency excludes connection and TLS handshakes. + e.use_time = string.format("%.2f", (starttransfer - pretransfer) * 1000) + e.handshake_time = string.format("%.2f", pretransfer * 1000) + e.ping_type = "curl" end http_write_json(e) end @@ -515,17 +516,13 @@ function urltest_node() local e = {} e.index = index local result = luci.sys.exec("/usr/share/passwall/test.sh url_test_node " .. util.shellquote(id or "")) - local code = tonumber(luci.sys.exec("echo -n '" .. result .. "' | awk -F ':' '{print $1}'") or "0") - if code ~= 0 then - local use_time_str = luci.sys.exec("echo -n '" .. result .. "' | awk -F ':' '{print $2}'") - local use_time = tonumber(use_time_str) - if use_time then - if use_time_str:find("%.") then - e.use_time = string.format("%.2f", use_time * 1000) - else - e.use_time = string.format("%.2f", use_time / 1000) - end - end + local code, pretransfer, starttransfer = result:match("^%s*(%d+):([%d%.]+):([%d%.]+)%s*$") + code, pretransfer, starttransfer = tonumber(code), tonumber(pretransfer), tonumber(starttransfer) + if code and code > 0 then e.http_code = code end + if code and code >= 200 and code < 600 and pretransfer and pretransfer >= 0 and starttransfer and starttransfer > 0 and starttransfer >= pretransfer then + -- Keep node test parsing independent from the homepage test. + e.use_time = string.format("%.2f", (starttransfer - pretransfer) * 1000) + e.handshake_time = string.format("%.2f", pretransfer * 1000) end http_write_json(e) end diff --git a/luci-app-passwall/luasrc/passwall/api.lua b/luci-app-passwall/luasrc/passwall/api.lua index 40c217034..827856af7 100644 --- a/luci-app-passwall/luasrc/passwall/api.lua +++ b/luci-app-passwall/luasrc/passwall/api.lua @@ -169,7 +169,7 @@ function del_cache_var(key) end function set_cache_var(key, val) - sys.call(string.format('. /usr/share/passwall/utils.sh ; set_cache_var "%s" "%s"', key, val)) + sys.call(string.format('. /usr/share/passwall/utils.sh ; set_cache_var %s %s', util.shellquote(tostring(key)), util.shellquote(tostring(val)))) end function get_cache_var(key) diff --git a/luci-app-passwall/luasrc/view/passwall/global/status.htm b/luci-app-passwall/luasrc/view/passwall/global/status.htm index ff6e018ad..5f79de131 100644 --- a/luci-app-passwall/luasrc/view/passwall/global/status.htm +++ b/luci-app-passwall/luasrc/view/passwall/global/status.htm @@ -139,7 +139,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md -
+
@@ -151,7 +151,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
-
+
@@ -163,7 +163,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
-
+
@@ -175,7 +175,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
-
+
@@ -246,6 +246,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md if (s) { var div = s.parentNode.parentNode.parentNode.parentNode; div.removeAttribute('onclick'); + div.title = "<%:HTTP request latency, for reference only%>"; s.innerHTML = '<%:Check...%>'; var sendDate = (new Date()).getTime(); XHR.get('<%=api.url("connect_status")%>', { @@ -263,6 +264,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md s.className="red"; } s.innerHTML = use_time + " ms"; + div.title += "\n<%:Initial handshake time%>: " + rv.handshake_time + " ms"; } else if (rv.status) { s.className="green"; s.innerHTML = "<%:Working...%>"; diff --git a/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm b/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm index f2f692b59..c69a075b5 100644 --- a/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm +++ b/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm @@ -710,6 +710,7 @@ table td, .table .td { function urltest_node(cbi_id, dom) { if (dom.dataset.busy == "1" || !cbi_id) return; dom.dataset.busy = "1"; + dom.title = "<%:HTTP request latency, for reference only%>"; dom.style.color = ""; dom.innerText = "<%:Check...%>"; XHR.get('<%=api.url("urltest_node")%>', { @@ -720,7 +721,7 @@ table td, .table .td { let text = "<%:Error%>"; if (x && x.status == 200) { if (result.use_time == null || result.use_time.trim() == "") { - text = "<%:Timeout%>"; + text = result.http_code >= 400 ? "HTTP " + result.http_code : "<%:Timeout%>"; } else { let use_time = parseInt(result.use_time); if (use_time < 1000) @@ -728,6 +729,7 @@ table td, .table .td { else if (use_time < 2000) color = "#fb9a05"; text = use_time + " ms"; + dom.title += "\n<%:Initial handshake time%>: " + result.handshake_time + " ms"; } } dom.innerText = text; @@ -1417,7 +1419,7 @@ table td, .table .td { innerHTML = innerHTML.split("{{tcping}}").join('---'); } if (o["address"] && _port) { - innerHTML = innerHTML.split("{{url_test}}").join('<%:Test%>'); + innerHTML = innerHTML.split("{{url_test}}").join('<%:Test%>'); } else { innerHTML = innerHTML.split("{{url_test}}").join('---'); } diff --git a/luci-app-passwall/po/zh-cn/passwall.po b/luci-app-passwall/po/zh-cn/passwall.po index 332ddd8d7..6198ffff4 100644 --- a/luci-app-passwall/po/zh-cn/passwall.po +++ b/luci-app-passwall/po/zh-cn/passwall.po @@ -64,8 +64,11 @@ msgstr "连接失败" msgid "Touch Check" msgstr "点我检测" -msgid "TLS handshake test, latency for reference only" -msgstr "TLS握手测试,延时仅供参考" +msgid "HTTP request latency, for reference only" +msgstr "HTTP 请求延迟,仅供参考" + +msgid "Initial handshake time" +msgstr "首次握手耗时" msgid "Kernel Unsupported" msgstr "内核不支持" diff --git a/luci-app-passwall/root/usr/share/passwall/haproxy.lua b/luci-app-passwall/root/usr/share/passwall/haproxy.lua index a2ce23b6e..8ac9c3f33 100644 --- a/luci-app-passwall/root/usr/share/passwall/haproxy.lua +++ b/luci-app-passwall/root/usr/share/passwall/haproxy.lua @@ -21,7 +21,7 @@ local haproxy_dns = "127.0.0.1" local cpu_thread = sys.exec('echo -n $(cat /proc/cpuinfo | grep "processor" | wc -l)') or "1" local health_check_type = api.uci_get_c("@global_haproxy[0]", "health_check_type") or "tcp" -local health_check_inter = api.uci_get_c("@global_haproxy[0]", "health_check_inter") or "20" +local health_check_inter = api.uci_get_c("@global_haproxy[0]", "health_check_inter") or "60" local balancingStrategy = api.uci_get_c("@global_haproxy[0]", "balancingStrategy") or "roundrobin" local console_port = api.uci_get_c("@global_haproxy[0]", "console_port") or "1188" local bind_local = api.uci_get_c("@global_haproxy[0]", "bind_local") or "0" @@ -44,7 +44,7 @@ global nbthread {{nbthread}} external-check insecure-fork-wanted - spread-checks 50 + spread-checks 20 tune.max-checks-per-thread 5 defaults @@ -99,7 +99,7 @@ api.uci_foreach_c("haproxy_config", function(t) local lbss = t.lbss local listen_port = tonumber(t.haproxy_port) or 0 local server_node = api.uci_get_c(lbss) - local hop = (health_check_type == "script_logic") and (server_node.hysteria_hop or server_node.hysteria2_hop) or nil + local hop = (health_check_type == "script_logic") and server_node and (server_node.hysteria_hop or server_node.hysteria2_hop) or nil hop = hop and hop:gsub(":", "-") or nil if server_node and server_node.address and (server_node.port or hop) then server_remark = server_node.address .. ":" .. (server_node.port or hop) @@ -153,9 +153,12 @@ api.uci_foreach_c("haproxy_config", function(t) end end) -local inter = tonumber((health_check_inter or "20"):match("^%d+")) -inter = (inter and inter >= 20) and inter or 20 -local inter_arg = "inter " .. inter .. "s fastinter " .. inter / 2 .. "s downinter " .. inter * 1.5 .. "s" +local inter = tonumber(health_check_inter:match("^%d+")) or 60 +inter = math.max(inter, 20) +-- 限制过渡状态的确认间隔,避免正常检测间隔较长时摘除或恢复过慢。 +local fastinter = math.max(10, math.min(15, math.floor(inter / 2))) +local downinter = math.ceil(inter * 1.5) +local inter_arg = string.format("inter %ds fastinter %ds downinter %ds", inter, fastinter, downinter) local sortTable = {} for i in pairs(listens) do @@ -165,7 +168,28 @@ for i in pairs(listens) do end table.sort(sortTable, function(a,b) return (a < b) end) -for i, port in pairs(sortTable) do +local function server_endpoint(o) + local address = o.server_address:gsub("^%[(.*)%]$", "%1") + if address:find(":", 1, true) then address = "[" .. address .. "]" end + return address .. ":" .. o.server_port +end + +local check_counts = {} +if health_check_type == "script_logic" then + for _, port in ipairs(sortTable) do + for _, o in ipairs(listens[port]) do + -- 域名可能解析到不同 IP,仅对相同的实际代理端点共享检测。 + if api.is_ip(o.server_address) then + local endpoint = server_endpoint(o) + check_counts[endpoint] = (check_counts[endpoint] or 0) + 1 + end + end + end +end +local shared_checks = {} +local shared_check_names = {} + +for i, port in ipairs(sortTable) do log(" + 入口 %s:%s" % {bind_address, port}) f_out:write("\n" .. string.format([[ @@ -184,7 +208,7 @@ listen %s local count_M, count_B = 1, 1 for i, o in ipairs(listens[port]) do - local remark = o.server_remark or "" + local remark = (o.server_remark or ""):gsub("[%[%]]", "") -- 防止重名导致无法运行 if tostring(o.backup) ~= "1" then remark = "M" .. count_M .. "-" .. remark @@ -193,8 +217,18 @@ listen %s remark = "B" .. count_B .. "-" .. remark count_B = count_B + 1 end - local server = o.server_address .. ":" .. o.server_port - local server_conf = "server {{remark}} {{server}} weight {{weight}} {{resolvers}} check {{inter_arg}} rise 2 fall 2 {{backup}}" + local server = server_endpoint(o) + local check_arg = "check " .. inter_arg .. " rise 2 fall 2" + if (check_counts[server] or 0) > 1 then + local name = shared_check_names[server] + if not name then + name = "check_" .. (#shared_checks + 1) + shared_check_names[server] = name + table.insert(shared_checks, {name = name, server = server}) + end + check_arg = "track " .. appname .. "_health_checks/" .. name + end + local server_conf = "server {{remark}} {{server}} weight {{weight}} {{resolvers}} {{check_arg}} {{backup}}" server_conf = server_conf:gsub("{{remark}}", remark) server_conf = server_conf:gsub("{{server}}", server) server_conf = server_conf:gsub("{{weight}}", o.lbweight) @@ -203,7 +237,7 @@ listen %s resolvers = "" end server_conf = server_conf:gsub("{{resolvers}}", resolvers) - server_conf = server_conf:gsub("{{inter_arg}}", inter_arg) + server_conf = server_conf:gsub("{{check_arg}}", check_arg) server_conf = server_conf:gsub("{{backup}}", tostring(o.backup) == "1" and "backup" or "") f_out:write(" " .. api.trim(server_conf) .. "\n") @@ -216,6 +250,20 @@ listen %s end end +-- 独立检测 backend,避免入口服务器的手动维护状态传播到其他入口。 +if #shared_checks > 0 then + f_out:write(string.format([[ + +backend %s_health_checks + mode tcp + option external-check + external-check command "/usr/share/%s/haproxy_check.sh" +]], appname, appname)) + for _, check in ipairs(shared_checks) do + f_out:write(string.format("\tserver %s %s check %s rise 2 fall 2\n", check.name, check.server, inter_arg)) + end +end + --控制台配置 local console_user = api.uci_get_c("@global_haproxy[0]", "console_user") local console_password = api.uci_get_c("@global_haproxy[0]", "console_password") @@ -234,8 +282,7 @@ f_out:close() --passwall内置健康检查URL if health_check_type == "script_logic" then - local probeUrl = api.uci_get_c("@global_haproxy[0]", "health_probe_url") or "https://www.google.com/generate_204" - local f_url = io.open(haproxy_path .. "/Probe_URL", "w") - f_url:write(probeUrl) - f_url:close() + local probeUrl = api.trim(api.uci_get_c("@global_haproxy[0]", "health_probe_url") or "") + if probeUrl == "" then probeUrl = "https://www.google.com/generate_204" end + api.set_cache_var("HAPROXY_PROBE_URL", probeUrl) end diff --git a/luci-app-passwall/root/usr/share/passwall/haproxy_check.sh b/luci-app-passwall/root/usr/share/passwall/haproxy_check.sh index 3b5700f36..4fc3e78ae 100755 --- a/luci-app-passwall/root/usr/share/passwall/haproxy_check.sh +++ b/luci-app-passwall/root/usr/share/passwall/haproxy_check.sh @@ -1,7 +1,7 @@ #!/bin/sh export PATH=/usr/sbin:/usr/bin:/sbin:/bin:/root/bin -CONFIG=passwall +. /usr/share/passwall/utils.sh listen_address=$1 listen_port=$2 @@ -13,19 +13,16 @@ busybox pgrep -af "${CONFIG}/" | grep -E 'app\.sh.*(start|stop)|nftables\.sh|ipt exit 0 } -probe_file="/tmp/etc/passwall/haproxy/Probe_URL" -probeUrl="https://www.google.com/generate_204" -if [ -f "$probe_file" ]; then - firstLine=$(head -n 1 "$probe_file" | tr -d ' \t\n') - [ -n "$firstLine" ] && probeUrl="$firstLine" -fi +probeUrl=$(get_cache_var "HAPROXY_PROBE_URL") +probeUrl="${probeUrl:-https://www.google.com/generate_204}" -extra_params="-x socks5h://${server_address}:${server_port}" -if /usr/bin/curl --help all | grep -q "\-\-retry-all-errors"; then - extra_params="${extra_params} --retry-all-errors" -fi +case "$server_address" in + \[*\]) ;; + *:*) server_address="[$server_address]" ;; +esac -status=$(/usr/bin/curl -I -o /dev/null -skL ${extra_params} --connect-timeout 3 --retry 1 --max-time 6 -w "%{http_code}" "${probeUrl}") +# 每轮只探测一次,连续成功/失败由 HAProxy 的 rise/fall 判定。 +status=$(/usr/bin/curl -q -I -o /dev/null -sk --noproxy "" -x "socks5h://${server_address}:${server_port}" --connect-timeout 3 --max-time 6 -w "%{http_code}" "$probeUrl") || exit 1 case "$status" in 200|204) diff --git a/luci-app-passwall/root/usr/share/passwall/test.sh b/luci-app-passwall/root/usr/share/passwall/test.sh index 656c700b6..141bcc05f 100755 --- a/luci-app-passwall/root/usr/share/passwall/test.sh +++ b/luci-app-passwall/root/usr/share/passwall/test.sh @@ -73,7 +73,7 @@ url_test_node() { fi sleep 2s probeUrl=$(config_n_get @global_other[0] url_test_url https://www.google.com/generate_204) - result=$(curl --connect-timeout 3 --max-time 5 -o /dev/null -I -skL -w "%{http_code}:%{time_pretransfer}" "$@" -x "$curlx" "${probeUrl}") + result=$(curl --connect-timeout 3 --max-time 5 -o /dev/null -I -sk -w "%{http_code}:%{time_pretransfer}:%{time_starttransfer}" "$@" -x "$curlx" "${probeUrl}") || result=0 # 结束 SS 插件进程 if [ "$_type" != "socks" ]; then pid_file="${TMP_PATH}/${test_flag}_plugin.pid" diff --git a/luci-app-smartsafehub/CHANGELOG.md b/luci-app-smartsafehub/CHANGELOG.md index 211ab5b9b..ddd1e5b3f 100644 --- a/luci-app-smartsafehub/CHANGELOG.md +++ b/luci-app-smartsafehub/CHANGELOG.md @@ -1,5 +1,365 @@ # 변경 기록 +## [0.2.28-r1] - 2026-10-10 + +### 추가 + +- 게스트 Wi-Fi 기능을 추가하였습니다. +- Wi-Fi 비밀번호를 QR 코드로 볼 수 있는 기능을 추가하였습니다. + +### 개선 + +- 새로운 기기와 계정을 연동하는 프로세스를 적용하였습니다. +- 성능 개선을 적용하였습니다. +- 사용성 개선을 위해 일부 UI를 변경하였습니다. + +### 수정 + +- SafeShield의 최소 버전을 0.3.25 이상으로 강제합니다. + +## [0.2.27-r27] - 2026-10-10 + +### 수정 + +- SafeShield의 최소 버전을 0.3.25 이상으로 강제합니다. + +## [0.2.27-r26] - 2026-10-09 + +### 수정 + +- 웹사이트에서 공유기 라이선스를 할당하거나 해제하면 SafeShield 보호 요금제에도 자동으로 반영되도록 수정했습니다. +- SafeShield 갱신 요청이 일시적으로 실패하더라도 다음 연결 확인 때 다시 시도합니다. + +### 테스트 + +- PRO 라이선스 할당·해제 및 변경 없는 상태에서의 중복 갱신 방지를 확인합니다. +- 일시적인 갱신 실패 후 재시도와 잘못된 요금제 응답 처리를 검증합니다. + +## [0.2.27-r25] - 2026-10-09 + +### 수정 + +- 차단 목록 갱신에 실패한 뒤에도 버튼이 '갱신 중'으로 잠겨 있던 문제를 수정했습니다. +- 마지막 실패 단계와 오류 안내는 그대로 표시하면서 '다시 갱신' 버튼으로 재시도할 수 있습니다. + +### 테스트 + +- 실패한 단계가 남아 있어도 다시 갱신할 수 있는지 확인하는 테스트를 추가했습니다. + +## [0.2.27-r24] - 2026-10-09 + +### 추가 + +- 기존 Wi-Fi를 유지하면서 2.4GHz 게스트 Wi-Fi를 별도로 만들고 켜고 끌 수 있습니다. +- 게스트 Wi-Fi는 기본적으로 꺼져 있으며, 별도 비밀번호를 설정해야 활성화할 수 있습니다. + +### 개선 + +- 방문자 기기의 내부 네트워크 및 공유기 관리 화면 접근을 차단하고 게스트 기기 간 통신도 격리합니다. +- 게스트 설정 적용에 실패하면 변경 전 설정으로 되돌리도록 보호 기능을 추가했습니다. + +### 테스트 + +- 게스트 Wi-Fi의 기본 설정, 네트워크 격리, API 권한 및 화면 연결을 확인하는 테스트를 추가했습니다. + +## [0.2.27-r23] - 2026-10-09 + +### 개선 + +- 공유기 웹사이트의 화면 처리 라이브러리를 Preact 11.0.1로 업데이트했습니다. +- 로그인, Wi-Fi, 네트워크 설정 등 기존 화면이 새 버전의 타입 정의와 호환되도록 정리했습니다. + +### 테스트 + +- 라이브러리 버전과 변경된 타입 사용 방식이 일치하는지 확인하는 테스트를 추가했습니다. + +## [0.2.27-r22] - 2026-10-09 + +### 개선 + +- 웹사이트에 로그인하지 않아도 공유기가 자동으로 펌웨어를 받을 수 있도록 다운로드 인증 토큰을 지원합니다. +- 서버가 발급한 다운로드 링크를 그대로 사용하거나 별도 토큰을 전달하는 방식 모두 지원합니다. +- 다운로드 인증 정보는 설정이나 상태 로그에 남기지 않습니다. + +### 테스트 + +- 토큰 다운로드, 잘못된 토큰 거부, 기존 다운로드 링크 사용을 검증하는 테스트를 추가했습니다. + +## [0.2.27-r21] - 2026-10-09 + +### 개선 + +- 활동 기록 동기화가 대기 중일 때 설정 및 상태를 반복 조회하는 횟수를 줄였습니다. +- 새 활동이 발생하면 기존처럼 빠르게 반응하고, 서버 연결 실패 시 재시도 대기 시간을 유지합니다. +- 기존 동기화 명령과 상태 파일 형식을 그대로 유지합니다. + +### 테스트 + +- 유휴 상태의 조회 간격, 활동 알림 처리 및 오류 재시도 동작을 확인하는 테스트를 추가했습니다. + +## [0.2.27-r20] - 2026-10-09 + +### 개선 + +- 기기 연결, 진단, 활동 동기화의 실행 정보를 개별적으로 확인할 수 있도록 개선했습니다. +- 기존 동기화 주기와 실패 시 재시도 방식은 유지하면서 실행 진단 형식을 통일했습니다. +- 진단 서비스의 종료 대기 정책을 다른 관리 서비스와 맞췄습니다. + +### 추가 + +- 각 관리 기능의 마지막 실행 시간, 처리 시간, 성공 여부와 연속 실패 횟수를 확인하는 명령을 추가했습니다. + +### 테스트 + +- 실행 진단 정보 기록 및 CLI 호환성 테스트를 추가했습니다. + +## [0.2.27-r19] - 2026-10-09 + +### 추가 + +- Wi-Fi QR 코드를 PNG 이미지로 다운로드하여 저장하거나 인쇄할 수 있습니다. + +### 개선 + +- 다운로드 버튼에 아이콘을 추가하고, 저장에 실패했을 때 안내 메시지를 보여줍니다. + +### 테스트 + +- QR 코드 다운로드 및 파일명 생성 기능이 유지되는지 확인합니다. + +## [0.2.27-r18] - 2026-10-09 + +### 추가 + +- Wi-Fi 설정에서 QR 코드를 열어 휴대전화로 간편하게 연결할 수 있습니다. +- 기존에 저장한 비밀번호를 다시 입력하지 않아도 QR 코드를 만들 수 있습니다. + +### 개선 + +- Wi-Fi 비밀번호는 QR 코드를 열 때만 확인하고 외부 서비스로 전송하지 않습니다. + +### 테스트 + +- QR 코드 생성과 관리자 인증, 일반 Wi-Fi 조회의 비밀번호 비노출을 확인합니다. + +## [0.2.27-r17] - 2026-10-09 + +### 개선 + +- SafeShield 보호 상태 카드의 원형 진행 표시 선 두께를 조금 더 얇게 조정해 단계 숫자가 더 잘 보이도록 했습니다. + +### 테스트 + +- SafeShield 원형 진행 표시의 선 두께가 의도한 값으로 유지되는지 계약 테스트로 확인합니다. + +## [0.2.27-r16] - 2026-10-09 + +### 수정 + +- 계정 연결 및 해제 후 늦게 도착한 이전 상태가 화면을 덮어쓰지 않도록 수정했습니다. +- 업데이트 도중 다른 탭에서 돌아왔을 때 진행 상황을 다시 확인하도록 개선했습니다. + +### 개선 + +- 계정 화면과 사이드바의 중복 상태 조회를 줄였습니다. +- 조회 성공 시각을 기준으로 캐시를 유지하고, 실패한 요청은 잠시 기다렸다가 재시도합니다. + +### 테스트 + +- 계정 상태 요청 순서, 캐시 성공 시각, 업데이트 복귀·재시도 계약을 검증합니다. + +## [0.2.27-r15] - 2026-10-09 + +### 개선 + +- 계정이 연결되지 않았을 때 사이드바 항목을 화면 읽기 도구와 툴팁에서도 쉽게 확인할 수 있도록 했습니다. + +### 테스트 + +- 계정 메뉴의 접근성 문구와 시각적 알림의 중복 읽기 방지를 확인합니다. + +## [0.2.27-r14] - 2026-10-09 + +### 개선 + +- 최근 활동 페이지가 불필요한 시스템·네트워크 조회 없이 활동 기록만 가져오도록 개선했습니다. +- 개발 환경에서 1초 이상 걸리는 RPC를 구분해 확인할 수 있습니다. 민감한 요청 정보는 기록하지 않습니다. + +### 테스트 + +- 최근 활동 전용 RPC 권한과 개발용 지연 기록 조건을 검증합니다. + +## [0.2.27-r13] - 2026-10-09 + +### 개선 + +- 계정 연결 상태를 1분마다 공유기에서 확인하고, 탭으로 돌아왔을 때도 갱신합니다. +- 화면을 빠르게 다시 열면 최근 데이터를 재사용하며 오래된 데이터는 백그라운드에서 갱신합니다. +- 관리 소프트웨어와 펌웨어 업데이트 알림을 모든 화면에서 일관되게 확인합니다. + +### 테스트 + +- 상태 갱신, 캐시 재사용, 전역 업데이트 조회 계약을 검증합니다. + +## [0.2.27-r12] - 2026-10-09 + +### 추가 + +- 대시보드에서 SmartSafeHub 계정이 연결되지 않은 경우 계정 연결 안내와 바로가기 버튼을 표시합니다. + +### 개선 + +- 계정 연결 여부는 공유기에 저장된 상태만 확인하며, 웹사이트 서버 동기화로 대시보드 로딩이 지연되지 않도록 했습니다. +- 연결 상태를 확인할 수 없거나 이미 계정이 연결된 경우에는 안내를 표시하지 않습니다. + +### 테스트 + +- 미연결 안내, 계정 화면 이동 및 불필요한 서버 동기화 방지 계약을 검증합니다. + +## [0.2.27-r11] - 2026-10-08 + +### 개선 + +- 대시보드에서 시스템 상태와 최근 활동을 한 번의 조회로 불러와 불필요한 요청을 줄였습니다. +- 대시보드와 SafeShield 화면이 보호 상태 및 차단 통계를 공유하여 화면 이동 시 다시 불러오는 횟수를 줄였습니다. +- 업데이트가 진행 중일 때는 화면을 이동해도 진행 상황 확인을 유지하고, 평소에는 관련 화면에서만 조회하도록 개선했습니다. +- 대시보드의 데이터를 갱신할 때 기존 정보를 유지해 화면 전체가 다시 로딩되지 않도록 개선했습니다. + +### 테스트 + +- 중복 조회 방지, 화면별 폴링 조건 및 기존 데이터 유지 동작에 대한 회귀 테스트를 추가했습니다. + +## [0.2.27-r10] - 2026-10-08 + +### 개선 + +- SafeShield 상태의 현재 플랜과 권한 상태를 `license`가 아니라 `entitlement`로 표현해 계정 구독 권한과 과거 로컬 라이선스 키 개념을 분리했습니다. +- Health Reporter 내부 상태도 `licenseStatus` 대신 `entitlementStatus`를 사용하도록 정리했습니다. +- 더 이상 사용하지 않는 SafeShield 라이선스 키 읽기·변경 프런트엔드 코드와 SmartSafeHub 중계 RPC/ACL을 제거했습니다. + +### 테스트 + +- SafeShield/Health 상태가 entitlement 명칭만 사용하는지, 제거된 라이선스 키 RPC가 다시 노출되지 않는지 계약 테스트를 보강했습니다. + +### 호환성 + +- SafeShield `0.3.24-r4`의 `status.entitlement` 스키마를 사용합니다. `status.license` 호환 경로는 유지하지 않습니다. + +## [0.2.27-r9] - 2026-10-08 + +### 개선 + +- 웹사이트에서 SmartSafeHub 기기 등록을 해제하면 공유기가 백그라운드 계정 상태 동기화에서 자동으로 미연결 상태를 반영하도록 개선했습니다. +- 계정 연결 또는 등록 해제 상태가 바뀌면 SafeShield 보호 정보를 한 번 갱신해 현재 계정 권한에 맞는 보호 상태로 빠르게 전환합니다. +- 공유기 웹사이트의 SmartSafeHub 계정 화면을 열어 둔 경우에도 로컬 등록 상태를 주기적으로 다시 읽어 웹사이트에서 수행한 등록 해제를 화면에 자동으로 반영합니다. + +### 테스트 + +- 연결된 기기의 웹사이트 등록 해제를 감지하면 계정 상태와 Cloud 활동 credential을 정리하고 SafeShield 갱신을 한 번 요청하는지 검증하는 회귀 테스트를 추가했습니다. +- 계정 화면이 백그라운드 로컬 상태 변경을 주기적으로 반영하는 UI 계약을 추가했습니다. + +## [0.2.27-r8] - 2026-10-08 + +### 개선 + +- SmartSafeHub 계정에 연결된 Free 사용자도 Cloud 활동 기록을 선택적으로 사용할 수 있도록 변경했습니다. Free는 최근 7일, Pro와 Ultimate는 최근 90일 동안 Cloud에 보관합니다. +- Cloud 활동 기록 사용 가능 여부를 기존 라이선스 상태가 아니라 SmartSafeHub 기기 계정 연결 상태와 서버에서 받은 현재 플랜을 기준으로 판단하도록 정리했습니다. +- 원격 상태 보고는 기존과 동일하게 Pro 이상에서만 사용할 수 있습니다. + +### 테스트 + +- 계정에 연결된 Free 기기의 7일 Cloud 활동 업로드, Pro의 90일 보관, 계정 미연결 기기의 전송 차단을 검증하는 테스트를 보강했습니다. + +## [0.2.27-r7] - 2026-10-08 + +### 개선 + +- SmartSafeHub 계정 연결 코드의 유효 시간을 문장 속 안내 대신 `10분 동안 유효` 배지로 분리해 더 쉽게 확인할 수 있도록 개선했습니다. +- SmartSafeHub 계정 페이지의 요금제 표시를 SafeShield와 같은 플랜 배지로 통일하고, FREE 요금제에는 `기본 플랜` 안내를 함께 표시하도록 정리했습니다. + +### 테스트 + +- 연결 코드 유효 시간 강조와 SafeShield 플랜 배지 스타일 재사용을 검증하는 계정 UI 계약 테스트를 보강했습니다. + +## [0.2.27-r6] - 2026-10-08 + +### 개선 + +- SmartSafeHub 계정 페이지는 공유기에 저장된 마지막 연결 상태를 먼저 표시하고, 서버의 최신 상태 확인은 백그라운드에서 수행하도록 개선했습니다. +- 이미 계정에 연결된 기기에서 서버 확인이 일시적으로 지연되더라도 연결 상태를 의심하게 만드는 경고 대신 마지막 확인 상태를 안내하도록 정리했습니다. +- 연결 코드 대기 중 발생할 수 있는 중복 상태 동기화 요청을 하나의 진행 중 요청으로 합쳐 불필요한 Cloud 요청을 줄였습니다. + +### 수정 + +- SmartSafeHub 서버 응답을 기다리느라 계정 페이지 전체가 오래 로딩되는 문제를 수정했습니다. + +### 테스트 + +- 로컬 상태 우선 표시, 백그라운드 동기화, 중복 요청 방지와 연결된 기기의 지연 안내 문구를 검증하는 계정 UI 계약 테스트를 보강했습니다. + +## [0.2.27-r5] - 2026-10-08 + +### 개선 + +- SmartSafeHub 계정 연결이 완료되면 SafeShield 보호 데이터를 자동으로 한 번 갱신해, 연결 전에 실패했던 보호 데이터 확인 상태가 그대로 남지 않도록 개선했습니다. +- 계정 연결 완료 감지와 SafeShield 갱신 요청을 공유기 백그라운드 기기 동기화 흐름에서 처리해 브라우저 화면이 닫혀 있어도 다음 동기화에서 복구할 수 있도록 했습니다. + +### 테스트 + +- 활성 연결 코드가 계정 등록으로 소비되면 SafeShield 갱신을 한 번만 요청하고, 이후 기기 동기화에서는 같은 갱신을 반복하지 않는 회귀 테스트를 추가했습니다. + +## [0.2.27-r4] - 2026-10-08 + +### 추가 + +- `SYSTEM > SmartSafeHub 계정` 메뉴를 추가해 기기 계정 연결과 현재 플랜을 SafeShield와 분리해서 관리할 수 있도록 했습니다. + +### 개선 + +- 계정 연결 코드가 발급된 동안 서버의 등록 상태를 자동으로 확인하고, 웹사이트에서 기기 등록이 완료되면 연결 코드를 즉시 숨기고 `연결됨` 상태로 전환합니다. +- 공유기 웹사이트를 새로고침할 때도 서버에서 최신 연결 상태를 먼저 확인해 이미 사용된 연결 코드가 다시 표시되지 않도록 했습니다. +- SafeShield 화면에서는 계정 연결 UI를 제거하고 보호 데이터와 사용자 규칙에 집중하도록 정리했습니다. + +### 테스트 + +- 계정 메뉴 라우팅, 연결 코드 polling, 등록 완료 후 코드 숨김, device registration RPC 동기화와 SafeShield 화면 분리를 검증하는 계약 테스트를 추가·보강했습니다. + +## [0.2.27-r3] - 2026-10-08 + +### 개선 + +- SmartSafeHub 계정 연결 코드를 별도의 강조 영역에 크게 표시하고, 한 번에 복사할 수 있는 버튼과 복사 완료 안내를 추가했습니다. +- 모바일과 다크 모드에서도 연결 코드와 복사 버튼을 쉽게 확인할 수 있도록 표시 방식을 개선했습니다. + +### 테스트 + +- 연결 코드 강조 표시와 복사 기능이 SafeShield 화면에 유지되는지 UI 계약 테스트를 보강했습니다. + +## [0.2.27-r2] - 2026-10-03 + +### 추가 + +- 공유기가 SmartSafeHub 서버에 기기 자체로 등록되고 계정 연결 코드를 발급할 수 있도록 기기 등록 기능을 추가했습니다. + +### 개선 + +- SafeShield 화면의 라이선스 키 입력 영역을 SmartSafeHub 계정 연결 상태와 연결 코드 방식으로 변경했습니다. +- 기기 등록 API를 `/api/v1/devices/*` 아래의 bootstrap, sync, pairing session 흐름으로 통일해 역할을 명확하게 구분했습니다. +- 구독 플랜은 계정에 등록된 기기를 기준으로 서버에서 자동 동기화되도록 변경했습니다. +- 기기 credential은 펌웨어 업데이트 후에도 유지되어 서버 장애나 credential 갱신 중에도 기존 기기 identity를 계속 사용할 수 있도록 했습니다. + +### 수정 + +- 기존 라이선스 데몬이 기기 소유권과 Cloud 권한 동기화를 함께 담당하던 구조를 제거했습니다. + +### 테스트 + +- 기기 등록 RPC와 계정 연결 코드 계약을 검증하는 테스트를 추가했습니다. + +### 호환성 + +- 서버의 기존 라이선스 API는 전환 기간 동안 그대로 유지되므로 이전 버전 기기와 새 기기 등록 방식을 함께 운영할 수 있습니다. + ## [0.2.27-r1] - 2026-10-03 ### 개선 diff --git a/luci-app-smartsafehub/Makefile b/luci-app-smartsafehub/Makefile index b9223107f..679aee27a 100644 --- a/luci-app-smartsafehub/Makefile +++ b/luci-app-smartsafehub/Makefile @@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-smartsafehub -PKG_VERSION:=0.2.27 -PKG_RELEASE:=7 +PKG_VERSION:=0.2.28 +PKG_RELEASE:=8 PKG_MAINTAINER:=Beomjun Kang PKG_LICENSE:=GPL-3.0-or-later @@ -17,11 +17,12 @@ LUCI_TITLE:=SmartSafeHub LUCI_URL:=https://github.com/Junatum/luci-app-smartsafehub LUCI_DESCRIPTION:=A modern, user-friendly OpenWrt management platform with Wi-Fi control, device management, system monitoring, and SafeShield DNS protection. LUCI_DEPENDS:=+luci-base +rpcd-mod-ucode +ucode +ucode-mod-ubus +ucode-mod-fs +ucode-mod-uci +procd +uclient-fetch +jsonfilter +igmpproxy +safeshield -LUCI_EXTRA_DEPENDS:=safeshield (>=0.3.24) +LUCI_EXTRA_DEPENDS:=safeshield (>=0.3.25) LUCI_PKGARCH:=all define Package/luci-app-smartsafehub/conffiles /etc/config/smartsafehub +/etc/smartsafehub/device-credential.json endef define Package/luci-app-smartsafehub/preinst @@ -74,9 +75,9 @@ if [ -z "$${IPKG_INSTROOT}" ]; then /etc/init.d/smartsafehub-health enable /etc/init.d/smartsafehub-health restart >/dev/null 2>&1 || true fi - if [ -x /etc/init.d/smartsafehub-license ]; then - /etc/init.d/smartsafehub-license enable - /etc/init.d/smartsafehub-license restart >/dev/null 2>&1 || true + if [ -x /etc/init.d/smartsafehub-device ]; then + /etc/init.d/smartsafehub-device enable + /etc/init.d/smartsafehub-device restart >/dev/null 2>&1 || true fi if [ -x /etc/init.d/igmpproxy ]; then if [ "$$(uci -q get smartsafehub.iptv.enabled 2>/dev/null)" = "1" ]; then diff --git a/luci-app-smartsafehub/README.md b/luci-app-smartsafehub/README.md index fd8484767..84c785c40 100644 --- a/luci-app-smartsafehub/README.md +++ b/luci-app-smartsafehub/README.md @@ -16,7 +16,7 @@ SmartSafeHub는 OpenWrt 공유기를 위한 통합 홈 게이트웨이 관리 UI - OpenWrt: **25.12 버전 이상** - 백엔드: rpcd ucode 모듈 - 프런트엔드: Preact, TypeScript, Vite, Tailwind CSS -- SafeShield: **safeshield (>= 0.3.24)** +- SafeShield: **safeshield (>= 0.3.25)** - 라이선스: **GPL-3.0-or-later** 변경 내역은 [CHANGELOG.md](CHANGELOG.md)를 참고하세요. @@ -29,10 +29,11 @@ SmartSafeHub는 OpenWrt 공유기를 위한 통합 홈 게이트웨이 관리 UI - **Wi-Fi 관리**: SSID, 사용 여부와 WPA2/WPA3 보안 설정을 관리하고 실패 시 설정을 롤백합니다. - **연결 기기**: DHCP, ARP와 무선 정보를 결합해 현재 연결된 기기를 보여줍니다. - **SafeShield 통합**: DNS 보호 상태, 차단 통계, 사용자 Allow/Block 규칙과 차단 목록 갱신을 관리합니다. +- **SmartSafeHub 계정 연결**: 공유기를 계정에 연결해 Cloud 기능을 사용하며, 웹사이트에서 등록을 해제하면 공유기도 백그라운드 동기화로 자동 반영합니다. - **업데이트 관리**: SmartSafeHub 관리 소프트웨어와 펌웨어 업데이트를 확인하고 적용합니다. - **장치 진단**: 로컬 진단, 진단 정보 다운로드와 사용자가 동의한 원격 Health Reporter를 제공합니다. - **관리자 보안**: 최초 관리자 비밀번호 설정과 현재 비밀번호 확인을 거친 비밀번호 변경, 변경 후 재로그인을 제공합니다. -- **최근 활동**: 인터넷, 보호, 업데이트, 라이선스와 진단 상태 변화를 로컬 이벤트 타임라인으로 정규화하며, Cloud 활동 기록은 사용자가 명시적으로 켠 경우에만 전송합니다. +- **최근 활동**: 인터넷, 보호, 업데이트, 계정 권한과 진단 상태 변화를 로컬 이벤트 타임라인으로 정규화하며, Cloud 활동 기록은 사용자가 명시적으로 켠 경우에만 전송합니다. - **IPTV (Beta)**: SK Broadband와 LG U+ 환경을 위한 IGMP Proxy/Snooping 구성을 제공합니다. 기능별 동작과 현재 제약은 [docs/FEATURES.md](docs/FEATURES.md)에서 확인할 수 있습니다. @@ -82,7 +83,7 @@ SMARTSAFEHUB_DEV_ROUTER=http://192.168.1.1 npm run dev | [FEATURES.md](docs/FEATURES.md) | 기능별 상세 동작, UI 정책, 성능·안정성 설계와 현재 제약 | | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | 프런트엔드, rpcd, 데이터 흐름과 보안 경계 | | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | 로컬 개발, 저장소 구조, 빌드, ucode 검사, 배포 전 검증과 버전 관리 | -| [OPERATIONS.md](docs/OPERATIONS.md) | 설치, 설치 후 확인, 라이선스 동기화, 진단과 트러블슈팅 | +| [OPERATIONS.md](docs/OPERATIONS.md) | 설치, 설치 후 확인, 기기 계정/권한 동기화, 진단과 트러블슈팅 | | [STATISTICS_TESTING.md](docs/STATISTICS_TESTING.md) | 저사양 장비를 포함한 통계 기능 검증 절차 | | [CHANGELOG.md](CHANGELOG.md) | 릴리스별 변경 내역 | diff --git a/luci-app-smartsafehub/docs/ARCHITECTURE.md b/luci-app-smartsafehub/docs/ARCHITECTURE.md index 8a217dda9..70f6be744 100644 --- a/luci-app-smartsafehub/docs/ARCHITECTURE.md +++ b/luci-app-smartsafehub/docs/ARCHITECTURE.md @@ -53,12 +53,12 @@ rpcd ucode: smartsafehub rpcd ucode: safeshield ├─ system / network ├─ status / config ├─ wireless / hostapd ├─ enable / refresh ├─ DHCP leases / ARP ├─ local rules - ├─ reboot / wifi reload ├─ license storage / identity + ├─ reboot / wifi reload ├─ device identity / entitlement ├─ update status/settings └─ DNS protection lifecycle - └─ license lifecycle + └─ device account lifecycle │ - ├─ smartsafehub-license (procd) - │ └─ Hub activate / periodic status sync + ├─ smartsafehub-device (procd) + │ └─ Hub bootstrap / account / entitlement sync └─ smartsafehub-updater (procd) └─ apk update / targeted package upgrade @@ -101,7 +101,7 @@ root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json - `smartsafehub.wifi_summary` - `smartsafehub.updates_status` - `smartsafehub.health_status` -- `smartsafehub.license_status` +- `smartsafehub.device_registration_status` - `safeshield.status` - `safeshield.config` - `safeshield.rules_list` @@ -115,16 +115,13 @@ root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json - `smartsafehub.updates_settings_update` - `smartsafehub.health_run` - `smartsafehub.health_reporter_update` -- `smartsafehub.license_activate` +- `smartsafehub.device_registration_refresh` +- `smartsafehub.device_pairing_refresh` - `safeshield.set_enabled` - `safeshield.config_update` (통계 수집 설정에 한정) - `safeshield.refresh` - `safeshield.rule_add` - `safeshield.rule_delete` -- `safeshield.license_get` -- `safeshield.license_update` - -`license_get`은 동작 자체는 읽기이지만 평문 라이선스 키를 반환하는 민감 API이므로 일반 상태 조회 권한과 분리해 write ACL 그룹에 포함합니다. 브라우저의 주기적 상태 polling, 로컬 Health 진단과 진단 다운로드에서는 호출하지 않습니다. opt-in된 Health Reporter daemon만 실제 HTTPS 보고 시 서버 인증을 위해 일시적으로 호출하며 키를 파일이나 payload에 저장하지 않습니다. rpcd는 로그인 시점에 ACL 그룹을 세션 권한으로 확장하므로 패키지 업그레이드로 새 RPC 메서드가 추가되면 이미 로그인되어 있던 세션에는 새 메서드 권한이 아직 없을 수 있습니다. postinst는 `rpcd reload`로 새 ucode plugin/ACL을 즉시 로드하고, 프런트엔드는 `Access denied` 발생 시 오래전부터 존재한 `system_root_password_status`를 같은 session ID로 확인합니다. 이 control RPC도 거부될 때만 실제 세션 만료로 처리하고, control RPC가 성공하면 새 ACL만 부족한 유효 세션으로 간주해 전체 애플리케이션을 강제 로그아웃시키지 않습니다. @@ -483,14 +480,14 @@ SafeShield가 소유하는 기능: - enable/disable lifecycle - 수동 refresh - local allow/block 규칙 -- 라이선스 키 저장·조회·제거와 장치 identity 제공 +- 장치 identity와 현재 entitlement 상태 제공 - 규칙 파일, dnsmasq, procd와 refresh scheduling SmartSafeHub는 API 응답을 화면 모델로 정규화할 뿐 SafeShield의 UCI, `/etc/safeshield/*`, `/tmp/dnsmasq.d/*` 또는 `/etc/init.d/safeshield`를 직접 수정하지 않습니다. 통계 수집 ON/OFF는 SafeShield 공식 `safeshield.config_update`에 `statistics_enabled`만 전달해 변경하며, 그 외 일반 설정 편집에는 사용하지 않습니다. `set_enabled`는 비동기 요청이므로 mutation 응답으로 최종 상태를 추정하지 않고 `safeshield.status`를 다시 조회해 runtime 수렴을 확인합니다. -라이선스 상태의 기본 화면 조회는 `safeshield.status`의 `configured`, `key_masked`, plan/status 정보만 사용합니다. 브라우저가 평문 키를 가져오는 것은 사용자가 **현재 키 불러오기**를 명시적으로 실행한 경우뿐입니다. 새 키 등록과 변경은 SmartSafeHub의 `license_activate` RPC가 SafeShield `status.device` identity를 사용해 Hub `/api/v1/licenses/activate`를 먼저 통과시킨 뒤 성공한 경우에만 `safeshield.license_update`로 저장합니다. 사용자의 로컬 제거는 기존처럼 `license_update`에 빈 키를 전달합니다. +SafeShield 상태는 `safeshield.status.entitlement`의 `plan/status`로 현재 Hub 권한을 노출합니다. 로컬 라이선스 키를 저장하거나 브라우저에 제공하지 않으며, 계정 소유권과 구독 권한은 SmartSafeHub Device credential을 인증한 `/api/v1/devices/sync` 응답에서 결정됩니다. -서버에서 해제한 라이선스의 로컬 수렴은 `/usr/libexec/smartsafehub-license` daemon이 담당합니다. 기본 300초마다 `license_get`으로 현재 키를 메모리에 일시 조회하고 `/api/v1/licenses/status`를 호출하며, 성공 응답이 명시적으로 `device_action=clear_license`를 지시한 경우에만 `safeshield.license_update`로 제거합니다. 서버/API 장애는 로컬 권한을 즉시 파괴하지 않는 fail-open 상태로 기록합니다. 명시적 activate와 status-sync는 activation lock으로 직렬화해 상태 파일과 키 갱신이 서로 덮어쓰지 않게 합니다. 로컬 Health 진단·진단 다운로드·일반 UI polling에는 평문 키가 포함되지 않습니다. opt-in된 유료/Trial Health Reporter daemon 역시 실제 서버 보고 직전에만 `safeshield.license_get`으로 키를 메모리에 조회해 HTTPS 인증 헤더에 사용하고 즉시 폐기합니다. +`/usr/libexec/smartsafehub-device`는 기본 주기 동기화에서 계정 연결 상태와 현재 플랜을 `/tmp/smartsafehub/device.json`에 반영하고 필요한 Cloud runtime credential을 `/tmp`에만 저장합니다. 웹사이트에서 등록 해제가 확인되면 계정 종속 credential을 정리하고 SafeShield refresh를 한 번 요청합니다. Hub/API 장애만으로는 기존 로컬 DNS 보호나 적용된 차단 목록을 제거하지 않습니다. Health Reporter도 현재 device sync entitlement를 기준으로 eligibility를 판단하고 Device credential로 서버에 인증합니다. #### `system.uc` @@ -531,15 +528,13 @@ SafeShield 기능은 아래 공식 API를 직접 소비합니다. | SafeShield API | 유형 | 설명 | |---|---|---| | `safeshield.status` | 읽기 | 상태, runtime, artifact, health | -| `safeshield.config` | 읽기 | 공개 설정과 마스킹된 라이선스 상태 | +| `safeshield.config` | 읽기 | 공개 설정 | | `safeshield.set_enabled` | 쓰기 | 비동기 enable/disable lifecycle 요청 | | `safeshield.config_update` | 쓰기 | `statistics_enabled` 통계 수집 설정 변경 | | `safeshield.refresh` | 쓰기 | 비동기 refresh 요청 | | `safeshield.rules_list` | 읽기 | 사용자 허용·차단 규칙 조회 | | `safeshield.rule_add` | 쓰기 | 사용자 규칙 추가 | | `safeshield.rule_delete` | 쓰기 | 사용자 규칙 삭제 | -| `safeshield.license_get` | 민감 읽기 | 사용자가 요청한 경우 현재 평문 라이선스 키 조회 | -| `safeshield.license_update` | 쓰기 | 라이선스 키 등록·변경, 빈 키로 제거 | ## 7. 주요 데이터 흐름 @@ -625,48 +620,34 @@ SafeShieldRulesPage SmartSafeHub는 규칙 입력 형식을 프런트엔드에서 1차 검증하지만, 규칙 파일과 적용 lifecycle의 authoritative source는 SafeShield입니다. -### 7.7 SmartSafeHub / SafeShield 라이선스 lifecycle +### 7.7 SmartSafeHub 계정 / entitlement lifecycle ```text -기본 화면 상태 조회 - → safeshield.status - → configured + key_masked만 사용 +공유기 초기화 + → /etc/smartsafehub/device-credential.json 생성 + → smartsafehub-device bootstrap + → Hub Device UUID 연결 -현재 키 불러오기 - → 사용자 명시 동작 - → safeshield.license_get - → 평문 키를 입력란에 채워 수정 가능 +계정 연결 + → device_pairing_refresh + → Hub pairing code 발급 + → 웹사이트에서 사용자 계정에 등록 + → smartsafehub-device status-sync + → account.connected=true + entitlement plan/status 저장 -등록 / 변경 - → smartsafehub.license_activate - → safeshield.status.device에서 authoritative identity 수집 - → private /tmp request file 생성 - → smartsafehub-license activate --request-file ... - → POST /api/v1/licenses/activate - → Hub 성공 시에만 safeshield.license_update { license_key: "..." } +주기 동기화 + → Authorization: Device + → POST /api/v1/devices/sync + → 현재 account/entitlement와 Cloud runtime credential 갱신 -주기적 서버 상태 수렴 - → smartsafehub-license daemon (기본 300초) - → safeshield.license_get + safeshield.status.device.physical_fingerprint - → POST /api/v1/licenses/status - ├─ none + licensed: 유지 - ├─ clear_license: safeshield.license_update { license_key: "" } - └─ 통신 실패/비정상 응답: 로컬 키 유지 + 진단 오류 기록 - -사용자 로컬 제거 - → 사용자 확인 - → safeshield.license_update { license_key: "" } +웹사이트에서 등록 해제 + → 다음 device sync에서 account.connected=false 감지 + → 계정 종속 Cloud credential 정리 + → SafeShield refresh 1회 요청 + → Device credential은 유지해 재등록 가능 ``` -SafeShield는 키 저장과 device identity의 authoritative source이며 Hub 계정/장치 activation lifecycle은 SmartSafeHub가 소유합니다. SmartSafeHub는 SafeShield UCI를 직접 수정하지 않고 항상 공식 ubus API를 사용합니다. activate 요청의 평문 키는 command line이나 `/tmp/smartsafehub/license.json`에 기록하지 않고 mode 0600의 일시 request file로 helper에 넘긴 뒤 완료 시 제거합니다. - -`smartsafehub-license`는 `daemon`, `activate`, `status-sync`, `status` subcommand로 구성합니다. 이 명령 경계와 상태 모델은 향후 `smartsafehub-agent license ...`로 통합할 때 기능 코드를 큰 단일 loop로 합치지 않고 license 모듈 단위로 그대로 옮길 수 있게 의도한 것입니다. 현재는 독립 procd 서비스라 장애 격리와 `logread -e smartsafehub-license`, 수동 `status-sync` 같은 디버깅 경로를 유지합니다. - -daemon의 startup/check interval은 interrupt 가능한 child `sleep` + `wait` 경계로 구현합니다. SIGTERM/SIGINT 시 wait 중인 child를 종료하고 scheduler loop를 빠져나오므로 300초 대기 중 procd stop/restart가 SIGKILL까지 지연되지 않습니다. HTTP 요청은 최대 10초이므로 init script의 `term_timeout`은 15초로 두어 요청 중 종료에도 정상 정리 여유를 둡니다. - -`/tmp/smartsafehub/license.json`은 현재 동작 상태와 별도로 마지막 Hub/activation 진단을 유지합니다. `lastHttpStatus`는 성공한 Hub JSON API 요청에서 200을 기록하고 실제 HTTP 상태를 신뢰할 수 없는 fetch 실패에서는 `null`을 기록합니다. `lastActivationResult`와 `lastActivationErrorCode`는 마지막 명시적 activate의 성공/실패 결과이며, 이후 `status-sync`, 서버 revoke에 따른 clear, 로컬 unconfigured 전환이 발생해도 덮어쓰지 않습니다. 따라서 현재 상태(`phase`, `lastResult`)와 마지막 사용자 activation 결과를 독립적으로 진단할 수 있습니다. - -라이선스 입력란은 비밀번호 필드로 취급하지 않고 일반 텍스트 입력으로 사용합니다. 브라우저 비밀번호 관리자 대상이 되지 않도록 autocomplete 및 주요 password-manager ignore 속성을 적용합니다. +SafeShield는 Hub 동기화 응답에서 받은 `entitlement.plan/status`와 artifact 정보를 `safeshield.status.entitlement`로 노출합니다. 로컬 라이선스 키 저장·조회 API는 사용하지 않습니다. Hub 장애만으로 기존 로컬 DNS 보호나 마지막으로 적용된 차단 목록을 제거하지 않습니다. ### 7.8 진단 파일 @@ -689,14 +670,12 @@ SettingsPage의 기존 system snapshot ├─ 30분 heartbeat ├─ 이상 fingerprint 변경 즉시 보고 └─ 실패 시 5분 backoff - → license_get (일시 인증) + → Device credential 인증 → privacy whitelist payload → POST /api/v1/health/reports ``` -Health Reporter가 서버 인증 헤더를 만들 때 사용하는 `safeshield.license_get` 응답은 `license.key`에 평문 키를 담는 중첩 구조를 사용합니다. Reporter는 이 실제 계약을 우선 읽고, 과거 개발 빌드의 최상위 `key`는 호환 fallback으로만 처리합니다. 키는 상태 파일이나 보고 payload에 저장하지 않습니다. - -Hub 수신 API는 라이선스/Trial eligibility를 서버에서도 독립적으로 검증해야 합니다. 공유기의 클라이언트 측 gating은 서버 권한 검사를 대신하지 않습니다. +Health Reporter는 `/tmp/smartsafehub/device.json`의 현재 entitlement를 기준으로 Pro/Ultimate eligibility를 판단하고 `/etc/smartsafehub/device-credential.json`의 Device credential로 서버에 인증합니다. Hub 수신 API도 현재 계정 entitlement를 독립적으로 검증하므로 공유기의 클라이언트 측 gating은 서버 권한 검사를 대신하지 않습니다. ### 7.10 다크 테마 상태 패널 @@ -721,10 +700,9 @@ PKG_VERSION + PKG_RELEASE - 모든 원격 호출은 `/admin/ubus`를 통과합니다. - ACL에 등록하지 않은 메서드는 호출할 수 없습니다. - 읽기와 쓰기 메서드를 분리합니다. -- 평문 라이선스 키를 반환하는 `license_get`은 일반 read ACL과 분리합니다. 브라우저에서는 사용자 명시 동작에서만 호출하고, opt-in된 Health Reporter daemon에서는 실제 서버 보고의 인증 순간에만 일시 사용합니다. -- 진단 파일은 비밀번호와 라이선스 키를 요청하거나 저장하지 않습니다. +- 진단 파일은 비밀번호나 Device credential 같은 인증 비밀을 요청하거나 저장하지 않습니다. - 진단 파일에 포함될 수 있는 호스트명, WAN IPv4와 Wi-Fi SSID를 사용자에게 사전 안내합니다. -- Health Reporter payload는 별도 whitelist builder를 사용하며 네트워크 식별 정보, DNS 요청 내용, 로그 원문과 라이선스 키를 포함하지 않습니다. +- Health Reporter payload는 별도 whitelist builder를 사용하며 네트워크 식별 정보, DNS 요청 내용, 로그 원문과 Device credential을 포함하지 않습니다. - Health Reporter는 기본 OFF이고 유료/Trial 사용자도 직접 opt-in해야 하며 OFF 이후 자동 보고 요청을 보내지 않습니다. ### 8.2 입력 검증 @@ -833,7 +811,7 @@ SmartSafeHub의 휘발성 런타임 파일은 `/tmp/smartsafehub/` 한 단계 업데이트 기능은 rpcd와 실제 APK 작업을 분리합니다. `updates_check`와 `updates_install`은 요청을 검증한 뒤 `/usr/libexec/smartsafehub-updater`를 백그라운드에서 시작하고 즉시 반환합니다. updater는 `apk update`와 패키지 조회·설치를 수행하고 `/tmp/smartsafehub/updates.state`에 결과를 atomic write합니다. `updates_status`는 이 로컬 상태 파일과 UCI 설정만 읽으므로 저장소 응답 속도가 제품 UI API에 영향을 주지 않습니다. -업데이트 감지 대상은 `luci-app-smartsafehub` 하나입니다. 새 버전이 확인되면 `apk add --upgrade luci-app-smartsafehub`만 실행합니다. Makefile은 `LUCI_DEPENDS:=... +safeshield`와 `LUCI_EXTRA_DEPENDS:=safeshield (>=0.3.23)`를 함께 선언합니다. 따라서 빌드 시 SafeShield 선택 관계를 유지하면서, 설치·업데이트 시 APK dependency resolver가 최소 `0.3.23` 조건을 만족하도록 필요한 경우 SafeShield를 함께 갱신합니다. +업데이트 감지 대상은 `luci-app-smartsafehub` 하나입니다. 새 버전이 확인되면 `apk add --upgrade luci-app-smartsafehub`만 실행합니다. Makefile은 `LUCI_DEPENDS:=... +safeshield`와 `LUCI_EXTRA_DEPENDS:=safeshield (>=0.3.24-r4)`를 함께 선언합니다. 따라서 빌드 시 SafeShield 선택 관계를 유지하면서, 설치·업데이트 시 APK dependency resolver가 최소 `0.3.24-r4` 조건을 만족하도록 필요한 경우 SafeShield를 함께 갱신합니다. 새 버전이 있으면 updater는 SmartSafeHub 전용 `/etc/apk/repositories.d/smartsafehub.list`의 APK repository URL에서 `https://repo.smartsafehub.com/` base를 유도하고 먼저 `releases/luci-app-smartsafehub/index.json`을 조회합니다. 다른 repository 파일은 release channel 결정에 사용하지 않습니다. index의 newest-first 순서를 이용해 현재 설치 버전 이후부터 APK가 제시한 최신 버전까지의 `.json`만 내려받고 `/tmp/smartsafehub/release-notes.json` 하나의 bundle로 atomic cache합니다. rpcd는 bundle의 설치/최신 버전 범위, 각 릴리즈의 schema·package·version과 크기 제한을 검증한 뒤 `updates_status.releaseNotes`와 `releaseNotesComplete`로 노출합니다. index 또는 일부 릴리즈 노트를 가져오지 못한 경우에도 가능한 노트만 표시하며, 이 메타데이터는 signed APK metadata를 대체하지 않는 표시용 보조 정보이므로 다운로드·파싱 실패는 update check/install 결과에 영향을 주지 않습니다. @@ -848,9 +826,3 @@ WAN 고정 IPv4의 주소, gateway와 DNS도 동일한 `Ipv4OctetInput` 컴포 ### Hash route 새로고침 보존 SmartSafeHub는 현재 route를 별도 storage에 복제하지 않고 브라우저의 URL fragment를 직접 사용한다. uHTTPd exact-root handler가 `/`을 내부 rewrite하면 브라우저 navigation이 발생하지 않으므로 `/#settings`, `/#system` 같은 fragment는 일반 새로고침에서도 그대로 유지된다. - -### License activation RPC 경계 - -`smartsafehub.license_activate`는 rpcd 실행 컨텍스트에서 `safeshield.status`를 다시 동기 호출하지 않습니다. RPC는 mode 0600 request 파일에 라이선스 키만 기록하고 detached `smartsafehub-license activate` helper를 시작한 뒤 즉시 반환합니다. helper가 별도 프로세스에서 SafeShield authoritative device identity/profile을 읽고 Hub `/api/v1/licenses/activate`를 호출하며, 성공한 경우에만 SafeShield `license_update`로 로컬 키를 저장합니다. 이 경계는 rpcd nested ubus 대기를 피하고 향후 `smartsafehub-agent license activate`로 이동할 때도 그대로 유지합니다. - -프런트엔드는 activation 상태를 1초 간격으로 확인하되 `license_status` RPC timeout을 5초로 제한하고 일시적인 timeout/네트워크 오류를 최대 2회 재시도합니다. 라이선스 관련 진행/성공/오류 피드백은 작업 위치인 라이선스 카드 안에서 표시합니다. diff --git a/luci-app-smartsafehub/docs/DEVELOPMENT.md b/luci-app-smartsafehub/docs/DEVELOPMENT.md index 7649503e1..584b67f2e 100644 --- a/luci-app-smartsafehub/docs/DEVELOPMENT.md +++ b/luci-app-smartsafehub/docs/DEVELOPMENT.md @@ -38,10 +38,10 @@ procd uclient-fetch jsonfilter igmpproxy -safeshield (>= 0.3.24) +safeshield (>= 0.3.24-r4) ``` -`LUCI_DEPENDS`의 `+igmpproxy`는 IPTV Beta의 멀티캐스트 proxy runtime을 함께 설치하고, `+safeshield`는 빌드 시 SafeShield 패키지 선택 관계를 유지합니다. `LUCI_EXTRA_DEPENDS:=safeshield (>=0.3.24)`는 설치·업데이트 시 필요한 최소 SafeShield 버전을 강제합니다. +`LUCI_DEPENDS`의 `+igmpproxy`는 IPTV Beta의 멀티캐스트 proxy runtime을 함께 설치하고, `+safeshield`는 빌드 시 SafeShield 패키지 선택 관계를 유지합니다. `LUCI_EXTRA_DEPENDS:=safeshield (>=0.3.24-r4)`는 설치·업데이트 시 필요한 최소 SafeShield 버전을 강제합니다. 프런트엔드 빌드에는 **Node.js 24 이상**이 필요합니다. @@ -80,13 +80,13 @@ luci-app-smartsafehub/ │ ├── etc/init.d/smartsafehub-updater │ ├── etc/init.d/smartsafehub-firmware │ ├── etc/init.d/smartsafehub-health -│ ├── etc/init.d/smartsafehub-license +│ ├── etc/init.d/smartsafehub-device │ ├── etc/init.d/smartsafehub-maintenance │ ├── usr/libexec/smartsafehub-events │ ├── usr/libexec/smartsafehub-updater │ ├── usr/libexec/smartsafehub-firmware │ ├── usr/libexec/smartsafehub-health -│ ├── usr/libexec/smartsafehub-license +│ ├── usr/libexec/smartsafehub-device │ ├── usr/libexec/smartsafehub-maintenance │ ├── etc/uci-defaults/91-smartsafehub-firmware-identity │ ├── usr/libexec/smartsafehub-backup diff --git a/luci-app-smartsafehub/docs/FEATURES.md b/luci-app-smartsafehub/docs/FEATURES.md index 9d6443f49..123196b56 100644 --- a/luci-app-smartsafehub/docs/FEATURES.md +++ b/luci-app-smartsafehub/docs/FEATURES.md @@ -100,17 +100,15 @@ README에는 제품을 빠르게 파악하는 데 필요한 핵심 기능만 유 - 수동 갱신 요청의 성공 안내 배너는 유지하지 않고 실제 진행 상태를 보호 카드의 단계 UI로 표시하며, 실패한 경우에만 오류 피드백을 유지 - 갱신 단계의 원형 progress ring은 track 3px / active arc 4px의 얇은 stroke와 절제된 shadow를 유지하면서, 전체 지름도 소폭 줄여 카드 본문 대비 존재감이 과해 보이지 않도록 조정했습니다. 라이트/다크 모드 모두에서 단계 숫자는 계속 선명하게 읽을 수 있습니다. - 갱신 데몬, dnsmasq와 DNS 런타임 상태 표시 -- 라이선스, 플랜, 아티팩트와 차단 목록 상태 표시 +- SmartSafeHub 계정의 entitlement 플랜, 아티팩트와 차단 목록 상태 표시 - 유료 플랜은 PRO(teal), ULTIMATE(bronze), PLUS 등 기타 유료 플랜(blue)의 정적인 premium chip으로 구분합니다. 외부 glow와 shine 애니메이션은 사용하지 않고 얕은 그림자와 1px 테두리만 유지하며, FREE 플랜은 `https://www.smartsafehub.com/pricing/` 요금제 안내 CTA를 보호 카드에 표시 - 로컬 DNS 요청·차단 수, 차단율과 최근 24시간 시간대별 차단 통계 표시 - 대시보드와 SafeShield의 최근 24시간 통계에서는 제품이 실제로 처리한 결과인 `차단` 수치를 동일한 teal 강조색으로 표시하고, DNS 요청 수와 차단율은 기본 텍스트 색상으로 유지해 지표의 우선순위를 일관되게 표현 - DHCP 식별 정보를 이용한 기기별 DNS 요청·차단 수·차단율과 IP/MAC 표시. 차단 수 기준 상위 3개 기기를 기본 미리보기로 보여주며 `차단 TOP 3` 배지와 `전체 N개 기기 중 차단 수 기준 상위 3개` 안내로 현재 표시 범위를 명확히 표현. 필요할 때 전체 목록을 펼쳐 10개 단위 페이지네이션으로 확인 - 통계 RPC는 SafeShield 화면에서만 60초 간격으로 조회하며 숨겨진 브라우저 탭에서는 polling 중지 -- 새 라이선스 등록·변경은 `smartsafehub.license_activate`가 키만 private request로 넘기고 즉시 반환한 뒤 detached `smartsafehub-license activate` helper가 SafeShield 장치 identity를 조회해 Hub `/api/v1/licenses/activate`에서 검증합니다. Hub 성공 뒤에만 SafeShield 공식 `license_update` API로 로컬 저장하며 rpcd 안에서 nested ubus 호출을 수행하지 않습니다. -- 라이선스 등록·조회·제거의 진행/성공/오류 피드백은 SafeShield 페이지 상단이 아니라 라이선스 입력 카드 안에 표시합니다. activation 상태 조회는 1초 간격, 5초 RPC timeout을 사용하고 일시적인 통신 오류를 제한적으로 재시도합니다. -- `smartsafehub-license` daemon이 기본 5분마다 Hub `/api/v1/licenses/status`를 확인하고, 서버가 명시적으로 `clear_license`를 반환한 경우에만 SafeShield 공식 API로 로컬 키 제거 -- Hub 상태 확인 실패만으로는 로컬 라이선스를 제거하지 않는 fail-open 동작을 사용하며, 활성화와 주기 확인은 single-flight 경계로 직렬화 -- 현재 라이선스 키는 사용자가 `현재 키 불러오기`를 선택했을 때만 `safeshield.license_get`으로 평문 조회 +- SafeShield 상태는 로컬 라이선스 키가 아니라 `status.entitlement.plan/status`로 현재 계정 플랜과 권한 상태를 표시합니다. +- 계정 연결과 플랜 동기화는 `smartsafehub-device`가 Device credential로 `/api/v1/devices/sync`를 호출해 담당하며, SafeShield는 동기화 응답의 entitlement와 보호 아티팩트 정보만 소비합니다. +- 웹사이트에서 기기 등록을 해제하면 device sync가 미연결 상태를 감지하고 Cloud credential을 정리한 뒤 SafeShield 보호 정보를 다시 갱신합니다. 로컬 DNS 보호와 사용자 규칙은 계속 유지됩니다. - 사용자 허용 목록과 차단 목록 관리 - 규칙 저장과 유효성 검사는 SafeShield 공식 API가 담당 - 규칙 변경은 SafeShield 엔진의 cached-artifact local apply 경로로 즉시 반영 @@ -247,13 +245,13 @@ Health observer의 첫 주기는 WAN/Health 현재 상태를 baseline으로만 공유기 웹사이트는 기존 로그인 세션과 RPC 시그니처를 그대로 유지하기 위해 인자 없는 read-only `smartsafehub.status` 응답에 최근 활동을 함께 포함해 읽고, 대시보드에는 최근 3건, `최근 활동` 전용 화면에는 최대 128건을 날짜별로 묶어 표시합니다. 대시보드의 compact 타임라인은 각 항목 사이에 작은 세로 간격을 두어 짧은 제목/설명이 연속해서 붙어 보이지 않도록 합니다. 저장된 원본에는 한국어 제목/설명을 넣지 않고 프론트엔드가 `event_type + metadata`를 렌더링합니다. 로컬 UI는 무료 장치 기능이며 **현재 부팅 이후의 휘발성 이력**을 제공합니다. -Pro/Ultimate 장치에서는 공유기 `최근 활동` 화면에서 **Cloud 활동 기록 전송을 사용자가 직접 ON/OFF**할 수 있습니다. `smartsafehub.activity.cloud_sync_enabled=0`이 기본값이며, Cloud 전송은 사용자가 명시적으로 ON으로 저장한 경우에만 동작합니다. 옵션이 없거나 해석할 수 없는 값이면 OFF로 처리해 설정 누락만으로 활동 기록이 서버에 전송되지 않도록 합니다. +SmartSafeHub 계정에 연결된 장치에서는 공유기 `최근 활동` 화면에서 **Cloud 활동 기록 전송을 사용자가 직접 ON/OFF**할 수 있습니다. Free는 최근 7일, Pro/Ultimate는 최근 90일 동안 Cloud에 보관합니다. `smartsafehub.activity.cloud_sync_enabled=0`이 기본값이며, Cloud 전송은 사용자가 명시적으로 ON으로 저장한 경우에만 동작합니다. 옵션이 없거나 해석할 수 없는 값이면 OFF로 처리해 설정 누락만으로 활동 기록이 서버에 전송되지 않도록 합니다. -Cloud 전송이 ON일 때 `/usr/libexec/smartsafehub-activity-sync`가 같은 이벤트의 Cloud outbox를 `/api/v1/activity/events`로 batch 전송합니다. Hub 1.4.86부터 `activity_history` upload credential은 artifact resolve와 분리된 `/api/v1/licenses/status`에서 발급되지만, **license status API의 단일 소유자는 `/usr/libexec/smartsafehub-license`** 입니다. `smartsafehub-license status-sync`가 기존 라이선스 reconciliation과 함께 `activity_history` token/URL을 검증해 `/tmp/smartsafehub/activity-sync-credential.json`에 원자적으로 저장하고, `smartsafehub-activity-sync`는 이 runtime credential을 소비해 upload/ack만 담당합니다. 따라서 activity sync가 license key/device fingerprint를 다시 읽거나 `/licenses/status`와 `/licenses/resolve`를 별도로 호출하지 않습니다. +Cloud 전송이 ON일 때 `/usr/libexec/smartsafehub-activity-sync`가 같은 이벤트의 Cloud outbox를 `/api/v1/activity/events`로 batch 전송합니다. `activity_history` upload credential은 `/usr/libexec/smartsafehub-device`가 `/api/v1/devices/sync`에서 현재 계정 연결 상태와 플랜을 확인할 때 함께 받아 `/tmp/smartsafehub/activity-sync-credential.json`에 원자적으로 저장합니다. `smartsafehub-activity-sync`는 이 짧은 수명의 runtime credential만 소비해 upload/ack을 담당하며 라이선스 API를 직접 호출하지 않습니다. Cloud 전송을 끄면 activity-sync daemon을 잠시 중지한 뒤 Cloud-only outbox, wake marker와 `/tmp` runtime credential을 정리하고 `disabled` 상태로 다시 시작합니다. 이후 이벤트 writer는 `activity-history.jsonl`에만 새 이벤트를 기록하므로 로컬 최근 활동은 계속 보이지만 서버 전송 대기 데이터는 생기지 않습니다. 다시 켜도 OFF 기간의 이벤트를 소급 업로드하지 않고 **ON 이후 새로 발생한 이벤트부터** Cloud outbox에 넣습니다. 서버에 이미 저장된 과거 Cloud 기록을 삭제하는 기능과 전송 OFF는 별개의 정책입니다. -발급받은 credential은 flash가 아닌 `/tmp`에만 보관하며, 기존 token이 만료 10분 전보다 충분히 유효하면 그대로 재사용합니다. credential이 없거나 만료 임박한 경우에만 activity sync가 `smartsafehub-license status-sync`를 요청해 canonical license 경로를 갱신합니다. Cloud 전송 OFF에서는 license daemon이 라이선스 reconciliation 자체는 계속 수행하지만 optional activity credential을 로컬 runtime cache에 보관하지 않습니다. 유효한 Pro/Ultimate 상태인데 optional activity credential만 누락되면 `ACTIVITY_CREDENTIAL_UNAVAILABLE`로 최대 128건 Cloud outbox를 보존하고, license status 자체를 확인하지 못하면 `ACTIVITY_LICENSE_STATUS_FAILED`로 보존·재시도합니다. 서버가 명시적으로 라이선스를 해제하거나 미설정 상태가 확인된 경우에만 Cloud-only outbox를 정리합니다. `/licenses/resolve`로 fallback하지 않으므로 Cloud credential 확인이 artifact resolve/download token 발급이나 다운로드 감사 로그를 발생시키지 않습니다. +발급받은 credential은 flash가 아닌 `/tmp`에만 보관하며, 기존 token이 만료 10분 전보다 충분히 유효하면 그대로 재사용합니다. credential이 없거나 만료 임박한 경우에만 activity sync가 `smartsafehub-device status-sync`를 요청합니다. 계정에 연결된 Free/Pro/Ultimate 기기인데 Hub가 activity credential을 제공하지 않으면 `ACTIVITY_CREDENTIAL_UNAVAILABLE`로 최대 128건 Cloud outbox를 보존하고, device sync 자체를 확인하지 못하면 `ACTIVITY_DEVICE_SYNC_FAILED`로 보존·재시도합니다. 계정 연결이 해제된 것이 명시적으로 확인된 경우에만 Cloud-only outbox를 정리합니다. 전송 성공 응답의 `received == accepted + duplicates + expired`가 snapshot batch와 일치할 때 그 snapshot의 `event_id`만 ack하며, 업로드 실패나 불완전 응답에서는 ack하지 않습니다. 실패 주기는 15분 → 30분 → 60분으로 증가해 60분에서 상한을 유지하고, 새 이벤트 wake marker도 활성 backoff를 우회하지 않습니다. Cloud ack와 ON/OFF 전환은 로컬 `activity-history.jsonl`을 삭제하지 않습니다. 공유기 `최근 활동` 화면에서는 ON/OFF 스위치, entitlement, 전송 대기 건수, 마지막 성공 시각과 서버 retention을 함께 확인할 수 있습니다. diff --git a/luci-app-smartsafehub/docs/OPERATIONS.md b/luci-app-smartsafehub/docs/OPERATIONS.md index c177fb325..12c90fd48 100644 --- a/luci-app-smartsafehub/docs/OPERATIONS.md +++ b/luci-app-smartsafehub/docs/OPERATIONS.md @@ -88,57 +88,34 @@ safeshield.refresh safeshield.rules_list safeshield.rule_add safeshield.rule_delete -safeshield.license_get -safeshield.license_update ``` -`license_get`은 평문 라이선스 키를 반환하므로 브라우저의 일반 상태 조회에는 사용하지 않습니다. 사용자가 현재 키를 명시적으로 불러올 때 호출하며 LuCI ACL에서도 일반 read 권한과 분리합니다. 로컬 Health 진단, 진단 다운로드와 주기적 UI polling은 `safeshield.status`의 마스킹된 라이선스 정보만 사용합니다. 예외적으로 opt-in된 유료/Trial Health Reporter daemon은 실제 HTTPS 보고 직전에 서버 인증 헤더를 만들기 위해 평문 키를 일시적으로 조회하며, 키를 런타임 상태 파일이나 보고 payload에 기록하지 않습니다. +### SmartSafeHub entitlement lifecycle -SafeShield `license_get`의 현재 응답 계약은 `{ "license": { "configured": true, "key": "..." } }` 형태이며 Health Reporter와 라이선스 상태 동기화 daemon은 필요한 순간에만 `license.key`를 메모리에서 읽습니다. 이전 개발 빌드의 최상위 `key` 응답은 Health Reporter 호환 fallback으로만 허용합니다. - -### SmartSafeHub 라이선스 lifecycle - -Hub 계정과 장치의 라이선스 연결 lifecycle은 SafeShield 엔진이 아니라 SmartSafeHub 관리 계층이 소유합니다. SafeShield는 계속해서 실제 키 저장소와 장치 identity의 authoritative source 역할만 담당합니다. +Hub 계정 연결과 플랜 권한은 SafeShield 라이선스 키가 아니라 SmartSafeHub Device credential을 기준으로 동기화합니다. Device credential은 `/etc/smartsafehub/device-credential.json`에 저장하고, 계정/플랜의 마지막 확인 상태는 `/tmp/smartsafehub/device.json`에서 확인합니다. ```text -사용자가 라이선스 등록/변경 - → smartsafehub.license_activate - → mode 0600 private request에 키만 기록 - → detached /usr/libexec/smartsafehub-license activate 시작 후 RPC 즉시 반환 - → helper가 safeshield.status에서 authoritative device identity 조회 - → POST /api/v1/licenses/activate - → 성공한 경우에만 safeshield.license_update +공유기 부팅 또는 주기 동기화 + → smartsafehub-device status-sync + → Authorization: Device + → POST /api/v1/devices/sync + ├─ account.connected=true + │ ├─ 현재 entitlement plan/status 저장 + │ └─ Activity 등 Cloud runtime credential 갱신 + └─ account.connected=false + ├─ 로컬 계정 연결 상태 해제 + ├─ Cloud runtime credential 정리 + └─ SafeShield 보호 정보 재동기화 -smartsafehub-license daemon - → 기본 300초 주기 - → safeshield.license_get으로 현재 키를 일시 조회 - → safeshield.status에서 physical_fingerprint 조회 - → POST /api/v1/licenses/status - ├─ device_action=none: 로컬 상태 유지 - │ └─ Cloud 활동 ON일 때만 activity_history credential을 /tmp runtime cache에 저장 - ├─ device_action=clear_license: safeshield.license_update { license_key: "" } + activity credential 제거 - └─ 네트워크/API 실패: 오류만 기록하고 로컬 키/유효한 기존 credential 유지 - -smartsafehub-activity-sync - ├─ Cloud 활동 OFF: outbox/wake/credential 정리 후 네트워크 작업 없이 disabled 유지 - └─ Cloud 활동 ON - → 유효한 cached activity credential 사용 - → credential 없음/만료 임박: smartsafehub-license status-sync 요청 - → POST /api/v1/activity/events - → 성공한 snapshot event_id만 ack +SafeShield refresh + → 같은 Device credential로 /api/v1/devices/sync + → entitlement.plan/status와 artifact 정보 수신 + → safeshield.status.entitlement에 현재 권한 표시 ``` -`smartsafehub-license`는 `daemon`, `activate`, `status-sync`, `status` 명령을 독립 subcommand로 제공합니다. `license_activate` RPC 자체는 SafeShield를 동기 호출하지 않으며, 장치 identity와 profile 구성은 detached `activate` subcommand 안에서 수행합니다. 이는 현재는 작은 독립 procd 서비스로 장애 범위와 디버깅 경계를 유지하면서, 향후 주기적인 Hub 동기화 작업이 늘어나면 명령 경계를 그대로 `smartsafehub-agent license ...` 모듈로 옮길 수 있도록 하기 위한 구조입니다. updater처럼 장시간 설치·재부팅 상태 머신을 가지는 기능은 별도 서비스로 유지하는 것을 전제로 합니다. +Hub 연결 실패는 기존 로컬 DNS 보호나 마지막으로 적용된 차단 목록을 제거하지 않습니다. 웹사이트에서 등록 해제가 명시적으로 확인된 경우에만 계정 종속 Cloud 상태를 정리하며, Device credential 자체는 유지해 같은 기기를 다시 연결할 수 있습니다. -daemon의 startup/check 대기는 foreground `sleep`이 아니라 interrupt 가능한 child wait로 처리합니다. SIGTERM/SIGINT를 받으면 대기 중인 sleep child를 깨우고 loop를 종료하므로 5분 상태 확인 주기 중에도 procd stop/restart가 오래 기다리지 않습니다. Hub 요청 자체는 10초 timeout을 사용하고 procd `term_timeout`은 15초로 두어, 요청 중 종료가 들어와도 정상 정리 시간을 확보한 뒤 강제 종료하도록 합니다. - -런타임 상태는 `/tmp/smartsafehub/license.json`에 atomic write하며 평문 라이선스 키를 저장하지 않습니다. 명시적 활성화와 주기 `status-sync`가 겹치면 activation single-flight lock이 우선하며, status-sync는 활성화 결과를 덮어쓰지 않고 다음 주기까지 건너뜁니다. SafeShield의 `license_get` 자체가 실패한 경우는 미설정 상태로 오인하지 않고 `LICENSE_LOCAL_READ_FAILED`로 기록합니다. - -운영 진단을 위해 상태 파일에는 `lastHttpStatus`, `lastActivationResult`, `lastActivationErrorCode`도 기록합니다. 정상 Hub JSON 응답은 현재 API 계약에 따라 HTTP 200으로 기록하며, `uclient-fetch`가 transport/HTTP 실패로 종료되어 실제 상태 코드를 신뢰할 수 없는 경우 `lastHttpStatus`는 `null`로 기록합니다. `lastActivationResult`와 `lastActivationErrorCode`는 이후의 주기 `status-sync`나 `unconfigured` 전환에서도 유지되어 마지막 명시적 activation 결과를 별도로 추적할 수 있습니다. - -### 라이선스 셸 계약 테스트 - -`tests/test-license.sh`는 activate/status 동기화, stale activation lock 복구, activation 진단 필드 보존과 장기 sleep 중 SIGTERM 정상 종료를 검증합니다. 각 시나리오는 mock 환경을 명시적으로 초기화해 Linux `dash`와 macOS `/bin/sh`처럼 함수 앞 임시 환경 변수의 처리 차이가 있는 환경에서도 이전 실패 주기의 값이 다음 테스트에 누적되지 않도록 합니다. +Health Reporter는 `smartsafehub-device`가 동기화한 현재 플랜을 기준으로 Pro/Ultimate에서만 활성화됩니다. 서버 보고 인증도 라이선스 키가 아니라 Device credential을 사용합니다. ## SmartSafeHub Reset Policy v1 @@ -187,7 +164,7 @@ logread | grep -Ei 'rpcd|ucode|smartsafehub|safeshield' | tail -200 ### Cloud 활동 기록 재시도 정책 -Cloud 활동 기록 전송이 ON이고 Activity API 또는 license status/Cloud upload가 일시적으로 통신할 수 없는 경우 로컬 최근 활동과 Cloud outbox는 유지됩니다. credential 갱신 또는 upload 실패는 15분, 30분, 60분 순으로 backoff하며 이후 60분 상한을 유지합니다. backoff 중 새 이벤트가 발생해도 즉시 네트워크 재시도를 강제하지 않습니다. `smartsafehub-activity-sync sync-once`는 운영자가 배포 직후 즉시 동기화를 확인할 때 사용할 수 있습니다. Cloud 전송이 OFF이면 이 네트워크 재시도 경로 자체를 실행하지 않고 outbox도 만들지 않습니다. 공유기 웹사이트의 로컬 최근 활동은 Cloud 통신/전송 설정과 무관하게 최대 128건을 표시합니다. +Cloud 활동 기록 전송이 ON이고 Activity API 또는 device sync/Cloud upload가 일시적으로 통신할 수 없는 경우 로컬 최근 활동과 Cloud outbox는 유지됩니다. credential 갱신 또는 upload 실패는 15분, 30분, 60분 순으로 backoff하며 이후 60분 상한을 유지합니다. backoff 중 새 이벤트가 발생해도 즉시 네트워크 재시도를 강제하지 않습니다. `smartsafehub-activity-sync sync-once`는 운영자가 배포 직후 즉시 동기화를 확인할 때 사용할 수 있습니다. Cloud 전송이 OFF이면 이 네트워크 재시도 경로 자체를 실행하지 않고 outbox도 만들지 않습니다. 공유기 웹사이트의 로컬 최근 활동은 Cloud 통신/전송 설정과 무관하게 최대 128건을 표시합니다. ## 프런트엔드 캐시 문제 diff --git a/luci-app-smartsafehub/frontend/package-lock.json b/luci-app-smartsafehub/frontend/package-lock.json index 52baf8a5c..372c8b865 100644 --- a/luci-app-smartsafehub/frontend/package-lock.json +++ b/luci-app-smartsafehub/frontend/package-lock.json @@ -1,23 +1,23 @@ { "name": "luci-app-smartsafehub-frontend", - "version": "0.2.27", + "version": "0.2.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "luci-app-smartsafehub-frontend", - "version": "0.2.27", + "version": "0.2.28", "dependencies": { "chart.js": "4.5.1", - "preact": "10.29.8" + "preact": "11.0.1" }, "devDependencies": { "@preact/preset-vite": "2.10.6", "@tailwindcss/vite": "4.3.3", - "@types/node": "26.6.2", + "@types/node": "26.6.4", "tailwindcss": "4.3.3", "typescript": "7.0.2", - "vite": "8.3.0" + "vite": "8.3.4" }, "engines": { "node": ">=24.0" @@ -448,13 +448,13 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.147.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", - "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@preact/preset-vite": { @@ -550,9 +550,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", - "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "cpu": [ "arm" ], @@ -567,9 +567,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", - "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -584,9 +584,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", - "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -601,9 +601,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", - "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -618,9 +618,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", - "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -635,9 +635,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", - "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -652,9 +652,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", - "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], @@ -669,9 +669,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", - "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], @@ -686,9 +686,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", - "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], @@ -703,9 +703,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", - "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], @@ -720,9 +720,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", - "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], @@ -737,9 +737,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", - "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], @@ -754,9 +754,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", - "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -771,9 +771,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", - "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -788,9 +788,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", - "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -1125,9 +1125,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", - "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2145,9 +2145,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -2218,9 +2218,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -2238,18 +2238,17 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.1.tgz", "license": "MIT", "funding": { "type": "opencollective", @@ -2265,13 +2264,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", - "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.147.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2281,21 +2280,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.6", - "@rolldown/binding-android-arm64": "1.2.6", - "@rolldown/binding-darwin-arm64": "1.2.6", - "@rolldown/binding-darwin-x64": "1.2.6", - "@rolldown/binding-freebsd-x64": "1.2.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", - "@rolldown/binding-linux-arm64-gnu": "1.2.6", - "@rolldown/binding-linux-arm64-musl": "1.2.6", - "@rolldown/binding-linux-ppc64-gnu": "1.2.6", - "@rolldown/binding-linux-s390x-gnu": "1.2.6", - "@rolldown/binding-linux-x64-gnu": "1.2.6", - "@rolldown/binding-linux-x64-musl": "1.2.6", - "@rolldown/binding-openharmony-arm64": "1.2.6", - "@rolldown/binding-win32-arm64-msvc": "1.2.6", - "@rolldown/binding-win32-x64-msvc": "1.2.6" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/semver": { @@ -2329,9 +2328,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2468,16 +2467,16 @@ } }, "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.6", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/luci-app-smartsafehub/frontend/package.json b/luci-app-smartsafehub/frontend/package.json index 0f66741bd..019509424 100644 --- a/luci-app-smartsafehub/frontend/package.json +++ b/luci-app-smartsafehub/frontend/package.json @@ -1,7 +1,7 @@ { "name": "luci-app-smartsafehub-frontend", "private": true, - "version": "0.2.27", + "version": "0.2.28", "type": "module", "engines": { "node": ">=24.0" @@ -14,14 +14,14 @@ }, "dependencies": { "chart.js": "4.5.1", - "preact": "10.29.8" + "preact": "11.0.1" }, "devDependencies": { "@preact/preset-vite": "2.10.6", "@tailwindcss/vite": "4.3.3", - "@types/node": "26.6.2", + "@types/node": "26.6.4", "tailwindcss": "4.3.3", "typescript": "7.0.2", - "vite": "8.3.0" + "vite": "8.3.4" } } diff --git a/luci-app-smartsafehub/frontend/src/api/rpc.ts b/luci-app-smartsafehub/frontend/src/api/rpc.ts index ecd5e3368..b9b44ce4b 100644 --- a/luci-app-smartsafehub/frontend/src/api/rpc.ts +++ b/luci-app-smartsafehub/frontend/src/api/rpc.ts @@ -258,6 +258,7 @@ export async function callRpc( options: RpcCallOptions = {}, ): Promise { const bootstrap = getBootstrap(); + const startedAt = performance.now(); const id = requestId++; const controller = new AbortController(); const timeout = window.setTimeout( @@ -315,6 +316,11 @@ export async function callRpc( ); } finally { window.clearTimeout(timeout); + const elapsedMs = Math.round(performance.now() - startedAt); + // Development diagnostics only: never record parameters, tokens, or responses. + if (import.meta.env.DEV && elapsedMs >= 1000) { + console.warn(`[RPC slow] ${object}.${method}: ${elapsedMs}ms`); + } } } diff --git a/luci-app-smartsafehub/frontend/src/api/safeshield.ts b/luci-app-smartsafehub/frontend/src/api/safeshield.ts index 19d4fe838..8d8c512b7 100644 --- a/luci-app-smartsafehub/frontend/src/api/safeshield.ts +++ b/luci-app-smartsafehub/frontend/src/api/safeshield.ts @@ -5,8 +5,6 @@ import type { } from '../types/rules'; import type { SafeShieldEnabledResult, - SafeShieldLicenseReadResult, - SafeShieldLicenseUpdateResult, SafeShieldRefreshResult, SafeShieldStatistics, SafeShieldStatisticsEnabledResult, @@ -25,7 +23,7 @@ interface RawSafeShieldStatus { stage?: unknown; summary?: Record; runtime?: Record; - license?: Record; + entitlement?: Record; artifact?: Record; local_overrides?: Record; blocklist?: Record; @@ -80,7 +78,6 @@ interface RawSafeShieldMutation { added?: unknown; deleted?: unknown; refresh?: unknown; - license?: unknown; error?: unknown; } @@ -262,9 +259,7 @@ function unavailableStatus(): SafeShieldStatus { lastResult: null, lastErrorCode: null, }, - license: { - configured: false, - keyMasked: '', + entitlement: { plan: null, status: null, }, @@ -314,7 +309,7 @@ function normalizeStatus(sourceValue: RawSafeShieldStatus): SafeShieldStatus { const summary = objectValue(source.summary); const runtime = objectValue(source.runtime); - const license = objectValue(source.license); + const entitlement = objectValue(source.entitlement); const artifact = objectValue(source.artifact); const localOverrides = objectValue(source.local_overrides); const blocklist = objectValue(source.blocklist); @@ -342,11 +337,9 @@ function normalizeStatus(sourceValue: RawSafeShieldStatus): SafeShieldStatus { lastResult: stringValue(runtime.last_result, null), lastErrorCode: stringValue(runtime.last_error_code, null), }, - license: { - configured: boolValue(license.configured), - keyMasked: plainString(license.key_masked), - plan: stringValue(license.plan, null), - status: stringValue(license.status, null), + entitlement: { + plan: stringValue(entitlement.plan, null), + status: stringValue(entitlement.status, null), }, artifact: { resolved: boolValue(artifact.resolved), @@ -558,33 +551,3 @@ export function deleteSafeShieldRule( ): Promise { return mutateSafeShieldRule('rule_delete', action, domain, refresh); } - -export async function fetchSafeShieldLicense(): Promise { - const response = await callSafeShield('license_get'); - const license = objectValue(response.license); - - return { - configured: boolValue(license.configured), - key: plainString(license.key), - }; -} - -export async function updateSafeShieldLicense( - licenseKey: string, -): Promise { - const response = await callManagedSafeShield( - 'safeshield_license_update', - { license_key: licenseKey }, - 'license_update', - ); - const license = objectValue(response.license); - - return { - changed: boolValue(response.changed), - license: { - configured: boolValue(license.configured), - keyMasked: plainString(license.key_masked), - }, - refresh: normalizeRefresh(response.refresh), - }; -} diff --git a/luci-app-smartsafehub/frontend/src/api/smartsafehub.ts b/luci-app-smartsafehub/frontend/src/api/smartsafehub.ts index 4ede1b88c..a6b4e352e 100644 --- a/luci-app-smartsafehub/frontend/src/api/smartsafehub.ts +++ b/luci-app-smartsafehub/frontend/src/api/smartsafehub.ts @@ -8,10 +8,6 @@ import type { ConnectedDevicesSummary } from '../types/devices'; import type { HealthAccepted, HealthStatus } from '../types/health'; import type { IptvSettings, IptvSettingsInput, IptvUpdateResult } from '../types/iptv'; import type { LanSettings, LanSettingsInput, LanUpdateResult } from '../types/lan'; -import type { - SmartSafeHubLicenseActivationAccepted, - SmartSafeHubLicenseStatus, -} from '../types/license'; import type { FirmwareAccepted, FirmwareStatus, @@ -32,6 +28,9 @@ import type { SoftwareUpdateStatus, } from '../types/updates'; import type { + GuestWifiSummary, + GuestWifiUpdateInput, + GuestWifiUpdateResult, WifiSummary, WifiUpdateInput, WifiUpdateResult, @@ -45,42 +44,15 @@ import type { import { callApi } from './rpc'; const API_OBJECT = 'smartsafehub'; +const GUEST_API_OBJECT = 'smartsafehub_guest'; const LAN_API_OBJECT = 'smartsafehub_network'; export function fetchConnectedDevices(): Promise { return callApi(API_OBJECT, 'connected_devices'); } -interface SmartSafeHubStatusWithActivity extends SmartSafeHubStatus { - activityHistory?: ActivityHistory; -} - -function emptyActivityHistory(): ActivityHistory { - return { - schema: 1, - scope: 'current_boot', - volatile: true, - maxEvents: 128, - cloud: { - enabled: false, - phase: 'preparing', - eligible: null, - plan: null, - retentionDays: 0, - pendingEvents: 0, - lastAttemptAt: 0, - lastSuccessAt: 0, - lastUploadedCount: 0, - lastErrorCode: null, - nextSyncAt: 0, - }, - events: [], - }; -} - export async function fetchActivityHistory(): Promise { - const status = await callApi(API_OBJECT, 'status'); - const activity = status.activityHistory ?? emptyActivityHistory(); + const activity = await callApi(API_OBJECT, 'activity_history'); return { ...activity, @@ -101,14 +73,30 @@ export function fetchStatus(): Promise { return callApi(API_OBJECT, 'status'); } -export function fetchSmartSafeHubLicenseStatus(): Promise { - return callApi(API_OBJECT, 'license_status', {}, { timeoutMs: 5000 }); +export interface DeviceRegistrationStatus { + schema: number; + component: 'device'; + phase: string; + lastResult: string; + lastErrorCode: string | null; + accountRegistered: boolean | null; + plan: string | null; + pairingCode: string | null; + pairingExpiresAt: string | null; + nextSyncAt: number; + lastSuccessAt: number; } -export function requestSmartSafeHubLicenseActivation( - licenseKey: string, -): Promise { - return callApi(API_OBJECT, 'license_activate', { license_key: licenseKey }); +export function fetchDeviceRegistrationStatus(): Promise { + return callApi(API_OBJECT, 'device_registration_status', {}, { timeoutMs: 5000 }); +} + +export function refreshDeviceRegistrationStatus(): Promise { + return callApi(API_OBJECT, 'device_registration_refresh', {}, { timeoutMs: 30000 }); +} + +export function requestDevicePairingCode(): Promise { + return callApi(API_OBJECT, 'device_pairing_refresh', {}, { timeoutMs: 15000 }); } export function fetchHealthStatus(): Promise { @@ -321,8 +309,30 @@ export function updateIptvSettings( ); } -export function fetchWifiSummary(): Promise { - return callApi(API_OBJECT, 'wifi_summary'); +export interface WifiQrCredentials { + ssid: string; + security: 'none' | 'psk2' | 'sae' | 'sae-mixed'; + password: string; +} + +export function fetchWifiQr(section: string): Promise { + return callApi(API_OBJECT, 'wifi_qr', { section }, { timeoutMs: 8000 }); +} + +// A separate authenticated RPC, so guest credentials never enter summary responses. +export function fetchGuestWifiQr(): Promise { + return callApi(GUEST_API_OBJECT, 'wifi_guest_qr', {}, { timeoutMs: 8000 }); +} + +export async function fetchWifiSummary(): Promise { + // The optional guest RPC object must never break ordinary Wi-Fi management. + const summary = await callApi>(API_OBJECT, 'wifi_summary'); + try { + const guest = await callApi(GUEST_API_OBJECT, 'wifi_guest_summary'); + return { ...summary, guest, guestError: false }; + } catch { + return { ...summary, guest: null, guestError: true }; + } } export function updateWifiNetwork( @@ -335,3 +345,7 @@ export function updateWifiNetwork( { timeoutMs: 35_000 }, ); } + +export function updateGuestWifi(input: GuestWifiUpdateInput): Promise { + return callApi(GUEST_API_OBJECT, 'wifi_guest_update', { ...input }, { timeoutMs: 95_000 }); +} diff --git a/luci-app-smartsafehub/frontend/src/app/App.tsx b/luci-app-smartsafehub/frontend/src/app/App.tsx index 73cc85f30..96e1495a3 100644 --- a/luci-app-smartsafehub/frontend/src/app/App.tsx +++ b/luci-app-smartsafehub/frontend/src/app/App.tsx @@ -22,6 +22,8 @@ import { useStatus } from '../hooks/useStatus'; import { useScheduledRebootSettings } from '../hooks/useScheduledRebootSettings'; import { useSystemActions } from '../hooks/useSystemActions'; import { useSystemTimeSettings } from '../hooks/useSystemTimeSettings'; +import { useDeviceRegistration } from '../hooks/useDeviceRegistration'; +import { useDashboardAccountStatus } from '../hooks/useDashboardAccountStatus'; import { useWifi } from '../hooks/useWifi'; import { ActivityPage } from '../pages/ActivityPage'; import { ConnectedDevicesPage } from '../pages/ConnectedDevicesPage'; @@ -32,6 +34,7 @@ import { WanPage } from '../pages/WanPage'; import { SafeShieldPage } from '../pages/SafeShieldPage'; import { SafeShieldRulesPage } from '../pages/SafeShieldRulesPage'; import { SettingsPage } from '../pages/SettingsPage'; +import { SmartSafeHubAccountPage } from '../pages/SmartSafeHubAccountPage'; import { UpdatePage } from '../pages/UpdatePage'; import { WifiPage } from '../pages/WifiPage'; import { browserTimezone } from '../utils/timezone'; @@ -43,7 +46,7 @@ interface AppProps { export function App({ onAdministratorPasswordChanged }: AppProps) { const route = useHashRoute(); const configurationBackup = useConfigurationBackup(); - const activity = useActivityHistory(route === 'home' || route === 'activity'); + const activity = useActivityHistory(route === 'activity'); const status = useStatus(route === 'home' || route === 'settings'); const updates = useSoftwareUpdates(true); const firmware = useFirmwareUpdates(true); @@ -53,11 +56,17 @@ export function App({ onAdministratorPasswordChanged }: AppProps) { const wifi = useWifi(route === 'wifi'); const dashboardDevices = useConnectedDevices(route === 'home', false); const devices = useConnectedDevices(route === 'devices'); - const dashboardSafeShield = useSafeShieldStatus(route === 'home'); - const dashboardSafeShieldStatistics = useSafeShieldStatistics(route === 'home', false); - const safeshield = useSafeShieldStatus(route === 'safeshield'); - const safeshieldStatistics = useSafeShieldStatistics(route === 'safeshield'); + // Both views consume the same resource: switching pages must not re-query RPC. + const safeshield = useSafeShieldStatus(route === 'home' || route === 'safeshield'); + const safeshieldStatistics = useSafeShieldStatistics(route === 'home' || route === 'safeshield', route === 'safeshield'); + const dashboardSafeShield = safeshield; + const dashboardSafeShieldStatistics = safeshieldStatistics; const rules = useSafeShieldRules(route === 'rules'); + const deviceRegistration = useDeviceRegistration(route === 'account'); + const dashboardAccountRegistered = useDashboardAccountStatus( + route === 'account', + deviceRegistration.data?.accountRegistered ?? null, + ); const systemActions = useSystemActions(status.data); const health = useHealth(route === 'home' || route === 'settings'); const scheduledReboot = useScheduledRebootSettings(route === 'settings'); @@ -102,9 +111,11 @@ export function App({ onAdministratorPasswordChanged }: AppProps) { ? safeshield : route === 'rules' ? rules - : route === 'system' - ? updates - : status; + : route === 'account' + ? deviceRegistration + : route === 'system' + ? updates + : status; let content: ComponentChildren; @@ -171,6 +182,7 @@ export function App({ onAdministratorPasswordChanged }: AppProps) { onDismissFeedback={wifi.dismissFeedback} onRetry={() => void wifi.refresh()} onUpdate={wifi.update} + onUpdateGuest={wifi.updateGuest} updatingSection={wifi.updatingSection} /> ); @@ -202,6 +214,21 @@ export function App({ onAdministratorPasswordChanged }: AppProps) { ); break; + case 'account': + content = ( + void deviceRegistration.refresh()} + /> + ); + break; + case 'system': content = ( void safeshieldActions.refreshBlocklist()} - onRemoveLicense={safeshieldActions.removeLicense} onRetry={() => void safeshield.refresh()} onRetryStatistics={() => void safeshieldStatistics.refresh()} onSetEnabled={(enabled) => void safeshieldActions.setEnabled(enabled)} onSetStatisticsEnabled={(enabled) => void safeshieldActions.setStatisticsEnabled(enabled) } - onUpdateLicense={safeshieldActions.updateLicense} /> ); break; @@ -338,9 +361,10 @@ export function App({ onAdministratorPasswordChanged }: AppProps) { default: content = ( void Promise.all([ - activity.refresh(), status.refresh(), dashboardDevices.refresh(), - dashboardSafeShield.refresh(), - dashboardSafeShieldStatistics.refresh(), + safeshield.refresh(), + safeshieldStatistics.refresh(), updates.refresh(), firmware.refresh(), health.refresh(), @@ -386,7 +409,6 @@ export function App({ onAdministratorPasswordChanged }: AppProps) { const refreshCurrent = () => { if (route === 'home') { void Promise.all([ - activity.refresh(), status.refresh(), dashboardDevices.refresh(), dashboardSafeShield.refresh(), @@ -435,6 +457,7 @@ export function App({ onAdministratorPasswordChanged }: AppProps) { return ( -
+
+
{phase === 'checking' ? (
@@ -796,8 +747,8 @@ export function SafeShieldPage({ @@ -890,237 +841,110 @@ export function SafeShieldPage({
-
-
+
+
-
+

- License + Protection data

-

- 라이선스 -

+
+

+ 보호 데이터 +

+ + {data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk ? ( + + ) : ( + + )} + {data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk + ? '최신' + : '확인 필요'} + +

- 현재 플랜은 {planName}이며, 라이선스 키를 등록하거나 변경할 수 있습니다. + SafeShield가 기기 성능에 맞춰 사용하는 보호 목록의 구성과 최신 상태를 확인합니다.

- +
-
-
- - {!data.license.configured ? ( - 라이선스 미설정 - ) : null} +
+
+
기기 최적화
+
+ {getArtifactOptimizationLabel(data.artifact.tier)} +
- {data.license.configured && data.license.keyMasked ? ( - {data.license.keyMasked} - ) : null} -
+
+
마지막 업데이트
+
+ {formatArtifactUpdatedAt(data.artifact.version)} +
+
+ -
{ - event.preventDefault(); - void onUpdateLicense(licenseKey).then((updated) => { - if (updated) { - resetLicenseEditor(); - } - }); - }} - > -
- - - {data.license.configured - ? '현재 키를 확인하거나 새 키로 교체할 수 있습니다.' - : '새 라이선스 키를 등록해 프리미엄 기능을 준비하세요.'} - -
-
- { - setLicenseKey(event.currentTarget.value); - setLicenseKeyLoaded(false); - }} - placeholder={data.license.configured ? '새 라이선스 키 입력' : '라이선스 키 입력'} - spellcheck={false} - type="text" - value={licenseKey} +
+ + 세부 정보 + +
+ - -
-
- - {data.license.configured ? ( - - ) : null} -
- {action === 'license-update' ? ( -
- - 라이선스를 확인하고 이 기기에 적용하고 있습니다… -
- ) : null} - - + + +
-
-
-
-
-

- Protection data -

-
-

- 보호 데이터 -

- - {data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk ? ( - - ) : ( - - )} - {data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk - ? '최신' - : '확인 필요'} - -
-

- SafeShield가 기기 성능에 맞춰 사용하는 보호 목록의 구성과 최신 상태를 확인합니다. -

-
- - - -
- -
-
-
기기 최적화
-
- {getArtifactOptimizationLabel(data.artifact.tier)} -
-
-
-
마지막 업데이트
-
- {formatArtifactUpdatedAt(data.artifact.version)} -
-
-
- -
- - 세부 정보 - -
- - -
-
-
- -
-

- Custom rules -

-

- 사용자 규칙 -

-
- -
-

- 허용하거나 차단할 도메인을 직접 관리해 SafeShield 보호 정책에 반영합니다. -

- - - 사용자 규칙 관리 - -
-
+
+

+ Custom rules +

+

+ 사용자 규칙 +

+
+ +
+

+ 허용하거나 차단할 도메인을 직접 관리해 SafeShield 보호 정책에 반영합니다. +

+ + + 사용자 규칙 관리 + +
diff --git a/luci-app-smartsafehub/frontend/src/pages/SafeShieldRulesPage.tsx b/luci-app-smartsafehub/frontend/src/pages/SafeShieldRulesPage.tsx index de2cce038..38833342a 100644 --- a/luci-app-smartsafehub/frontend/src/pages/SafeShieldRulesPage.tsx +++ b/luci-app-smartsafehub/frontend/src/pages/SafeShieldRulesPage.tsx @@ -1,4 +1,4 @@ -import type { JSX } from 'preact'; +import type { TargetedEvent, TargetedSubmitEvent } from 'preact'; import { useMemo, useState } from 'preact/hooks'; import { @@ -87,7 +87,7 @@ function RuleListCard({ }, [rules, search]); async function handleSubmit( - event: JSX.TargetedSubmitEvent, + event: TargetedSubmitEvent, ): Promise { event.preventDefault(); const domain = normalizeDomain(input); @@ -168,7 +168,7 @@ function RuleListCard({ class="min-h-11 min-w-0 flex-1 rounded-xl border-2 border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-inner outline-none transition placeholder:text-slate-400 focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100 disabled:cursor-not-allowed disabled:bg-slate-100" disabled={busy} id={`${kind}-domain`} - onInput={(event: JSX.TargetedEvent) => + onInput={(event: TargetedEvent) => setInput(event.currentTarget.value) } placeholder="example.com" @@ -204,7 +204,7 @@ function RuleListCard({ ) => + onInput={(event: TargetedEvent) => setSearch(event.currentTarget.value) } placeholder={`${theme.label} 검색`} diff --git a/luci-app-smartsafehub/frontend/src/pages/SettingsPage.tsx b/luci-app-smartsafehub/frontend/src/pages/SettingsPage.tsx index fd50891d9..dbc6491e5 100644 --- a/luci-app-smartsafehub/frontend/src/pages/SettingsPage.tsx +++ b/luci-app-smartsafehub/frontend/src/pages/SettingsPage.tsx @@ -428,9 +428,9 @@ function reporterLastReportLabel(reporter: HealthReporterStatus): string { function reporterMembershipLabel(reporter: HealthReporterStatus | undefined): string { const plan = reporter?.plan?.trim().toUpperCase(); - const licenseStatus = reporter?.licenseStatus?.trim().toLowerCase(); + const entitlementStatus = reporter?.entitlementStatus?.trim().toLowerCase(); - if (plan && (licenseStatus === 'trial' || licenseStatus === 'trialing')) { + if (plan && (entitlementStatus === 'trial' || entitlementStatus === 'trialing')) { return `${plan} · 체험`; } return plan || '유료 · Trial'; @@ -790,7 +790,7 @@ function PasswordInputField(props: { name={props.name} onInput={(event) => props.onInput(event.currentTarget.value)} placeholder={props.placeholder} - type={visible ? 'text' : 'password'} + {...(visible ? { type: 'text' as const } : { type: 'password' as const })} value={props.value} />
diff --git a/luci-app-smartsafehub/frontend/src/pages/SmartSafeHubAccountPage.tsx b/luci-app-smartsafehub/frontend/src/pages/SmartSafeHubAccountPage.tsx new file mode 100644 index 000000000..4568feffc --- /dev/null +++ b/luci-app-smartsafehub/frontend/src/pages/SmartSafeHubAccountPage.tsx @@ -0,0 +1,261 @@ +import { useEffect, useState } from 'preact/hooks'; + +import { formatRelativeTime } from '../app/format'; +import { CheckCircleIcon, ClockIcon, CopyIcon, KeyIcon, ReloadIcon } from '../components/Icons'; +import { ErrorPanel, LoadingPanel } from '../components/StatePanels'; +import type { DeviceRegistrationStatus } from '../api/smartsafehub'; + +const DEVICE_CONSOLE_URL = 'https://www.smartsafehub.com/dashboard/devices/'; + +async function copyTextToClipboard(value: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } + catch { + // Local router pages can run outside a secure context. + } + } + + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.inset = '0 auto auto -9999px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, value.length); + const copied = document.execCommand('copy'); + textarea.remove(); + if (!copied) throw new Error('clipboard_copy_failed'); +} + +type AccountPlanTone = 'free' | 'pro' | 'ultimate' | 'paid'; + +function planLabel(plan: string | null): string { + return (plan || 'free').toUpperCase(); +} + +function planTone(plan: string): AccountPlanTone { + if (plan === 'FREE') return 'free'; + if (plan === 'PRO') return 'pro'; + if (plan === 'ULTIMATE') return 'ultimate'; + return 'paid'; +} + +function AccountPlanBadge({ plan }: { plan: string }) { + const tone = planTone(plan); + const paid = tone !== 'free'; + + return ( + + + {plan} + + ); +} + +function pairingExpired(status: DeviceRegistrationStatus): boolean { + if (!status.pairingCode || !status.pairingExpiresAt) return false; + const expiresAt = Date.parse(status.pairingExpiresAt); + return Number.isFinite(expiresAt) && expiresAt <= Date.now(); +} + +interface SmartSafeHubAccountPageProps { + data: DeviceRegistrationStatus | null; + error: string | null; + loading: boolean; + pairingBusy: boolean; + refreshing: boolean; + syncError: string | null; + onRequestPairing: () => Promise; + onRetry: () => void; +} + +export function SmartSafeHubAccountPage({ + data, + error, + loading, + pairingBusy, + refreshing, + syncError, + onRequestPairing, + onRetry, +}: SmartSafeHubAccountPageProps) { + const [pairingError, setPairingError] = useState(null); + const [pairingCopied, setPairingCopied] = useState(false); + + useEffect(() => { + if (!pairingCopied) return; + const timeout = window.setTimeout(() => setPairingCopied(false), 1800); + return () => window.clearTimeout(timeout); + }, [pairingCopied]); + + if (loading && !data) return ; + if (error && !data) return ; + + const status = data; + const connected = status?.accountRegistered === true; + const currentPlan = planLabel(status?.plan ?? null); + const expired = status ? pairingExpired(status) : false; + const pairingCode = !connected && !expired ? status?.pairingCode : null; + + async function requestPairing(): Promise { + setPairingError(null); + setPairingCopied(false); + try { + await onRequestPairing(); + } + catch { + setPairingError('계정 연결 코드를 발급하지 못했습니다. 잠시 후 다시 시도해 주세요.'); + } + } + + async function copyPairingCode(): Promise { + if (!pairingCode) return; + try { + await copyTextToClipboard(pairingCode); + setPairingError(null); + setPairingCopied(true); + } + catch { + setPairingCopied(false); + setPairingError('연결 코드를 복사하지 못했습니다. 코드를 직접 선택해 복사해 주세요.'); + } + } + + return ( +
+
+
+
+

Account connection

+

기기 계정 연결

+

+ 계정에 연결하면 웹사이트에서 기기를 관리하고 Cloud 기능과 구독 권한을 사용할 수 있습니다. +

+
+ + + +
+ + {connected ? ( +
+
+ + + +
+

SmartSafeHub 계정에 연결됨

+

연결 코드는 더 이상 필요하지 않으며 새 코드도 발급하지 않습니다.

+
+
+ + 웹사이트에서 기기 관리 + +

+ 웹사이트에서 기기 등록을 해제하면 공유기도 다음 계정 상태 동기화에서 자동으로 연결 해제를 반영합니다. +

+
+ ) : ( +
+
+
+

계정 연결 코드

+ + + 10분 동안 유효 + +
+

+ 코드를 발급한 뒤 smartsafehub.com에 로그인하여 기기를 연결하세요. +

+
+ + {pairingCode ? ( +
+
+ 연결 코드 + {pairingCode} +
+ +
+ ) : expired ? ( +

+ 이전 연결 코드가 만료되었습니다. 새 코드를 발급해 주세요. +

+ ) : null} + + + {pairingError ?

{pairingError}

: null} +
+ )} +
+ + +
+ ); +} diff --git a/luci-app-smartsafehub/frontend/src/pages/WanPage.tsx b/luci-app-smartsafehub/frontend/src/pages/WanPage.tsx index 466abb4c0..b73335782 100644 --- a/luci-app-smartsafehub/frontend/src/pages/WanPage.tsx +++ b/luci-app-smartsafehub/frontend/src/pages/WanPage.tsx @@ -1,4 +1,4 @@ -import type { JSX } from 'preact'; +import type { TargetedSubmitEvent } from 'preact'; import { useEffect, useMemo, useState } from 'preact/hooks'; import { CustomSelect } from '../components/CustomSelect'; @@ -339,7 +339,7 @@ export function WanPage({ setValidationError(null); }; - const submit = async (event: JSX.TargetedSubmitEvent) => { + const submit = async (event: TargetedSubmitEvent) => { event.preventDefault(); if (!isSupportedProtocol(form.protocol)) { @@ -516,7 +516,7 @@ export function WanPage({ ? '변경할 때만 입력' : 'PPPoE 비밀번호' } - type={showPassword ? 'text' : 'password'} + {...(showPassword ? { type: 'text' as const } : { type: 'password' as const })} value={form.pppoePassword} /> - 저장된 비밀번호는 화면이나 API로 다시 노출하지 않습니다. + 저장된 비밀번호는 일반 설정 조회에 노출되지 않습니다. QR 코드에는 비밀번호가 포함됩니다.
@@ -272,7 +293,7 @@ function WifiNetworkCard({ 이 Wi-Fi 사용
+ {showQr ? setShowQr(false)} /> : null} {validationError ? (

{validationError} @@ -330,6 +352,7 @@ export function WifiPage({ onDismissFeedback, onRetry, onUpdate, + onUpdateGuest, }: WifiPageProps) { if (loading) { return ; @@ -358,7 +381,7 @@ export function WifiPage({

변경한 Wi-Fi 이름과 비밀번호로 다시 연결하면 SmartSafeHub를 계속 사용할 수 - 있습니다. VLAN, 게스트 네트워크와 고급 무선 옵션은 기존 LuCI에서 관리합니다. + 있습니다. VLAN 등 고급 무선 옵션은 기존 LuCI에서 관리합니다.

@@ -385,6 +408,23 @@ export function WifiPage({

)} + {data.guest && ( + <> +