mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-08 21:24:21 +08:00
update 2026-11-07 22:36:55
This commit is contained in:
1 parent
dad27f3210
commit
ec19855d48
25 files changed
+1388
-445
No files matched your search
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=honk
|
PKG_NAME:=honk
|
||||||
PKG_VERSION:=2026.11.06
|
PKG_VERSION:=2026.11.08
|
||||||
PKG_RELEASE:=2
|
PKG_RELEASE:=1
|
||||||
|
|
||||||
# Pinned honk-core revision (upstream daeuniverse/honk main), cross-compiled by
|
# Pinned honk-core revision (upstream daeuniverse/honk main), cross-compiled by
|
||||||
# ci/build-core.sh. Every release run publishes its core archives under an
|
# ci/build-core.sh. Every release run publishes its core archives under an
|
||||||
|
|||||||
+9
-1
@@ -92,10 +92,18 @@ done
|
|||||||
wait "$maint_pid"
|
wait "$maint_pid"
|
||||||
operation_rc=$?
|
operation_rc=$?
|
||||||
json_load "$(cat "$job_dir/output" 2>/dev/null)" 2>/dev/null || {
|
json_load "$(cat "$job_dir/output" 2>/dev/null)" 2>/dev/null || {
|
||||||
|
# 脚本没吐出结果(崩溃、语法错误、被信号杀掉)时,stderr 里往往就是唯一线索,
|
||||||
|
# 只报 operation_failed 会让用户和我们都无法定位。
|
||||||
|
invalid_reason=$(sed -n '$p' "$job_dir/stderr" 2>/dev/null)
|
||||||
|
if [ -n "$invalid_reason" ]; then
|
||||||
|
logger -t honk-maintenance "$operation produced no result: $invalid_reason" 2>/dev/null || :
|
||||||
|
else
|
||||||
|
logger -t honk-maintenance "$operation produced no result and no error output" 2>/dev/null || :
|
||||||
|
fi
|
||||||
if [ -e /etc/.honk-maintenance/journal ] || [ -L /etc/.honk-maintenance/journal ] || [ -d /run/honk.lock ]; then
|
if [ -e /etc/.honk-maintenance/journal ] || [ -L /etc/.honk-maintenance/journal ] || [ -d /run/honk.lock ]; then
|
||||||
write_status rollback_required recovery false maintenance_recovery_required
|
write_status rollback_required recovery false maintenance_recovery_required
|
||||||
else
|
else
|
||||||
write_status failed complete false operation_failed
|
write_status failed complete false maintenance_no_result
|
||||||
fi
|
fi
|
||||||
logger -t honk-maintenance "$operation failed (invalid result)" 2>/dev/null || :
|
logger -t honk-maintenance "$operation failed (invalid result)" 2>/dev/null || :
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
+58
-20
@@ -396,8 +396,14 @@ check_root_file() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
safe_relpath() {
|
safe_relpath() {
|
||||||
case "$1" in ''|/*|*..*|*[^A-Za-z0-9._/-]*|*//*|*/./*|./*|*/.) return 1 ;; esac
|
case "$1" in ''|/*|*..*|*//*|*/./*|./*|*/.) return 1 ;; esac
|
||||||
return 0
|
# 空白、控制字符、通配符、反斜杠会打乱 tar 成员解析与解包,一律拒绝;
|
||||||
|
# 中文等 UTF-8 名放行(路由上不一定有 od/hexdump,只能用 awk 的字符类判)。
|
||||||
|
printf '%s\n' "$1" | LC_ALL=C awk '
|
||||||
|
$0 ~ /[[:space:][:cntrl:]]/ { bad = 1 }
|
||||||
|
$0 ~ /\*/ || $0 ~ /\?/ || $0 ~ /\[/ || $0 ~ /\\/ { bad = 1 }
|
||||||
|
END { exit bad ? 1 : 0 }
|
||||||
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
size_of() {
|
size_of() {
|
||||||
@@ -504,30 +510,47 @@ is_excluded() {
|
|||||||
collect_tree() {
|
collect_tree() {
|
||||||
collect_root=$1
|
collect_root=$1
|
||||||
collect_out=$2
|
collect_out=$2
|
||||||
[ -d "$collect_root" ] && [ ! -L "$collect_root" ] || return 1
|
collect_error=
|
||||||
if find "$collect_root" -type l -print 2>/dev/null | grep -q .; then return 1; fi
|
[ -d "$collect_root" ] && [ ! -L "$collect_root" ] || { collect_error=honk_tree_missing; return 1; }
|
||||||
if find "$collect_root" ! -type f ! -type d -print 2>/dev/null | grep -q .; then return 1; fi
|
if find "$collect_root" -type l -print 2>/dev/null | grep -q .; then collect_error=honk_tree_has_symlink; return 1; fi
|
||||||
: > "$collect_out" || return 1
|
if find "$collect_root" ! -type f ! -type d -print 2>/dev/null | grep -q .; then collect_error=honk_tree_has_special_file; return 1; fi
|
||||||
|
: > "$collect_out" || { collect_error=temporary_storage_unavailable; return 1; }
|
||||||
(
|
(
|
||||||
cd "$collect_root" || exit 1
|
cd "$collect_root" || exit 1
|
||||||
find . -type f -print
|
find . -type f -print
|
||||||
) > "$collect_out.raw" || return 1
|
) > "$collect_out.raw" || { collect_error=honk_tree_unreadable; return 1; }
|
||||||
: > "$collect_out"
|
: > "$collect_out"
|
||||||
collect_count=0
|
collect_count=0
|
||||||
collect_total=0
|
collect_total=0
|
||||||
while IFS= read -r collect_path; do
|
while IFS= read -r collect_path; do
|
||||||
collect_path=${collect_path#./}
|
collect_path=${collect_path#./}
|
||||||
is_excluded "$collect_path" && continue
|
is_excluded "$collect_path" && continue
|
||||||
safe_relpath "$collect_path" || exit 1
|
safe_relpath "$collect_path" || {
|
||||||
[ -f "$collect_root/$collect_path" ] && [ ! -L "$collect_root/$collect_path" ] || return 1
|
collect_error=unsafe_honk_file_name
|
||||||
collect_size=$(size_of "$collect_root/$collect_path") || return 1
|
# 文件名进面板日志(维护→日志→维护),否则用户不知道是哪个文件。
|
||||||
[ "$collect_size" -le "$MAX_FILE" ] || return 1
|
logger -t honk-maintenance "unsafe file name: $collect_path" 2>/dev/null || :
|
||||||
|
printf '%s\n' "unsafe file name: $collect_path" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
[ -f "$collect_root/$collect_path" ] && [ ! -L "$collect_root/$collect_path" ] || { collect_error=honk_tree_has_special_file; return 1; }
|
||||||
|
collect_size=$(size_of "$collect_root/$collect_path") || { collect_error=honk_tree_unreadable; return 1; }
|
||||||
|
[ "$collect_size" -le "$MAX_FILE" ] || {
|
||||||
|
collect_error=honk_backup_file_too_large
|
||||||
|
logger -t honk-maintenance "file exceeds $MAX_FILE bytes: $collect_path" 2>/dev/null || :
|
||||||
|
printf '%s\n' "file too large: $collect_path" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
collect_count=$((collect_count + 1))
|
collect_count=$((collect_count + 1))
|
||||||
collect_total=$((collect_total + collect_size))
|
collect_total=$((collect_total + collect_size))
|
||||||
[ "$collect_count" -lt "$MAX_FILES" ] && [ "$collect_total" -le $((MAX_EXPANDED - 1048576)) ] || return 1
|
[ "$collect_count" -lt "$MAX_FILES" ] && [ "$collect_total" -le $((MAX_EXPANDED - 1048576)) ] || {
|
||||||
printf '%s\n' "$collect_path" >> "$collect_out" || exit 1
|
collect_error=honk_backup_tree_too_large
|
||||||
done < "$collect_out.raw" || return 1
|
logger -t honk-maintenance "tree exceeds limits after $collect_count files, $collect_total bytes: $collect_path" 2>/dev/null || :
|
||||||
[ -f "$collect_root/config.dae" ] && [ -f "$collect_root/system.dae" ] || return 1
|
printf '%s\n' "tree too large at: $collect_path" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
printf '%s\n' "$collect_path" >> "$collect_out" || { collect_error=temporary_storage_unavailable; return 1; }
|
||||||
|
done < "$collect_out.raw" || { collect_error=honk_tree_unreadable; return 1; }
|
||||||
|
[ -f "$collect_root/config.dae" ] && [ -f "$collect_root/system.dae" ] || { collect_error=honk_config_incomplete; return 1; }
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,7 +647,10 @@ check_tar_members() {
|
|||||||
{
|
{
|
||||||
if (NF < 2 || substr($1,1,1) != "-") exit 1
|
if (NF < 2 || substr($1,1,1) != "-") exit 1
|
||||||
name=$NF
|
name=$NF
|
||||||
if (name !~ /^[A-Za-z0-9_.\/-]+$/ || name ~ /^\// || name ~ /(^|\/)\.\.(\/|$)/ || name ~ /(^|\/)\.($|\/)/ || name ~ /\/\//) exit 1
|
# 空白、控制字符、通配符会打乱成员名解析,其余(含 UTF-8)放行。
|
||||||
|
if (name ~ /[[:space:][:cntrl:]]/) exit 1
|
||||||
|
if (name ~ /\*/ || name ~ /\?/ || name ~ /\[/ || name ~ /\\/) exit 1
|
||||||
|
if (name ~ /^\// || name ~ /(^|\/)\.\.(\/|$)/ || name ~ /(^|\/)\.($|\/)/ || name ~ /\/\//) exit 1
|
||||||
seen[name]++
|
seen[name]++
|
||||||
if (seen[name] > 1) exit 1
|
if (seen[name] > 1) exit 1
|
||||||
count++
|
count++
|
||||||
@@ -651,7 +677,10 @@ extract_archive() {
|
|||||||
NR==2 { if (NF!=2 || $1!="core_commit" || length($2)!=40 || $2 !~ /^[0-9a-f]+$/) exit 1; next }
|
NR==2 { if (NF!=2 || $1!="core_commit" || length($2)!=40 || $2 !~ /^[0-9a-f]+$/) exit 1; next }
|
||||||
NR==3 { if (NF!=2 || $1!="state" || ($2!="present" && $2!="absent")) exit 1; next }
|
NR==3 { if (NF!=2 || $1!="state" || ($2!="present" && $2!="absent")) exit 1; next }
|
||||||
$1=="file" {
|
$1=="file" {
|
||||||
if (NF!=4 || $2 !~ /^[A-Za-z0-9_.\/-]+$/ || $2 ~ /^\// || $2 ~ /(^|\/)\.\.(\/|$)/ || $2 ~ /(^|\/)\.($|\/)/ || $2 ~ /\/\//) exit 1
|
if (NF!=4) exit 1
|
||||||
|
if ($2 ~ /[[:space:][:cntrl:]]/) exit 1
|
||||||
|
if ($2 ~ /\*/ || $2 ~ /\?/ || $2 ~ /\[/ || $2 ~ /\\/) exit 1
|
||||||
|
if ($2 ~ /^\// || $2 ~ /(^|\/)\.\.(\/|$)/ || $2 ~ /(^|\/)\.($|\/)/ || $2 ~ /\/\//) exit 1
|
||||||
if ($3 !~ /^[0-9]+$/ || $3 > 67108864 || $4 !~ /^[0-9a-f]{64}$/) exit 1
|
if ($3 !~ /^[0-9]+$/ || $3 > 67108864 || $4 !~ /^[0-9a-f]{64}$/) exit 1
|
||||||
if (seen[$2]++) exit 1
|
if (seen[$2]++) exit 1
|
||||||
total += $3; count++; next
|
total += $3; count++; next
|
||||||
@@ -818,6 +847,8 @@ validate_candidate() {
|
|||||||
candidate=$1
|
candidate=$1
|
||||||
uci_dir=$2
|
uci_dir=$2
|
||||||
state=$3
|
state=$3
|
||||||
|
# local = 本机自己拍的快照(备份/重置),archive = 从别的设备导入的包。
|
||||||
|
identity=${4:-local}
|
||||||
config_tree=$candidate/etc/honk
|
config_tree=$candidate/etc/honk
|
||||||
mkdir -p "$temp_dir/uci-tmp" || return 1
|
mkdir -p "$temp_dir/uci-tmp" || return 1
|
||||||
load_uci_values uci "$uci_dir" || return 1
|
load_uci_values uci "$uci_dir" || return 1
|
||||||
@@ -838,7 +869,14 @@ validate_candidate() {
|
|||||||
candidate_ip=
|
candidate_ip=
|
||||||
network_get_device candidate_device "$u_network" || return 1
|
network_get_device candidate_device "$u_network" || return 1
|
||||||
network_get_ipaddr candidate_ip "$u_network" || return 1
|
network_get_ipaddr candidate_ip "$u_network" || return 1
|
||||||
validate_config_tree "$config_tree" "$candidate_device" "$candidate_ip" "$u_port" "$u_initialized" || return 2
|
if [ "$identity" = archive ]; then
|
||||||
|
# 包里记的是原设备的 IP,落地时会被改写;拿本机 IP 比会拒掉换设备的备份。
|
||||||
|
archive_lan=$(sed -n "s/^ lan_interface: '\([^']*\)'$/\1/p" "$config_tree/system.dae")
|
||||||
|
archive_listen=$(sed -n "s/^ listen: '\([^']*\)'$/\1/p" "$config_tree/system.dae")
|
||||||
|
validate_config_tree "$config_tree" "$archive_lan" "${archive_listen%:*}" "${archive_listen##*:}" "$u_initialized" || return 2
|
||||||
|
else
|
||||||
|
validate_config_tree "$config_tree" "$candidate_device" "$candidate_ip" "$u_port" "$u_initialized" || return 2
|
||||||
|
fi
|
||||||
if [ "$state" = present ] || { [ "$state" = preview ] && { [ -e "$config_tree/state/honk.db" ] || [ -L "$config_tree/state/honk.db" ]; }; }; then
|
if [ "$state" = present ] || { [ "$state" = preview ] && { [ -e "$config_tree/state/honk.db" ] || [ -L "$config_tree/state/honk.db" ]; }; }; then
|
||||||
archive_sqlite_check "$config_tree/state/honk.db" || return 1
|
archive_sqlite_check "$config_tree/state/honk.db" || return 1
|
||||||
fi
|
fi
|
||||||
@@ -935,7 +973,7 @@ backup() {
|
|||||||
[ "$u_initialized" = 0 ] || { backup_abort initialized_state_missing; return 1; }
|
[ "$u_initialized" = 0 ] || { backup_abort initialized_state_missing; return 1; }
|
||||||
fi
|
fi
|
||||||
mkdir -p "$temp_dir/archive/etc" "$temp_dir/archive/uci" || { backup_abort temporary_storage_unavailable; return 1; }
|
mkdir -p "$temp_dir/archive/etc" "$temp_dir/archive/uci" || { backup_abort temporary_storage_unavailable; return 1; }
|
||||||
collect_tree "$HONK_ROOT" "$temp_dir/files.list" || { backup_abort unsupported_honk_tree_entry; return 1; }
|
collect_tree "$HONK_ROOT" "$temp_dir/files.list" || { backup_abort "${collect_error:-unsupported_honk_tree_entry}"; return 1; }
|
||||||
while IFS= read -r relpath; do
|
while IFS= read -r relpath; do
|
||||||
[ -n "$relpath" ] || continue
|
[ -n "$relpath" ] || continue
|
||||||
dest=$temp_dir/archive/etc/honk/$relpath
|
dest=$temp_dir/archive/etc/honk/$relpath
|
||||||
@@ -1016,7 +1054,7 @@ restore() {
|
|||||||
error empty_archive_would_clear_active_state
|
error empty_archive_would_clear_active_state
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
validate_candidate "$restore_stage" "$uci_dir" "$archive_state"
|
validate_candidate "$restore_stage" "$uci_dir" "$archive_state" archive
|
||||||
validate_rc=$?
|
validate_rc=$?
|
||||||
if [ "$validate_rc" -ne 0 ]; then
|
if [ "$validate_rc" -ne 0 ]; then
|
||||||
[ "$validate_rc" = 2 ] && error unsupported_candidate_config || error candidate_validation_failed
|
[ "$validate_rc" = 2 ] && error unsupported_candidate_config || error candidate_validation_failed
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ PKG_NAME:=luci-app-amlogic
|
|||||||
# DD = day (01 = 1st)
|
# DD = day (01 = 1st)
|
||||||
# NN = daily release sequence (01 = first release of the day, 02 = second, ...)
|
# NN = daily release sequence (01 = first release of the day, 02 = second, ...)
|
||||||
# Example below: 26.10.0101 = October 1, 2026, 1st release of the day
|
# Example below: 26.10.0101 = October 1, 2026, 1st release of the day
|
||||||
PKG_VERSION:=26.10.2801
|
PKG_VERSION:=26.11.0801
|
||||||
# PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump)
|
# PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump)
|
||||||
# 1 = Lua version (lua branch) → tag: 26.10.0101
|
# 1 = Lua version (lua branch) → tag: 26.10.0101
|
||||||
# 2 = JS version (main branch) → tag: 26.10.0101-js
|
# 2 = JS version (main branch) → tag: 26.10.0101-js
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ check_updated() {
|
|||||||
firmware_tags_array+=("${firmware_tags_name}")
|
firmware_tags_array+=("${firmware_tags_name}")
|
||||||
fi
|
fi
|
||||||
done < <(
|
done < <(
|
||||||
curl -fsSL -m 10 \
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
|
||||||
https://github.com/${server_firmware_url}/releases?page=${i} |
|
https://github.com/${server_firmware_url}/releases?page=${i} |
|
||||||
grep -oE 'releases/tag/([^" ]+)'
|
grep -oE 'releases/tag/([^" ]+)'
|
||||||
)
|
)
|
||||||
@@ -177,7 +177,7 @@ check_updated() {
|
|||||||
|
|
||||||
# Retrieve the HTML code of the tags list page
|
# Retrieve the HTML code of the tags list page
|
||||||
html_code="$(
|
html_code="$(
|
||||||
curl -fsSL -m 10 \
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
|
||||||
https://github.com/${server_firmware_url}/releases/expanded_assets/${firmware_releases_tag}
|
https://github.com/${server_firmware_url}/releases/expanded_assets/${firmware_releases_tag}
|
||||||
)"
|
)"
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ download_firmware() {
|
|||||||
|
|
||||||
# Download to OpenWrt file
|
# Download to OpenWrt file
|
||||||
firmware_download_name="openwrt_${BOARD}_k${main_line_version}_github${firmware_suffix}"
|
firmware_download_name="openwrt_${BOARD}_k${main_line_version}_github${firmware_suffix}"
|
||||||
curl -fsSL "${latest_url}" -o "${FIRMWARE_DOWNLOAD_PATH}/${firmware_download_name}"
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${latest_url}" -o "${FIRMWARE_DOWNLOAD_PATH}/${firmware_download_name}"
|
||||||
if [[ "$?" -eq "0" && -s "${FIRMWARE_DOWNLOAD_PATH}/${firmware_download_name}" ]]; then
|
if [[ "$?" -eq "0" && -s "${FIRMWARE_DOWNLOAD_PATH}/${firmware_download_name}" ]]; then
|
||||||
tolog "03.02 OpenWrt downloaded successfully."
|
tolog "03.02 OpenWrt downloaded successfully."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ check_kernel() {
|
|||||||
|
|
||||||
# Check the version on the server
|
# Check the version on the server
|
||||||
latest_version="$(
|
latest_version="$(
|
||||||
curl -fsSL -m 10 \
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
|
||||||
${kernel_api}/releases/expanded_assets/kernel_${kernel_tag} |
|
${kernel_api}/releases/expanded_assets/kernel_${kernel_tag} |
|
||||||
grep -oE "${main_line_version}\.[0-9]+[^\"]*\.tar\.gz" | sed 's/.tar.gz//' |
|
grep -oE "${main_line_version}\.[0-9]+[^\"]*\.tar\.gz" | sed 's/.tar.gz//' |
|
||||||
sort -urV | head -n 1
|
sort -urV | head -n 1
|
||||||
@@ -185,7 +185,7 @@ check_kernel() {
|
|||||||
|
|
||||||
# Get the sha256 value of the latest version
|
# Get the sha256 value of the latest version
|
||||||
latest_kernel_sha256="$(
|
latest_kernel_sha256="$(
|
||||||
curl -fsSL -m 10 \
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
|
||||||
${kernel_api}/releases/expanded_assets/kernel_${kernel_tag} |
|
${kernel_api}/releases/expanded_assets/kernel_${kernel_tag} |
|
||||||
awk -v pattern="${latest_version}\.tar\.gz" -v RS='</li>' '$0 ~ pattern { print $0 "</li>"; exit }' |
|
awk -v pattern="${latest_version}\.tar\.gz" -v RS='</li>' '$0 ~ pattern { print $0 "</li>"; exit }' |
|
||||||
grep -o 'value="sha256:[^"]*' | sed 's/value="sha256://'
|
grep -o 'value="sha256:[^"]*' | sed 's/value="sha256://'
|
||||||
@@ -225,7 +225,7 @@ download_kernel() {
|
|||||||
|
|
||||||
kernel_down_from="https://github.com/${kernel_repo}/releases/download/kernel_${kernel_tag}/${kernel_file_name}.tar.gz"
|
kernel_down_from="https://github.com/${kernel_repo}/releases/download/kernel_${kernel_tag}/${kernel_file_name}.tar.gz"
|
||||||
|
|
||||||
curl -fsSL "${kernel_down_from}" -o ${KERNEL_DOWNLOAD_PATH}/${kernel_file_name}.tar.gz
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${kernel_down_from}" -o "${KERNEL_DOWNLOAD_PATH}/${kernel_file_name}.tar.gz"
|
||||||
[[ "${?}" -ne "0" ]] && tolog "03.02 The kernel download failed." "1"
|
[[ "${?}" -ne "0" ]] && tolog "03.02 The kernel download failed." "1"
|
||||||
|
|
||||||
# Verify sha256
|
# Verify sha256
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ check_plugin() {
|
|||||||
if [[ "${plugin_branch}" == "main" ]]; then
|
if [[ "${plugin_branch}" == "main" ]]; then
|
||||||
# JS branch: match tags ending with -js (e.g. 3.1.305-js)
|
# JS branch: match tags ending with -js (e.g. 3.1.305-js)
|
||||||
latest_version="$(
|
latest_version="$(
|
||||||
curl -fsSL -m 10 \
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
|
||||||
https://github.com/ophub/luci-app-amlogic/releases |
|
https://github.com/ophub/luci-app-amlogic/releases |
|
||||||
grep -oE 'expanded_assets/[0-9]+\.[0-9]+\.[0-9]+-js' | sed 's|expanded_assets/||g' |
|
grep -oE 'expanded_assets/[0-9]+\.[0-9]+\.[0-9]+-js' | sed 's|expanded_assets/||g' |
|
||||||
sort -urV | head -n 1
|
sort -urV | head -n 1
|
||||||
@@ -140,7 +140,7 @@ check_plugin() {
|
|||||||
else
|
else
|
||||||
# Lua branch: match tags with digits only, no suffix (e.g. 3.1.305)
|
# Lua branch: match tags with digits only, no suffix (e.g. 3.1.305)
|
||||||
latest_version="$(
|
latest_version="$(
|
||||||
curl -fsSL -m 10 \
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error \
|
||||||
https://github.com/ophub/luci-app-amlogic/releases |
|
https://github.com/ophub/luci-app-amlogic/releases |
|
||||||
grep -oE 'expanded_assets/[0-9]+\.[0-9]+\.[0-9]+' | sed 's|expanded_assets/||g' |
|
grep -oE 'expanded_assets/[0-9]+\.[0-9]+\.[0-9]+' | sed 's|expanded_assets/||g' |
|
||||||
grep -v -- '-' |
|
grep -v -- '-' |
|
||||||
@@ -212,7 +212,7 @@ download_plugin() {
|
|||||||
|
|
||||||
tolog "03.03 Querying GitHub API for release assets..."
|
tolog "03.03 Querying GitHub API for release assets..."
|
||||||
api_url="https://api.github.com/repos/ophub/luci-app-amlogic/releases/tags/${latest_version}"
|
api_url="https://api.github.com/repos/ophub/luci-app-amlogic/releases/tags/${latest_version}"
|
||||||
asset_list="$(curl -fsSL -m 15 "${api_url}" | jq -r '.assets[].name' | xargs)"
|
asset_list="$(curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${api_url}" | jq -r '.assets[].name' | xargs)"
|
||||||
if [[ -z "${asset_list}" ]]; then
|
if [[ -z "${asset_list}" ]]; then
|
||||||
tolog "03.03 Failed to fetch release assets from GitHub API." "1"
|
tolog "03.03 Failed to fetch release assets from GitHub API." "1"
|
||||||
fi
|
fi
|
||||||
@@ -229,14 +229,14 @@ download_plugin() {
|
|||||||
# Download the main plugin file
|
# Download the main plugin file
|
||||||
plugin_full_url="${download_repo}/${latest_version}/${plugin_file_name}"
|
plugin_full_url="${download_repo}/${latest_version}/${plugin_file_name}"
|
||||||
tolog "03.05 Downloading main plugin..."
|
tolog "03.05 Downloading main plugin..."
|
||||||
curl -fsSL "${plugin_full_url}" -o "${TMP_CHECK_DIR}/${plugin_file_name}"
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${plugin_full_url}" -o "${TMP_CHECK_DIR}/${plugin_file_name}"
|
||||||
[[ "${?}" -ne "0" ]] && tolog "03.05 Plugin [ ${plugin_file_name} ] download failed." "1"
|
[[ "${?}" -ne "0" ]] && tolog "03.05 Plugin [ ${plugin_file_name} ] download failed." "1"
|
||||||
|
|
||||||
# Download language packs
|
# Download language packs
|
||||||
for langfile in "${lang_file_list[@]}"; do
|
for langfile in "${lang_file_list[@]}"; do
|
||||||
lang_full_url="${download_repo}/${latest_version}/${langfile}"
|
lang_full_url="${download_repo}/${latest_version}/${langfile}"
|
||||||
tolog "03.06 Downloading language pack [ ${langfile} ]..."
|
tolog "03.06 Downloading language pack [ ${langfile} ]..."
|
||||||
curl -fsSL "${lang_full_url}" -o "${TMP_CHECK_DIR}/${langfile}"
|
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --remove-on-error "${lang_full_url}" -o "${TMP_CHECK_DIR}/${langfile}"
|
||||||
[[ "${?}" -ne "0" ]] && tolog "03.06 Language pack [ ${langfile} ] download failed." "1"
|
[[ "${?}" -ne "0" ]] && tolog "03.06 Language pack [ ${langfile} ] download failed." "1"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
LUCI_TITLE:=LuCI support for fastnet
|
LUCI_TITLE:=LuCI support for fastnet
|
||||||
LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry
|
LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry
|
||||||
LUCI_PKGARCH:=all
|
LUCI_PKGARCH:=all
|
||||||
PKG_VERSION:=0.7.8-r5
|
PKG_VERSION:=0.8.0-r5
|
||||||
PKG_RELEASE:=
|
PKG_RELEASE:=
|
||||||
LUCI_MINIFY_CSS:=0
|
LUCI_MINIFY_CSS:=0
|
||||||
LUCI_MINIFY_JS:=0
|
LUCI_MINIFY_JS:=0
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-app-honk
|
PKG_NAME:=luci-app-honk
|
||||||
PKG_VERSION:=2026.11.06
|
PKG_VERSION:=2026.11.08
|
||||||
PKG_RELEASE:=4
|
PKG_RELEASE:=1
|
||||||
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
|
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
|
||||||
|
|
||||||
PKG_SOURCE:=doona-46e153cf-c6229ba102e8.tar.gz
|
PKG_SOURCE:=doona-46e153cf-c6229ba102e8.tar.gz
|
||||||
|
|||||||
@@ -382,7 +382,6 @@ return view.extend({
|
|||||||
const restoreCfgBtn = E('button', { 'class': 'cbi-button cbi-button-negative', 'type': 'button' }, _('Restore config'));
|
const restoreCfgBtn = E('button', { 'class': 'cbi-button cbi-button-negative', 'type': 'button' }, _('Restore config'));
|
||||||
/* 忘记密码用:只清管理员记录,不动订阅和规则 */
|
/* 忘记密码用:只清管理员记录,不动订阅和规则 */
|
||||||
const resetAdminBtn = E('button', { 'class': 'cbi-button', 'type': 'button' }, _('Reset administrator'));
|
const resetAdminBtn = E('button', { 'class': 'cbi-button', 'type': 'button' }, _('Reset administrator'));
|
||||||
const fileInput = E('input', { 'type': 'file', 'accept': '.tar.gz,.gz,application/gzip', 'style': 'display:none' });
|
|
||||||
let backupBusy = false;
|
let backupBusy = false;
|
||||||
writeControls.push(exportBtn, importBtn, restoreCfgBtn, resetAdminBtn);
|
writeControls.push(exportBtn, importBtn, restoreCfgBtn, resetAdminBtn);
|
||||||
|
|
||||||
@@ -419,18 +418,12 @@ return view.extend({
|
|||||||
}).finally(function() { setBackupBusy(false); });
|
}).finally(function() { setBackupBusy(false); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* 只让用户选一次文件:上传对话框自己会弹选择框。 */
|
||||||
importBtn.addEventListener('click', function(ev) {
|
importBtn.addEventListener('click', function(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (backupBusy) return;
|
if (backupBusy) return;
|
||||||
fileInput.click();
|
if (!confirm(_('Stop Honk before restoring. Restoring replaces Honk user data. Continue?')))
|
||||||
});
|
|
||||||
fileInput.addEventListener('change', function(ev) {
|
|
||||||
const file = ev.target.files && ev.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!confirm(_('Stop Honk before restoring. Restoring replaces Honk user data. Continue?'))) {
|
|
||||||
fileInput.value = '';
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
setBackupBusy(true);
|
setBackupBusy(true);
|
||||||
backupMessage.textContent = _('Restoring…');
|
backupMessage.textContent = _('Restoring…');
|
||||||
honk.restorePrepare().then(honk.ensureOk).then(function() {
|
honk.restorePrepare().then(honk.ensureOk).then(function() {
|
||||||
@@ -449,7 +442,6 @@ return view.extend({
|
|||||||
backupMessage.textContent = honk.errorMessage(error, _('Restore failed.'));
|
backupMessage.textContent = honk.errorMessage(error, _('Restore failed.'));
|
||||||
}).finally(function() {
|
}).finally(function() {
|
||||||
setBackupBusy(false);
|
setBackupBusy(false);
|
||||||
fileInput.value = '';
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,7 +485,6 @@ return view.extend({
|
|||||||
backupSection.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Config backup')));
|
backupSection.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Config backup')));
|
||||||
backupSection.appendChild(E('p', { 'class': 'honk-note' }, _('Export a backup archive, import one from your computer, or restore Honk to its default configuration.')));
|
backupSection.appendChild(E('p', { 'class': 'honk-note' }, _('Export a backup archive, import one from your computer, or restore Honk to its default configuration.')));
|
||||||
backupSection.appendChild(E('div', { 'class': 'honk-actions' }, [ exportBtn, importBtn, restoreCfgBtn, resetAdminBtn ]));
|
backupSection.appendChild(E('div', { 'class': 'honk-actions' }, [ exportBtn, importBtn, restoreCfgBtn, resetAdminBtn ]));
|
||||||
backupSection.appendChild(fileInput);
|
|
||||||
backupSection.appendChild(backupMessage);
|
backupSection.appendChild(backupMessage);
|
||||||
|
|
||||||
/* --- System check and repair card --- */
|
/* --- System check and repair card --- */
|
||||||
|
|||||||
@@ -31,13 +31,14 @@ function metaItem(label, value) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function field(label, input, help) {
|
function field(label, control, help) {
|
||||||
/* 说明文字放进控件列下方,两栏布局时才不会挤到右边去 */
|
/* 说明文字放进控件列下方,两栏布局时才不会挤到右边去 */
|
||||||
const cell = E('div', { 'class': 'cbi-value-field' }, [ input ]);
|
const controlId = control.id || (control.querySelector && control.querySelector('input, select, textarea') || {}).id;
|
||||||
|
const cell = E('div', { 'class': 'cbi-value-field' }, [ control ]);
|
||||||
if (help)
|
if (help)
|
||||||
cell.appendChild(E('div', { 'class': 'cbi-value-description honk-note' }, help));
|
cell.appendChild(E('div', { 'class': 'cbi-value-description honk-note' }, help));
|
||||||
return E('div', { 'class': 'cbi-value' }, [
|
return E('div', { 'class': 'cbi-value' }, [
|
||||||
E('label', { 'class': 'cbi-value-title', 'for': input.id }, label),
|
E('label', { 'class': 'cbi-value-title', 'for': controlId || null }, label),
|
||||||
cell
|
cell
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,9 @@ function restoreFields(root, values) {
|
|||||||
|
|
||||||
input.value = values[key].value;
|
input.value = values[key].value;
|
||||||
input.checked = values[key].checked;
|
input.checked = values[key].checked;
|
||||||
|
/* 自绘开关听的是 change,回填不会自己触发。 */
|
||||||
|
if (input.classList.contains('honk-switch-input'))
|
||||||
|
input.dispatchEvent(new Event('change'));
|
||||||
if (values[key].focused) {
|
if (values[key].focused) {
|
||||||
input.focus();
|
input.focus();
|
||||||
if (values[key].selectionStart !== null && typeof input.setSelectionRange === 'function')
|
if (values[key].selectionStart !== null && typeof input.setSelectionRange === 'function')
|
||||||
@@ -212,7 +216,8 @@ return view.extend({
|
|||||||
const form = E('form');
|
const form = E('form');
|
||||||
const networkSelect = E('select', { 'class': 'cbi-input-select', 'name': 'lan_network', 'id': 'honk-lan-network' });
|
const networkSelect = E('select', { 'class': 'cbi-input-select', 'name': 'lan_network', 'id': 'honk-lan-network' });
|
||||||
const portInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'listen_port', 'id': 'honk-listen-port', 'min': '1024', 'max': '65535', 'step': '1' });
|
const portInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'listen_port', 'id': 'honk-listen-port', 'min': '1024', 'max': '65535', 'step': '1' });
|
||||||
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
|
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
|
||||||
|
const clashSwitch = honk.switchControl(clashEnabledInput);
|
||||||
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
|
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
|
||||||
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
|
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
|
||||||
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
|
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
|
||||||
@@ -227,7 +232,7 @@ return view.extend({
|
|||||||
if (status.lan_network)
|
if (status.lan_network)
|
||||||
networkSelect.value = status.lan_network;
|
networkSelect.value = status.lan_network;
|
||||||
portInput.value = status.listen_port ? String(status.listen_port) : '9527';
|
portInput.value = status.listen_port ? String(status.listen_port) : '9527';
|
||||||
clashEnabledInput.checked = status.clash_api_enabled === true;
|
clashEnabledInput.checked = status.clash_api_enabled === true;
|
||||||
clashPortInput.value = status.clash_api_port ? String(status.clash_api_port) : '9090';
|
clashPortInput.value = status.clash_api_port ? String(status.clash_api_port) : '9090';
|
||||||
|
|
||||||
card.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Service settings')));
|
card.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Service settings')));
|
||||||
@@ -238,12 +243,13 @@ return view.extend({
|
|||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
});
|
});
|
||||||
save.disabled = true;
|
save.disabled = true;
|
||||||
|
clashSwitch.syncSwitch();
|
||||||
}
|
}
|
||||||
if (!(data.networks || []).length)
|
if (!(data.networks || []).length)
|
||||||
networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found')));
|
networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found')));
|
||||||
form.appendChild(field(_('LAN network'), networkSelect));
|
form.appendChild(field(_('LAN network'), networkSelect));
|
||||||
form.appendChild(field(_('Listen port'), portInput));
|
form.appendChild(field(_('Listen port'), portInput));
|
||||||
form.appendChild(field(_('Enable Clash API'), clashEnabledInput));
|
form.appendChild(field(_('Enable Clash API'), clashSwitch));
|
||||||
form.appendChild(field(_('Clash API port'), clashPortInput));
|
form.appendChild(field(_('Clash API port'), clashPortInput));
|
||||||
form.appendChild(field(_('Clash API secret'), clashSecretInput, status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it.') : _('Required when enabling. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
|
form.appendChild(field(_('Clash API secret'), clashSecretInput, status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it.') : _('Required when enabling. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
|
||||||
form.appendChild(E('p', { 'class': 'honk-note' }, _('Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting.')));
|
form.appendChild(E('p', { 'class': 'honk-note' }, _('Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting.')));
|
||||||
|
|||||||
@@ -92,6 +92,73 @@ const callUpdateCheck = rpc.declare({ object: 'honk', method: 'update_check', ex
|
|||||||
const callUpdateApply = rpc.declare({ object: 'honk', method: 'update_apply', expect: { '': {} } });
|
const callUpdateApply = rpc.declare({ object: 'honk', method: 'update_apply', expect: { '': {} } });
|
||||||
|
|
||||||
let pageStylesInstalled = false;
|
let pageStylesInstalled = false;
|
||||||
|
let switchStylesInstalled = false;
|
||||||
|
|
||||||
|
/* 开关样式随 JS 注入:CSS 与 JS 分开缓存,主题又各改各的 checkbox/button。 */
|
||||||
|
const SWITCH_CSS = [
|
||||||
|
'.honk-page .honk-switch-wrap{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;vertical-align:middle;line-height:1}',
|
||||||
|
'.honk-page .honk-switch-label{font-size:11px;font-weight:600;letter-spacing:.3px;opacity:.62}',
|
||||||
|
'.honk-page .honk-switch{position:relative!important;flex:0 0 auto;width:42px!important;height:22px!important;min-width:0!important;min-height:0!important;margin:0!important;padding:0!important;border:0!important;border-radius:999px!important;background:rgba(128,128,128,.28)!important;background-image:none!important;box-shadow:none!important;line-height:1!important;text-transform:none!important;transform:none!important;overflow:hidden;cursor:pointer;transition:background .18s ease,opacity .18s ease}',
|
||||||
|
'.honk-page .honk-switch .honk-switch-knob{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.96);box-shadow:0 1px 4px rgba(0,0,0,.2);transition:left .18s ease}',
|
||||||
|
'.honk-page .honk-switch.is-on{background:rgba(74,160,101,.65)!important}',
|
||||||
|
'.honk-page .honk-switch:disabled{opacity:.45;cursor:not-allowed}',
|
||||||
|
'.honk-page .honk-switch:focus-visible{outline:2px solid rgba(74,160,101,.75);outline-offset:2px}',
|
||||||
|
/* 0,3,1 才压得过 honk.css 里那颗 46px !important 的 checkbox。 */
|
||||||
|
'.honk-page .honk-switch-wrap>input.honk-switch-input{position:absolute!important;width:1px!important;height:1px!important;margin:0!important;padding:0!important;border:0!important;opacity:0!important;pointer-events:none!important;appearance:none!important;-webkit-appearance:none!important;box-shadow:none!important;background:none!important}'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function installSwitchStyles() {
|
||||||
|
if (switchStylesInstalled || document.getElementById('honk-switch-styles'))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'honk-switch-styles';
|
||||||
|
style.appendChild(document.createTextNode(SWITCH_CSS));
|
||||||
|
document.head.appendChild(style);
|
||||||
|
switchStylesInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题对 input[type=checkbox] 的处理各不相同(Argon 会放大成错位的胶囊,
|
||||||
|
Bootstrap 干脆是原生小方框),所以开关自己画:真 input 留在表单里保证
|
||||||
|
取值/回填逻辑不变,外面套一层可点的胶囊和 ON/OFF 文字。 */
|
||||||
|
function switchControl(input) {
|
||||||
|
installSwitchStyles();
|
||||||
|
input.classList.add('honk-switch-input');
|
||||||
|
input.setAttribute('tabindex', '-1');
|
||||||
|
|
||||||
|
const caption = E('span', { 'class': 'honk-switch-label' }, '');
|
||||||
|
const knob = E('span', { 'class': 'honk-switch-knob' });
|
||||||
|
const toggle = E('button', {
|
||||||
|
'class': 'honk-switch',
|
||||||
|
'type': 'button',
|
||||||
|
'role': 'switch',
|
||||||
|
'aria-checked': input.checked ? 'true' : 'false'
|
||||||
|
}, knob);
|
||||||
|
|
||||||
|
const sync = function() {
|
||||||
|
toggle.classList.toggle('is-on', !!input.checked);
|
||||||
|
toggle.setAttribute('aria-checked', input.checked ? 'true' : 'false');
|
||||||
|
toggle.disabled = !!input.disabled;
|
||||||
|
caption.textContent = input.checked ? 'ON' : 'OFF';
|
||||||
|
knob.style.left = input.checked ? '23px' : '3px';
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.addEventListener('click', function(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (input.disabled)
|
||||||
|
return;
|
||||||
|
input.checked = !input.checked;
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
input.addEventListener('change', sync);
|
||||||
|
|
||||||
|
/* 表单回填(restoreFields)直接改 input.checked,不会触发 change。 */
|
||||||
|
const wrapper = E('span', { 'class': 'honk-switch-wrap' }, [ input, caption, toggle ]);
|
||||||
|
wrapper.syncSwitch = sync;
|
||||||
|
sync();
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
function installStyles() {
|
function installStyles() {
|
||||||
if (pageStylesInstalled || document.getElementById('honk-page-styles'))
|
if (pageStylesInstalled || document.getElementById('honk-page-styles'))
|
||||||
@@ -100,7 +167,7 @@ function installStyles() {
|
|||||||
const stylesheet = document.createElement('link');
|
const stylesheet = document.createElement('link');
|
||||||
stylesheet.id = 'honk-page-styles';
|
stylesheet.id = 'honk-page-styles';
|
||||||
stylesheet.rel = 'stylesheet';
|
stylesheet.rel = 'stylesheet';
|
||||||
stylesheet.href = L.resource('view/honk/honk.css');
|
stylesheet.href = L.resource('view/honk/honk.css') + (L.env.resource_version ? '?v=' + L.env.resource_version : '');
|
||||||
document.head.appendChild(stylesheet);
|
document.head.appendChild(stylesheet);
|
||||||
pageStylesInstalled = true;
|
pageStylesInstalled = true;
|
||||||
}
|
}
|
||||||
@@ -205,6 +272,16 @@ function tokenMessage(token) {
|
|||||||
data_reset: _('Honk data reset. The service is stopped.'),
|
data_reset: _('Honk data reset. The service is stopped.'),
|
||||||
worker_lost: _('The maintenance worker stopped unexpectedly; check system recovery status before retrying.'),
|
worker_lost: _('The maintenance worker stopped unexpectedly; check system recovery status before retrying.'),
|
||||||
operation_failed: _('The maintenance operation failed.'),
|
operation_failed: _('The maintenance operation failed.'),
|
||||||
|
maintenance_no_result: _('The maintenance script stopped without reporting a result. Check Maintenance → Logs → Maintenance for the reason.'),
|
||||||
|
honk_tree_missing: _('The /etc/honk directory is missing or is a symlink.'),
|
||||||
|
honk_tree_has_symlink: _('There is a symlink under /etc/honk. The backup cannot carry it; remove it and try again.'),
|
||||||
|
honk_tree_has_special_file: _('There is a non-regular file (device, socket, fifo) under /etc/honk. Remove it and try again.'),
|
||||||
|
honk_tree_unreadable: _('A file under /etc/honk could not be read. Check the filesystem and permissions.'),
|
||||||
|
honk_backup_file_too_large: _('A single file under /etc/honk is larger than 64 MB. The service log names it.'),
|
||||||
|
honk_backup_tree_too_large: _('The backup would exceed 512 files or 127 MB. The service log names the entry that crossed the limit.'),
|
||||||
|
honk_config_incomplete: _('config.dae or system.dae is missing under /etc/honk. Reinstall or repair Honk before backing up.'),
|
||||||
|
unsupported_honk_tree_entry: _('The backup found an entry under /etc/honk it cannot archive (a symlink, a special file, or too many files).'),
|
||||||
|
unsafe_honk_file_name: _('A file under /etc/honk has a name the backup cannot carry (spaces, wildcards or "../"). The service log names it; rename it and try again.'),
|
||||||
job_timeout: _('The maintenance operation timed out.'),
|
job_timeout: _('The maintenance operation timed out.'),
|
||||||
maintenance_recovery_required: _('Recovery is required. Run system recovery before starting another maintenance operation.'),
|
maintenance_recovery_required: _('Recovery is required. Run system recovery before starting another maintenance operation.'),
|
||||||
update_recovery_required: _('The signed update did not finish. Keep Honk stopped and reinstall a complete verified package set before retrying.'),
|
update_recovery_required: _('The signed update did not finish. Keep Honk stopped and reinstall a complete verified package set before retrying.'),
|
||||||
@@ -502,5 +579,6 @@ return baseclass.extend({
|
|||||||
resultMessage: resultMessage,
|
resultMessage: resultMessage,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
jobPhaseMessage: jobPhaseMessage,
|
jobPhaseMessage: jobPhaseMessage,
|
||||||
|
switchControl: switchControl,
|
||||||
installStyles: installStyles
|
installStyles: installStyles
|
||||||
});
|
});
|
||||||
@@ -21,10 +21,14 @@ function getNetworkNames(networks) {
|
|||||||
}).sort();
|
}).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
function field(label, input) {
|
function field(label, control, help) {
|
||||||
|
const controlId = control.id || (control.querySelector && control.querySelector('input, select, textarea') || {}).id;
|
||||||
|
const cell = E('div', { 'class': 'cbi-value-field' }, [ control ]);
|
||||||
|
if (help)
|
||||||
|
cell.appendChild(E('div', { 'class': 'cbi-value-description honk-note' }, help));
|
||||||
return E('div', { 'class': 'cbi-value' }, [
|
return E('div', { 'class': 'cbi-value' }, [
|
||||||
E('label', { 'class': 'cbi-value-title', 'for': input.id }, label),
|
E('label', { 'class': 'cbi-value-title', 'for': controlId || null }, label),
|
||||||
E('div', { 'class': 'cbi-value-field' }, input)
|
cell
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +52,8 @@ return view.extend({
|
|||||||
const section = E('form', { 'class': 'honk-card' });
|
const section = E('form', { 'class': 'honk-card' });
|
||||||
const networkSelect = E('select', { 'class': 'cbi-input-select', 'name': 'lan_network', 'id': 'honk-lan-network' });
|
const networkSelect = E('select', { 'class': 'cbi-input-select', 'name': 'lan_network', 'id': 'honk-lan-network' });
|
||||||
const portInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'listen_port', 'id': 'honk-listen-port', 'min': '1024', 'max': '65535', 'step': '1' });
|
const portInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'listen_port', 'id': 'honk-listen-port', 'min': '1024', 'max': '65535', 'step': '1' });
|
||||||
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
|
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
|
||||||
|
const clashSwitch = honk.switchControl(clashEnabledInput);
|
||||||
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
|
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
|
||||||
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
|
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
|
||||||
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
|
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
|
||||||
@@ -64,7 +69,8 @@ return view.extend({
|
|||||||
if (data.status && data.status.lan_network)
|
if (data.status && data.status.lan_network)
|
||||||
networkSelect.value = data.status.lan_network;
|
networkSelect.value = data.status.lan_network;
|
||||||
portInput.value = data.status && data.status.listen_port ? String(data.status.listen_port) : '9527';
|
portInput.value = data.status && data.status.listen_port ? String(data.status.listen_port) : '9527';
|
||||||
clashEnabledInput.checked = !!(data.status && data.status.clash_api_enabled === true);
|
clashEnabledInput.checked = !!(data.status && data.status.clash_api_enabled === true);
|
||||||
|
clashSwitch.syncSwitch();
|
||||||
clashPortInput.value = data.status && data.status.clash_api_port ? String(data.status.clash_api_port) : '9090';
|
clashPortInput.value = data.status && data.status.clash_api_port ? String(data.status.clash_api_port) : '9090';
|
||||||
|
|
||||||
section.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Honk settings')));
|
section.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Honk settings')));
|
||||||
@@ -76,6 +82,7 @@ return view.extend({
|
|||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
});
|
});
|
||||||
save.disabled = true;
|
save.disabled = true;
|
||||||
|
clashSwitch.syncSwitch();
|
||||||
}
|
}
|
||||||
if (data.statusError)
|
if (data.statusError)
|
||||||
section.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read current settings: %s').format(data.statusError)));
|
section.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read current settings: %s').format(data.statusError)));
|
||||||
@@ -85,7 +92,7 @@ return view.extend({
|
|||||||
networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found')));
|
networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found')));
|
||||||
section.appendChild(field(_('LAN network'), networkSelect));
|
section.appendChild(field(_('LAN network'), networkSelect));
|
||||||
section.appendChild(field(_('Listen port'), portInput));
|
section.appendChild(field(_('Listen port'), portInput));
|
||||||
section.appendChild(field(_('Enable Clash API'), clashEnabledInput));
|
section.appendChild(field(_('Enable Clash API'), clashSwitch));
|
||||||
section.appendChild(field(_('Clash API port'), clashPortInput));
|
section.appendChild(field(_('Clash API port'), clashPortInput));
|
||||||
section.appendChild(field(_('Clash API secret'), clashSecretInput));
|
section.appendChild(field(_('Clash API secret'), clashSecretInput));
|
||||||
section.appendChild(E('p', { 'class': 'honk-note' }, data.status && data.status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it. Use your existing HTTPS reverse proxy for external access.') : _('A secret is required when enabling Clash API. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
|
section.appendChild(E('p', { 'class': 'honk-note' }, data.status && data.status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it. Use your existing HTTPS reverse proxy for external access.') : _('A secret is required when enabling Clash API. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ msgstr "发现兼容更新,但尚无完整且已验证的回滚软件包组。
|
|||||||
msgid "A compatible update is available."
|
msgid "A compatible update is available."
|
||||||
msgstr "发现兼容更新。"
|
msgstr "发现兼容更新。"
|
||||||
|
|
||||||
|
msgid "A file under /etc/honk has a name the backup cannot carry (spaces, wildcards or \"../\"). The service log names it; rename it and try again."
|
||||||
|
msgstr "/etc/honk 下有文件名无法写入备份(含空格、通配符或 ../)。文件名已记入日志,改名后重试。"
|
||||||
|
|
||||||
msgid "A previous operation did not finish cleanly."
|
msgid "A previous operation did not finish cleanly."
|
||||||
msgstr "上一次操作未能正常结束。"
|
msgstr "上一次操作未能正常结束。"
|
||||||
|
|
||||||
@@ -908,6 +911,9 @@ msgstr "系统检查结果:%s"
|
|||||||
msgid "Temporary upload cleared."
|
msgid "Temporary upload cleared."
|
||||||
msgstr "Temporary upload cleared."
|
msgstr "Temporary upload cleared."
|
||||||
|
|
||||||
|
msgid "The backup found an entry under /etc/honk it cannot archive (a symlink, a special file, or too many files)."
|
||||||
|
msgstr "/etc/honk 下有无法归档的条目(符号链接、特殊文件或文件数量超出上限)。"
|
||||||
|
|
||||||
msgid "The Clash API listener is unavailable. Check the service logs."
|
msgid "The Clash API listener is unavailable. Check the service logs."
|
||||||
msgstr "Clash API 监听不可用,请检查服务日志。"
|
msgstr "Clash API 监听不可用,请检查服务日志。"
|
||||||
|
|
||||||
@@ -1483,3 +1489,27 @@ msgstr "预检失败,请稍后重试。"
|
|||||||
|
|
||||||
msgid "Switch to panel management"
|
msgid "Switch to panel management"
|
||||||
msgstr "切换为面板管理"
|
msgstr "切换为面板管理"
|
||||||
|
|
||||||
|
msgid "The maintenance script stopped without reporting a result. Check Maintenance → Logs → Maintenance for the reason."
|
||||||
|
msgstr "维护脚本没有返回结果(崩溃或被杀)。请在「维护 → 日志 → 维护」里看具体原因。"
|
||||||
|
|
||||||
|
msgid "The /etc/honk directory is missing or is a symlink."
|
||||||
|
msgstr "/etc/honk 目录不存在,或者是个符号链接。"
|
||||||
|
|
||||||
|
msgid "There is a symlink under /etc/honk. The backup cannot carry it; remove it and try again."
|
||||||
|
msgstr "/etc/honk 下面有符号链接,备份无法携带。删掉它再试。"
|
||||||
|
|
||||||
|
msgid "There is a non-regular file (device, socket, fifo) under /etc/honk. Remove it and try again."
|
||||||
|
msgstr "/etc/honk 下面有非普通文件(设备、socket、管道),删掉它再试。"
|
||||||
|
|
||||||
|
msgid "A file under /etc/honk could not be read. Check the filesystem and permissions."
|
||||||
|
msgstr "/etc/honk 下面的文件读不出来,请检查存储和权限。"
|
||||||
|
|
||||||
|
msgid "A single file under /etc/honk is larger than 64 MB. The service log names it."
|
||||||
|
msgstr "/etc/honk 里有单个文件超过 64 MB,日志里写了是哪个。"
|
||||||
|
|
||||||
|
msgid "The backup would exceed 512 files or 127 MB. The service log names the entry that crossed the limit."
|
||||||
|
msgstr "备份会超过 512 个文件或 127 MB,日志里写了是哪个条目越界。"
|
||||||
|
|
||||||
|
msgid "config.dae or system.dae is missing under /etc/honk. Reinstall or repair Honk before backing up."
|
||||||
|
msgstr "/etc/honk 里缺 config.dae 或 system.dae,先修复或重装 Honk 再备份。"
|
||||||
@@ -124,7 +124,9 @@ end
|
|||||||
o = s:option(ListValue, "domain_resolver", translate("Domain DNS Resolve"))
|
o = s:option(ListValue, "domain_resolver", translate("Domain DNS Resolve"))
|
||||||
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" ..
|
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" ..
|
||||||
translate("Supports only Xray or Sing-box node types.") .. "<br>" .. string.format('<font color="red">%s</font>',
|
translate("Supports only Xray or Sing-box node types.") .. "<br>" .. string.format('<font color="red">%s</font>',
|
||||||
translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead."))
|
translate("Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."))
|
||||||
|
o.default = ""
|
||||||
|
o:value("disable", translate("Disable"))
|
||||||
o:value("", translate("Auto"))
|
o:value("", translate("Auto"))
|
||||||
o:value("tcp", "TCP")
|
o:value("tcp", "TCP")
|
||||||
o:value("udp", "UDP")
|
o:value("udp", "UDP")
|
||||||
|
|||||||
@@ -1721,6 +1721,9 @@ msgstr "如果节点地址是域名,则将使用此 DNS 进行解析。"
|
|||||||
msgid "Note: For node-specific DNS only. Keep Auto to avoid extra overhead."
|
msgid "Note: For node-specific DNS only. Keep Auto to avoid extra overhead."
|
||||||
msgstr "注意:仅用于节点专用 DNS,通常请保持自动,以免增加开销。"
|
msgstr "注意:仅用于节点专用 DNS,通常请保持自动,以免增加开销。"
|
||||||
|
|
||||||
|
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
|
||||||
|
msgstr "注意:自动尝试提取 Clash 订阅中的节点 DNS,禁用则不提取。"
|
||||||
|
|
||||||
msgid "Enable Happy Eyeballs"
|
msgid "Enable Happy Eyeballs"
|
||||||
msgstr "启用 Happy Eyeballs"
|
msgstr "启用 Happy Eyeballs"
|
||||||
|
|
||||||
|
|||||||
@@ -624,7 +624,83 @@ local function encode_node(node)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function parseClashNode(raw, remark)
|
local function parse_dns_endpoint(value)
|
||||||
|
if type(value) ~= "string" then return nil end
|
||||||
|
value = api.trim(value)
|
||||||
|
-- Clash fragments and URL queries cannot be preserved by both node DNS backends.
|
||||||
|
if value == "" or value:find("[%s#?@,]") then return nil end
|
||||||
|
local url = value:find("://", 1, true) and value or "udp://" .. value
|
||||||
|
local ok, endpoint = pcall(api.parseURL, url)
|
||||||
|
if not ok or not endpoint then return nil end
|
||||||
|
local protocol = endpoint.protocol
|
||||||
|
if protocol ~= "udp" and protocol ~= "tcp" and protocol ~= "https" then return nil end
|
||||||
|
local host = endpoint.hostname
|
||||||
|
if not host or not (api.is_ip(host) or api.datatypes.hostname(host)) then return nil end
|
||||||
|
local bracketed = endpoint.host:sub(1, 1) == "["
|
||||||
|
local ipv6 = api.datatypes.ip6addr(host)
|
||||||
|
if bracketed and not ipv6 then return nil end
|
||||||
|
if protocol == "https" and ipv6 and not bracketed then return nil end
|
||||||
|
local port = endpoint.port or (protocol == "https" and 443 or 53)
|
||||||
|
if port < 1 or port > 65535 then return nil end
|
||||||
|
if protocol ~= "https" and endpoint.pathname then return nil end
|
||||||
|
return { protocol = protocol, host = host:lower():gsub("%.$", ""), port = port, url = url }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_loopback_dns(host)
|
||||||
|
return host == "localhost" or (api.datatypes.ip4addr(host) and host:match("^127%.")) or host:match("^[0:]+:0*1$")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_unspecified_dns(host)
|
||||||
|
return host == "0.0.0.0" or host:match("^[0:]+$")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extract_clash_dns(data)
|
||||||
|
local dns = data.dns
|
||||||
|
if type(dns) ~= "table" or not dns["proxy-server-nameserver"] then return nil end
|
||||||
|
-- A subscription-wide resolver cannot represent per-domain node DNS policies.
|
||||||
|
local node_policy = dns["proxy-server-nameserver-policy"]
|
||||||
|
if type(node_policy) == "table" and next(node_policy) then return nil end
|
||||||
|
local listener = parse_dns_endpoint(dns.listen)
|
||||||
|
local resolving = {}
|
||||||
|
local policy = dns["nameserver-policy"]
|
||||||
|
local has_policy = type(policy) == "table" and next(policy)
|
||||||
|
local function pick(candidates, depth)
|
||||||
|
if depth > 4 then return nil end
|
||||||
|
if type(candidates) == "string" then candidates = { candidates } end
|
||||||
|
if type(candidates) ~= "table" then return nil end
|
||||||
|
for _, value in ipairs(candidates) do
|
||||||
|
local endpoint = parse_dns_endpoint(value)
|
||||||
|
if endpoint and not is_unspecified_dns(endpoint.host) then
|
||||||
|
if is_loopback_dns(endpoint.host) then
|
||||||
|
local key = endpoint.protocol .. "://" .. endpoint.host .. ":" .. endpoint.port
|
||||||
|
local listen_matches = listener and listener.protocol ~= "https" and listener.port == endpoint.port and (
|
||||||
|
listener.host == endpoint.host or
|
||||||
|
(listener.host == "0.0.0.0" and not endpoint.host:find(":", 1, true)) or
|
||||||
|
(is_unspecified_dns(listener.host) and listener.host ~= "0.0.0.0" and endpoint.host:find(":", 1, true)) or
|
||||||
|
(endpoint.host == "localhost" and is_loopback_dns(listener.host)))
|
||||||
|
if endpoint.protocol ~= "https" and dns.enable == true and listen_matches and not resolving[key] and not has_policy then
|
||||||
|
resolving[key] = true
|
||||||
|
local resolver = pick(dns.nameserver, depth + 1)
|
||||||
|
resolving[key] = nil
|
||||||
|
if resolver then return resolver end
|
||||||
|
end
|
||||||
|
elseif endpoint.protocol == "https" then
|
||||||
|
return { domain_resolver = "https", domain_resolver_dns_https = endpoint.url }
|
||||||
|
elseif api.datatypes.ip4addr(endpoint.host) then
|
||||||
|
return { domain_resolver = endpoint.protocol, domain_resolver_dns = endpoint.host .. ":" .. endpoint.port }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local resolver = pick(dns["proxy-server-nameserver"], 0)
|
||||||
|
if resolver then
|
||||||
|
local address = resolver.domain_resolver_dns_https or resolver.domain_resolver .. "://" .. resolver.domain_resolver_dns
|
||||||
|
api.log("订阅转换 → 已提取节点域名 DNS:" .. address)
|
||||||
|
end
|
||||||
|
return resolver
|
||||||
|
end
|
||||||
|
|
||||||
|
function parseClashNode(raw, remark, auto_dns)
|
||||||
if not raw then return "" end
|
if not raw then return "" end
|
||||||
domain_redir = {}
|
domain_redir = {}
|
||||||
local ok, lyaml = pcall(require, "lyaml")
|
local ok, lyaml = pcall(require, "lyaml")
|
||||||
@@ -662,7 +738,8 @@ function parseClashNode(raw, remark)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return #links > 0 and table.concat(links, "\n") or ""
|
local resolver = #links > 0 and auto_dns and extract_clash_dns(data) or nil
|
||||||
|
return #links > 0 and table.concat(links, "\n") or "", resolver
|
||||||
end
|
end
|
||||||
|
|
||||||
function parse_clash_sub_info(headers)
|
function parse_clash_sub_info(headers)
|
||||||
|
|||||||
@@ -1973,12 +1973,12 @@ local function update_node(manual)
|
|||||||
end
|
end
|
||||||
-- sing-box/xray 节点域名解析
|
-- sing-box/xray 节点域名解析
|
||||||
if kkk == "type" and (vvv == "Xray" or vvv == "sing-box") then
|
if kkk == "type" and (vvv == "Xray" or vvv == "sing-box") then
|
||||||
if domain_resolver then
|
if domain_resolver and domain_resolver ~= "" and domain_resolver ~= "disable" then
|
||||||
uci_set(cfgid, "domain_resolver", domain_resolver)
|
uci_set(cfgid, "domain_resolver", domain_resolver)
|
||||||
if domain_resolver_dns then
|
if domain_resolver == "https" and domain_resolver_dns_https then
|
||||||
uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns)
|
|
||||||
elseif domain_resolver_dns_https then
|
|
||||||
uci_set(cfgid, "domain_resolver_dns_https", domain_resolver_dns_https)
|
uci_set(cfgid, "domain_resolver_dns_https", domain_resolver_dns_https)
|
||||||
|
elseif (domain_resolver == "udp" or domain_resolver == "tcp") and domain_resolver_dns then
|
||||||
|
uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if domain_strategy then
|
if domain_strategy then
|
||||||
@@ -2178,7 +2178,15 @@ local execute = function()
|
|||||||
local stdout = f:read("*all")
|
local stdout = f:read("*all")
|
||||||
f:close()
|
f:close()
|
||||||
local raw_data = api.trim(stdout)
|
local raw_data = api.trim(stdout)
|
||||||
raw_data = parseClashNode(raw_data)
|
local auto_dns = value.domain_resolver == nil or value.domain_resolver == ""
|
||||||
|
local resolver
|
||||||
|
raw_data, resolver = parseClashNode(raw_data, remark, auto_dns)
|
||||||
|
if auto_dns and resolver then
|
||||||
|
-- Only enrich this run's sub_cfg; keep the subscription's Auto setting.
|
||||||
|
value.domain_resolver = resolver.domain_resolver
|
||||||
|
value.domain_resolver_dns = resolver.domain_resolver_dns
|
||||||
|
value.domain_resolver_dns_https = resolver.domain_resolver_dns_https
|
||||||
|
end
|
||||||
-- 优先提取标准 Clash 订阅响应头中的账户用量与到期时间(不受节点变化与否影响)
|
-- 优先提取标准 Clash 订阅响应头中的账户用量与到期时间(不受节点变化与否影响)
|
||||||
if headers and headers ~= "" then
|
if headers and headers ~= "" then
|
||||||
subscribe_info[cfgid] = parse_clash_sub_info(headers)
|
subscribe_info[cfgid] = parse_clash_sub_info(headers)
|
||||||
@@ -2187,8 +2195,15 @@ local execute = function()
|
|||||||
log_buffer = {}
|
log_buffer = {}
|
||||||
local parsed_nodes = parse_link(raw_data, "2", remark, value)
|
local parsed_nodes = parse_link(raw_data, "2", remark, value)
|
||||||
local old_md5 = value.md5 or ""
|
local old_md5 = value.md5 or ""
|
||||||
local new_md5 = #parsed_nodes > 0 and api.md5_string(jsonStringify(parsed_nodes)) or ""
|
local md5_content = jsonStringify(parsed_nodes)
|
||||||
-- 仅当解析出有效节点且节点集合与上次完全一致时,跳过节点重写与服务重启
|
if value.domain_resolver and value.domain_resolver ~= "" and value.domain_resolver ~= "disable" then
|
||||||
|
local protocol = value.domain_resolver
|
||||||
|
local dns = (protocol == "udp" or protocol == "tcp") and value.domain_resolver_dns or ""
|
||||||
|
local doh = protocol == "https" and value.domain_resolver_dns_https or ""
|
||||||
|
md5_content = md5_content .. jsonStringify({protocol, dns or "", doh or ""})
|
||||||
|
end
|
||||||
|
local new_md5 = #parsed_nodes > 0 and api.md5_string(md5_content) or ""
|
||||||
|
-- 节点集合和最终生效的 DNS 都未变化时,跳过节点重写与服务重启。
|
||||||
if not manual_sub and new_md5 ~= "" and old_md5 == new_md5 then
|
if not manual_sub and new_md5 ~= "" and old_md5 == new_md5 then
|
||||||
log_buffer = nil -- 无物理变动,直接丢弃暂存的丢弃与计数明细
|
log_buffer = nil -- 无物理变动,直接丢弃暂存的丢弃与计数明细
|
||||||
log('订阅:【' .. remark .. '】没有变化,无需更新。')
|
log('订阅:【' .. remark .. '】没有变化,无需更新。')
|
||||||
@@ -2251,6 +2266,7 @@ elseif arg[1] == "add" then
|
|||||||
local f = assert(io.open("/tmp/links.conf", 'r'))
|
local f = assert(io.open("/tmp/links.conf", 'r'))
|
||||||
local raw = f:read('*all')
|
local raw = f:read('*all')
|
||||||
f:close()
|
f:close()
|
||||||
|
raw = parseClashNode(raw, arg[2])
|
||||||
parse_link(raw, "1", arg[2])
|
parse_link(raw, "1", arg[2])
|
||||||
if update_node(1) then uci_save(true) end
|
if update_node(1) then uci_save(true) end
|
||||||
api.remove("/tmp/links.conf")
|
api.remove("/tmp/links.conf")
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ end
|
|||||||
|
|
||||||
o = s:option(ListValue, "domain_resolver", translate("Domain DNS Resolve"))
|
o = s:option(ListValue, "domain_resolver", translate("Domain DNS Resolve"))
|
||||||
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" ..
|
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" ..
|
||||||
translate("Supports only Xray or Sing-box node types.")
|
translate("Supports only Xray or Sing-box node types.") .. "<br>" .. string.format('<font color="red">%s</font>',
|
||||||
|
translate("Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."))
|
||||||
|
o.default = ""
|
||||||
|
o:value("disable", translate("Disable"))
|
||||||
o:value("", translate("Auto"))
|
o:value("", translate("Auto"))
|
||||||
o:value("tcp", "TCP")
|
o:value("tcp", "TCP")
|
||||||
o:value("udp", "UDP")
|
o:value("udp", "UDP")
|
||||||
|
|||||||
@@ -2584,3 +2584,57 @@ msgstr "نمایش همه ردیفها"
|
|||||||
|
|
||||||
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
|
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
|
||||||
msgstr "فیلتر فقط ردیفها را در این صفحه پنهان میکند و چیزی را روی روتر تغییر نمیدهد. انتخاب همه، ردیفهای پنهان را رد میکند."
|
msgstr "فیلتر فقط ردیفها را در این صفحه پنهان میکند و چیزی را روی روتر تغییر نمیدهد. انتخاب همه، ردیفهای پنهان را رد میکند."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard SS node %s, unsupported cipher: %s"
|
||||||
|
msgstr "تبدیل اشتراک: گره SS %s کنار گذاشته شد؛ رمز پشتیبانینشده: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard SS node %s, unsupported plugin: %s"
|
||||||
|
msgstr "تبدیل اشتراک: گره SS %s کنار گذاشته شد؛ افزونه پشتیبانینشده: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Hysteria node %s, unsupported transport: %s"
|
||||||
|
msgstr "تبدیل اشتراک: گره Hysteria %s کنار گذاشته شد؛ انتقال پشتیبانینشده: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard TUIC node %s, TUIC v4 is not supported."
|
||||||
|
msgstr "تبدیل اشتراک: گره TUIC %s کنار گذاشته شد؛ TUIC v4 پشتیبانی نمیشود."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard TUIC node %s, authentication information is incomplete."
|
||||||
|
msgstr "تبدیل اشتراک: گره TUIC %s کنار گذاشته شد؛ اطلاعات احراز هویت ناقص است."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s"
|
||||||
|
msgstr "تبدیل اشتراک: گره AnyTLS %s کنار گذاشته شد؛ Sing-Box از AnyTLS + %s پشتیبانی نمیکند"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s"
|
||||||
|
msgstr "تبدیل اشتراک: گره Snell %s کنار گذاشته شد؛ Sing-Box از Snell + %s پشتیبانی نمیکند"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4."
|
||||||
|
msgstr "تبدیل اشتراک: گره Snell %s کنار گذاشته شد؛ Sing-Box نسخههای Snell پایینتر از 4 را پشتیبانی نمیکند."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: skip a malformed node."
|
||||||
|
msgstr "تبدیل اشتراک: گره با قالب ناقص رد شد."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, node information is incomplete."
|
||||||
|
msgstr "تبدیل اشتراک: گره %s کنار گذاشته شد؛ اطلاعات گره ناقص است."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, invalid bandwidth format."
|
||||||
|
msgstr "تبدیل اشتراک: گره %s کنار گذاشته شد؛ قالب پهنای باند نامعتبر است."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard unsupported node %s, node type: %s"
|
||||||
|
msgstr "تبدیل اشتراک: گره پشتیبانینشده %s کنار گذاشته شد؛ نوع گره: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: extracted node domain DNS: %s"
|
||||||
|
msgstr "تبدیل اشتراک: DNS دامنه گره استخراج شد: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: YAML parsing failed: %s"
|
||||||
|
msgstr "تبدیل اشتراک: تجزیه YAML ناموفق بود: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: Clash subscription detected, converting..."
|
||||||
|
msgstr "تبدیل اشتراک: اشتراک Clash شناسایی شد؛ در حال تبدیل..."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, conversion failed."
|
||||||
|
msgstr "تبدیل اشتراک: گره %s کنار گذاشته شد؛ تبدیل ناموفق بود."
|
||||||
|
|
||||||
|
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
|
||||||
|
msgstr "توجه: حالت خودکار تلاش میکند DNS گره را از اشتراک Clash استخراج کند. غیرفعال کردن، استخراج را متوقف میکند."
|
||||||
|
|
||||||
|
msgid "Never expires"
|
||||||
|
msgstr "بدون انقضا"
|
||||||
@@ -2585,3 +2585,57 @@ msgstr "Показать все строки"
|
|||||||
|
|
||||||
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
|
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
|
||||||
msgstr "Фильтр только скрывает строки на этой странице и ничего не меняет на роутере. «Выделить все» пропускает скрытые строки."
|
msgstr "Фильтр только скрывает строки на этой странице и ничего не меняет на роутере. «Выделить все» пропускает скрытые строки."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard SS node %s, unsupported cipher: %s"
|
||||||
|
msgstr "Преобразование подписки: узел SS %s пропущен, неподдерживаемый шифр: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard SS node %s, unsupported plugin: %s"
|
||||||
|
msgstr "Преобразование подписки: узел SS %s пропущен, неподдерживаемый плагин: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Hysteria node %s, unsupported transport: %s"
|
||||||
|
msgstr "Преобразование подписки: узел Hysteria %s пропущен, неподдерживаемый транспорт: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard TUIC node %s, TUIC v4 is not supported."
|
||||||
|
msgstr "Преобразование подписки: узел TUIC %s пропущен, TUIC v4 не поддерживается."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard TUIC node %s, authentication information is incomplete."
|
||||||
|
msgstr "Преобразование подписки: узел TUIC %s пропущен, неполные данные аутентификации."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s"
|
||||||
|
msgstr "Преобразование подписки: узел AnyTLS %s пропущен, Sing-Box не поддерживает AnyTLS + %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s"
|
||||||
|
msgstr "Преобразование подписки: узел Snell %s пропущен, Sing-Box не поддерживает Snell + %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4."
|
||||||
|
msgstr "Преобразование подписки: узел Snell %s пропущен, Sing-Box не поддерживает версии Snell ниже 4."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: skip a malformed node."
|
||||||
|
msgstr "Преобразование подписки: узел с неполным форматом пропущен."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, node information is incomplete."
|
||||||
|
msgstr "Преобразование подписки: узел %s пропущен, неполные данные узла."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, invalid bandwidth format."
|
||||||
|
msgstr "Преобразование подписки: узел %s пропущен, неверный формат пропускной способности."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard unsupported node %s, node type: %s"
|
||||||
|
msgstr "Преобразование подписки: неподдерживаемый узел %s пропущен, тип узла: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: extracted node domain DNS: %s"
|
||||||
|
msgstr "Преобразование подписки: получен DNS для доменов узлов: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: YAML parsing failed: %s"
|
||||||
|
msgstr "Преобразование подписки: ошибка разбора YAML: %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: Clash subscription detected, converting..."
|
||||||
|
msgstr "Преобразование подписки: обнаружена подписка Clash, выполняется преобразование..."
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, conversion failed."
|
||||||
|
msgstr "Преобразование подписки: узел %s пропущен, ошибка преобразования."
|
||||||
|
|
||||||
|
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
|
||||||
|
msgstr "Примечание: в режиме «Авто» выполняется извлечение DNS узлов из подписок Clash. «Отключить» отменяет извлечение."
|
||||||
|
|
||||||
|
msgid "Never expires"
|
||||||
|
msgstr "Бессрочно"
|
||||||
@@ -2570,3 +2570,57 @@ msgstr "显示全部行"
|
|||||||
|
|
||||||
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
|
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
|
||||||
msgstr "筛选只在本页隐藏行,不会改动路由器上的设置。全选会跳过隐藏行。"
|
msgstr "筛选只在本页隐藏行,不会改动路由器上的设置。全选会跳过隐藏行。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard SS node %s, unsupported cipher: %s"
|
||||||
|
msgstr "订阅转换:丢弃 SS 节点 %s,不支持的加密方式:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard SS node %s, unsupported plugin: %s"
|
||||||
|
msgstr "订阅转换:丢弃 SS 节点 %s,不支持的插件:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Hysteria node %s, unsupported transport: %s"
|
||||||
|
msgstr "订阅转换:丢弃 Hysteria 节点 %s,不支持的传输:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard TUIC node %s, TUIC v4 is not supported."
|
||||||
|
msgstr "订阅转换:丢弃 TUIC 节点 %s,不支持 TUIC v4。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard TUIC node %s, authentication information is incomplete."
|
||||||
|
msgstr "订阅转换:丢弃 TUIC 节点 %s,认证信息不完整。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s"
|
||||||
|
msgstr "订阅转换:丢弃 AnyTLS 节点 %s,Sing-Box 不支持 AnyTLS + %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s"
|
||||||
|
msgstr "订阅转换:丢弃 Snell 节点 %s,Sing-Box 不支持 Snell + %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4."
|
||||||
|
msgstr "订阅转换:丢弃 Snell 节点 %s,Sing-Box 不支持低于 4 的 Snell 版本。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: skip a malformed node."
|
||||||
|
msgstr "订阅转换:跳过格式不完整的节点。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, node information is incomplete."
|
||||||
|
msgstr "订阅转换:丢弃节点 %s,节点信息不完整。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, invalid bandwidth format."
|
||||||
|
msgstr "订阅转换:丢弃节点 %s,带宽格式不正确。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard unsupported node %s, node type: %s"
|
||||||
|
msgstr "订阅转换:丢弃不支持的节点 %s,节点类型:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: extracted node domain DNS: %s"
|
||||||
|
msgstr "订阅转换:已提取节点域名 DNS:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: YAML parsing failed: %s"
|
||||||
|
msgstr "订阅转换:YAML 解析失败:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: Clash subscription detected, converting..."
|
||||||
|
msgstr "订阅转换:检测到 Clash 订阅,正在转换……"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, conversion failed."
|
||||||
|
msgstr "订阅转换:丢弃节点 %s,转换失败。"
|
||||||
|
|
||||||
|
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
|
||||||
|
msgstr "注意:自动尝试提取 Clash 订阅中的节点 DNS,禁用则不提取。"
|
||||||
|
|
||||||
|
msgid "Never expires"
|
||||||
|
msgstr "长期有效"
|
||||||
@@ -2576,3 +2576,57 @@ msgstr "顯示全部列"
|
|||||||
|
|
||||||
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
|
msgid "The filter only hides rows on this page and changes nothing on the router. Select all skips hidden rows."
|
||||||
msgstr "篩選只在本頁隱藏列,不會改動路由器上的設定。全選會跳過隱藏列。"
|
msgstr "篩選只在本頁隱藏列,不會改動路由器上的設定。全選會跳過隱藏列。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard SS node %s, unsupported cipher: %s"
|
||||||
|
msgstr "訂閱轉換:捨棄 SS 節點 %s,不支援的加密方式:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard SS node %s, unsupported plugin: %s"
|
||||||
|
msgstr "訂閱轉換:捨棄 SS 節點 %s,不支援的外掛:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Hysteria node %s, unsupported transport: %s"
|
||||||
|
msgstr "訂閱轉換:捨棄 Hysteria 節點 %s,不支援的傳輸:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard TUIC node %s, TUIC v4 is not supported."
|
||||||
|
msgstr "訂閱轉換:捨棄 TUIC 節點 %s,不支援 TUIC v4。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard TUIC node %s, authentication information is incomplete."
|
||||||
|
msgstr "訂閱轉換:捨棄 TUIC 節點 %s,驗證資訊不完整。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s"
|
||||||
|
msgstr "訂閱轉換:捨棄 AnyTLS 節點 %s,Sing-Box 不支援 AnyTLS + %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s"
|
||||||
|
msgstr "訂閱轉換:捨棄 Snell 節點 %s,Sing-Box 不支援 Snell + %s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4."
|
||||||
|
msgstr "訂閱轉換:捨棄 Snell 節點 %s,Sing-Box 不支援低於 4 的 Snell 版本。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: skip a malformed node."
|
||||||
|
msgstr "訂閱轉換:略過格式不完整的節點。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, node information is incomplete."
|
||||||
|
msgstr "訂閱轉換:捨棄節點 %s,節點資訊不完整。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, invalid bandwidth format."
|
||||||
|
msgstr "訂閱轉換:捨棄節點 %s,頻寬格式不正確。"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard unsupported node %s, node type: %s"
|
||||||
|
msgstr "訂閱轉換:捨棄不支援的節點 %s,節點類型:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: extracted node domain DNS: %s"
|
||||||
|
msgstr "訂閱轉換:已擷取節點網域 DNS:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: YAML parsing failed: %s"
|
||||||
|
msgstr "訂閱轉換:YAML 解析失敗:%s"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: Clash subscription detected, converting..."
|
||||||
|
msgstr "訂閱轉換:偵測到 Clash 訂閱,正在轉換……"
|
||||||
|
|
||||||
|
msgid "Subscription conversion: discard node %s, conversion failed."
|
||||||
|
msgstr "訂閱轉換:捨棄節點 %s,轉換失敗。"
|
||||||
|
|
||||||
|
msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."
|
||||||
|
msgstr "注意:自動嘗試擷取 Clash 訂閱中的節點 DNS,停用則不擷取。"
|
||||||
|
|
||||||
|
msgid "Never expires"
|
||||||
|
msgstr "長期有效"
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
#!/usr/bin/lua
|
||||||
|
|
||||||
|
-- Copyright(c): lwb1978 2025–2027
|
||||||
|
|
||||||
|
local api = require("luci.passwall2.api")
|
||||||
|
local base64 = api.base64Encode
|
||||||
|
local json = api.jsonc
|
||||||
|
local i18n = api.i18n
|
||||||
|
|
||||||
|
local domain_redir = {}
|
||||||
|
|
||||||
|
local function urlencode(value)
|
||||||
|
return api.UrlEncode(value == nil and "" or tostring(value))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function add_param(params, key, value)
|
||||||
|
if value ~= nil and value ~= "" then
|
||||||
|
if type(value) == "boolean" then value = value and "1" or "0" end
|
||||||
|
params[#params + 1] = key .. "=" .. urlencode(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_ech(node)
|
||||||
|
local opts = node["ech-opts"]
|
||||||
|
if type(opts) ~= "table" or opts.enable ~= true then return nil end
|
||||||
|
if opts.config and opts.config ~= "" then return opts.config end
|
||||||
|
local name = opts["query-server-name"]
|
||||||
|
if not name or name == "" then name = node.servername or node.sni or node.server end
|
||||||
|
if name == "" then name = node.server end
|
||||||
|
return name .. "+https://223.5.5.5/dns-query"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bandwidth_mbps(value)
|
||||||
|
if value == nil then return nil end
|
||||||
|
local number = tonumber(value)
|
||||||
|
if number then return number end
|
||||||
|
local amount, prefix, unit = tostring(value):match("^%s*(%d+)%s*([KMGT]?)([Bb])ps%s*$")
|
||||||
|
local scales = { [""] = 1, K = 1000, M = 1000000, G = 1000000000, T = 1000000000000 }
|
||||||
|
if not amount then return nil end
|
||||||
|
return tonumber(amount) * scales[prefix] * (unit == "B" and 8 or 1) / 1000000
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inherit(base, overrides)
|
||||||
|
local result = {}
|
||||||
|
for key, value in pairs(base or {}) do result[key] = value end
|
||||||
|
for key, value in pairs(overrides or {}) do result[key] = value end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_xhttp_extra(opts)
|
||||||
|
local extra = {}
|
||||||
|
local fields = {
|
||||||
|
headers = "headers", ["x-padding-bytes"] = "xPaddingBytes",
|
||||||
|
["no-grpc-header"] = "noGRPCHeader", ["x-padding-obfs-mode"] = "xPaddingObfsMode",
|
||||||
|
["x-padding-key"] = "xPaddingKey", ["x-padding-header"] = "xPaddingHeader",
|
||||||
|
["x-padding-placement"] = "xPaddingPlacement", ["x-padding-method"] = "xPaddingMethod",
|
||||||
|
["uplink-http-method"] = "uplinkHTTPMethod", ["session-placement"] = "sessionIDPlacement",
|
||||||
|
["session-key"] = "sessionIDKey", ["session-table"] = "sessionIDTable",
|
||||||
|
["session-length"] = "sessionIDLength", ["seq-placement"] = "seqPlacement", ["seq-key"] = "seqKey",
|
||||||
|
["uplink-data-placement"] = "uplinkDataPlacement", ["uplink-data-key"] = "uplinkDataKey",
|
||||||
|
["uplink-chunk-size"] = "uplinkChunkSize", ["sc-max-each-post-bytes"] = "scMaxEachPostBytes",
|
||||||
|
["sc-min-posts-interval-ms"] = "scMinPostsIntervalMs"
|
||||||
|
}
|
||||||
|
for source, target in pairs(fields) do extra[target] = opts[source] end
|
||||||
|
-- jsonc treats empty tables as arrays and repeated table references as null.
|
||||||
|
if type(extra.headers) == "table" then
|
||||||
|
extra.headers = next(extra.headers) and inherit(extra.headers) or nil
|
||||||
|
end
|
||||||
|
local reuse = opts["reuse-settings"]
|
||||||
|
if reuse then
|
||||||
|
local xmux = {}
|
||||||
|
local ranges = {
|
||||||
|
["max-concurrency"] = "maxConcurrency", ["max-connections"] = "maxConnections",
|
||||||
|
["c-max-reuse-times"] = "cMaxReuseTimes", ["h-max-request-times"] = "hMaxRequestTimes",
|
||||||
|
["h-max-reusable-secs"] = "hMaxReusableSecs"
|
||||||
|
}
|
||||||
|
for source, target in pairs(ranges) do
|
||||||
|
local value = reuse[source]
|
||||||
|
if value ~= nil then xmux[target] = tonumber(value) or value end
|
||||||
|
end
|
||||||
|
xmux.hKeepAlivePeriod = tonumber(reuse["h-keep-alive-period"])
|
||||||
|
if next(xmux) then extra.xmux = xmux end
|
||||||
|
end
|
||||||
|
return extra
|
||||||
|
end
|
||||||
|
|
||||||
|
local function host_format(host)
|
||||||
|
if not host then return "" end
|
||||||
|
local str = host:match("%[(.-)%]") or host
|
||||||
|
if api.datatypes.ip6addr(str) then
|
||||||
|
return "[" .. str .. "]"
|
||||||
|
end
|
||||||
|
return host
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_alpn(alpn) -- Sort and remove duplicates.
|
||||||
|
if not alpn then return nil end
|
||||||
|
|
||||||
|
local seen = {}
|
||||||
|
local order = { "h3", "h2", "http/1.1" }
|
||||||
|
|
||||||
|
if type(alpn) == "table" then
|
||||||
|
for _, v in ipairs(alpn) do
|
||||||
|
if v then seen[v] = true end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
seen[tostring(alpn)] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
local t = {}
|
||||||
|
for _, v in ipairs(order) do
|
||||||
|
if seen[v] then table.insert(t, v) end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #t == 0 then return nil end
|
||||||
|
return table.concat(t, ",")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_common(node)
|
||||||
|
local o = {}
|
||||||
|
|
||||||
|
o.server = host_format(node.server)
|
||||||
|
o.port = node.port
|
||||||
|
o.name = node.name
|
||||||
|
|
||||||
|
-- ===== TLS =====
|
||||||
|
o.tls = {}
|
||||||
|
|
||||||
|
if node["reality-opts"] then
|
||||||
|
o.tls.security = "reality"
|
||||||
|
o.tls.pbk = node["reality-opts"]["public-key"]
|
||||||
|
o.tls.sid = node["reality-opts"]["short-id"]
|
||||||
|
elseif node.tls then
|
||||||
|
o.tls.security = "tls"
|
||||||
|
end
|
||||||
|
|
||||||
|
o.tls.sni = node.servername or node.sni
|
||||||
|
o.tls.fp = node["client-fingerprint"]
|
||||||
|
o.tls.pcs = node.fingerprint
|
||||||
|
o.tls.vcn = node["name-cert-verify"]
|
||||||
|
o.tls.insecure = node["skip-cert-verify"] == true
|
||||||
|
|
||||||
|
o.tls.alpn = build_alpn(node.alpn)
|
||||||
|
|
||||||
|
o.tls.ech = build_ech(node)
|
||||||
|
|
||||||
|
-- ===== transport =====
|
||||||
|
o.transport = {}
|
||||||
|
local net = node.network or "tcp"
|
||||||
|
o.transport.type = net
|
||||||
|
|
||||||
|
local function get_first(v)
|
||||||
|
if type(v) == "table" then return v[1] end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
if net == "ws" then
|
||||||
|
local opts = node["ws-opts"]
|
||||||
|
if opts then
|
||||||
|
local path = opts.path or "/"
|
||||||
|
if opts["v2ray-http-upgrade"] ~= true and opts["max-early-data"] then
|
||||||
|
path = path .. (path:find("?", 1, true) and "&ed=" or "?ed=") .. opts["max-early-data"]
|
||||||
|
end
|
||||||
|
if opts["v2ray-http-upgrade"] ~= true and opts["early-data-header-name"] then
|
||||||
|
path = path .. (path:find("?", 1, true) and "&eh=" or "?eh=") .. opts["early-data-header-name"]
|
||||||
|
end
|
||||||
|
o.transport.path = path
|
||||||
|
o.transport.host = opts.headers and opts.headers.Host
|
||||||
|
if opts["v2ray-http-upgrade"] == true then o.transport.type = "httpupgrade" end
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif net == "grpc" then
|
||||||
|
local opts = node["grpc-opts"]
|
||||||
|
if opts then
|
||||||
|
o.transport.serviceName = opts["grpc-service-name"]
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif net == "http" then
|
||||||
|
-- Use TCP + HTTP camouflage so each core selects its matching transport.
|
||||||
|
o.transport.type = "tcp"
|
||||||
|
o.transport.headerType = "http"
|
||||||
|
o.transport.host = node.server
|
||||||
|
o.transport.path = "/"
|
||||||
|
local opts = node["http-opts"]
|
||||||
|
if opts then
|
||||||
|
-- Clash http-opts carries the camouflage Host under headers.Host
|
||||||
|
-- (a list), like ws-opts; opts.host is not standard for http.
|
||||||
|
o.transport.host = get_first(opts.host) or (opts.headers and get_first(opts.headers.Host)) or o.transport.host
|
||||||
|
o.transport.path = get_first(opts.path) or o.transport.path
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif net == "h2" then
|
||||||
|
local opts = node["h2-opts"]
|
||||||
|
if opts then
|
||||||
|
o.transport.host = get_first(opts.host)
|
||||||
|
o.transport.path = opts.path
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif net == "xhttp" then
|
||||||
|
local opts = node["xhttp-opts"]
|
||||||
|
if opts then
|
||||||
|
o.transport.host = opts.host
|
||||||
|
o.transport.path = opts.path
|
||||||
|
o.transport.mode = opts.mode
|
||||||
|
|
||||||
|
local extra = build_xhttp_extra(opts)
|
||||||
|
|
||||||
|
-- download-settings
|
||||||
|
if opts["download-settings"] then
|
||||||
|
local d = opts["download-settings"]
|
||||||
|
local effective = inherit(node, d)
|
||||||
|
local ds = {
|
||||||
|
address = domain_redir[effective.server] or effective.server,
|
||||||
|
port = effective.port,
|
||||||
|
network = "xhttp",
|
||||||
|
security = effective.tls and "tls" or "none"
|
||||||
|
}
|
||||||
|
local sni = effective.servername or effective.sni or effective.server
|
||||||
|
local alpn = build_alpn(effective.alpn)
|
||||||
|
if effective.tls or effective["reality-opts"] then
|
||||||
|
ds.tlsSettings = {
|
||||||
|
serverName = sni,
|
||||||
|
allowInsecure = effective["skip-cert-verify"] == true,
|
||||||
|
fingerprint = effective["client-fingerprint"],
|
||||||
|
pinnedPeerCertSha256 = effective.fingerprint and api.sha256_sb_xray(effective.fingerprint) or nil,
|
||||||
|
verifyPeerCertByName = effective["name-cert-verify"],
|
||||||
|
alpn = alpn and api.split(alpn, ",") or nil,
|
||||||
|
echConfigList = build_ech(effective)
|
||||||
|
}
|
||||||
|
local reality = effective["reality-opts"]
|
||||||
|
if reality then
|
||||||
|
ds.security = "reality"
|
||||||
|
ds.realitySettings = {
|
||||||
|
serverName = sni, fingerprint = effective["client-fingerprint"] or "chrome",
|
||||||
|
publicKey = reality["public-key"], shortId = reality["short-id"] or ""
|
||||||
|
}
|
||||||
|
ds.tlsSettings = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local xs = build_xhttp_extra(inherit(opts, d))
|
||||||
|
xs.path = d.path or opts.path or "/"
|
||||||
|
xs.host = d.host or opts.host or sni
|
||||||
|
xs.mode = opts.mode or "auto"
|
||||||
|
ds.xhttpSettings = xs
|
||||||
|
|
||||||
|
extra.downloadSettings = ds
|
||||||
|
end
|
||||||
|
|
||||||
|
if next(extra) then
|
||||||
|
o.transport.extra = json.stringify(extra)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return o
|
||||||
|
end
|
||||||
|
|
||||||
|
-- VLESS
|
||||||
|
local function encode_vless(node)
|
||||||
|
local o = build_common(node)
|
||||||
|
|
||||||
|
local link = "vless://" .. node.uuid .. "@" .. o.server .. ":" .. o.port
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
if type(node.flow) == "string" and node.flow:sub(1, 5) == "xtls-" then add_param(p, "flow", node.flow) end
|
||||||
|
add_param(p, "encryption", node.encryption)
|
||||||
|
|
||||||
|
-- TLS
|
||||||
|
add_param(p, "security", o.tls.security)
|
||||||
|
add_param(p, "pbk", o.tls.pbk)
|
||||||
|
add_param(p, "sid", o.tls.sid)
|
||||||
|
add_param(p, "sni", o.tls.sni)
|
||||||
|
add_param(p, "fp", o.tls.fp)
|
||||||
|
add_param(p, "alpn", o.tls.alpn)
|
||||||
|
add_param(p, "ech", o.tls.ech)
|
||||||
|
add_param(p, "pcs", o.tls.pcs)
|
||||||
|
add_param(p, "vcn", o.tls.vcn)
|
||||||
|
add_param(p, "allowInsecure", o.tls.insecure)
|
||||||
|
|
||||||
|
-- transport
|
||||||
|
add_param(p, "type", o.transport.type)
|
||||||
|
add_param(p, "headerType", o.transport.headerType)
|
||||||
|
add_param(p, "host", o.transport.host)
|
||||||
|
add_param(p, "path", o.transport.path)
|
||||||
|
add_param(p, "serviceName", o.transport.serviceName)
|
||||||
|
add_param(p, "mode", o.transport.mode)
|
||||||
|
add_param(p, "extra", o.transport.extra)
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, link .. "#" .. urlencode(o.name or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Trojan
|
||||||
|
local function encode_trojan(node)
|
||||||
|
local o = build_common(node)
|
||||||
|
|
||||||
|
local link = "trojan://" .. urlencode(node.password) .. "@" .. o.server .. ":" .. o.port
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
add_param(p, "security", o.tls.security)
|
||||||
|
add_param(p, "pbk", o.tls.pbk)
|
||||||
|
add_param(p, "sid", o.tls.sid)
|
||||||
|
add_param(p, "sni", o.tls.sni)
|
||||||
|
add_param(p, "fp", o.tls.fp)
|
||||||
|
add_param(p, "alpn", o.tls.alpn)
|
||||||
|
add_param(p, "ech", o.tls.ech)
|
||||||
|
add_param(p, "pcs", o.tls.pcs)
|
||||||
|
add_param(p, "vcn", o.tls.vcn)
|
||||||
|
add_param(p, "allowInsecure", o.tls.insecure)
|
||||||
|
add_param(p, "type", o.transport.type)
|
||||||
|
add_param(p, "headerType", o.transport.headerType)
|
||||||
|
add_param(p, "host", o.transport.host)
|
||||||
|
add_param(p, "path", o.transport.path)
|
||||||
|
add_param(p, "serviceName", o.transport.serviceName)
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, link .. "#" .. urlencode(o.name or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- VMess
|
||||||
|
local function encode_vmess(node)
|
||||||
|
local o = build_common(node)
|
||||||
|
|
||||||
|
local obj = {
|
||||||
|
v = "2",
|
||||||
|
ps = o.name,
|
||||||
|
add = node.server,
|
||||||
|
port = tostring(node.port),
|
||||||
|
id = node.uuid,
|
||||||
|
aid = tostring(node.alterId or 0),
|
||||||
|
net = o.transport.type,
|
||||||
|
security = node.cipher,
|
||||||
|
scy = node.cipher,
|
||||||
|
type = o.transport.headerType or "none",
|
||||||
|
host = o.transport.host or "",
|
||||||
|
path = urlencode(o.transport.path or ""),
|
||||||
|
tls = o.tls.security == "tls" and "tls" or "",
|
||||||
|
sni = o.tls.sni,
|
||||||
|
alpn = o.tls.alpn,
|
||||||
|
fp = o.tls.fp,
|
||||||
|
ech = o.tls.ech,
|
||||||
|
pcs = o.tls.pcs,
|
||||||
|
vcn = o.tls.vcn,
|
||||||
|
insecure = o.tls.insecure and "1" or "0",
|
||||||
|
tfo = node.tfo and "1" or "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.transport.type == "grpc" then
|
||||||
|
obj.path = urlencode(o.transport.serviceName or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, "vmess://" .. base64(json.stringify(obj))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SS
|
||||||
|
local function encode_ss(node)
|
||||||
|
local ss_method = {
|
||||||
|
["none"]=1, ["aes-128-gcm"]=1, ["aes-192-gcm"]=1, ["aes-256-gcm"]=1, ["chacha20-ietf-poly1305"]=1, ["xchacha20-ietf-poly1305"]=1, ["2022-blake3-aes-128-gcm"]=1, ["2022-blake3-aes-256-gcm"]=1, ["2022-blake3-chacha20-poly1305"]=1,
|
||||||
|
["aes-128-ctr"]=1, ["aes-192-ctr"]=1, ["aes-256-ctr"]=1, ["aes-128-cfb"]=1, ["aes-192-cfb"]=1, ["aes-256-cfb"]=1, ["rc4-md5"]=1, ["chacha20-ietf"]=1, ["xchacha20"]=1,
|
||||||
|
}
|
||||||
|
if not ss_method[node.cipher or ""] then
|
||||||
|
return 1, i18n.translatef("Subscription conversion: discard SS node %s, unsupported cipher: %s", node.name or "", node.cipher or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local userinfo = node.cipher .. ":" .. node.password
|
||||||
|
local base = userinfo .. "@" .. host_format(node.server) .. ":" .. node.port
|
||||||
|
local link = "ss://" .. base64(base)
|
||||||
|
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
if node.udp then add_param(p, "udp", true) end
|
||||||
|
if node["udp-over-tcp"] then add_param(p, "uot", true) end
|
||||||
|
|
||||||
|
if node.plugin then
|
||||||
|
local plugin = (node.plugin == "obfs") and "obfs-local" or node.plugin
|
||||||
|
if plugin == "shadow-tls" then
|
||||||
|
local shadow_tls = base64(json.stringify(node["plugin-opts"] or {}))
|
||||||
|
add_param(p, "shadow-tls", shadow_tls)
|
||||||
|
elseif plugin == "obfs-local" or plugin == "v2ray-plugin" then
|
||||||
|
local opts = {}
|
||||||
|
for k, v in pairs(node["plugin-opts"] or {}) do
|
||||||
|
if plugin == "obfs-local" then
|
||||||
|
if k == "mode" then k = "obfs" end
|
||||||
|
if k == "host" then k = "obfs-host" end
|
||||||
|
elseif plugin == "v2ray-plugin" then
|
||||||
|
if k == "mode" and v == "websocket" then
|
||||||
|
v = nil
|
||||||
|
elseif type(v) == "boolean" then
|
||||||
|
if v == true then
|
||||||
|
table.insert(opts, k)
|
||||||
|
end
|
||||||
|
v = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if v ~= nil then
|
||||||
|
if type(v) == "boolean" then
|
||||||
|
v = v and "1" or "0"
|
||||||
|
end
|
||||||
|
table.insert(opts, k .. "=" .. v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #opts > 0 then plugin = plugin .. ";" .. table.concat(opts, ";") end
|
||||||
|
add_param(p, "plugin", plugin)
|
||||||
|
else
|
||||||
|
return 1, i18n.translatef("Subscription conversion: discard SS node %s, unsupported plugin: %s", node.name or "", plugin)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, link .. "#" .. urlencode(node.name or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Hysteria
|
||||||
|
local function encode_hysteria(node)
|
||||||
|
if node.protocol and node.protocol ~= "udp" then
|
||||||
|
return 1, i18n.translatef("Subscription conversion: discard Hysteria node %s, unsupported transport: %s", node.name, node.protocol)
|
||||||
|
end
|
||||||
|
local link = "hysteria://" .. host_format(node.server) .. ":" .. (node.port or "")
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
add_param(p, "auth", node["auth-str"])
|
||||||
|
add_param(p, "mport", node.ports)
|
||||||
|
add_param(p, "obfsParam", node.obfs)
|
||||||
|
add_param(p, "sni", node.sni)
|
||||||
|
add_param(p, "upmbps", bandwidth_mbps(node.up))
|
||||||
|
add_param(p, "downmbps", bandwidth_mbps(node.down))
|
||||||
|
add_param(p, "insecure", node["skip-cert-verify"] == true)
|
||||||
|
add_param(p, "vcn", node["name-cert-verify"])
|
||||||
|
add_param(p, "pinSHA256", node.fingerprint)
|
||||||
|
|
||||||
|
add_param(p, "alpn", build_alpn(node.alpn))
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, link .. "#" .. urlencode(node.name or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Hysteria2
|
||||||
|
local function encode_hysteria2(node)
|
||||||
|
-- Hopping subscriptions may omit port; keep a valid fallback for all cores.
|
||||||
|
local port = node.port or tostring(node.ports or ""):match("^%s*(%d+)")
|
||||||
|
local link = "hysteria2://" .. urlencode(node.password or "") .. "@" .. host_format(node.server) .. ":" .. port
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
add_param(p, "mport", node.ports)
|
||||||
|
add_param(p, "hopInterval", node["hop-interval"])
|
||||||
|
add_param(p, "obfs", node.obfs)
|
||||||
|
add_param(p, "obfs-password", node["obfs-password"])
|
||||||
|
add_param(p, "minPacketSize", node["obfs-min-packet-size"])
|
||||||
|
add_param(p, "maxPacketSize", node["obfs-max-packet-size"])
|
||||||
|
add_param(p, "upmbps", bandwidth_mbps(node.up))
|
||||||
|
add_param(p, "downmbps", bandwidth_mbps(node.down))
|
||||||
|
add_param(p, "ech", build_ech(node))
|
||||||
|
add_param(p, "vcn", node["name-cert-verify"])
|
||||||
|
add_param(p, "sni", node.sni)
|
||||||
|
add_param(p, "insecure", node["skip-cert-verify"] == true)
|
||||||
|
add_param(p, "pinSHA256", node.fingerprint)
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, link .. "#" .. urlencode(node.name or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TUIC
|
||||||
|
local function encode_tuic(node)
|
||||||
|
if node.token then
|
||||||
|
return 1, i18n.translatef("Subscription conversion: discard TUIC node %s, TUIC v4 is not supported.", node.name)
|
||||||
|
end
|
||||||
|
if not node.uuid or node.password == nil then
|
||||||
|
return 1, i18n.translatef("Subscription conversion: discard TUIC node %s, authentication information is incomplete.", node.name)
|
||||||
|
end
|
||||||
|
local link = "tuic://" .. urlencode(node.uuid) .. ":" .. urlencode(node.password) .. "@" .. host_format(node.server) .. ":" .. node.port
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
add_param(p, "congestion_control", node["congestion-controller"])
|
||||||
|
add_param(p, "alpn", build_alpn(node.alpn))
|
||||||
|
add_param(p, "sni", node.sni)
|
||||||
|
if node["disable-sni"] then add_param(p, "disable_sni", true) end
|
||||||
|
add_param(p, "allowInsecure", node["skip-cert-verify"] == true)
|
||||||
|
add_param(p, "udp_relay_mode", node["udp-relay-mode"])
|
||||||
|
add_param(p, "pcs", node.fingerprint)
|
||||||
|
add_param(p, "vcn", node["name-cert-verify"])
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, link .. "#" .. urlencode(node.name or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- AnyTLS
|
||||||
|
local function encode_anytls(node)
|
||||||
|
local err_msg
|
||||||
|
if node["shadow-tls-opts"] then
|
||||||
|
err_msg = "ShadowTLS"
|
||||||
|
elseif node["restls-opts"] then
|
||||||
|
err_msg = "ResTLS"
|
||||||
|
elseif node["jls-opts"] then
|
||||||
|
err_msg = "JLS"
|
||||||
|
end
|
||||||
|
if err_msg then
|
||||||
|
err_msg = i18n.translatef("Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s", node.name or "", err_msg)
|
||||||
|
return 1, err_msg
|
||||||
|
end
|
||||||
|
|
||||||
|
local o = build_common(node)
|
||||||
|
|
||||||
|
local link = "anytls://" .. urlencode(node.password or "") .. "@" .. host_format(node.server) .. ":" .. node.port
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
add_param(p, "security", o.tls.security)
|
||||||
|
add_param(p, "pbk", o.tls.pbk)
|
||||||
|
add_param(p, "sid", o.tls.sid)
|
||||||
|
add_param(p, "sni", o.tls.sni)
|
||||||
|
add_param(p, "alpn", o.tls.alpn)
|
||||||
|
add_param(p, "fp", o.tls.fp)
|
||||||
|
add_param(p, "ech", o.tls.ech)
|
||||||
|
add_param(p, "pcs", o.tls.pcs)
|
||||||
|
add_param(p, "insecure", o.tls.insecure)
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, link .. "#" .. urlencode(node.name or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SSR
|
||||||
|
local function encode_ssr(node)
|
||||||
|
local link = host_format(node.server) .. ":" .. node.port .. ":" .. (node.protocol or "") .. ":" ..
|
||||||
|
(node.cipher or "") .. ":" .. (node.obfs or "") .. ":" .. base64(node.password)
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
if node["obfs-param"] then table.insert(p, "obfsparam=" .. base64(node["obfs-param"])) end
|
||||||
|
if node["protocol-param"] then table.insert(p, "protoparam=" .. base64(node["protocol-param"])) end
|
||||||
|
table.insert(p, "remarks=" .. base64(node.name))
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, "ssr://" .. base64(link)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- snell
|
||||||
|
local function encode_snell(node)
|
||||||
|
local err_msg
|
||||||
|
local obfs = node["obfs-opts"] or {}
|
||||||
|
if obfs.mode and obfs.mode ~= "http" and obfs.mode ~= "none" then
|
||||||
|
err_msg = obfs.mode
|
||||||
|
end
|
||||||
|
if err_msg then
|
||||||
|
err_msg = i18n.translatef("Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s", node.name or "", err_msg)
|
||||||
|
return 1, err_msg
|
||||||
|
end
|
||||||
|
local version = node.version and tonumber(node.version) or 4
|
||||||
|
version = (version == 5) and 4 or version
|
||||||
|
if version < 4 then
|
||||||
|
err_msg = i18n.translatef("Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4.", node.name or "")
|
||||||
|
return 1, err_msg
|
||||||
|
end
|
||||||
|
|
||||||
|
local link = "snell://" .. host_format(node.server) .. ":" .. node.port
|
||||||
|
local p = {}
|
||||||
|
|
||||||
|
add_param(p, "psk", node.psk)
|
||||||
|
add_param(p, "version", version)
|
||||||
|
if obfs.mode == "http" then
|
||||||
|
add_param(p, "obfs", "http")
|
||||||
|
add_param(p, "obfs-host", obfs.host)
|
||||||
|
end
|
||||||
|
add_param(p, "reuse", node.reuse and "1" or "0")
|
||||||
|
|
||||||
|
if #p > 0 then
|
||||||
|
link = link .. "?" .. table.concat(p, "&")
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0, link .. "#" .. urlencode(node.name or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function encode_node(node)
|
||||||
|
if type(node) ~= "table" or not node.type or not node.name then
|
||||||
|
return 1, i18n.translate("Subscription conversion: skip a malformed node.")
|
||||||
|
end
|
||||||
|
if not node.server or node.server == "" or not (node.port or (node.type == "hysteria2" and node.ports)) then
|
||||||
|
return 1, i18n.translatef("Subscription conversion: discard node %s, node information is incomplete.", tostring(node.name))
|
||||||
|
end
|
||||||
|
node = inherit(node)
|
||||||
|
|
||||||
|
local t = node.type
|
||||||
|
if t == "hysteria" or t == "hysteria2" then
|
||||||
|
local up, down = bandwidth_mbps(node.up), bandwidth_mbps(node.down)
|
||||||
|
if (node.up ~= nil and up == nil) or (node.down ~= nil and down == nil) then
|
||||||
|
return 1, i18n.translatef("Subscription conversion: discard node %s, invalid bandwidth format.", tostring(node.name))
|
||||||
|
end
|
||||||
|
node.up, node.down = up, down
|
||||||
|
end
|
||||||
|
|
||||||
|
node.server = domain_redir[node.server] or node.server
|
||||||
|
|
||||||
|
if t == "vless" then return encode_vless(node)
|
||||||
|
elseif t == "trojan" then return encode_trojan(node)
|
||||||
|
elseif t == "vmess" then return encode_vmess(node)
|
||||||
|
elseif t == "ss" then return encode_ss(node)
|
||||||
|
elseif t == "hysteria" then return encode_hysteria(node)
|
||||||
|
elseif t == "hysteria2" then return encode_hysteria2(node)
|
||||||
|
elseif t == "tuic" then return encode_tuic(node)
|
||||||
|
elseif t == "anytls" then return encode_anytls(node)
|
||||||
|
elseif t == "ssr" then return encode_ssr(node)
|
||||||
|
elseif t == "snell" then return encode_snell(node)
|
||||||
|
else api.log(2, i18n.translatef("Subscription conversion: discard unsupported node %s, node type: %s", node.name, t))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_dns_endpoint(value)
|
||||||
|
if type(value) ~= "string" then return nil end
|
||||||
|
value = api.trim(value)
|
||||||
|
-- Clash fragments and URL queries cannot be preserved by both node DNS backends.
|
||||||
|
if value == "" or value:find("[%s#?@,]") then return nil end
|
||||||
|
local url = value:find("://", 1, true) and value or "udp://" .. value
|
||||||
|
local ok, endpoint = pcall(api.parseURL, url)
|
||||||
|
if not ok or not endpoint then return nil end
|
||||||
|
local protocol = endpoint.protocol
|
||||||
|
if protocol ~= "udp" and protocol ~= "tcp" and protocol ~= "https" then return nil end
|
||||||
|
local host = endpoint.hostname
|
||||||
|
if not host or not (api.is_ip(host) or api.datatypes.hostname(host)) then return nil end
|
||||||
|
local bracketed = endpoint.host:sub(1, 1) == "["
|
||||||
|
local ipv6 = api.datatypes.ip6addr(host)
|
||||||
|
if bracketed and not ipv6 then return nil end
|
||||||
|
if protocol == "https" and ipv6 and not bracketed then return nil end
|
||||||
|
local port = endpoint.port or (protocol == "https" and 443 or 53)
|
||||||
|
if port < 1 or port > 65535 then return nil end
|
||||||
|
if protocol ~= "https" and endpoint.pathname then return nil end
|
||||||
|
return { protocol = protocol, host = host:lower():gsub("%.$", ""), port = port, url = url }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_loopback_dns(host)
|
||||||
|
return host == "localhost" or (api.datatypes.ip4addr(host) and host:match("^127%.")) or host:match("^[0:]+:0*1$")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_unspecified_dns(host)
|
||||||
|
return host == "0.0.0.0" or host:match("^[0:]+$")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extract_clash_dns(data)
|
||||||
|
local dns = data.dns
|
||||||
|
if type(dns) ~= "table" or not dns["proxy-server-nameserver"] then return nil end
|
||||||
|
-- A subscription-wide resolver cannot represent per-domain node DNS policies.
|
||||||
|
local node_policy = dns["proxy-server-nameserver-policy"]
|
||||||
|
if type(node_policy) == "table" and next(node_policy) then return nil end
|
||||||
|
local listener = parse_dns_endpoint(dns.listen)
|
||||||
|
local resolving = {}
|
||||||
|
local policy = dns["nameserver-policy"]
|
||||||
|
local has_policy = type(policy) == "table" and next(policy)
|
||||||
|
local function pick(candidates, depth)
|
||||||
|
if depth > 4 then return nil end
|
||||||
|
if type(candidates) == "string" then candidates = { candidates } end
|
||||||
|
if type(candidates) ~= "table" then return nil end
|
||||||
|
for _, value in ipairs(candidates) do
|
||||||
|
local endpoint = parse_dns_endpoint(value)
|
||||||
|
if endpoint and not is_unspecified_dns(endpoint.host) then
|
||||||
|
if is_loopback_dns(endpoint.host) then
|
||||||
|
local key = endpoint.protocol .. "://" .. endpoint.host .. ":" .. endpoint.port
|
||||||
|
local listen_matches = listener and listener.protocol ~= "https" and listener.port == endpoint.port and (
|
||||||
|
listener.host == endpoint.host or
|
||||||
|
(listener.host == "0.0.0.0" and not endpoint.host:find(":", 1, true)) or
|
||||||
|
(is_unspecified_dns(listener.host) and listener.host ~= "0.0.0.0" and endpoint.host:find(":", 1, true)) or
|
||||||
|
(endpoint.host == "localhost" and is_loopback_dns(listener.host)))
|
||||||
|
if endpoint.protocol ~= "https" and dns.enable == true and listen_matches and not resolving[key] and not has_policy then
|
||||||
|
resolving[key] = true
|
||||||
|
local resolver = pick(dns.nameserver, depth + 1)
|
||||||
|
resolving[key] = nil
|
||||||
|
if resolver then return resolver end
|
||||||
|
end
|
||||||
|
elseif endpoint.protocol == "https" then
|
||||||
|
return { domain_resolver = "https", domain_resolver_dns_https = endpoint.url }
|
||||||
|
elseif api.datatypes.ip4addr(endpoint.host) then
|
||||||
|
return { domain_resolver = endpoint.protocol, domain_resolver_dns = endpoint.host .. ":" .. endpoint.port }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local resolver = pick(dns["proxy-server-nameserver"], 0)
|
||||||
|
if resolver then
|
||||||
|
local address = resolver.domain_resolver_dns_https or resolver.domain_resolver .. "://" .. resolver.domain_resolver_dns
|
||||||
|
api.log(1, i18n.translatef("Subscription conversion: extracted node domain DNS: %s", address))
|
||||||
|
end
|
||||||
|
return resolver
|
||||||
|
end
|
||||||
|
|
||||||
|
function parseClashNode(raw, remark, auto_dns)
|
||||||
|
if not raw then return "" end
|
||||||
|
domain_redir = {}
|
||||||
|
local ok, lyaml = pcall(require, "lyaml")
|
||||||
|
if not ok then return raw end
|
||||||
|
|
||||||
|
local parsed, data = pcall(lyaml.load, raw)
|
||||||
|
if not parsed then
|
||||||
|
api.log(1, i18n.translatef("Subscription conversion: YAML parsing failed: %s", tostring(remark or "")))
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
if not data or type(data) ~= "table" then return raw end
|
||||||
|
if type(data.proxies) ~= "table" then return "" end
|
||||||
|
|
||||||
|
api.log(1, i18n.translate("Subscription conversion: Clash subscription detected, converting..."))
|
||||||
|
|
||||||
|
-- Some airports use hosts domain redirect to disguise the real node domain name.
|
||||||
|
for k, v in pairs(type(data.hosts) == "table" and data.hosts or {}) do
|
||||||
|
if type(k) == "string" and type(v) == "string" then
|
||||||
|
if api.datatypes.hostname(k) and api.datatypes.hostname(v) then
|
||||||
|
domain_redir[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local links = {}
|
||||||
|
for _, node in ipairs(data.proxies) do
|
||||||
|
local converted, err, link = pcall(encode_node, node)
|
||||||
|
if not converted then
|
||||||
|
local name = type(node) == "table" and node.name or ""
|
||||||
|
api.log(2, i18n.translatef("Subscription conversion: discard node %s, conversion failed.", tostring(name or "")))
|
||||||
|
elseif err == 1 and link then
|
||||||
|
api.log(2, link)
|
||||||
|
elseif link then
|
||||||
|
table.insert(links, link)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local resolver = #links > 0 and auto_dns and extract_clash_dns(data) or nil
|
||||||
|
return #links > 0 and table.concat(links, "\n") or "", resolver
|
||||||
|
end
|
||||||
|
|
||||||
|
function parse_clash_sub_info(headers)
|
||||||
|
local userinfo = headers:match("[Ss]ubscription%-userinfo:%s*([^\r\n]+)")
|
||||||
|
if not userinfo then return nil end
|
||||||
|
|
||||||
|
local upload = tonumber(userinfo:match("upload=(%d+)")) or 0
|
||||||
|
local download = tonumber(userinfo:match("download=(%d+)")) or 0
|
||||||
|
local total = tonumber(userinfo:match("total=(%d+)")) or 0
|
||||||
|
local expire = tonumber(userinfo:match("expire=(%d+)"))
|
||||||
|
local remain = total - (upload + download)
|
||||||
|
if remain < 0 then remain = 0 end
|
||||||
|
|
||||||
|
local function format_size(bytes)
|
||||||
|
local units = { "B", "KB", "MB", "GB", "TB", "PB" }
|
||||||
|
local i = 1
|
||||||
|
while bytes >= 1024 and i < #units do
|
||||||
|
bytes = bytes / 1024
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
if bytes >= 100 then
|
||||||
|
return string.format("%.0f%s", bytes, units[i])
|
||||||
|
elseif bytes >= 10 then
|
||||||
|
return string.format("%.1f%s", bytes, units[i])
|
||||||
|
else
|
||||||
|
return string.format("%.2f%s", bytes, units[i])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local rem_traffic = format_size(remain)
|
||||||
|
|
||||||
|
local expired_date
|
||||||
|
if expire and expire > 0 then
|
||||||
|
local t = os.date("*t", expire)
|
||||||
|
expired_date = string.format("%d-%d-%d", t.year, t.month, t.day)
|
||||||
|
else
|
||||||
|
expired_date = i18n.translate("Never expires")
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
rem_traffic = rem_traffic,
|
||||||
|
expired_date = expired_date
|
||||||
|
}
|
||||||
|
end
|
||||||
@@ -9,6 +9,8 @@ require 'luci.sys'
|
|||||||
local api = require "luci.passwall2.api"
|
local api = require "luci.passwall2.api"
|
||||||
local c_config = api.c_config
|
local c_config = api.c_config
|
||||||
|
|
||||||
|
loadfile("/usr/share/passwall2/clash_subconverter.lua")()
|
||||||
|
|
||||||
local datatypes = api.datatypes
|
local datatypes = api.datatypes
|
||||||
local split = api.split
|
local split = api.split
|
||||||
local base64Decode = api.base64Decode
|
local base64Decode = api.base64Decode
|
||||||
@@ -67,8 +69,6 @@ uci:revert(c_config)
|
|||||||
math.randomseed(os.time() + math.floor(os.clock() * 1000))
|
math.randomseed(os.time() + math.floor(os.clock() * 1000))
|
||||||
local restart_required = false
|
local restart_required = false
|
||||||
|
|
||||||
local lyaml = require "lyaml"
|
|
||||||
|
|
||||||
local has_ss_rust = api.is_finded("sslocal")
|
local has_ss_rust = api.is_finded("sslocal")
|
||||||
local has_ssr = api.is_finded("ssr-local") and api.is_finded("ssr-redir")
|
local has_ssr = api.is_finded("ssr-local") and api.is_finded("ssr-redir")
|
||||||
local has_singbox = api.finded_com("sing-box")
|
local has_singbox = api.finded_com("sing-box")
|
||||||
@@ -475,6 +475,7 @@ end
|
|||||||
|
|
||||||
-- Retrieve subscribe information (remaining data allowance, expiration time).
|
-- Retrieve subscribe information (remaining data allowance, expiration time).
|
||||||
local subscribe_info = {}
|
local subscribe_info = {}
|
||||||
|
local subscribe_info_from_headers = {}
|
||||||
local function get_subscribe_info(cfgid, value)
|
local function get_subscribe_info(cfgid, value)
|
||||||
if type(cfgid) ~= "string" or cfgid == "" or type(value) ~= "string" then
|
if type(cfgid) ~= "string" or cfgid == "" or type(value) ~= "string" then
|
||||||
return
|
return
|
||||||
@@ -513,346 +514,6 @@ local function set_ss_implementation(ss_type, result)
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
local function parseClashNode(node, add_mode, group, sub_cfg)
|
|
||||||
local sub_allowinsecure = DEFAULT_ALLOWINSECURE
|
|
||||||
local sub_ss_type = DEFAULT_SS_TYPE
|
|
||||||
local sub_trojan_type = DEFAULT_TROJAN_TYPE
|
|
||||||
local sub_vmess_type = DEFAULT_VMESS_TYPE
|
|
||||||
local sub_vless_type = DEFAULT_VLESS_TYPE
|
|
||||||
local sub_hysteria2_type = DEFAULT_HYSTERIA2_TYPE
|
|
||||||
local sub_hy_up_mbps, sub_hy_down_mbps
|
|
||||||
if sub_cfg then
|
|
||||||
if sub_cfg.allowInsecure and sub_cfg.allowInsecure ~= "1" then
|
|
||||||
sub_allowinsecure = nil
|
|
||||||
end
|
|
||||||
local ss_type = sub_cfg.ss_type or "global"
|
|
||||||
if ss_type ~= "global" and core_has[ss_type] then
|
|
||||||
sub_ss_type = ss_type
|
|
||||||
end
|
|
||||||
local trojan_type = sub_cfg.trojan_type or "global"
|
|
||||||
if trojan_type ~= "global" and core_has[trojan_type] then
|
|
||||||
sub_trojan_type = trojan_type
|
|
||||||
end
|
|
||||||
local vmess_type = sub_cfg.vmess_type or "global"
|
|
||||||
if vmess_type ~= "global" and core_has[vmess_type] then
|
|
||||||
sub_vmess_type = vmess_type
|
|
||||||
end
|
|
||||||
local vless_type = sub_cfg.vless_type or "global"
|
|
||||||
if vless_type ~= "global" and core_has[vless_type] then
|
|
||||||
sub_vless_type = vless_type
|
|
||||||
end
|
|
||||||
local hysteria2_type = sub_cfg.hysteria2_type or "global"
|
|
||||||
if hysteria2_type ~= "global" and core_has[hysteria2_type] then
|
|
||||||
sub_hysteria2_type = hysteria2_type
|
|
||||||
end
|
|
||||||
sub_hy_up_mbps = sub_cfg.hysteria_up_mbps
|
|
||||||
sub_hy_down_mbps = sub_cfg.hysteria_down_mbps
|
|
||||||
end
|
|
||||||
local result = {
|
|
||||||
timeout = 60,
|
|
||||||
add_mode = add_mode, -- `0` for manual configuration, `1` for import, `2` for subscription
|
|
||||||
group = group
|
|
||||||
}
|
|
||||||
result.remarks = node.name
|
|
||||||
result.address = node.server
|
|
||||||
result.port = node.port
|
|
||||||
|
|
||||||
if node.type == 'ss' then
|
|
||||||
result = set_ss_implementation(sub_ss_type, result)
|
|
||||||
if not result then return nil end
|
|
||||||
result.method = node.cipher
|
|
||||||
result.ss_method = node.cipher
|
|
||||||
result.password = node.password
|
|
||||||
if node.plugin == "obfs" then
|
|
||||||
result.plugin = "obfs-local"
|
|
||||||
elseif node.plugin == "v2ray-plugin" then
|
|
||||||
result.plugin = "v2ray-plugin"
|
|
||||||
end
|
|
||||||
if result.plugin then
|
|
||||||
result.plugin_enabled = "1"
|
|
||||||
if result.type == 'Xray' then
|
|
||||||
if result.plugin ~= "obfs-local" then
|
|
||||||
result.plugin_enabled = nil
|
|
||||||
result.error_msg = i18n.translatef("%s unsupport SS %s plugin.", "Xray", result.plugin)
|
|
||||||
end
|
|
||||||
elseif result.type == 'sing-box' then
|
|
||||||
if result.plugin ~= "obfs-local" and result.plugin ~= "v2ray-plugin" then
|
|
||||||
result.plugin_enabled = nil
|
|
||||||
result.error_msg = i18n.translatef("%s unsupport SS %s plugin.", "Sing-Box", result.plugin)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if node["plugin-opts"] and result.plugin_enabled == "1" then
|
|
||||||
if node.plugin == "obfs" then
|
|
||||||
local plugin_opts = ""
|
|
||||||
local opts_mode = node["plugin-opts"].mode
|
|
||||||
if opts_mode then
|
|
||||||
plugin_opts = plugin_opts .. "obfs=" .. opts_mode .. ";"
|
|
||||||
end
|
|
||||||
local opts_host = node["plugin-opts"].host
|
|
||||||
if opts_host then
|
|
||||||
plugin_opts = plugin_opts .. "obfs-host=" .. opts_host
|
|
||||||
end
|
|
||||||
result.plugin_opts = plugin_opts
|
|
||||||
elseif node.plugin == "v2ray-plugin" then
|
|
||||||
local plugin_opts = ""
|
|
||||||
local opts_mode = node["plugin-opts"].mode
|
|
||||||
local opts_tls = node["plugin-opts"].tls
|
|
||||||
if opts_tls then
|
|
||||||
plugin_opts = plugin_opts .. "tls;"
|
|
||||||
end
|
|
||||||
local opts_skip_cert_verify = node["plugin-opts"]["skip-cert-verify"]
|
|
||||||
local opts_host = node["plugin-opts"].host
|
|
||||||
if opts_host then
|
|
||||||
plugin_opts = plugin_opts .. "host=" .. opts_host .. ";"
|
|
||||||
end
|
|
||||||
local opts_path = node["plugin-opts"].path
|
|
||||||
local opts_mux = node["plugin-opts"].mux
|
|
||||||
if node["plugin-opts"].headers then
|
|
||||||
--todo
|
|
||||||
end
|
|
||||||
result.plugin_opts = plugin_opts
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif node.type == 'ssr' then
|
|
||||||
if not has_ssr then
|
|
||||||
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "SSR", "shadowsocksr-libev"))
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
result.type = "SSR"
|
|
||||||
result.method = node.cipher
|
|
||||||
result.password = node.password
|
|
||||||
result.obfs = node.obfs
|
|
||||||
result.protocol = node.protocol
|
|
||||||
result.obfs_param = node["obfs-param"]
|
|
||||||
result.protocol_param = node["protocol-param"]
|
|
||||||
elseif node.type == 'vmess' then
|
|
||||||
if sub_vmess_type == "sing-box" and has_singbox then
|
|
||||||
result.type = 'sing-box'
|
|
||||||
elseif sub_vmess_type == "xray" and has_xray then
|
|
||||||
result.type = "Xray"
|
|
||||||
else
|
|
||||||
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "VMess", "VMess"))
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
result.protocol = 'vmess'
|
|
||||||
result.uuid = node.uuid
|
|
||||||
result.alter_id = node.alterId
|
|
||||||
result.security = node.cipher or "auto"
|
|
||||||
result.tcp_fast_open = node.tfo
|
|
||||||
result.tls = "0"
|
|
||||||
if node.tls then
|
|
||||||
result.tls = "1"
|
|
||||||
result.tls_serverName = node.servername or ""
|
|
||||||
local insecure = node["skip-cert-verify"]
|
|
||||||
result.tls_allowInsecure = insecure and "1" or "0"
|
|
||||||
if sub_allowinsecure then
|
|
||||||
result.tls_allowInsecure = "1"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
result.transport = node.network and string.lower(node.network) or "tcp"
|
|
||||||
if result.type == "sing-box" and result.transport == "raw" then
|
|
||||||
result.transport = "tcp"
|
|
||||||
elseif result.type == "Xray" and result.transport == "tcp" then
|
|
||||||
result.transport = "raw"
|
|
||||||
end
|
|
||||||
if result.transport == 'ws' then
|
|
||||||
local ws_opts = node["ws-opts"]
|
|
||||||
if ws_opts then
|
|
||||||
if ws_opts.headers then
|
|
||||||
result.ws_host = ws_opts.headers.Host or ws_opts.headers.host
|
|
||||||
end
|
|
||||||
if ws_opts.path then
|
|
||||||
result.ws_path = ws_opts.path
|
|
||||||
if ws_opts["max-early-data"] then
|
|
||||||
if result.type == "sing-box" then
|
|
||||||
result.ws_enableEarlyData = "1"
|
|
||||||
result.ws_maxEarlyData = tonumber(ws_opts["max-early-data"])
|
|
||||||
result.ws_earlyDataHeaderName = "Sec-WebSocket-Protocol"
|
|
||||||
elseif result.type == "Xray" then
|
|
||||||
result.ws_path = result.ws_path .. "?ed=" .. ws_opts["max-early-data"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif result.transport == 'h2' then
|
|
||||||
local h2_opts = node["h2-opts"]
|
|
||||||
if h2_opts then
|
|
||||||
if result.type == "sing-box" then
|
|
||||||
result.http_path = h2_opts.path
|
|
||||||
result.http_host = h2_opts.host
|
|
||||||
elseif result.type == "Xray" then
|
|
||||||
result.xhttp_mode = "stream-one"
|
|
||||||
result.xhttp_path = h2_opts.path
|
|
||||||
result.xhttp_host = h2_opts.host
|
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif result.transport == 'grpc' then
|
|
||||||
local grpc_opts = node["grpc-opts"]
|
|
||||||
if grpc_opts then
|
|
||||||
result.grpc_serviceName = grpc_opts["grpc-service-name"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif node.type == 'vless' then
|
|
||||||
if sub_vless_type == "sing-box" and has_singbox then
|
|
||||||
result.type = 'sing-box'
|
|
||||||
elseif sub_vless_type == "xray" and has_xray then
|
|
||||||
result.type = "Xray"
|
|
||||||
else
|
|
||||||
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "VLESS", "VLESS"))
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
result.protocol = "vless"
|
|
||||||
result.uuid = node.uuid
|
|
||||||
result.tcp_fast_open = node.tfo
|
|
||||||
result.encryption = node.cipher or "none"
|
|
||||||
result.flow = node.flow
|
|
||||||
result.tls = "0"
|
|
||||||
if node.tls then
|
|
||||||
result.tls = "1"
|
|
||||||
result.tls_serverName = node.servername or ""
|
|
||||||
local insecure = node["skip-cert-verify"]
|
|
||||||
result.tls_allowInsecure = insecure and "1" or "0"
|
|
||||||
if sub_allowinsecure then
|
|
||||||
result.tls_allowInsecure = "1"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if node.tls and node["reality-opts"] and node["reality-opts"]["public-key"] then
|
|
||||||
result.reality = "1"
|
|
||||||
result.reality_publicKey = (node["reality-opts"] and node["reality-opts"]["public-key"]) or nil
|
|
||||||
result.reality_shortId = (node["reality-opts"] and node["reality-opts"]["short-id"]) or nil
|
|
||||||
end
|
|
||||||
result.transport = node.network and string.lower(node.network) or "tcp"
|
|
||||||
if result.type == "sing-box" and result.transport == "raw" then
|
|
||||||
result.transport = "tcp"
|
|
||||||
elseif result.type == "Xray" and result.transport == "tcp" then
|
|
||||||
result.transport = "raw"
|
|
||||||
end
|
|
||||||
if result.transport == 'ws' then
|
|
||||||
local ws_opts = node["ws-opts"]
|
|
||||||
if ws_opts then
|
|
||||||
if ws_opts.headers then
|
|
||||||
result.ws_host = ws_opts.headers.Host or ws_opts.headers.host
|
|
||||||
end
|
|
||||||
if ws_opts.path then
|
|
||||||
result.ws_path = ws_opts.path
|
|
||||||
if ws_opts["max-early-data"] then
|
|
||||||
if result.type == "sing-box" then
|
|
||||||
result.ws_enableEarlyData = "1"
|
|
||||||
result.ws_maxEarlyData = tonumber(ws_opts["max-early-data"])
|
|
||||||
result.ws_earlyDataHeaderName = "Sec-WebSocket-Protocol"
|
|
||||||
elseif result.type == "Xray" then
|
|
||||||
result.ws_path = result.ws_path .. "?ed=" .. ws_opts["max-early-data"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif node.type == 'trojan' then
|
|
||||||
if sub_trojan_type == "sing-box" and has_singbox then
|
|
||||||
result.type = 'sing-box'
|
|
||||||
result.protocol = 'trojan'
|
|
||||||
elseif sub_trojan_type == "xray" and has_xray then
|
|
||||||
result.type = 'Xray'
|
|
||||||
result.protocol = 'trojan'
|
|
||||||
else
|
|
||||||
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "Trojan", "Trojan"))
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
result.password = node.password
|
|
||||||
result.tls = '1'
|
|
||||||
result.tls_serverName = node.sni or ""
|
|
||||||
local insecure = node["skip-cert-verify"]
|
|
||||||
result.tls_allowInsecure = insecure and "1" or "0"
|
|
||||||
if sub_allowinsecure then
|
|
||||||
result.tls_allowInsecure = "1"
|
|
||||||
end
|
|
||||||
if node.alpn then
|
|
||||||
--todo
|
|
||||||
end
|
|
||||||
if node.network == "grpc" then
|
|
||||||
if node["grpc-opts"] then
|
|
||||||
result.grpc_serviceName = node["grpc-opts"]["grpc-service-name"]
|
|
||||||
end
|
|
||||||
elseif node.network == "ws" then
|
|
||||||
local ws_opts = node["ws-opts"]
|
|
||||||
if ws_opts then
|
|
||||||
result.ws_path = ws_opts.path
|
|
||||||
if ws_opts.headers then
|
|
||||||
result.ws_host = ws_opts.headers.Host or ws_opts.headers.host
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif node.type == 'anytls' then
|
|
||||||
if has_singbox then
|
|
||||||
result.type = 'sing-box'
|
|
||||||
result.protocol = "anytls"
|
|
||||||
else
|
|
||||||
log(2, i18n.translatef("Skip the %s node because the %s core program is not installed.", "AnyTLS", "Sing-Box 1.12"))
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
result.password = node.password
|
|
||||||
result.tls = '1'
|
|
||||||
result.tls_serverName = node.sni or ""
|
|
||||||
local insecure = node["skip-cert-verify"]
|
|
||||||
result.tls_allowInsecure = insecure and "1" or "0"
|
|
||||||
if sub_allowinsecure then
|
|
||||||
result.tls_allowInsecure = "1"
|
|
||||||
end
|
|
||||||
if node["client-fingerprint"] then
|
|
||||||
result.utls = "1"
|
|
||||||
result.fingerprint = node["client-fingerprint"]
|
|
||||||
end
|
|
||||||
if node.tls and node["reality-opts"] and node["reality-opts"]["public-key"] then
|
|
||||||
result.reality = "1"
|
|
||||||
result.reality_publicKey = node["reality-opts"]["public-key"]
|
|
||||||
result.reality_shortId = node["reality-opts"]["short-id"]
|
|
||||||
end
|
|
||||||
if node["disable-reuse"] then
|
|
||||||
result.anytls_disable_reuse = "1"
|
|
||||||
end
|
|
||||||
elseif node.type == 'snell' then
|
|
||||||
if has_singbox then
|
|
||||||
result.type = 'sing-box'
|
|
||||||
result.protocol = "snell"
|
|
||||||
else
|
|
||||||
log(2, i18n.translatef("Skip the %s node because the %s core program is not installed.", "Snell", "Sing-Box 1.14"))
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
result.password = node.password
|
|
||||||
result.snell_psk = node.psk
|
|
||||||
result.snell_version = node.version or "4"
|
|
||||||
if result.snell_version == "4" then
|
|
||||||
local obfs_opts = node["obfs-opts"] or {}
|
|
||||||
result.snell_obfs_mode = obfs_opts.mode or "none"
|
|
||||||
result.snell_obfs_host = obfs_opts.host
|
|
||||||
else
|
|
||||||
result.snell_mode = "default"
|
|
||||||
end
|
|
||||||
result.snell_reuse = node.reuse and "1" or "0"
|
|
||||||
end
|
|
||||||
if not result.remarks or result.remarks == "" then
|
|
||||||
if result.address and result.port then
|
|
||||||
result.remarks = result.address .. ':' .. result.port
|
|
||||||
else
|
|
||||||
result.remarks = "NULL"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Processing Clash data
|
|
||||||
local function processClashData(content, add_mode, group, sub_cfg)
|
|
||||||
local results = {}
|
|
||||||
for i, node in ipairs(content.proxies or {}) do
|
|
||||||
local result = parseClashNode(node, add_mode, group, sub_cfg)
|
|
||||||
if result then
|
|
||||||
table.insert(results, result)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return results
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Split URI hosts and ports, support IPv6 without a port, and strip paths.
|
-- Split URI hosts and ports, support IPv6 without a port, and strip paths.
|
||||||
local function parse_host_port(host_port, default_port)
|
local function parse_host_port(host_port, default_port)
|
||||||
local authority = (host_port or ""):gsub("/.*$", "")
|
local authority = (host_port or ""):gsub("/.*$", "")
|
||||||
@@ -2038,7 +1699,8 @@ end
|
|||||||
local function curl(url, file, ua, mode, hwid)
|
local function curl(url, file, ua, mode, hwid)
|
||||||
if not url or url == "" then return 22, 404 end
|
if not url or url == "" then return 22, 404 end
|
||||||
local curl_args = {
|
local curl_args = {
|
||||||
"-fskL", "-w %{http_code}", "--retry 3", "--connect-timeout 3", "-H 'Accept: */*'", "-H 'Accept-Encoding: identity'"
|
"-fskL", "--retry 3", "--connect-timeout 3", "-H 'Accept: */*'", "-H 'Accept-Encoding: identity'",
|
||||||
|
"--dump-header -", "-w '\\n%{http_code}'"
|
||||||
}
|
}
|
||||||
if ua and ua ~= "" and ua ~= "curl" then
|
if ua and ua ~= "" and ua ~= "curl" then
|
||||||
ua = (ua == "passwall2") and ("passwall2/" .. (api.get_version():match("^([^-]+)") or "")) or ua
|
ua = (ua == "passwall2") and ("passwall2/" .. (api.get_version():match("^([^-]+)") or "")) or ua
|
||||||
@@ -2060,7 +1722,15 @@ local function curl(url, file, ua, mode, hwid)
|
|||||||
end
|
end
|
||||||
luci.sys.call('rm -f "%s"' % cookie_file)
|
luci.sys.call('rm -f "%s"' % cookie_file)
|
||||||
|
|
||||||
return return_code, tonumber(result)
|
local http_code, headers = 0, ""
|
||||||
|
if result and result ~= "" then
|
||||||
|
local body, code = result:match("^(.-)%s*([0-9]+)$")
|
||||||
|
if code then
|
||||||
|
http_code = tonumber(code) or 0
|
||||||
|
headers = body:gsub("\r", "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return return_code, http_code, headers
|
||||||
end
|
end
|
||||||
|
|
||||||
function get_headers()
|
function get_headers()
|
||||||
@@ -2366,12 +2036,12 @@ local function update_node(manual)
|
|||||||
end
|
end
|
||||||
-- Sing-Box Node Domain resolver
|
-- Sing-Box Node Domain resolver
|
||||||
if kkk == "type" and (vvv == "Xray" or vvv == "sing-box") then
|
if kkk == "type" and (vvv == "Xray" or vvv == "sing-box") then
|
||||||
if domain_resolver then
|
if domain_resolver and domain_resolver ~= "" and domain_resolver ~= "disable" then
|
||||||
uci_set(cfgid, "domain_resolver", domain_resolver)
|
uci_set(cfgid, "domain_resolver", domain_resolver)
|
||||||
if domain_resolver_dns then
|
if domain_resolver == "https" and domain_resolver_dns_https then
|
||||||
uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns)
|
|
||||||
elseif domain_resolver_dns_https then
|
|
||||||
uci_set(cfgid, "domain_resolver_dns_https", domain_resolver_dns_https)
|
uci_set(cfgid, "domain_resolver_dns_https", domain_resolver_dns_https)
|
||||||
|
elseif (domain_resolver == "udp" or domain_resolver == "tcp") and domain_resolver_dns then
|
||||||
|
uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if domain_strategy then
|
if domain_strategy then
|
||||||
@@ -2432,7 +2102,7 @@ local function parse_link(raw, add_mode, group, sub_cfg)
|
|||||||
cfgid = sub_cfg[".name"]
|
cfgid = sub_cfg[".name"]
|
||||||
end
|
end
|
||||||
local keyword_filter = add_mode == "2" and prepare_keyword_filter(sub_cfg)
|
local keyword_filter = add_mode == "2" and prepare_keyword_filter(sub_cfg)
|
||||||
local nodes, szType, clashTable
|
local nodes, szType
|
||||||
local node_list = {}
|
local node_list = {}
|
||||||
-- ssd appear to be in this format, starting with ssd://.
|
-- ssd appear to be in this format, starting with ssd://.
|
||||||
if raw:find('ssd://') then
|
if raw:find('ssd://') then
|
||||||
@@ -2453,19 +2123,11 @@ local function parse_link(raw, add_mode, group, sub_cfg)
|
|||||||
end
|
end
|
||||||
nodes = servers
|
nodes = servers
|
||||||
else
|
else
|
||||||
-- Try parseYAML, if success, is clash.
|
-- Formats other than SSD are handled by the existing URI parser.
|
||||||
local yamlTable = lyaml.load(raw)
|
if add_mode == "1" then
|
||||||
if yamlTable and type(yamlTable) == "table" then
|
nodes = split(raw, "\n")
|
||||||
-- clash
|
|
||||||
szType = "clash"
|
|
||||||
clashTable = yamlTable
|
|
||||||
else
|
else
|
||||||
-- Formats other than ssd
|
nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n")
|
||||||
if add_mode == "1" then
|
|
||||||
nodes = split(raw, "\n")
|
|
||||||
else
|
|
||||||
nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2481,20 +2143,12 @@ local function parse_link(raw, add_mode, group, sub_cfg)
|
|||||||
else
|
else
|
||||||
tinsert(node_list, node)
|
tinsert(node_list, node)
|
||||||
end
|
end
|
||||||
if add_mode == "2" then
|
if add_mode == "2" and not subscribe_info_from_headers[cfgid] then
|
||||||
get_subscribe_info(cfgid, node.remarks)
|
get_subscribe_info(cfgid, node.remarks)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if szType == "clash" and clashTable then
|
|
||||||
nodes = {}
|
|
||||||
local clashNodes = processClashData(clashTable, add_mode, group, sub_cfg)
|
|
||||||
for _, v in ipairs(clashNodes) do
|
|
||||||
nodeFilter(v)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
for _, v in ipairs(nodes) do
|
for _, v in ipairs(nodes) do
|
||||||
if v and (szType == 'ssd' or not string.match(v, "^%s*$")) then
|
if v and (szType == 'ssd' or not string.match(v, "^%s*$")) then
|
||||||
xpcall(function ()
|
xpcall(function ()
|
||||||
@@ -2562,6 +2216,7 @@ local execute = function()
|
|||||||
local cfgid = value[".name"]
|
local cfgid = value[".name"]
|
||||||
local remark = value.remark or ""
|
local remark = value.remark or ""
|
||||||
local url = value.url or ""
|
local url = value.url or ""
|
||||||
|
local headers
|
||||||
|
|
||||||
local url_is_local
|
local url_is_local
|
||||||
if fs.access(url) then
|
if fs.access(url) then
|
||||||
@@ -2576,7 +2231,7 @@ local execute = function()
|
|||||||
log(1, i18n.translatef("Start subscribing: %s", '【' .. remark .. '】' .. url .. ' [' .. result .. ']'))
|
log(1, i18n.translatef("Start subscribing: %s", '【' .. remark .. '】' .. url .. ' [' .. result .. ']'))
|
||||||
tmp_file = "/tmp/" .. cfgid
|
tmp_file = "/tmp/" .. cfgid
|
||||||
local return_code
|
local return_code
|
||||||
return_code, value.http_code = curl(url, tmp_file, ua, access_mode, value.hwid)
|
return_code, value.http_code, headers = curl(url, tmp_file, ua, access_mode, value.hwid)
|
||||||
if return_code ~= 0 then
|
if return_code ~= 0 then
|
||||||
fail_list[#fail_list + 1] = value
|
fail_list[#fail_list + 1] = value
|
||||||
luci.sys.call("rm -f " .. tmp_file)
|
luci.sys.call("rm -f " .. tmp_file)
|
||||||
@@ -2588,11 +2243,36 @@ local execute = function()
|
|||||||
local stdout = f:read("*all")
|
local stdout = f:read("*all")
|
||||||
f:close()
|
f:close()
|
||||||
local raw_data = api.trim(stdout)
|
local raw_data = api.trim(stdout)
|
||||||
|
local auto_dns = value.domain_resolver == nil or value.domain_resolver == ""
|
||||||
|
local resolver
|
||||||
|
raw_data, resolver = parseClashNode(raw_data, remark, auto_dns)
|
||||||
|
if auto_dns and resolver then
|
||||||
|
-- Only enrich this run's sub_cfg; keep the subscription's Auto setting.
|
||||||
|
value.domain_resolver = resolver.domain_resolver
|
||||||
|
value.domain_resolver_dns = resolver.domain_resolver_dns
|
||||||
|
value.domain_resolver_dns_https = resolver.domain_resolver_dns_https
|
||||||
|
end
|
||||||
|
if headers and headers ~= "" then
|
||||||
|
local info = parse_clash_sub_info(headers)
|
||||||
|
if info then
|
||||||
|
-- Response-header account data takes precedence over node remarks.
|
||||||
|
subscribe_info[cfgid] = info
|
||||||
|
subscribe_info_from_headers[cfgid] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
-- Buffer parsing details until the parsed nodes are known to have changed.
|
-- Buffer parsing details until the parsed nodes are known to have changed.
|
||||||
log_buffer = {}
|
log_buffer = {}
|
||||||
local parsed_nodes = parse_link(raw_data, "2", remark, value)
|
local parsed_nodes = parse_link(raw_data, "2", remark, value)
|
||||||
local old_md5 = value.md5 or ""
|
local old_md5 = value.md5 or ""
|
||||||
local new_md5 = #parsed_nodes > 0 and api.md5_string(jsonStringify(parsed_nodes)) or ""
|
local md5_content = jsonStringify(parsed_nodes)
|
||||||
|
if value.domain_resolver and value.domain_resolver ~= "" and value.domain_resolver ~= "disable" then
|
||||||
|
local protocol = value.domain_resolver
|
||||||
|
local dns = (protocol == "udp" or protocol == "tcp") and value.domain_resolver_dns or ""
|
||||||
|
local doh = protocol == "https" and value.domain_resolver_dns_https or ""
|
||||||
|
md5_content = md5_content .. jsonStringify({protocol, dns or "", doh or ""})
|
||||||
|
end
|
||||||
|
local new_md5 = #parsed_nodes > 0 and api.md5_string(md5_content) or ""
|
||||||
|
-- Skip rewriting nodes only when both nodes and effective DNS are unchanged.
|
||||||
if not manual_sub and new_md5 ~= "" and old_md5 == new_md5 then
|
if not manual_sub and new_md5 ~= "" and old_md5 == new_md5 then
|
||||||
log_buffer = nil
|
log_buffer = nil
|
||||||
log(1, i18n.translatef("Subscription: [%s] No changes, no update required.", remark))
|
log(1, i18n.translatef("Subscription: [%s] No changes, no update required.", remark))
|
||||||
@@ -2656,6 +2336,7 @@ elseif arg[1] == "add" then
|
|||||||
local f = assert(io.open("/tmp/links.conf", 'r'))
|
local f = assert(io.open("/tmp/links.conf", 'r'))
|
||||||
local raw = f:read('*all')
|
local raw = f:read('*all')
|
||||||
f:close()
|
f:close()
|
||||||
|
raw = parseClashNode(raw, arg[2])
|
||||||
parse_link(raw, "1", arg[2])
|
parse_link(raw, "1", arg[2])
|
||||||
if update_node(1) then uci_save(true) end
|
if update_node(1) then uci_save(true) end
|
||||||
luci.sys.call("rm -f /tmp/links.conf")
|
luci.sys.call("rm -f /tmp/links.conf")
|
||||||
|
|||||||
Reference in new issue
Block a user