diff --git a/honk/Makefile b/honk/Makefile index 3176c308..3a9eae84 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=honk -PKG_VERSION:=2026.11.06 -PKG_RELEASE:=2 +PKG_VERSION:=2026.11.08 +PKG_RELEASE:=1 # Pinned honk-core revision (upstream daeuniverse/honk main), cross-compiled by # ci/build-core.sh. Every release run publishes its core archives under an diff --git a/honk/files/job.sh b/honk/files/job.sh index 96e0d084..3e2454c2 100644 --- a/honk/files/job.sh +++ b/honk/files/job.sh @@ -92,10 +92,18 @@ done wait "$maint_pid" operation_rc=$? 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 write_status rollback_required recovery false maintenance_recovery_required else - write_status failed complete false operation_failed + write_status failed complete false maintenance_no_result fi logger -t honk-maintenance "$operation failed (invalid result)" 2>/dev/null || : exit 1 diff --git a/honk/files/maintenance.sh b/honk/files/maintenance.sh index 35c1950d..1c8967c4 100755 --- a/honk/files/maintenance.sh +++ b/honk/files/maintenance.sh @@ -396,8 +396,14 @@ check_root_file() { } safe_relpath() { - case "$1" in ''|/*|*..*|*[^A-Za-z0-9._/-]*|*//*|*/./*|./*|*/.) return 1 ;; esac - return 0 + case "$1" in ''|/*|*..*|*//*|*/./*|./*|*/.) return 1 ;; esac + # 空白、控制字符、通配符、反斜杠会打乱 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() { @@ -504,30 +510,47 @@ is_excluded() { collect_tree() { collect_root=$1 collect_out=$2 - [ -d "$collect_root" ] && [ ! -L "$collect_root" ] || return 1 - if find "$collect_root" -type l -print 2>/dev/null | grep -q .; then return 1; fi - if find "$collect_root" ! -type f ! -type d -print 2>/dev/null | grep -q .; then return 1; fi - : > "$collect_out" || return 1 + collect_error= + [ -d "$collect_root" ] && [ ! -L "$collect_root" ] || { collect_error=honk_tree_missing; return 1; } + if find "$collect_root" -type l -print 2>/dev/null | grep -q .; then collect_error=honk_tree_has_symlink; return 1; fi + 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 find . -type f -print - ) > "$collect_out.raw" || return 1 + ) > "$collect_out.raw" || { collect_error=honk_tree_unreadable; return 1; } : > "$collect_out" collect_count=0 collect_total=0 while IFS= read -r collect_path; do collect_path=${collect_path#./} is_excluded "$collect_path" && continue - safe_relpath "$collect_path" || exit 1 - [ -f "$collect_root/$collect_path" ] && [ ! -L "$collect_root/$collect_path" ] || return 1 - collect_size=$(size_of "$collect_root/$collect_path") || return 1 - [ "$collect_size" -le "$MAX_FILE" ] || return 1 + safe_relpath "$collect_path" || { + collect_error=unsafe_honk_file_name + # 文件名进面板日志(维护→日志→维护),否则用户不知道是哪个文件。 + 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_total=$((collect_total + collect_size)) - [ "$collect_count" -lt "$MAX_FILES" ] && [ "$collect_total" -le $((MAX_EXPANDED - 1048576)) ] || return 1 - printf '%s\n' "$collect_path" >> "$collect_out" || exit 1 - done < "$collect_out.raw" || return 1 - [ -f "$collect_root/config.dae" ] && [ -f "$collect_root/system.dae" ] || return 1 + [ "$collect_count" -lt "$MAX_FILES" ] && [ "$collect_total" -le $((MAX_EXPANDED - 1048576)) ] || { + collect_error=honk_backup_tree_too_large + logger -t honk-maintenance "tree exceeds limits after $collect_count files, $collect_total bytes: $collect_path" 2>/dev/null || : + 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 } @@ -624,7 +647,10 @@ check_tar_members() { { if (NF < 2 || substr($1,1,1) != "-") exit 1 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]++ if (seen[name] > 1) exit 1 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==3 { if (NF!=2 || $1!="state" || ($2!="present" && $2!="absent")) exit 1; next } $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 (seen[$2]++) exit 1 total += $3; count++; next @@ -818,6 +847,8 @@ validate_candidate() { candidate=$1 uci_dir=$2 state=$3 + # local = 本机自己拍的快照(备份/重置),archive = 从别的设备导入的包。 + identity=${4:-local} config_tree=$candidate/etc/honk mkdir -p "$temp_dir/uci-tmp" || return 1 load_uci_values uci "$uci_dir" || return 1 @@ -838,7 +869,14 @@ validate_candidate() { candidate_ip= network_get_device candidate_device "$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 archive_sqlite_check "$config_tree/state/honk.db" || return 1 fi @@ -935,7 +973,7 @@ backup() { [ "$u_initialized" = 0 ] || { backup_abort initialized_state_missing; return 1; } fi 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 [ -n "$relpath" ] || continue dest=$temp_dir/archive/etc/honk/$relpath @@ -1016,7 +1054,7 @@ restore() { error empty_archive_would_clear_active_state return 1 fi - validate_candidate "$restore_stage" "$uci_dir" "$archive_state" + validate_candidate "$restore_stage" "$uci_dir" "$archive_state" archive validate_rc=$? if [ "$validate_rc" -ne 0 ]; then [ "$validate_rc" = 2 ] && error unsupported_candidate_config || error candidate_validation_failed diff --git a/luci-app-amlogic/Makefile b/luci-app-amlogic/Makefile index 36717e8e..cfc36ba5 100644 --- a/luci-app-amlogic/Makefile +++ b/luci-app-amlogic/Makefile @@ -22,7 +22,7 @@ PKG_NAME:=luci-app-amlogic # DD = day (01 = 1st) # 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 -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) # 1 = Lua version (lua branch) → tag: 26.10.0101 # 2 = JS version (main branch) → tag: 26.10.0101-js diff --git a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh index e730da90..cde405ef 100755 --- a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh +++ b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh @@ -146,7 +146,7 @@ check_updated() { firmware_tags_array+=("${firmware_tags_name}") fi 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} | grep -oE 'releases/tag/([^" ]+)' ) @@ -177,7 +177,7 @@ check_updated() { # Retrieve the HTML code of the tags list page 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} )" @@ -268,7 +268,7 @@ download_firmware() { # Download to OpenWrt file 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 tolog "03.02 OpenWrt downloaded successfully." else diff --git a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_kernel.sh b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_kernel.sh index 79e44dce..b6d9425f 100755 --- a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_kernel.sh +++ b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_kernel.sh @@ -173,7 +173,7 @@ check_kernel() { # Check the version on the server 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} | grep -oE "${main_line_version}\.[0-9]+[^\"]*\.tar\.gz" | sed 's/.tar.gz//' | sort -urV | head -n 1 @@ -185,7 +185,7 @@ check_kernel() { # Get the sha256 value of the latest version 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} | awk -v pattern="${latest_version}\.tar\.gz" -v RS='' '$0 ~ pattern { print $0 ""; exit }' | 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" - 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" # Verify sha256 diff --git a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_plugin.sh b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_plugin.sh index 6ed52bf3..5606f7a0 100755 --- a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_plugin.sh +++ b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_plugin.sh @@ -132,7 +132,7 @@ check_plugin() { if [[ "${plugin_branch}" == "main" ]]; then # JS branch: match tags ending with -js (e.g. 3.1.305-js) 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 | grep -oE 'expanded_assets/[0-9]+\.[0-9]+\.[0-9]+-js' | sed 's|expanded_assets/||g' | sort -urV | head -n 1 @@ -140,7 +140,7 @@ check_plugin() { else # Lua branch: match tags with digits only, no suffix (e.g. 3.1.305) 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 | grep -oE 'expanded_assets/[0-9]+\.[0-9]+\.[0-9]+' | sed 's|expanded_assets/||g' | grep -v -- '-' | @@ -212,7 +212,7 @@ download_plugin() { tolog "03.03 Querying GitHub API for release assets..." 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 tolog "03.03 Failed to fetch release assets from GitHub API." "1" fi @@ -229,14 +229,14 @@ download_plugin() { # Download the main plugin file plugin_full_url="${download_repo}/${latest_version}/${plugin_file_name}" 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" # Download language packs for langfile in "${lang_file_list[@]}"; do lang_full_url="${download_repo}/${latest_version}/${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" done diff --git a/luci-app-fastnet/Makefile b/luci-app-fastnet/Makefile index 14a15f61..58217bd5 100644 --- a/luci-app-fastnet/Makefile +++ b/luci-app-fastnet/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for fastnet LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry LUCI_PKGARCH:=all -PKG_VERSION:=0.7.8-r5 +PKG_VERSION:=0.8.0-r5 PKG_RELEASE:= LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 diff --git a/luci-app-honk/Makefile b/luci-app-honk/Makefile index a9f9ac43..a5260bcb 100644 --- a/luci-app-honk/Makefile +++ b/luci-app-honk/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-honk -PKG_VERSION:=2026.11.06 -PKG_RELEASE:=4 +PKG_VERSION:=2026.11.08 +PKG_RELEASE:=1 PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) PKG_SOURCE:=doona-46e153cf-c6229ba102e8.tar.gz diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/maintenance.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/maintenance.js index 56af883c..9756d4ec 100644 --- a/luci-app-honk/htdocs/luci-static/resources/view/honk/maintenance.js +++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/maintenance.js @@ -382,7 +382,6 @@ return view.extend({ 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 fileInput = E('input', { 'type': 'file', 'accept': '.tar.gz,.gz,application/gzip', 'style': 'display:none' }); let backupBusy = false; writeControls.push(exportBtn, importBtn, restoreCfgBtn, resetAdminBtn); @@ -419,18 +418,12 @@ return view.extend({ }).finally(function() { setBackupBusy(false); }); }); + /* 只让用户选一次文件:上传对话框自己会弹选择框。 */ importBtn.addEventListener('click', function(ev) { ev.preventDefault(); if (backupBusy) return; - fileInput.click(); - }); - 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 = ''; + if (!confirm(_('Stop Honk before restoring. Restoring replaces Honk user data. Continue?'))) return; - } setBackupBusy(true); backupMessage.textContent = _('Restoring…'); honk.restorePrepare().then(honk.ensureOk).then(function() { @@ -449,7 +442,6 @@ return view.extend({ backupMessage.textContent = honk.errorMessage(error, _('Restore failed.')); }).finally(function() { setBackupBusy(false); - fileInput.value = ''; }); }); @@ -493,7 +485,6 @@ return view.extend({ 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('div', { 'class': 'honk-actions' }, [ exportBtn, importBtn, restoreCfgBtn, resetAdminBtn ])); - backupSection.appendChild(fileInput); backupSection.appendChild(backupMessage); /* --- System check and repair card --- */ diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js index ce725165..26e977a5 100644 --- a/luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js +++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js @@ -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) cell.appendChild(E('div', { 'class': 'cbi-value-description honk-note' }, help)); 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 ]); } @@ -68,6 +69,9 @@ function restoreFields(root, values) { input.value = values[key].value; input.checked = values[key].checked; + /* 自绘开关听的是 change,回填不会自己触发。 */ + if (input.classList.contains('honk-switch-input')) + input.dispatchEvent(new Event('change')); if (values[key].focused) { input.focus(); if (values[key].selectionStart !== null && typeof input.setSelectionRange === 'function') @@ -212,7 +216,8 @@ return view.extend({ const form = E('form'); 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 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 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'); }); @@ -227,7 +232,7 @@ return view.extend({ if (status.lan_network) networkSelect.value = status.lan_network; 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'; card.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Service settings'))); @@ -238,12 +243,13 @@ return view.extend({ input.disabled = true; }); save.disabled = true; + clashSwitch.syncSwitch(); } if (!(data.networks || []).length) networkSelect.appendChild(E('option', { 'value': '' }, _('No network interfaces found'))); form.appendChild(field(_('LAN network'), networkSelect)); 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 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.'))); diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js index f783b075..8984aa3f 100644 --- a/luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js +++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js @@ -92,6 +92,73 @@ const callUpdateCheck = rpc.declare({ object: 'honk', method: 'update_check', ex const callUpdateApply = rpc.declare({ object: 'honk', method: 'update_apply', expect: { '': {} } }); 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() { if (pageStylesInstalled || document.getElementById('honk-page-styles')) @@ -100,7 +167,7 @@ function installStyles() { const stylesheet = document.createElement('link'); stylesheet.id = 'honk-page-styles'; 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); pageStylesInstalled = true; } @@ -205,6 +272,16 @@ function tokenMessage(token) { data_reset: _('Honk data reset. The service is stopped.'), worker_lost: _('The maintenance worker stopped unexpectedly; check system recovery status before retrying.'), 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.'), 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.'), @@ -502,5 +579,6 @@ return baseclass.extend({ resultMessage: resultMessage, errorMessage: errorMessage, jobPhaseMessage: jobPhaseMessage, + switchControl: switchControl, installStyles: installStyles }); diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js index f259c43e..29a77c8c 100644 --- a/luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js +++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js @@ -21,10 +21,14 @@ function getNetworkNames(networks) { }).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' }, [ - E('label', { 'class': 'cbi-value-title', 'for': input.id }, label), - E('div', { 'class': 'cbi-value-field' }, input) + E('label', { 'class': 'cbi-value-title', 'for': controlId || null }, label), + cell ]); } @@ -48,7 +52,8 @@ return view.extend({ const section = E('form', { 'class': 'honk-card' }); 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 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 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'); }); @@ -64,7 +69,8 @@ return view.extend({ if (data.status && data.status.lan_network) networkSelect.value = data.status.lan_network; 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'; section.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Honk settings'))); @@ -76,6 +82,7 @@ return view.extend({ input.disabled = true; }); save.disabled = true; + clashSwitch.syncSwitch(); } if (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'))); section.appendChild(field(_('LAN network'), networkSelect)); 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 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.'))); diff --git a/luci-app-honk/po/zh-cn/honk.po b/luci-app-honk/po/zh-cn/honk.po index bd1810c5..0cb71bb9 100644 --- a/luci-app-honk/po/zh-cn/honk.po +++ b/luci-app-honk/po/zh-cn/honk.po @@ -30,6 +30,9 @@ msgstr "发现兼容更新,但尚无完整且已验证的回滚软件包组。 msgid "A compatible update is available." 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." msgstr "上一次操作未能正常结束。" @@ -908,6 +911,9 @@ msgstr "系统检查结果:%s" msgid "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." msgstr "Clash API 监听不可用,请检查服务日志。" @@ -1483,3 +1489,27 @@ msgstr "预检失败,请稍后重试。" msgid "Switch to panel management" 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 再备份。" diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua index 8d85cf49..f504e826 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua @@ -124,7 +124,9 @@ end 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.") .. "
" .. translate("Supports only Xray or Sing-box node types.") .. "
" .. string.format('%s', - 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("tcp", "TCP") o:value("udp", "UDP") diff --git a/luci-app-passwall/po/zh-cn/passwall.po b/luci-app-passwall/po/zh-cn/passwall.po index 567480a5..1cf806af 100644 --- a/luci-app-passwall/po/zh-cn/passwall.po +++ b/luci-app-passwall/po/zh-cn/passwall.po @@ -1721,6 +1721,9 @@ msgstr "如果节点地址是域名,则将使用此 DNS 进行解析。" msgid "Note: For node-specific DNS only. Keep Auto to avoid extra overhead." msgstr "注意:仅用于节点专用 DNS,通常请保持自动,以免增加开销。" +msgid "Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction." +msgstr "注意:自动尝试提取 Clash 订阅中的节点 DNS,禁用则不提取。" + msgid "Enable Happy Eyeballs" msgstr "启用 Happy Eyeballs" diff --git a/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua b/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua index 8a316c13..3eb86b45 100644 --- a/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua +++ b/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua @@ -624,7 +624,83 @@ local function encode_node(node) 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 domain_redir = {} local ok, lyaml = pcall(require, "lyaml") @@ -662,7 +738,8 @@ function parseClashNode(raw, remark) 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 function parse_clash_sub_info(headers) diff --git a/luci-app-passwall/root/usr/share/passwall/subscribe.lua b/luci-app-passwall/root/usr/share/passwall/subscribe.lua index f43c4723..356d6f23 100755 --- a/luci-app-passwall/root/usr/share/passwall/subscribe.lua +++ b/luci-app-passwall/root/usr/share/passwall/subscribe.lua @@ -1973,12 +1973,12 @@ local function update_node(manual) end -- sing-box/xray 节点域名解析 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) - if domain_resolver_dns then - uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns) - elseif domain_resolver_dns_https then + if domain_resolver == "https" and domain_resolver_dns_https then 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 if domain_strategy then @@ -2178,7 +2178,15 @@ local execute = function() local stdout = f:read("*all") f:close() 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 订阅响应头中的账户用量与到期时间(不受节点变化与否影响) if headers and headers ~= "" then subscribe_info[cfgid] = parse_clash_sub_info(headers) @@ -2187,8 +2195,15 @@ local execute = function() log_buffer = {} local parsed_nodes = parse_link(raw_data, "2", remark, value) 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 log_buffer = nil -- 无物理变动,直接丢弃暂存的丢弃与计数明细 log('订阅:【' .. remark .. '】没有变化,无需更新。') @@ -2251,6 +2266,7 @@ elseif arg[1] == "add" then local f = assert(io.open("/tmp/links.conf", 'r')) local raw = f:read('*all') f:close() + raw = parseClashNode(raw, arg[2]) parse_link(raw, "1", arg[2]) if update_node(1) then uci_save(true) end api.remove("/tmp/links.conf") diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe_config.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe_config.lua index e6fc4c9b..36407bdc 100644 --- a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe_config.lua +++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_subscribe_config.lua @@ -118,7 +118,10 @@ end 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.") .. "
" .. - translate("Supports only Xray or Sing-box node types.") + translate("Supports only Xray or Sing-box node types.") .. "
" .. string.format('%s', + 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("tcp", "TCP") o:value("udp", "UDP") diff --git a/luci-app-passwall2/po/fa/passwall2.po b/luci-app-passwall2/po/fa/passwall2.po index b5923cea..2f264474 100644 --- a/luci-app-passwall2/po/fa/passwall2.po +++ b/luci-app-passwall2/po/fa/passwall2.po @@ -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." 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 "بدون انقضا" diff --git a/luci-app-passwall2/po/ru/passwall2.po b/luci-app-passwall2/po/ru/passwall2.po index 63fb7a08..700bedd5 100644 --- a/luci-app-passwall2/po/ru/passwall2.po +++ b/luci-app-passwall2/po/ru/passwall2.po @@ -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." 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 "Бессрочно" diff --git a/luci-app-passwall2/po/zh-cn/passwall2.po b/luci-app-passwall2/po/zh-cn/passwall2.po index 2982c732..0017efa4 100644 --- a/luci-app-passwall2/po/zh-cn/passwall2.po +++ b/luci-app-passwall2/po/zh-cn/passwall2.po @@ -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." 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 "长期有效" diff --git a/luci-app-passwall2/po/zh-tw/passwall2.po b/luci-app-passwall2/po/zh-tw/passwall2.po index 0d38f819..1d993c71 100644 --- a/luci-app-passwall2/po/zh-tw/passwall2.po +++ b/luci-app-passwall2/po/zh-tw/passwall2.po @@ -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." 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 "長期有效" diff --git a/luci-app-passwall2/root/usr/share/passwall2/clash_subconverter.lua b/luci-app-passwall2/root/usr/share/passwall2/clash_subconverter.lua new file mode 100644 index 00000000..704a05c8 --- /dev/null +++ b/luci-app-passwall2/root/usr/share/passwall2/clash_subconverter.lua @@ -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 diff --git a/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua b/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua index cab33c56..69ec808a 100755 --- a/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua +++ b/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua @@ -9,6 +9,8 @@ require 'luci.sys' local api = require "luci.passwall2.api" local c_config = api.c_config +loadfile("/usr/share/passwall2/clash_subconverter.lua")() + local datatypes = api.datatypes local split = api.split local base64Decode = api.base64Decode @@ -67,8 +69,6 @@ uci:revert(c_config) math.randomseed(os.time() + math.floor(os.clock() * 1000)) local restart_required = false -local lyaml = require "lyaml" - local has_ss_rust = api.is_finded("sslocal") local has_ssr = api.is_finded("ssr-local") and api.is_finded("ssr-redir") local has_singbox = api.finded_com("sing-box") @@ -475,6 +475,7 @@ end -- Retrieve subscribe information (remaining data allowance, expiration time). local subscribe_info = {} +local subscribe_info_from_headers = {} local function get_subscribe_info(cfgid, value) if type(cfgid) ~= "string" or cfgid == "" or type(value) ~= "string" then return @@ -513,346 +514,6 @@ local function set_ss_implementation(ss_type, result) return result 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. local function parse_host_port(host_port, default_port) local authority = (host_port or ""):gsub("/.*$", "") @@ -2038,7 +1699,8 @@ end local function curl(url, file, ua, mode, hwid) if not url or url == "" then return 22, 404 end 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 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 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 function get_headers() @@ -2366,12 +2036,12 @@ local function update_node(manual) end -- Sing-Box Node Domain resolver 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) - if domain_resolver_dns then - uci_set(cfgid, "domain_resolver_dns", domain_resolver_dns) - elseif domain_resolver_dns_https then + if domain_resolver == "https" and domain_resolver_dns_https then 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 if domain_strategy then @@ -2432,7 +2102,7 @@ local function parse_link(raw, add_mode, group, sub_cfg) cfgid = sub_cfg[".name"] end local keyword_filter = add_mode == "2" and prepare_keyword_filter(sub_cfg) - local nodes, szType, clashTable + local nodes, szType local node_list = {} -- ssd appear to be in this format, starting with ssd://. if raw:find('ssd://') then @@ -2453,19 +2123,11 @@ local function parse_link(raw, add_mode, group, sub_cfg) end nodes = servers else - -- Try parseYAML, if success, is clash. - local yamlTable = lyaml.load(raw) - if yamlTable and type(yamlTable) == "table" then - -- clash - szType = "clash" - clashTable = yamlTable + -- Formats other than SSD are handled by the existing URI parser. + if add_mode == "1" then + nodes = split(raw, "\n") else - -- Formats other than ssd - if add_mode == "1" then - nodes = split(raw, "\n") - else - nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n") - end + nodes = split(base64Decode(raw):gsub("\r\n", "\n"), "\n") end end @@ -2481,20 +2143,12 @@ local function parse_link(raw, add_mode, group, sub_cfg) else tinsert(node_list, node) end - if add_mode == "2" then + if add_mode == "2" and not subscribe_info_from_headers[cfgid] then get_subscribe_info(cfgid, node.remarks) 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 if v and (szType == 'ssd' or not string.match(v, "^%s*$")) then xpcall(function () @@ -2562,6 +2216,7 @@ local execute = function() local cfgid = value[".name"] local remark = value.remark or "" local url = value.url or "" + local headers local url_is_local if fs.access(url) then @@ -2576,7 +2231,7 @@ local execute = function() log(1, i18n.translatef("Start subscribing: %s", '【' .. remark .. '】' .. url .. ' [' .. result .. ']')) tmp_file = "/tmp/" .. cfgid 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 fail_list[#fail_list + 1] = value luci.sys.call("rm -f " .. tmp_file) @@ -2588,11 +2243,36 @@ local execute = function() local stdout = f:read("*all") f:close() 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. log_buffer = {} local parsed_nodes = parse_link(raw_data, "2", remark, value) 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 log_buffer = nil 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 raw = f:read('*all') f:close() + raw = parseClashNode(raw, arg[2]) parse_link(raw, "1", arg[2]) if update_node(1) then uci_save(true) end luci.sys.call("rm -f /tmp/links.conf")