mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-28 23:15:13 +08:00
This commit is contained in:
1 parent
c7bafc635d
commit
c71da84035
264 files changed
+8169
-3944
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
fi
|
||||
@@ -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() {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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 兜底
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" "文件无效"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+13
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+36
-3
@@ -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 >/dev/null 2>&1 1000>&- &
|
||||
|
||||
#tc qdisc replace dev ${dev} root cake
|
||||
|
||||
#ip link set $dev txqlen 100
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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)?
|
||||
|
||||
@@ -1 +1 @@
|
||||
snapshot
|
||||
both
|
||||
@@ -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/
|
||||
@@ -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 <git16@bob7.com>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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), {}); },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "如果本机转换失败,所选第三方服务会收到完整订阅链接,包括访问令牌。保持选择「仅在本机转换」可避免向第三方发送链接。"
|
||||
@@ -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": [ "*" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <name>.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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 >/dev/null 2>&1 &
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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('<span style="color:' + (c ? c.color : '#aaa') + '">' + (c ? c.label : itf) + ':</span> ' + self._bwLabel(series[j] || 0, false));
|
||||
lines.push('<span style="color:' + (c ? c.color : '#aaa') + '">' + '%h'.format(c ? c.label : itf) + ':</span> ' + self._bwLabel(series[j] || 0, false));
|
||||
});
|
||||
return lines.length ? lines.join('<br>') : 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('<span style="color:' + (c ? c.color : '#aaa') + '">' + (c ? c.label : itf) + ':</span> ' + self._bwLabel(series[j] || 0, false));
|
||||
lines.push('<span style="color:' + (c ? c.color : '#aaa') + '">' + '%h'.format(c ? c.label : itf) + ':</span> ' + self._bwLabel(series[j] || 0, false));
|
||||
});
|
||||
return lines.length ? lines.join('<br>') : 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')),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "Тест"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 "测试"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:= \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -139,7 +139,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('aliyun', 'https://www.aliyun.com')" title="<%:TLS handshake test, latency for reference only%>">
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('aliyun', 'https://www.aliyun.com')" title="<%:HTTP request latency, for reference only%>">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-1-3">
|
||||
<div class="img-con">
|
||||
@@ -151,7 +151,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('google', 'https://www.google.com/generate_204')" title="<%:TLS handshake test, latency for reference only%>">
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('google', 'https://www.google.com/generate_204')" title="<%:HTTP request latency, for reference only%>">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-1-3">
|
||||
<div class="img-con">
|
||||
@@ -163,7 +163,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('github', 'https://github.com')" title="<%:TLS handshake test, latency for reference only%>">
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('github', 'https://github.com')" title="<%:HTTP request latency, for reference only%>">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-1-3">
|
||||
<div class="img-con">
|
||||
@@ -175,7 +175,7 @@ https://github.com/pure-css/pure/blob/master/LICENSE.md
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('youtube', 'https://www.youtube.com')" title="<%:TLS handshake test, latency for reference only%>">
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('youtube', 'https://www.youtube.com/generate_204')" title="<%:HTTP request latency, for reference only%>">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-1-3">
|
||||
<div class="img-con">
|
||||
@@ -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...%>";
|
||||
|
||||
@@ -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('<span class="tcping_value" cbiid="{{id}}"><a href="javascript:void(0)" style="color:inherit">---</a></span>');
|
||||
}
|
||||
if (o["address"] && _port) {
|
||||
innerHTML = innerHTML.split("{{url_test}}").join('<span class="ping"><a href="javascript:void(0)" onclick="javascript:urltest_node(\'{{id}}\', this)" title="<%:TLS handshake test, latency for reference only%>"><%:Test%></a></span>');
|
||||
innerHTML = innerHTML.split("{{url_test}}").join('<span class="ping"><a href="javascript:void(0)" onclick="javascript:urltest_node(\'{{id}}\', this)" title="<%:HTTP request latency, for reference only%>"><%:Test%></a></span>');
|
||||
} else {
|
||||
innerHTML = innerHTML.split("{{url_test}}").join('<span class="ping" cbiid="{{id}}">---</span>');
|
||||
}
|
||||
|
||||
@@ -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 "内核不支持"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
### 개선
|
||||
|
||||
@@ -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 <kals323@gmail.com>
|
||||
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
|
||||
|
||||
@@ -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) | 릴리스별 변경 내역 |
|
||||
|
||||
|
||||
@@ -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 <credential>
|
||||
→ 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/<channel>` base를 유도하고 먼저 `releases/luci-app-smartsafehub/index.json`을 조회합니다. 다른 repository 파일은 release channel 결정에 사용하지 않습니다. index의 newest-first 순서를 이용해 현재 설치 버전 이후부터 APK가 제시한 최신 버전까지의 `<version>.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회 재시도합니다. 라이선스 관련 진행/성공/오류 피드백은 작업 위치인 라이선스 카드 안에서 표시합니다.
|
||||
@@ -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
|
||||
|
||||
@@ -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을 함께 확인할 수 있습니다.
|
||||
|
||||
|
||||
@@ -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 <credential>
|
||||
→ 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건을 표시합니다.
|
||||
|
||||
## 프런트엔드 캐시 문제
|
||||
|
||||
|
||||
+94
-95
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,7 @@ export async function callRpc<T>(
|
||||
options: RpcCallOptions = {},
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
);
|
||||
} 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
runtime?: Record<string, unknown>;
|
||||
license?: Record<string, unknown>;
|
||||
entitlement?: Record<string, unknown>;
|
||||
artifact?: Record<string, unknown>;
|
||||
local_overrides?: Record<string, unknown>;
|
||||
blocklist?: Record<string, unknown>;
|
||||
@@ -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<SafeShieldRuleMutationResult> {
|
||||
return mutateSafeShieldRule('rule_delete', action, domain, refresh);
|
||||
}
|
||||
|
||||
export async function fetchSafeShieldLicense(): Promise<SafeShieldLicenseReadResult> {
|
||||
const response = await callSafeShield<RawSafeShieldMutation>('license_get');
|
||||
const license = objectValue(response.license);
|
||||
|
||||
return {
|
||||
configured: boolValue(license.configured),
|
||||
key: plainString(license.key),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateSafeShieldLicense(
|
||||
licenseKey: string,
|
||||
): Promise<SafeShieldLicenseUpdateResult> {
|
||||
const response = await callManagedSafeShield<RawSafeShieldMutation>(
|
||||
'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),
|
||||
};
|
||||
}
|
||||
@@ -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<ConnectedDevicesSummary> {
|
||||
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<ActivityHistory> {
|
||||
const status = await callApi<SmartSafeHubStatusWithActivity>(API_OBJECT, 'status');
|
||||
const activity = status.activityHistory ?? emptyActivityHistory();
|
||||
const activity = await callApi<ActivityHistory>(API_OBJECT, 'activity_history');
|
||||
|
||||
return {
|
||||
...activity,
|
||||
@@ -101,14 +73,30 @@ export function fetchStatus(): Promise<SmartSafeHubStatus> {
|
||||
return callApi(API_OBJECT, 'status');
|
||||
}
|
||||
|
||||
export function fetchSmartSafeHubLicenseStatus(): Promise<SmartSafeHubLicenseStatus> {
|
||||
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<SmartSafeHubLicenseActivationAccepted> {
|
||||
return callApi(API_OBJECT, 'license_activate', { license_key: licenseKey });
|
||||
export function fetchDeviceRegistrationStatus(): Promise<DeviceRegistrationStatus> {
|
||||
return callApi(API_OBJECT, 'device_registration_status', {}, { timeoutMs: 5000 });
|
||||
}
|
||||
|
||||
export function refreshDeviceRegistrationStatus(): Promise<DeviceRegistrationStatus> {
|
||||
return callApi(API_OBJECT, 'device_registration_refresh', {}, { timeoutMs: 30000 });
|
||||
}
|
||||
|
||||
export function requestDevicePairingCode(): Promise<DeviceRegistrationStatus> {
|
||||
return callApi(API_OBJECT, 'device_pairing_refresh', {}, { timeoutMs: 15000 });
|
||||
}
|
||||
|
||||
export function fetchHealthStatus(): Promise<HealthStatus> {
|
||||
@@ -321,8 +309,30 @@ export function updateIptvSettings(
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchWifiSummary(): Promise<WifiSummary> {
|
||||
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<WifiQrCredentials> {
|
||||
return callApi(API_OBJECT, 'wifi_qr', { section }, { timeoutMs: 8000 });
|
||||
}
|
||||
|
||||
// A separate authenticated RPC, so guest credentials never enter summary responses.
|
||||
export function fetchGuestWifiQr(): Promise<WifiQrCredentials> {
|
||||
return callApi(GUEST_API_OBJECT, 'wifi_guest_qr', {}, { timeoutMs: 8000 });
|
||||
}
|
||||
|
||||
export async function fetchWifiSummary(): Promise<WifiSummary> {
|
||||
// The optional guest RPC object must never break ordinary Wi-Fi management.
|
||||
const summary = await callApi<Omit<WifiSummary, 'guest' | 'guestError'>>(API_OBJECT, 'wifi_summary');
|
||||
try {
|
||||
const guest = await callApi<GuestWifiSummary>(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<GuestWifiUpdateResult> {
|
||||
return callApi(GUEST_API_OBJECT, 'wifi_guest_update', { ...input }, { timeoutMs: 95_000 });
|
||||
}
|
||||
@@ -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 = (
|
||||
<SmartSafeHubAccountPage
|
||||
data={deviceRegistration.data}
|
||||
error={deviceRegistration.error}
|
||||
loading={deviceRegistration.loading}
|
||||
pairingBusy={deviceRegistration.pairingBusy}
|
||||
refreshing={deviceRegistration.refreshing}
|
||||
syncError={deviceRegistration.syncError}
|
||||
onRequestPairing={deviceRegistration.requestPairing}
|
||||
onRetry={() => void deviceRegistration.refresh()}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'system':
|
||||
content = (
|
||||
<UpdatePage
|
||||
@@ -295,7 +322,6 @@ export function App({ onAdministratorPasswordChanged }: AppProps) {
|
||||
<SafeShieldPage
|
||||
action={safeshieldActions.action}
|
||||
actionError={safeshieldActions.error}
|
||||
actionFeedbackTarget={safeshieldActions.feedbackTarget}
|
||||
actionMessage={safeshieldActions.message}
|
||||
data={safeshield.data}
|
||||
error={safeshield.error}
|
||||
@@ -305,16 +331,13 @@ export function App({ onAdministratorPasswordChanged }: AppProps) {
|
||||
statisticsLoading={safeshieldStatistics.loading}
|
||||
statisticsRefreshing={safeshieldStatistics.refreshing}
|
||||
onDismissFeedback={safeshieldActions.dismissFeedback}
|
||||
onReadLicense={safeshieldActions.readLicense}
|
||||
onRefreshBlocklist={() => 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 = (
|
||||
<HomePage
|
||||
activity={activity.data}
|
||||
activityError={activity.error}
|
||||
activityLoading={activity.loading}
|
||||
accountRegistered={dashboardAccountRegistered}
|
||||
activity={status.data?.activityHistory ?? null}
|
||||
activityError={status.error}
|
||||
activityLoading={status.loading}
|
||||
data={status.data}
|
||||
devices={dashboardDevices.data}
|
||||
devicesError={dashboardDevices.error}
|
||||
@@ -358,11 +382,10 @@ export function App({ onAdministratorPasswordChanged }: AppProps) {
|
||||
loading={status.loading}
|
||||
onRetry={() =>
|
||||
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 (
|
||||
<AppShell
|
||||
accountRegistered={dashboardAccountRegistered}
|
||||
loading={route === 'network' ? wan.loading || lan.loading : current.loading}
|
||||
onRefresh={refreshCurrent}
|
||||
refreshing={
|
||||
|
||||
@@ -49,8 +49,8 @@ export function AuthenticatedEntry({
|
||||
}
|
||||
|
||||
return (
|
||||
<main class="ssh-initial-setup-probe" role={phase === 'error' ? 'alert' : 'status'}>
|
||||
<div class="ssh-initial-setup-probe-card">
|
||||
<main class="ssh-initial-setup-probe">
|
||||
<div class="ssh-initial-setup-probe-card" role={phase === 'error' ? 'alert' : 'status'}>
|
||||
{phase === 'checking' ? (
|
||||
<ReloadIcon class="ssh-login-probe-spinner" aria-hidden="true" />
|
||||
) : (
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AppRoute =
|
||||
| 'devices'
|
||||
| 'safeshield'
|
||||
| 'rules'
|
||||
| 'account'
|
||||
| 'system'
|
||||
| 'settings';
|
||||
|
||||
@@ -75,6 +76,13 @@ export const ROUTES: readonly RouteDefinition[] = [
|
||||
title: '사용자 규칙',
|
||||
description: '직접 허용하거나 차단할 도메인을 관리합니다.',
|
||||
},
|
||||
{
|
||||
route: 'account',
|
||||
hash: '#account',
|
||||
label: 'SmartSafeHub 계정',
|
||||
title: 'SmartSafeHub 계정',
|
||||
description: '이 기기의 SmartSafeHub 계정 연결 상태와 구독 플랜을 확인합니다.',
|
||||
},
|
||||
{
|
||||
route: 'system',
|
||||
hash: '#system',
|
||||
|
||||
@@ -64,7 +64,7 @@ function versionTransition(event: ActivityEvent): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function licensePlan(value: string | null): string | null {
|
||||
function planLabel(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
@@ -291,35 +291,35 @@ export function activityPresentation(event: ActivityEvent): ActivityPresentation
|
||||
};
|
||||
|
||||
case 'license.activated': {
|
||||
const plan = licensePlan(metadataString(event, 'to_plan'));
|
||||
const plan = planLabel(metadataString(event, 'to_plan'));
|
||||
return {
|
||||
title: '라이선스 활성화',
|
||||
description: plan ? `${plan} 라이선스가 활성화되었습니다.` : '라이선스가 활성화되었습니다.',
|
||||
title: '요금제 활성화',
|
||||
description: plan ? `${plan} 요금제가 활성화되었습니다.` : '요금제가 활성화되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
case 'license.changed': {
|
||||
const fromPlan = licensePlan(metadataString(event, 'from_plan'));
|
||||
const toPlan = licensePlan(metadataString(event, 'to_plan'));
|
||||
const fromPlan = planLabel(metadataString(event, 'from_plan'));
|
||||
const toPlan = planLabel(metadataString(event, 'to_plan'));
|
||||
return {
|
||||
title: '라이선스 변경',
|
||||
title: '요금제 변경',
|
||||
description:
|
||||
fromPlan && toPlan
|
||||
? `${fromPlan} → ${toPlan}`
|
||||
: toPlan
|
||||
? `${toPlan} 라이선스로 변경되었습니다.`
|
||||
: '라이선스 상태가 변경되었습니다.',
|
||||
? `${toPlan} 요금제로 변경되었습니다.`
|
||||
: '요금제 상태가 변경되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
case 'license.cleared': {
|
||||
const reason = metadataString(event, 'reason');
|
||||
return {
|
||||
title: '라이선스 연결 해제',
|
||||
title: '계정 권한 해제',
|
||||
description:
|
||||
reason === 'manual'
|
||||
? '이 공유기에서 라이선스 연결을 해제했습니다.'
|
||||
: '서버의 라이선스 상태가 반영되어 이 공유기의 라이선스 연결이 해제되었습니다.',
|
||||
? '이 공유기에서 계정 권한 연결을 해제했습니다.'
|
||||
: '서버의 계정 권한 상태가 반영되어 이 공유기의 권한 연결이 해제되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProductNavigation } from './ProductNavigation';
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'smartsafehub.sidebar.collapsed';
|
||||
|
||||
interface AppShellProps {
|
||||
accountRegistered: boolean | null;
|
||||
children: ComponentChildren;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
@@ -32,6 +33,7 @@ function readSidebarCollapsed(): boolean {
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
accountRegistered,
|
||||
children,
|
||||
loading,
|
||||
onRefresh,
|
||||
@@ -69,6 +71,7 @@ export function AppShell({
|
||||
data-theme={theme}
|
||||
>
|
||||
<ProductNavigation
|
||||
accountRegistered={accountRegistered}
|
||||
collapsed={sidebarCollapsed}
|
||||
loading={loading}
|
||||
onRefresh={onRefresh}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import type { JSX } from 'preact';
|
||||
import type { CSSProperties } from 'preact';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import { ChevronDownIcon } from './Icons';
|
||||
@@ -93,7 +93,7 @@ export function CustomSelect({
|
||||
);
|
||||
const [placement, setPlacement] = useState<MenuPlacement>('bottom');
|
||||
const [menuMaxHeight, setMenuMaxHeight] = useState(MENU_MAX_HEIGHT);
|
||||
const [menuStyle, setMenuStyle] = useState<JSX.CSSProperties>({});
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
||||
const [portalRoot, setPortalRoot] = useState<Element | DocumentFragment | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { TargetedSubmitEvent } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import { WifiQrDialog } from './WifiQrDialog';
|
||||
import { AlertIcon, CheckCircleIcon, ChevronDownIcon, QrCodeIcon, RouterIcon } from './Icons';
|
||||
import type { GuestWifiSummary, GuestWifiUpdateInput } from '../types/wifi';
|
||||
|
||||
export function GuestWifiCard({
|
||||
guest,
|
||||
busy,
|
||||
saving,
|
||||
onUpdate,
|
||||
}: {
|
||||
guest: GuestWifiSummary;
|
||||
busy: boolean;
|
||||
saving: boolean;
|
||||
onUpdate: (input: GuestWifiUpdateInput) => Promise<boolean>;
|
||||
}) {
|
||||
// Keep guest settings out of the way until the administrator needs them.
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
const [ssid, setSsid] = useState(guest.ssid);
|
||||
const [password, setPassword] = useState('');
|
||||
const [enabled, setEnabled] = useState(guest.enabled);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSsid(guest.ssid);
|
||||
setEnabled(guest.enabled);
|
||||
setPassword('');
|
||||
setValidation(null);
|
||||
}, [guest]);
|
||||
|
||||
if (!guest.supported) {
|
||||
return (
|
||||
<section class="mt-5 rounded-2xl border border-slate-200 bg-white p-5 sm:p-6">
|
||||
<h2 class="m-0 text-lg font-extrabold text-slate-900">게스트 Wi-Fi</h2>
|
||||
<p class="mt-2 mb-0 text-sm text-slate-600">2.4GHz 기본 Wi-Fi 장치를 찾지 못해 게스트 Wi-Fi를 사용할 수 없습니다.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const dirty = ssid.trim() !== guest.ssid || enabled !== guest.enabled || password.length > 0;
|
||||
const qrDisabled = busy || dirty || !guest.enabled || !guest.passwordConfigured;
|
||||
|
||||
const submit = async (event: TargetedSubmitEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedSsid = ssid.trim();
|
||||
const bytes = new TextEncoder().encode(normalizedSsid).length;
|
||||
if (bytes < 1 || bytes > 32) {
|
||||
setValidation('Wi-Fi 이름은 UTF-8 기준 1~32바이트로 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (password && !((password.length >= 8 && password.length <= 63) || /^[0-9a-fA-F]{64}$/.test(password))) {
|
||||
setValidation('비밀번호는 8~63자 또는 64자리 16진수여야 합니다.');
|
||||
return;
|
||||
}
|
||||
if (enabled && !guest.passwordConfigured && !password) {
|
||||
setValidation('게스트 전용 비밀번호를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('게스트 Wi-Fi 설정을 적용하면 무선 연결이 잠시 끊어질 수 있습니다. 계속하시겠습니까?')) return;
|
||||
setValidation(null);
|
||||
const saved = await onUpdate({ ssid: normalizedSsid, enabled, password });
|
||||
if (saved) setPassword('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
aria-label="게스트 Wi-Fi 설정"
|
||||
class="mt-5 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-6"
|
||||
onSubmit={(event) => void submit(event)}
|
||||
>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<button
|
||||
aria-controls="ssh-guest-settings"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`게스트 Wi-Fi 설정 ${expanded ? '접기' : '펼치기'}`}
|
||||
class="flex min-w-0 flex-1 items-start gap-3 rounded-xl text-left focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span class="grid size-11 shrink-0 place-items-center rounded-xl bg-teal-50 text-teal-700">
|
||||
<RouterIcon class="size-6" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-xs font-extrabold uppercase tracking-[0.16em] text-slate-500">2.4 GHz · 분리 네트워크</span>
|
||||
<span class="mt-2 block text-xl font-extrabold tracking-tight text-slate-950">게스트 Wi-Fi</span>
|
||||
<span class="mt-2 block break-all text-sm text-slate-600">{guest.ssid}</span>
|
||||
</span>
|
||||
<ChevronDownIcon class={`mt-3 size-5 shrink-0 text-slate-500 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
{dirty && (
|
||||
<span aria-live="polite" class="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-extrabold text-amber-800">
|
||||
<AlertIcon class="size-3.5" />저장되지 않음
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
aria-label="게스트 Wi-Fi QR 코드 보기"
|
||||
class="inline-flex min-h-10 items-center gap-2 rounded-xl border border-slate-300 bg-white px-3.5 py-2 text-sm font-extrabold text-slate-800 transition hover:border-teal-300 hover:text-teal-700 focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={qrDisabled}
|
||||
onClick={() => setShowQr(true)}
|
||||
title={!guest.enabled ? '게스트 Wi-Fi를 켜고 저장하면 QR 코드를 사용할 수 있습니다.' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<QrCodeIcon class="size-4 shrink-0" />
|
||||
QR 코드 보기
|
||||
</button>
|
||||
<span class={`rounded-full px-3 py-1.5 text-xs font-extrabold ${guest.enabled ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{guest.enabled ? '사용 중' : '꺼짐'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={expanded ? '' : 'hidden'} id="ssh-guest-settings">
|
||||
<p class="mt-5 mb-0 text-sm leading-6 text-slate-600">방문자는 인터넷만 이용하며 내부 네트워크와 공유기 관리 화면에는 접근할 수 없습니다.</p>
|
||||
<div class="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm font-extrabold text-slate-800">게스트 Wi-Fi 이름 (SSID)</span>
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="mt-2 min-h-11 w-full rounded-xl border-2 border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-950 outline-none focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100 disabled:opacity-60"
|
||||
disabled={busy}
|
||||
maxLength={32}
|
||||
onInput={(event) => setSsid(event.currentTarget.value)}
|
||||
value={ssid}
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-extrabold text-slate-800">게스트 Wi-Fi 비밀번호</span>
|
||||
<input
|
||||
autocomplete="new-password"
|
||||
class="mt-2 min-h-11 w-full rounded-xl border-2 border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-950 outline-none focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100 disabled:opacity-60"
|
||||
disabled={busy}
|
||||
onInput={(event) => setPassword(event.currentTarget.value)}
|
||||
placeholder={guest.passwordConfigured ? '비워 두면 기존 비밀번호 유지' : '8자 이상의 별도 비밀번호 입력'}
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
<span class="mt-2 block text-xs text-slate-500">WPA2/WPA3 혼합 · 기본 Wi-Fi와 다른 비밀번호를 권장합니다.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-5 rounded-xl bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-700">
|
||||
내부망 접근 차단 · 게스트 기기 간 통신 차단 · SafeShield DNS 사용
|
||||
{guest.subnet && <span class="block text-xs text-slate-500">게스트 게이트웨이: {guest.subnet}</span>}
|
||||
</div>
|
||||
<div class="mt-5 flex flex-col gap-4 border-t border-slate-200 pt-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label class="inline-flex min-h-11 items-center gap-3 text-sm font-extrabold text-slate-800">
|
||||
<input
|
||||
checked={enabled}
|
||||
class="size-5 accent-teal-700"
|
||||
disabled={busy}
|
||||
onChange={(event) => setEnabled(event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
게스트 Wi-Fi 사용
|
||||
</label>
|
||||
<button
|
||||
class="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-teal-700 px-5 py-2.5 text-sm font-extrabold text-white transition hover:bg-teal-800 disabled:opacity-60 sm:w-auto"
|
||||
disabled={busy || !dirty}
|
||||
type="submit"
|
||||
>
|
||||
<CheckCircleIcon class={`size-5 ${saving ? 'animate-pulse' : ''}`} />
|
||||
{saving ? '적용 중' : '설정 저장'}
|
||||
</button>
|
||||
</div>
|
||||
{validation && <p class="mt-4 mb-0 rounded-xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{validation}</p>}
|
||||
</div>
|
||||
</form>
|
||||
{showQr && <WifiQrDialog guest section="ssh_guest" onClose={() => setShowQr(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComponentChildren, JSX } from 'preact';
|
||||
import type { ComponentChildren, SVGAttributes } from 'preact';
|
||||
|
||||
interface IconProps extends JSX.SVGAttributes<SVGSVGElement> {
|
||||
interface IconProps extends SVGAttributes<SVGSVGElement> {
|
||||
children?: ComponentChildren;
|
||||
title?: string;
|
||||
}
|
||||
@@ -97,6 +97,16 @@ export function WifiIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function QrCodeIcon(props: IconProps) {
|
||||
return (
|
||||
<IconBase {...props}>
|
||||
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4z" />
|
||||
<path d="M6.5 6.5h1M16.5 6.5h1M6.5 16.5h1" />
|
||||
<path d="M14 14h2v2h-2zM18 14h2v2h-2zM16 16h2v2h-2zM18 18h2v2h-2zM14 18h1" />
|
||||
</IconBase>
|
||||
);
|
||||
}
|
||||
|
||||
export function CableIcon(props: IconProps) {
|
||||
return (
|
||||
<IconBase {...props}>
|
||||
@@ -188,6 +198,15 @@ export function KeyIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon(props: IconProps) {
|
||||
return (
|
||||
<IconBase {...props}>
|
||||
<rect height="13" rx="2" width="13" x="8" y="8" />
|
||||
<path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
|
||||
</IconBase>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeIcon(props: IconProps) {
|
||||
return (
|
||||
<IconBase {...props}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DevicesIcon,
|
||||
GlobeIcon,
|
||||
HomeIcon,
|
||||
KeyIcon,
|
||||
LogOutIcon,
|
||||
MenuIcon,
|
||||
MoonIcon,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from './Icons';
|
||||
|
||||
interface ProductNavigationProps {
|
||||
accountRegistered: boolean | null;
|
||||
collapsed: boolean;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
@@ -44,7 +46,7 @@ const NAVIGATION_GROUPS: readonly {
|
||||
{ label: 'Overview', routes: ['home', 'activity'] },
|
||||
{ label: 'Network', routes: ['network', 'wifi', 'iptv', 'devices'] },
|
||||
{ label: 'Security', routes: ['safeshield', 'rules'] },
|
||||
{ label: 'System', routes: ['system', 'settings'] },
|
||||
{ label: 'System', routes: ['account', 'system', 'settings'] },
|
||||
];
|
||||
|
||||
function NavigationIcon({ route }: { route: AppRoute }) {
|
||||
@@ -65,6 +67,8 @@ function NavigationIcon({ route }: { route: AppRoute }) {
|
||||
return <ShieldIcon class="size-5" />;
|
||||
case 'rules':
|
||||
return <UserIcon class="size-5" />;
|
||||
case 'account':
|
||||
return <KeyIcon class="size-5" />;
|
||||
case 'system':
|
||||
return <UpdateIcon class="size-5" />;
|
||||
case 'settings':
|
||||
@@ -91,10 +95,12 @@ function navigationClass(active: boolean, collapsed = false): string {
|
||||
}
|
||||
|
||||
function NavigationItems({
|
||||
accountRegistered,
|
||||
collapsed = false,
|
||||
route,
|
||||
updateCount,
|
||||
}: {
|
||||
accountRegistered: boolean | null;
|
||||
collapsed?: boolean;
|
||||
route: AppRoute;
|
||||
updateCount: number;
|
||||
@@ -121,11 +127,15 @@ function NavigationItems({
|
||||
return (
|
||||
<a
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={collapsed ? item.label : undefined}
|
||||
aria-label={routeName === 'account' && accountRegistered === false
|
||||
? `${item.label} (계정 연결 필요)`
|
||||
: collapsed ? item.label : undefined}
|
||||
class={navigationClass(active, collapsed)}
|
||||
href={item.hash}
|
||||
key={routeName}
|
||||
title={collapsed ? item.label : undefined}
|
||||
title={routeName === 'account' && accountRegistered === false
|
||||
? 'SmartSafeHub 계정 연결 필요'
|
||||
: collapsed ? item.label : undefined}
|
||||
>
|
||||
<span
|
||||
class={`grid size-9 shrink-0 place-items-center rounded-lg ${
|
||||
@@ -152,6 +162,14 @@ function NavigationItems({
|
||||
β
|
||||
</span>
|
||||
) : null}
|
||||
{routeName === 'account' && accountRegistered === false ? (
|
||||
<span
|
||||
aria-label="계정 연결 필요"
|
||||
class={`ssh-account-nav-dot ${collapsed ? 'ssh-account-nav-dot-collapsed' : ''}`}
|
||||
aria-hidden="true"
|
||||
title="계정 연결 필요"
|
||||
/>
|
||||
) : null}
|
||||
{routeName === 'system' && updateCount > 0 ? (
|
||||
collapsed ? (
|
||||
<span
|
||||
@@ -178,6 +196,7 @@ function NavigationItems({
|
||||
}
|
||||
|
||||
export function ProductNavigation({
|
||||
accountRegistered,
|
||||
collapsed,
|
||||
loading,
|
||||
onRefresh,
|
||||
@@ -269,7 +288,7 @@ export function ProductNavigation({
|
||||
|
||||
{mobileMenuOpen ? (
|
||||
<div class="ssh-mobile-menu border-t border-slate-100 py-4" id="smartsafehub-mobile-menu">
|
||||
<NavigationItems route={route} updateCount={updateCount} />
|
||||
<NavigationItems accountRegistered={accountRegistered} route={route} updateCount={updateCount} />
|
||||
<div class="mt-5 space-y-1 border-t border-slate-100 pt-4">
|
||||
<a
|
||||
class={`${navigationClass(false)} text-rose-700 hover:bg-rose-50 hover:text-rose-800`}
|
||||
@@ -338,7 +357,7 @@ export function ProductNavigation({
|
||||
class={`flex-1 overflow-y-auto py-5 ${collapsed ? 'px-2' : 'px-3'}`}
|
||||
id="smartsafehub-desktop-navigation"
|
||||
>
|
||||
<NavigationItems collapsed={collapsed} route={route} updateCount={updateCount} />
|
||||
<NavigationItems accountRegistered={accountRegistered} collapsed={collapsed} route={route} updateCount={updateCount} />
|
||||
</nav>
|
||||
<div class={`border-t border-slate-100 ${collapsed ? 'p-2' : 'p-3'}`}>
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { fetchGuestWifiQr, fetchWifiQr } from '../api/smartsafehub';
|
||||
import { DownloadIcon } from './Icons';
|
||||
import { createQrMatrix } from '../utils/qrMatrix';
|
||||
|
||||
export function wifiQrPayload(ssid: string, security: string, password: string): string {
|
||||
const escape = (value: string) => value.replace(/([\\;,:"])/g, '\\$1');
|
||||
const type = security === 'none' ? 'nopass' : security === 'sae' ? 'WPA3' : 'WPA';
|
||||
return `WIFI:T:${type};S:${escape(ssid)};P:${escape(password)};;`;
|
||||
}
|
||||
|
||||
// Keep the quiet zone in the downloaded image identical to the on-screen SVG.
|
||||
export function wifiQrFilename(ssid: string): string {
|
||||
const safeName = ssid.replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, '_').replace(/[. ]+$/g, '').trim();
|
||||
return `${(safeName || 'Wi-Fi').slice(0, 64)}-QR.png`;
|
||||
}
|
||||
|
||||
export function drawWifiQrPng(
|
||||
matrix: boolean[][],
|
||||
context: CanvasRenderingContext2D,
|
||||
moduleSize = 8,
|
||||
): void {
|
||||
const margin = 4;
|
||||
const count = matrix.length;
|
||||
const size = (count + margin * 2) * moduleSize;
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, size, size);
|
||||
context.fillStyle = '#000000';
|
||||
matrix.forEach((row, y) => row.forEach((dark, x) => {
|
||||
if (dark) context.fillRect((x + margin) * moduleSize, (y + margin) * moduleSize, moduleSize, moduleSize);
|
||||
}));
|
||||
}
|
||||
|
||||
export function WifiQrDialog({ section, guest = false, onClose }: { section: string; guest?: boolean; onClose: () => void }) {
|
||||
const [qr, setQr] = useState<{ ssid: string; matrix: boolean[][] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (guest ? fetchGuestWifiQr() : fetchWifiQr(section)).then((data) => {
|
||||
if (!active) return;
|
||||
try { setQr({ ssid: data.ssid, matrix: createQrMatrix(wifiQrPayload(data.ssid, data.security, data.password)) }); }
|
||||
catch { setError('QR 코드를 생성할 수 없습니다.'); }
|
||||
}).catch(() => { if (active) setError('Wi-Fi 정보를 불러오지 못했습니다.'); });
|
||||
return () => { active = false; setQr(null); };
|
||||
}, [section, guest]);
|
||||
const count = qr?.matrix.length ?? 0;
|
||||
const downloadQr = () => {
|
||||
if (!qr) return;
|
||||
setDownloadError(null);
|
||||
try {
|
||||
const moduleSize = 8;
|
||||
const size = (qr.matrix.length + 8) * moduleSize;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Canvas is not supported');
|
||||
drawWifiQrPng(qr.matrix, context, moduleSize);
|
||||
const link = document.createElement('a');
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.download = wifiQrFilename(qr.ssid);
|
||||
link.click();
|
||||
} catch {
|
||||
setDownloadError('QR 코드 이미지를 저장하지 못했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4" role="presentation" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<section role="dialog" aria-modal="true" aria-label="Wi-Fi QR 코드" class="w-full max-w-sm rounded-2xl bg-white p-5 text-center text-slate-950 shadow-2xl sm:p-7">
|
||||
<h2 class="m-0 text-xl font-extrabold">Wi-Fi QR 코드</h2>
|
||||
<p class="mt-2 break-all text-sm font-semibold text-slate-600">{qr?.ssid ?? 'Wi-Fi 정보를 확인하는 중'}</p>
|
||||
{error ? <p role="alert" class="my-6 text-sm font-bold text-red-700">{error}</p> : null}
|
||||
{!qr && !error ? <p class="my-6 text-sm">QR 코드를 만드는 중...</p> : null}
|
||||
{qr ? <div class="mx-auto my-5 w-full max-w-[288px] rounded-xl border border-slate-200 bg-white p-3">
|
||||
<svg class="block h-auto w-full" viewBox={`0 0 ${count + 8} ${count + 8}`} role="img" aria-label={`${qr.ssid} Wi-Fi 연결 QR 코드`} shape-rendering="crispEdges">
|
||||
<rect width={count + 8} height={count + 8} fill="white" />
|
||||
{qr.matrix.map((row, y) => row.map((dark, x) => dark ? <rect key={`${y}-${x}`} x={x + 4} y={y + 4} width="1" height="1" fill="black" /> : null))}
|
||||
</svg>
|
||||
</div> : null}
|
||||
<p class="text-xs leading-5 text-slate-500">
|
||||
<span class="block">카메라로 스캔해 Wi-Fi에 연결하세요.</span>
|
||||
<span class="block">QR 코드에는 비밀번호가 포함됩니다.</span>
|
||||
</p>
|
||||
{downloadError ? <p role="alert" class="mt-3 text-sm font-bold text-red-700">{downloadError}</p> : null}
|
||||
{qr ? (
|
||||
<button
|
||||
class="mt-5 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-teal-700 px-4 font-extrabold text-white transition hover:bg-teal-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-200"
|
||||
onClick={downloadQr}
|
||||
type="button"
|
||||
>
|
||||
<DownloadIcon class="size-5" />
|
||||
QR 코드 다운로드
|
||||
</button>
|
||||
) : null}
|
||||
<button class="mt-2 min-h-11 w-full rounded-xl border border-slate-300 bg-white px-4 font-extrabold text-slate-700 transition hover:bg-slate-50" onClick={onClose} type="button">닫기</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ interface AsyncResourceOptions<T> {
|
||||
loader: () => Promise<T>;
|
||||
pollInterval?: number | ((data: T | null) => number | null);
|
||||
refreshOnFocus?: boolean;
|
||||
refreshOnVisible?: boolean;
|
||||
staleTimeMs?: number;
|
||||
}
|
||||
|
||||
export function useAsyncResource<T>({
|
||||
@@ -23,6 +25,8 @@ export function useAsyncResource<T>({
|
||||
loader,
|
||||
pollInterval,
|
||||
refreshOnFocus = false,
|
||||
refreshOnVisible = false,
|
||||
staleTimeMs = 30_000,
|
||||
}: AsyncResourceOptions<T>) {
|
||||
const [state, setState] = useState<AsyncResourceState<T>>({
|
||||
data: null,
|
||||
@@ -33,7 +37,11 @@ export function useAsyncResource<T>({
|
||||
const requested = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const inFlight = useRef<Promise<void> | null>(null);
|
||||
const lastRequestAt = useRef<number | null>(null);
|
||||
const lastAttemptAt = useRef<number | null>(null);
|
||||
const lastSuccessAt = useRef<number | null>(null);
|
||||
const failureCount = useRef(0);
|
||||
const RETRY_BASE_MS = 3_000;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
@@ -60,14 +68,17 @@ export function useAsyncResource<T>({
|
||||
|
||||
let request: Promise<void>;
|
||||
|
||||
lastRequestAt.current = Date.now();
|
||||
lastAttemptAt.current = Date.now();
|
||||
request = loader()
|
||||
.then((data) => {
|
||||
lastSuccessAt.current = Date.now();
|
||||
failureCount.current = 0;
|
||||
if (mounted.current) {
|
||||
setState({ data, error: null, loading: false, refreshing: false });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
failureCount.current += 1;
|
||||
if (mounted.current) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
@@ -100,8 +111,10 @@ export function useAsyncResource<T>({
|
||||
}
|
||||
|
||||
requested.current = true;
|
||||
void load();
|
||||
}, [active, load]);
|
||||
const fresh = state.data !== null && lastSuccessAt.current !== null &&
|
||||
Date.now() - lastSuccessAt.current < staleTimeMs;
|
||||
if (!fresh) void load();
|
||||
}, [active, load, staleTimeMs]);
|
||||
|
||||
const interval =
|
||||
typeof pollInterval === 'function' ? pollInterval(state.data) : pollInterval;
|
||||
@@ -122,11 +135,15 @@ export function useAsyncResource<T>({
|
||||
};
|
||||
|
||||
const millisecondsUntilStale = () => {
|
||||
if (lastRequestAt.current === null) {
|
||||
return 0;
|
||||
const now = Date.now();
|
||||
// Success timestamps determine freshness; failed attempts only throttle
|
||||
// retries so an unreachable router cannot cause a tight polling loop.
|
||||
if (failureCount.current > 0 && lastAttemptAt.current !== null) {
|
||||
const backoff = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** Math.min(failureCount.current - 1, 4));
|
||||
return Math.max(0, lastAttemptAt.current + backoff - now);
|
||||
}
|
||||
|
||||
return Math.max(0, interval - (Date.now() - lastRequestAt.current));
|
||||
if (lastSuccessAt.current === null) return 0;
|
||||
return Math.max(0, lastSuccessAt.current + interval - now);
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
@@ -174,7 +191,12 @@ export function useAsyncResource<T>({
|
||||
return;
|
||||
}
|
||||
|
||||
refreshIfStale();
|
||||
if (refreshOnVisible) {
|
||||
// Reconnect immediately after returning to an update in progress.
|
||||
void load(false).finally(schedule);
|
||||
} else {
|
||||
refreshIfStale();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -195,7 +217,7 @@ export function useAsyncResource<T>({
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
}
|
||||
};
|
||||
}, [active, interval, load, refreshOnFocus]);
|
||||
}, [active, interval, load, refreshOnFocus, refreshOnVisible]);
|
||||
|
||||
const refresh = useCallback(() => load(true), [load]);
|
||||
const replaceData = useCallback((data: T) => {
|
||||
@@ -203,6 +225,8 @@ export function useAsyncResource<T>({
|
||||
return;
|
||||
}
|
||||
|
||||
lastSuccessAt.current = Date.now();
|
||||
failureCount.current = 0;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
data,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { fetchDeviceRegistrationStatus } from '../api/smartsafehub';
|
||||
|
||||
const ACCOUNT_STATUS_INTERVAL_MS = 60_000;
|
||||
|
||||
// Router-local status only; never contact Hub from the dashboard/sidebar.
|
||||
export function useDashboardAccountStatus(
|
||||
accountRoute: boolean,
|
||||
accountPageRegistered: boolean | null,
|
||||
): boolean | null {
|
||||
const [registered, setRegistered] = useState<boolean | null>(null);
|
||||
const generation = useRef(0);
|
||||
|
||||
// The account page has fresher, locally confirmed state (5s polling or a
|
||||
// pairing/refresh result). Reflect it immediately instead of waiting up to
|
||||
// 60 seconds for the navigation's independent poll.
|
||||
useEffect(() => {
|
||||
if (accountRoute && accountPageRegistered !== null) {
|
||||
generation.current += 1;
|
||||
setRegistered(accountPageRegistered);
|
||||
}
|
||||
}, [accountRoute, accountPageRegistered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountRoute) return; // Account page owns the fresh local status.
|
||||
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
let inFlight = false;
|
||||
let lastAttempt = 0;
|
||||
|
||||
const check = async (force = false) => {
|
||||
if (cancelled || document.visibilityState === 'hidden' || inFlight ||
|
||||
(!force && Date.now() - lastAttempt < ACCOUNT_STATUS_INTERVAL_MS)) return;
|
||||
inFlight = true;
|
||||
const requestGeneration = ++generation.current;
|
||||
lastAttempt = Date.now();
|
||||
try {
|
||||
const status = await fetchDeviceRegistrationStatus();
|
||||
if (!cancelled && generation.current === requestGeneration) setRegistered(status.accountRegistered);
|
||||
} catch {
|
||||
// Unknown is not disconnected: retain last known state if available.
|
||||
if (!cancelled && generation.current === requestGeneration) setRegistered((previous) => previous ?? null);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') void check();
|
||||
};
|
||||
const onFocus = () => void check();
|
||||
void check(true);
|
||||
timer = window.setInterval(() => void check(), ACCOUNT_STATUS_INTERVAL_MS);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
generation.current += 1;
|
||||
if (timer !== null) window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [accountRoute]);
|
||||
|
||||
return registered;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import {
|
||||
fetchDeviceRegistrationStatus,
|
||||
refreshDeviceRegistrationStatus,
|
||||
requestDevicePairingCode,
|
||||
type DeviceRegistrationStatus,
|
||||
} from '../api/smartsafehub';
|
||||
|
||||
const PAIRING_POLL_INTERVAL_MS = 5_000;
|
||||
const LOCAL_STATUS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
function pairingStillValid(status: DeviceRegistrationStatus | null): boolean {
|
||||
if (!status?.pairingCode || status.accountRegistered === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!status.pairingExpiresAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expiresAt = Date.parse(status.pairingExpiresAt);
|
||||
return !Number.isFinite(expiresAt) || expiresAt > Date.now();
|
||||
}
|
||||
|
||||
export function useDeviceRegistration(enabled: boolean) {
|
||||
const [data, setData] = useState<DeviceRegistrationStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [pairingBusy, setPairingBusy] = useState(false);
|
||||
const refreshPromiseRef = useRef<Promise<DeviceRegistrationStatus | null> | null>(null);
|
||||
const requestGeneration = useRef(0);
|
||||
const mounted = useRef(true);
|
||||
const commitStatus = useCallback((status: DeviceRegistrationStatus, generation: number) => {
|
||||
if (!mounted.current || generation !== requestGeneration.current) return false;
|
||||
setData(status);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback((): Promise<DeviceRegistrationStatus | null> => {
|
||||
if (!enabled) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (refreshPromiseRef.current) {
|
||||
return refreshPromiseRef.current;
|
||||
}
|
||||
|
||||
const generation = ++requestGeneration.current;
|
||||
setSyncError(null);
|
||||
setRefreshing(true);
|
||||
const task = (async () => {
|
||||
try {
|
||||
const status = await refreshDeviceRegistrationStatus();
|
||||
if (commitStatus(status, generation)) setSyncError(null);
|
||||
return status;
|
||||
}
|
||||
catch {
|
||||
// Hub synchronization is best-effort for this local page. The device
|
||||
// helper may already have persisted a newer account state, so recover
|
||||
// that state without blocking or rolling back the visible UI.
|
||||
const localStatus = await fetchDeviceRegistrationStatus().catch(() => null);
|
||||
if (localStatus) {
|
||||
commitStatus(localStatus, generation);
|
||||
}
|
||||
if (mounted.current && generation === requestGeneration.current)
|
||||
setSyncError('SmartSafeHub 서버의 최신 상태 확인이 지연되고 있습니다.');
|
||||
return localStatus;
|
||||
}
|
||||
finally {
|
||||
if (mounted.current) setRefreshing(false);
|
||||
refreshPromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
|
||||
refreshPromiseRef.current = task;
|
||||
return task;
|
||||
}, [enabled, commitStatus]);
|
||||
|
||||
const requestPairing = useCallback(async () => {
|
||||
const generation = ++requestGeneration.current;
|
||||
setPairingBusy(true);
|
||||
try {
|
||||
const status = await requestDevicePairingCode();
|
||||
if (commitStatus(status, generation)) setSyncError(null);
|
||||
return status;
|
||||
}
|
||||
finally {
|
||||
setPairingBusy(false);
|
||||
}
|
||||
}, [commitStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
mounted.current = true;
|
||||
const generation = ++requestGeneration.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSyncError(null);
|
||||
|
||||
// Render the router-local state first. Hub availability must not gate the
|
||||
// SmartSafeHub account page; the cloud check continues in the background.
|
||||
void fetchDeviceRegistrationStatus()
|
||||
.then((localStatus) => {
|
||||
if (!active) return;
|
||||
if (commitStatus(localStatus, generation)) setLoading(false);
|
||||
void refresh();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
if (generation === requestGeneration.current) {
|
||||
setError('기기 등록 상태를 확인하지 못했습니다.');
|
||||
setLoading(false);
|
||||
}
|
||||
void refresh();
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
requestGeneration.current += 1;
|
||||
};
|
||||
}, [enabled, refresh, commitStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'hidden' || refreshPromiseRef.current) return;
|
||||
const generation = ++requestGeneration.current;
|
||||
void fetchDeviceRegistrationStatus()
|
||||
.then((localStatus) => {
|
||||
if (!active) return;
|
||||
if (commitStatus(localStatus, generation)) setSyncError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
// The periodic local read is best-effort. The initial load and
|
||||
// explicit refresh paths remain responsible for visible errors.
|
||||
});
|
||||
}, LOCAL_STATUS_POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [enabled, commitStatus]);
|
||||
|
||||
useEffect(() => () => { mounted.current = false; }, []);
|
||||
|
||||
const shouldPoll = useMemo(() => pairingStillValid(data), [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !shouldPoll) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let timer = 0;
|
||||
|
||||
const poll = async () => {
|
||||
await refresh();
|
||||
if (active) {
|
||||
timer = window.setTimeout(() => void poll(), PAIRING_POLL_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
timer = window.setTimeout(() => void poll(), PAIRING_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, refresh, shouldPoll]);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
pairingBusy,
|
||||
refresh,
|
||||
refreshing,
|
||||
requestPairing,
|
||||
syncError,
|
||||
};
|
||||
}
|
||||
@@ -43,8 +43,9 @@ function isActivePhase(phase: FirmwareStatus['phase'] | undefined): boolean {
|
||||
}
|
||||
|
||||
export function useFirmwareUpdates(active = true) {
|
||||
const [pollingPhase, setPollingPhase] = useState<FirmwareStatus['phase'] | null>(null);
|
||||
const resource = useAsyncResource({
|
||||
active,
|
||||
active: active || isActivePhase(pollingPhase ?? undefined),
|
||||
fallbackError: '펌웨어 업데이트 상태를 불러오지 못했습니다.',
|
||||
loader: fetchFirmwareStatus,
|
||||
pollInterval: (data: FirmwareStatus | null) =>
|
||||
@@ -52,7 +53,12 @@ export function useFirmwareUpdates(active = true) {
|
||||
? ACTIVE_POLL_INTERVAL_MS
|
||||
: BACKGROUND_POLL_INTERVAL_MS,
|
||||
refreshOnFocus: true,
|
||||
refreshOnVisible: isActivePhase(pollingPhase ?? undefined),
|
||||
});
|
||||
useEffect(() => {
|
||||
setPollingPhase(resource.data?.phase ?? null);
|
||||
}, [resource.data?.phase]);
|
||||
|
||||
const [action, setAction] = useState<FirmwareAction>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@@ -71,6 +77,7 @@ export function useFirmwareUpdates(active = true) {
|
||||
|
||||
const markPhase = useCallback(
|
||||
(phase: FirmwareStatus['phase']) => {
|
||||
setPollingPhase(phase);
|
||||
if (!resource.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const HASH_ROUTES: Readonly<Record<string, AppRoute>> = {
|
||||
'#devices': 'devices',
|
||||
'#safeshield': 'safeshield',
|
||||
'#rules': 'rules',
|
||||
'#account': 'account',
|
||||
'#system': 'system',
|
||||
'#settings': 'settings',
|
||||
};
|
||||
|
||||
@@ -1,75 +1,26 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import {
|
||||
fetchSafeShieldLicense,
|
||||
requestSafeShieldRefresh,
|
||||
setSafeShieldEnabled,
|
||||
setSafeShieldStatisticsEnabled,
|
||||
updateSafeShieldLicense,
|
||||
} from '../api/safeshield';
|
||||
import {
|
||||
fetchSmartSafeHubLicenseStatus,
|
||||
requestSmartSafeHubLicenseActivation,
|
||||
} from '../api/smartsafehub';
|
||||
import { RpcError } from '../api/rpc';
|
||||
import { errorMessage } from '../utils/errors';
|
||||
|
||||
export type SafeShieldFeedbackTarget = 'global' | 'license';
|
||||
|
||||
export type SafeShieldAction =
|
||||
| 'enable'
|
||||
| 'disable'
|
||||
| 'refresh'
|
||||
| 'statistics-enable'
|
||||
| 'statistics-disable'
|
||||
| 'license-read'
|
||||
| 'license-update'
|
||||
| 'license-remove';
|
||||
| 'statistics-disable';
|
||||
|
||||
interface SafeShieldActionState {
|
||||
action: SafeShieldAction | null;
|
||||
error: string | null;
|
||||
feedbackTarget: SafeShieldFeedbackTarget | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
const SUCCESS_FEEDBACK_TIMEOUT_MS = 4500;
|
||||
const LICENSE_ACTIVATION_POLL_INTERVAL_MS = 1000;
|
||||
const LICENSE_ACTIVATION_MAX_POLLS = 30;
|
||||
const LICENSE_ACTIVATION_MAX_TRANSIENT_ERRORS = 2;
|
||||
|
||||
function licenseActivationErrorMessage(code: string | null): string {
|
||||
switch (code) {
|
||||
case 'license_invalid':
|
||||
return '유효하지 않은 라이선스 키입니다.';
|
||||
case 'safeshield_upgrade_required':
|
||||
return '라이선스를 등록하려면 SafeShield를 먼저 업데이트해 주세요.';
|
||||
case 'LICENSE_DEVICE_IDENTITY_UNAVAILABLE':
|
||||
case 'LICENSE_DEVICE_PROFILE_UNAVAILABLE':
|
||||
return '라이선스 등록에 필요한 장치 정보를 확인하지 못했습니다.';
|
||||
case 'LICENSE_LOCAL_UPDATE_FAILED':
|
||||
return '서버 등록은 완료했지만 이 기기에 라이선스 키를 적용하지 못했습니다.';
|
||||
case 'LICENSE_ACTIVATE_HTTP_FAILED':
|
||||
return '라이선스 서버에 연결하지 못했습니다.';
|
||||
default:
|
||||
return '라이선스 등록을 완료하지 못했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
function wait(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function feedbackTargetForAction(action: SafeShieldAction): SafeShieldFeedbackTarget {
|
||||
return action.startsWith('license-') ? 'license' : 'global';
|
||||
}
|
||||
|
||||
function isTransientLicenseStatusError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof RpcError &&
|
||||
['RPC_TIMEOUT', 'NETWORK_ERROR', 'UBUS_10'].includes(error.code)
|
||||
);
|
||||
}
|
||||
|
||||
export function useSafeShieldActions(
|
||||
refreshStatus: () => Promise<void>,
|
||||
@@ -78,7 +29,6 @@ export function useSafeShieldActions(
|
||||
const [state, setState] = useState<SafeShieldActionState>({
|
||||
action: null,
|
||||
error: null,
|
||||
feedbackTarget: null,
|
||||
message: null,
|
||||
});
|
||||
const statusTimers = useRef<number[]>([]);
|
||||
@@ -111,26 +61,21 @@ export function useSafeShieldActions(
|
||||
const beginAction = useCallback(
|
||||
(action: SafeShieldAction) => {
|
||||
clearFeedbackTimer();
|
||||
setState({
|
||||
action,
|
||||
error: null,
|
||||
feedbackTarget: feedbackTargetForAction(action),
|
||||
message: null,
|
||||
});
|
||||
setState({ action, error: null, message: null });
|
||||
},
|
||||
[clearFeedbackTimer],
|
||||
);
|
||||
|
||||
const showSuccessMessage = useCallback(
|
||||
(message: string, feedbackTarget: SafeShieldFeedbackTarget = 'global') => {
|
||||
(message: string) => {
|
||||
clearFeedbackTimer();
|
||||
setState({ action: null, error: null, feedbackTarget, message });
|
||||
setState({ action: null, error: null, message });
|
||||
feedbackTimer.current = window.setTimeout(() => {
|
||||
feedbackTimer.current = null;
|
||||
setState((current) =>
|
||||
current.error !== null || current.action !== null
|
||||
? current
|
||||
: { ...current, feedbackTarget: null, message: null },
|
||||
: { ...current, message: null },
|
||||
);
|
||||
}, SUCCESS_FEEDBACK_TIMEOUT_MS);
|
||||
},
|
||||
@@ -194,7 +139,6 @@ export function useSafeShieldActions(
|
||||
setState({
|
||||
action: null,
|
||||
error: errorMessage(error, 'SafeShield 작업을 수행하지 못했습니다.'),
|
||||
feedbackTarget: 'global',
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
@@ -212,9 +156,6 @@ export function useSafeShieldActions(
|
||||
try {
|
||||
const result = await setSafeShieldStatisticsEnabled(enabled);
|
||||
|
||||
// Statistics-only configuration changes are reconciled synchronously,
|
||||
// so keep the busy indicator visible until the first statistics refresh
|
||||
// has observed the new runtime state.
|
||||
await refreshStatistics();
|
||||
|
||||
showSuccessMessage(
|
||||
@@ -236,7 +177,6 @@ export function useSafeShieldActions(
|
||||
setState({
|
||||
action: null,
|
||||
error: errorMessage(error, '차단 통계 설정을 변경하지 못했습니다.'),
|
||||
feedbackTarget: 'global',
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
@@ -249,150 +189,22 @@ export function useSafeShieldActions(
|
||||
|
||||
try {
|
||||
await requestSafeShieldRefresh();
|
||||
setState({ action: null, error: null, feedbackTarget: null, message: null });
|
||||
setState({ action: null, error: null, message: null });
|
||||
scheduleRefreshes([700, 2500, 6000, 12000]);
|
||||
} catch (error) {
|
||||
setState({
|
||||
action: null,
|
||||
error: errorMessage(error, 'SafeShield 작업을 수행하지 못했습니다.'),
|
||||
feedbackTarget: 'global',
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
}, [beginAction, scheduleRefreshes]);
|
||||
|
||||
const updateLicense = useCallback(
|
||||
async (licenseKey: string): Promise<boolean> => {
|
||||
const normalizedKey = licenseKey.trim();
|
||||
|
||||
if (!normalizedKey) {
|
||||
clearFeedbackTimer();
|
||||
setState({
|
||||
action: null,
|
||||
error: '라이선스 키를 입력해 주세요.',
|
||||
feedbackTarget: 'license',
|
||||
message: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
beginAction('license-update');
|
||||
|
||||
try {
|
||||
const activation = await requestSmartSafeHubLicenseActivation(normalizedKey);
|
||||
if (!activation.accepted) {
|
||||
throw new Error('라이선스 등록 요청을 시작하지 못했습니다.');
|
||||
}
|
||||
|
||||
let completed = false;
|
||||
let transientErrors = 0;
|
||||
for (let attempt = 0; attempt < LICENSE_ACTIVATION_MAX_POLLS; attempt += 1) {
|
||||
await wait(LICENSE_ACTIVATION_POLL_INTERVAL_MS);
|
||||
|
||||
let status: Awaited<ReturnType<typeof fetchSmartSafeHubLicenseStatus>>;
|
||||
try {
|
||||
status = await fetchSmartSafeHubLicenseStatus();
|
||||
} catch (error) {
|
||||
if (
|
||||
isTransientLicenseStatusError(error) &&
|
||||
transientErrors < LICENSE_ACTIVATION_MAX_TRANSIENT_ERRORS
|
||||
) {
|
||||
transientErrors += 1;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (
|
||||
status.phase === 'active' &&
|
||||
status.lastActivatedAt >= activation.startedAt
|
||||
) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
status.phase === 'error' &&
|
||||
status.lastAttemptAt >= activation.startedAt
|
||||
) {
|
||||
throw new Error(licenseActivationErrorMessage(status.lastErrorCode));
|
||||
}
|
||||
}
|
||||
|
||||
if (!completed) {
|
||||
throw new Error(
|
||||
'라이선스 등록 결과 확인이 지연되고 있습니다. 잠시 후 상태를 다시 확인해 주세요.',
|
||||
);
|
||||
}
|
||||
|
||||
showSuccessMessage('라이선스를 서버에 등록하고 이 기기에 적용했습니다.', 'license');
|
||||
await refreshStatus();
|
||||
scheduleRefreshes([800, 2500, 6000, 12000]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState({
|
||||
action: null,
|
||||
error: errorMessage(error, '라이선스를 등록하지 못했습니다.'),
|
||||
feedbackTarget: 'license',
|
||||
message: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[beginAction, clearFeedbackTimer, refreshStatus, scheduleRefreshes, showSuccessMessage],
|
||||
);
|
||||
|
||||
const readLicense = useCallback(async (): Promise<string | null> => {
|
||||
beginAction('license-read');
|
||||
|
||||
try {
|
||||
const result = await fetchSafeShieldLicense();
|
||||
setState({ action: null, error: null, feedbackTarget: null, message: null });
|
||||
return result.key;
|
||||
} catch (error) {
|
||||
setState({
|
||||
action: null,
|
||||
error: errorMessage(error, '라이선스 키를 불러오지 못했습니다.'),
|
||||
feedbackTarget: 'license',
|
||||
message: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, [beginAction]);
|
||||
|
||||
const removeLicense = useCallback(async (): Promise<boolean> => {
|
||||
beginAction('license-remove');
|
||||
|
||||
try {
|
||||
const result = await updateSafeShieldLicense('');
|
||||
showSuccessMessage(
|
||||
result.changed ? '라이선스 키를 제거했습니다.' : '설정된 라이선스 키가 없습니다.',
|
||||
'license',
|
||||
);
|
||||
await refreshStatus();
|
||||
|
||||
if (result.refresh.requested) {
|
||||
scheduleRefreshes([800, 2500, 6000, 12000]);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState({
|
||||
action: null,
|
||||
error: errorMessage(error, '라이선스 키를 제거하지 못했습니다.'),
|
||||
feedbackTarget: 'license',
|
||||
message: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [beginAction, refreshStatus, scheduleRefreshes, showSuccessMessage]);
|
||||
|
||||
const dismissFeedback = useCallback(() => {
|
||||
clearFeedbackTimer();
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: null,
|
||||
feedbackTarget: null,
|
||||
message: null,
|
||||
}));
|
||||
}, [clearFeedbackTimer]);
|
||||
@@ -400,11 +212,8 @@ export function useSafeShieldActions(
|
||||
return {
|
||||
...state,
|
||||
dismissFeedback,
|
||||
readLicense,
|
||||
refreshBlocklist,
|
||||
removeLicense,
|
||||
setEnabled,
|
||||
setStatisticsEnabled,
|
||||
updateLicense,
|
||||
};
|
||||
}
|
||||
@@ -21,8 +21,9 @@ const INSTALL_POLL_INTERVAL_MS = 3_000;
|
||||
const UPDATE_PACKAGE = 'luci-app-smartsafehub';
|
||||
|
||||
export function useSoftwareUpdates(active = true) {
|
||||
const [pollingPhase, setPollingPhase] = useState<SoftwareUpdateStatus['phase'] | null>(null);
|
||||
const resource = useAsyncResource({
|
||||
active,
|
||||
active: active || pollingPhase === 'checking' || pollingPhase === 'installing',
|
||||
fallbackError: '소프트웨어 업데이트 상태를 불러오지 못했습니다.',
|
||||
loader: fetchSoftwareUpdates,
|
||||
pollInterval: (data: SoftwareUpdateStatus | null) => {
|
||||
@@ -35,7 +36,12 @@ export function useSoftwareUpdates(active = true) {
|
||||
: BACKGROUND_POLL_INTERVAL_MS;
|
||||
},
|
||||
refreshOnFocus: true,
|
||||
refreshOnVisible: pollingPhase === 'checking' || pollingPhase === 'installing',
|
||||
});
|
||||
useEffect(() => {
|
||||
setPollingPhase(resource.data?.phase ?? null);
|
||||
}, [resource.data?.phase]);
|
||||
|
||||
const [action, setAction] = useState<SoftwareUpdateAction>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@@ -92,6 +98,7 @@ export function useSoftwareUpdates(active = true) {
|
||||
|
||||
const markPhase = useCallback(
|
||||
(phase: SoftwareUpdateStatus['phase']) => {
|
||||
setPollingPhase(phase);
|
||||
if (!resource.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user