update 2026-11-07 22:36:55

This commit is contained in:
action committed 2026-11-07 22:36:55 +08:00
1 parent dad27f3210
commit ec19855d48
25 files changed
+1388 -445

No files matched your search

+2 -2
View File
@@ -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
+9 -1
View File
@@ -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
+58 -20
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -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='</li>' '$0 ~ pattern { print $0 "</li>"; 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
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
@@ -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 --- */
@@ -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.')));
@@ -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
});
@@ -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.')));
+30
View File
@@ -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 再备份。"
@@ -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.") .. "<br>" ..
translate("Supports only Xray or Sing-box node types.") .. "<br>" .. string.format('<font color="red">%s</font>',
translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead."))
translate("Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."))
o.default = ""
o:value("disable", translate("Disable"))
o:value("", translate("Auto"))
o:value("tcp", "TCP")
o:value("udp", "UDP")
+3
View File
@@ -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"
@@ -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)
@@ -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")
@@ -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.") .. "<br>" ..
translate("Supports only Xray or Sing-box node types.")
translate("Supports only Xray or Sing-box node types.") .. "<br>" .. string.format('<font color="red">%s</font>',
translate("Note: Auto tries to extract node DNS from Clash subscriptions. Disable skips extraction."))
o.default = ""
o:value("disable", translate("Disable"))
o:value("", translate("Auto"))
o:value("tcp", "TCP")
o:value("udp", "UDP")
+54
View File
@@ -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 "بدون انقضا"
+54
View File
@@ -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 "Бессрочно"
+54
View File
@@ -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 "长期有效"
+54
View File
@@ -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 "長期有效"
@@ -0,0 +1,787 @@
#!/usr/bin/lua
-- Copyright(c): lwb1978 2025–2027
local api = require("luci.passwall2.api")
local base64 = api.base64Encode
local json = api.jsonc
local i18n = api.i18n
local domain_redir = {}
local function urlencode(value)
return api.UrlEncode(value == nil and "" or tostring(value))
end
local function add_param(params, key, value)
if value ~= nil and value ~= "" then
if type(value) == "boolean" then value = value and "1" or "0" end
params[#params + 1] = key .. "=" .. urlencode(value)
end
end
local function build_ech(node)
local opts = node["ech-opts"]
if type(opts) ~= "table" or opts.enable ~= true then return nil end
if opts.config and opts.config ~= "" then return opts.config end
local name = opts["query-server-name"]
if not name or name == "" then name = node.servername or node.sni or node.server end
if name == "" then name = node.server end
return name .. "+https://223.5.5.5/dns-query"
end
local function bandwidth_mbps(value)
if value == nil then return nil end
local number = tonumber(value)
if number then return number end
local amount, prefix, unit = tostring(value):match("^%s*(%d+)%s*([KMGT]?)([Bb])ps%s*$")
local scales = { [""] = 1, K = 1000, M = 1000000, G = 1000000000, T = 1000000000000 }
if not amount then return nil end
return tonumber(amount) * scales[prefix] * (unit == "B" and 8 or 1) / 1000000
end
local function inherit(base, overrides)
local result = {}
for key, value in pairs(base or {}) do result[key] = value end
for key, value in pairs(overrides or {}) do result[key] = value end
return result
end
local function build_xhttp_extra(opts)
local extra = {}
local fields = {
headers = "headers", ["x-padding-bytes"] = "xPaddingBytes",
["no-grpc-header"] = "noGRPCHeader", ["x-padding-obfs-mode"] = "xPaddingObfsMode",
["x-padding-key"] = "xPaddingKey", ["x-padding-header"] = "xPaddingHeader",
["x-padding-placement"] = "xPaddingPlacement", ["x-padding-method"] = "xPaddingMethod",
["uplink-http-method"] = "uplinkHTTPMethod", ["session-placement"] = "sessionIDPlacement",
["session-key"] = "sessionIDKey", ["session-table"] = "sessionIDTable",
["session-length"] = "sessionIDLength", ["seq-placement"] = "seqPlacement", ["seq-key"] = "seqKey",
["uplink-data-placement"] = "uplinkDataPlacement", ["uplink-data-key"] = "uplinkDataKey",
["uplink-chunk-size"] = "uplinkChunkSize", ["sc-max-each-post-bytes"] = "scMaxEachPostBytes",
["sc-min-posts-interval-ms"] = "scMinPostsIntervalMs"
}
for source, target in pairs(fields) do extra[target] = opts[source] end
-- jsonc treats empty tables as arrays and repeated table references as null.
if type(extra.headers) == "table" then
extra.headers = next(extra.headers) and inherit(extra.headers) or nil
end
local reuse = opts["reuse-settings"]
if reuse then
local xmux = {}
local ranges = {
["max-concurrency"] = "maxConcurrency", ["max-connections"] = "maxConnections",
["c-max-reuse-times"] = "cMaxReuseTimes", ["h-max-request-times"] = "hMaxRequestTimes",
["h-max-reusable-secs"] = "hMaxReusableSecs"
}
for source, target in pairs(ranges) do
local value = reuse[source]
if value ~= nil then xmux[target] = tonumber(value) or value end
end
xmux.hKeepAlivePeriod = tonumber(reuse["h-keep-alive-period"])
if next(xmux) then extra.xmux = xmux end
end
return extra
end
local function host_format(host)
if not host then return "" end
local str = host:match("%[(.-)%]") or host
if api.datatypes.ip6addr(str) then
return "[" .. str .. "]"
end
return host
end
local function build_alpn(alpn) -- Sort and remove duplicates.
if not alpn then return nil end
local seen = {}
local order = { "h3", "h2", "http/1.1" }
if type(alpn) == "table" then
for _, v in ipairs(alpn) do
if v then seen[v] = true end
end
else
seen[tostring(alpn)] = true
end
local t = {}
for _, v in ipairs(order) do
if seen[v] then table.insert(t, v) end
end
if #t == 0 then return nil end
return table.concat(t, ",")
end
local function build_common(node)
local o = {}
o.server = host_format(node.server)
o.port = node.port
o.name = node.name
-- ===== TLS =====
o.tls = {}
if node["reality-opts"] then
o.tls.security = "reality"
o.tls.pbk = node["reality-opts"]["public-key"]
o.tls.sid = node["reality-opts"]["short-id"]
elseif node.tls then
o.tls.security = "tls"
end
o.tls.sni = node.servername or node.sni
o.tls.fp = node["client-fingerprint"]
o.tls.pcs = node.fingerprint
o.tls.vcn = node["name-cert-verify"]
o.tls.insecure = node["skip-cert-verify"] == true
o.tls.alpn = build_alpn(node.alpn)
o.tls.ech = build_ech(node)
-- ===== transport =====
o.transport = {}
local net = node.network or "tcp"
o.transport.type = net
local function get_first(v)
if type(v) == "table" then return v[1] end
return v
end
if net == "ws" then
local opts = node["ws-opts"]
if opts then
local path = opts.path or "/"
if opts["v2ray-http-upgrade"] ~= true and opts["max-early-data"] then
path = path .. (path:find("?", 1, true) and "&ed=" or "?ed=") .. opts["max-early-data"]
end
if opts["v2ray-http-upgrade"] ~= true and opts["early-data-header-name"] then
path = path .. (path:find("?", 1, true) and "&eh=" or "?eh=") .. opts["early-data-header-name"]
end
o.transport.path = path
o.transport.host = opts.headers and opts.headers.Host
if opts["v2ray-http-upgrade"] == true then o.transport.type = "httpupgrade" end
end
elseif net == "grpc" then
local opts = node["grpc-opts"]
if opts then
o.transport.serviceName = opts["grpc-service-name"]
end
elseif net == "http" then
-- Use TCP + HTTP camouflage so each core selects its matching transport.
o.transport.type = "tcp"
o.transport.headerType = "http"
o.transport.host = node.server
o.transport.path = "/"
local opts = node["http-opts"]
if opts then
-- Clash http-opts carries the camouflage Host under headers.Host
-- (a list), like ws-opts; opts.host is not standard for http.
o.transport.host = get_first(opts.host) or (opts.headers and get_first(opts.headers.Host)) or o.transport.host
o.transport.path = get_first(opts.path) or o.transport.path
end
elseif net == "h2" then
local opts = node["h2-opts"]
if opts then
o.transport.host = get_first(opts.host)
o.transport.path = opts.path
end
elseif net == "xhttp" then
local opts = node["xhttp-opts"]
if opts then
o.transport.host = opts.host
o.transport.path = opts.path
o.transport.mode = opts.mode
local extra = build_xhttp_extra(opts)
-- download-settings
if opts["download-settings"] then
local d = opts["download-settings"]
local effective = inherit(node, d)
local ds = {
address = domain_redir[effective.server] or effective.server,
port = effective.port,
network = "xhttp",
security = effective.tls and "tls" or "none"
}
local sni = effective.servername or effective.sni or effective.server
local alpn = build_alpn(effective.alpn)
if effective.tls or effective["reality-opts"] then
ds.tlsSettings = {
serverName = sni,
allowInsecure = effective["skip-cert-verify"] == true,
fingerprint = effective["client-fingerprint"],
pinnedPeerCertSha256 = effective.fingerprint and api.sha256_sb_xray(effective.fingerprint) or nil,
verifyPeerCertByName = effective["name-cert-verify"],
alpn = alpn and api.split(alpn, ",") or nil,
echConfigList = build_ech(effective)
}
local reality = effective["reality-opts"]
if reality then
ds.security = "reality"
ds.realitySettings = {
serverName = sni, fingerprint = effective["client-fingerprint"] or "chrome",
publicKey = reality["public-key"], shortId = reality["short-id"] or ""
}
ds.tlsSettings = nil
end
end
local xs = build_xhttp_extra(inherit(opts, d))
xs.path = d.path or opts.path or "/"
xs.host = d.host or opts.host or sni
xs.mode = opts.mode or "auto"
ds.xhttpSettings = xs
extra.downloadSettings = ds
end
if next(extra) then
o.transport.extra = json.stringify(extra)
end
end
end
return o
end
-- VLESS
local function encode_vless(node)
local o = build_common(node)
local link = "vless://" .. node.uuid .. "@" .. o.server .. ":" .. o.port
local p = {}
if type(node.flow) == "string" and node.flow:sub(1, 5) == "xtls-" then add_param(p, "flow", node.flow) end
add_param(p, "encryption", node.encryption)
-- TLS
add_param(p, "security", o.tls.security)
add_param(p, "pbk", o.tls.pbk)
add_param(p, "sid", o.tls.sid)
add_param(p, "sni", o.tls.sni)
add_param(p, "fp", o.tls.fp)
add_param(p, "alpn", o.tls.alpn)
add_param(p, "ech", o.tls.ech)
add_param(p, "pcs", o.tls.pcs)
add_param(p, "vcn", o.tls.vcn)
add_param(p, "allowInsecure", o.tls.insecure)
-- transport
add_param(p, "type", o.transport.type)
add_param(p, "headerType", o.transport.headerType)
add_param(p, "host", o.transport.host)
add_param(p, "path", o.transport.path)
add_param(p, "serviceName", o.transport.serviceName)
add_param(p, "mode", o.transport.mode)
add_param(p, "extra", o.transport.extra)
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(o.name or "")
end
-- Trojan
local function encode_trojan(node)
local o = build_common(node)
local link = "trojan://" .. urlencode(node.password) .. "@" .. o.server .. ":" .. o.port
local p = {}
add_param(p, "security", o.tls.security)
add_param(p, "pbk", o.tls.pbk)
add_param(p, "sid", o.tls.sid)
add_param(p, "sni", o.tls.sni)
add_param(p, "fp", o.tls.fp)
add_param(p, "alpn", o.tls.alpn)
add_param(p, "ech", o.tls.ech)
add_param(p, "pcs", o.tls.pcs)
add_param(p, "vcn", o.tls.vcn)
add_param(p, "allowInsecure", o.tls.insecure)
add_param(p, "type", o.transport.type)
add_param(p, "headerType", o.transport.headerType)
add_param(p, "host", o.transport.host)
add_param(p, "path", o.transport.path)
add_param(p, "serviceName", o.transport.serviceName)
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(o.name or "")
end
-- VMess
local function encode_vmess(node)
local o = build_common(node)
local obj = {
v = "2",
ps = o.name,
add = node.server,
port = tostring(node.port),
id = node.uuid,
aid = tostring(node.alterId or 0),
net = o.transport.type,
security = node.cipher,
scy = node.cipher,
type = o.transport.headerType or "none",
host = o.transport.host or "",
path = urlencode(o.transport.path or ""),
tls = o.tls.security == "tls" and "tls" or "",
sni = o.tls.sni,
alpn = o.tls.alpn,
fp = o.tls.fp,
ech = o.tls.ech,
pcs = o.tls.pcs,
vcn = o.tls.vcn,
insecure = o.tls.insecure and "1" or "0",
tfo = node.tfo and "1" or "0"
}
if o.transport.type == "grpc" then
obj.path = urlencode(o.transport.serviceName or "")
end
return 0, "vmess://" .. base64(json.stringify(obj))
end
-- SS
local function encode_ss(node)
local ss_method = {
["none"]=1, ["aes-128-gcm"]=1, ["aes-192-gcm"]=1, ["aes-256-gcm"]=1, ["chacha20-ietf-poly1305"]=1, ["xchacha20-ietf-poly1305"]=1, ["2022-blake3-aes-128-gcm"]=1, ["2022-blake3-aes-256-gcm"]=1, ["2022-blake3-chacha20-poly1305"]=1,
["aes-128-ctr"]=1, ["aes-192-ctr"]=1, ["aes-256-ctr"]=1, ["aes-128-cfb"]=1, ["aes-192-cfb"]=1, ["aes-256-cfb"]=1, ["rc4-md5"]=1, ["chacha20-ietf"]=1, ["xchacha20"]=1,
}
if not ss_method[node.cipher or ""] then
return 1, i18n.translatef("Subscription conversion: discard SS node %s, unsupported cipher: %s", node.name or "", node.cipher or "")
end
local userinfo = node.cipher .. ":" .. node.password
local base = userinfo .. "@" .. host_format(node.server) .. ":" .. node.port
local link = "ss://" .. base64(base)
local p = {}
if node.udp then add_param(p, "udp", true) end
if node["udp-over-tcp"] then add_param(p, "uot", true) end
if node.plugin then
local plugin = (node.plugin == "obfs") and "obfs-local" or node.plugin
if plugin == "shadow-tls" then
local shadow_tls = base64(json.stringify(node["plugin-opts"] or {}))
add_param(p, "shadow-tls", shadow_tls)
elseif plugin == "obfs-local" or plugin == "v2ray-plugin" then
local opts = {}
for k, v in pairs(node["plugin-opts"] or {}) do
if plugin == "obfs-local" then
if k == "mode" then k = "obfs" end
if k == "host" then k = "obfs-host" end
elseif plugin == "v2ray-plugin" then
if k == "mode" and v == "websocket" then
v = nil
elseif type(v) == "boolean" then
if v == true then
table.insert(opts, k)
end
v = nil
end
end
if v ~= nil then
if type(v) == "boolean" then
v = v and "1" or "0"
end
table.insert(opts, k .. "=" .. v)
end
end
if #opts > 0 then plugin = plugin .. ";" .. table.concat(opts, ";") end
add_param(p, "plugin", plugin)
else
return 1, i18n.translatef("Subscription conversion: discard SS node %s, unsupported plugin: %s", node.name or "", plugin)
end
end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- Hysteria
local function encode_hysteria(node)
if node.protocol and node.protocol ~= "udp" then
return 1, i18n.translatef("Subscription conversion: discard Hysteria node %s, unsupported transport: %s", node.name, node.protocol)
end
local link = "hysteria://" .. host_format(node.server) .. ":" .. (node.port or "")
local p = {}
add_param(p, "auth", node["auth-str"])
add_param(p, "mport", node.ports)
add_param(p, "obfsParam", node.obfs)
add_param(p, "sni", node.sni)
add_param(p, "upmbps", bandwidth_mbps(node.up))
add_param(p, "downmbps", bandwidth_mbps(node.down))
add_param(p, "insecure", node["skip-cert-verify"] == true)
add_param(p, "vcn", node["name-cert-verify"])
add_param(p, "pinSHA256", node.fingerprint)
add_param(p, "alpn", build_alpn(node.alpn))
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- Hysteria2
local function encode_hysteria2(node)
-- Hopping subscriptions may omit port; keep a valid fallback for all cores.
local port = node.port or tostring(node.ports or ""):match("^%s*(%d+)")
local link = "hysteria2://" .. urlencode(node.password or "") .. "@" .. host_format(node.server) .. ":" .. port
local p = {}
add_param(p, "mport", node.ports)
add_param(p, "hopInterval", node["hop-interval"])
add_param(p, "obfs", node.obfs)
add_param(p, "obfs-password", node["obfs-password"])
add_param(p, "minPacketSize", node["obfs-min-packet-size"])
add_param(p, "maxPacketSize", node["obfs-max-packet-size"])
add_param(p, "upmbps", bandwidth_mbps(node.up))
add_param(p, "downmbps", bandwidth_mbps(node.down))
add_param(p, "ech", build_ech(node))
add_param(p, "vcn", node["name-cert-verify"])
add_param(p, "sni", node.sni)
add_param(p, "insecure", node["skip-cert-verify"] == true)
add_param(p, "pinSHA256", node.fingerprint)
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- TUIC
local function encode_tuic(node)
if node.token then
return 1, i18n.translatef("Subscription conversion: discard TUIC node %s, TUIC v4 is not supported.", node.name)
end
if not node.uuid or node.password == nil then
return 1, i18n.translatef("Subscription conversion: discard TUIC node %s, authentication information is incomplete.", node.name)
end
local link = "tuic://" .. urlencode(node.uuid) .. ":" .. urlencode(node.password) .. "@" .. host_format(node.server) .. ":" .. node.port
local p = {}
add_param(p, "congestion_control", node["congestion-controller"])
add_param(p, "alpn", build_alpn(node.alpn))
add_param(p, "sni", node.sni)
if node["disable-sni"] then add_param(p, "disable_sni", true) end
add_param(p, "allowInsecure", node["skip-cert-verify"] == true)
add_param(p, "udp_relay_mode", node["udp-relay-mode"])
add_param(p, "pcs", node.fingerprint)
add_param(p, "vcn", node["name-cert-verify"])
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- AnyTLS
local function encode_anytls(node)
local err_msg
if node["shadow-tls-opts"] then
err_msg = "ShadowTLS"
elseif node["restls-opts"] then
err_msg = "ResTLS"
elseif node["jls-opts"] then
err_msg = "JLS"
end
if err_msg then
err_msg = i18n.translatef("Subscription conversion: discard AnyTLS node %s, Sing-Box does not support AnyTLS + %s", node.name or "", err_msg)
return 1, err_msg
end
local o = build_common(node)
local link = "anytls://" .. urlencode(node.password or "") .. "@" .. host_format(node.server) .. ":" .. node.port
local p = {}
add_param(p, "security", o.tls.security)
add_param(p, "pbk", o.tls.pbk)
add_param(p, "sid", o.tls.sid)
add_param(p, "sni", o.tls.sni)
add_param(p, "alpn", o.tls.alpn)
add_param(p, "fp", o.tls.fp)
add_param(p, "ech", o.tls.ech)
add_param(p, "pcs", o.tls.pcs)
add_param(p, "insecure", o.tls.insecure)
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
-- SSR
local function encode_ssr(node)
local link = host_format(node.server) .. ":" .. node.port .. ":" .. (node.protocol or "") .. ":" ..
(node.cipher or "") .. ":" .. (node.obfs or "") .. ":" .. base64(node.password)
local p = {}
if node["obfs-param"] then table.insert(p, "obfsparam=" .. base64(node["obfs-param"])) end
if node["protocol-param"] then table.insert(p, "protoparam=" .. base64(node["protocol-param"])) end
table.insert(p, "remarks=" .. base64(node.name))
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, "ssr://" .. base64(link)
end
-- snell
local function encode_snell(node)
local err_msg
local obfs = node["obfs-opts"] or {}
if obfs.mode and obfs.mode ~= "http" and obfs.mode ~= "none" then
err_msg = obfs.mode
end
if err_msg then
err_msg = i18n.translatef("Subscription conversion: discard Snell node %s, Sing-Box does not support Snell + %s", node.name or "", err_msg)
return 1, err_msg
end
local version = node.version and tonumber(node.version) or 4
version = (version == 5) and 4 or version
if version < 4 then
err_msg = i18n.translatef("Subscription conversion: discard Snell node %s, Sing-Box does not support Snell versions below 4.", node.name or "")
return 1, err_msg
end
local link = "snell://" .. host_format(node.server) .. ":" .. node.port
local p = {}
add_param(p, "psk", node.psk)
add_param(p, "version", version)
if obfs.mode == "http" then
add_param(p, "obfs", "http")
add_param(p, "obfs-host", obfs.host)
end
add_param(p, "reuse", node.reuse and "1" or "0")
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
end
return 0, link .. "#" .. urlencode(node.name or "")
end
local function encode_node(node)
if type(node) ~= "table" or not node.type or not node.name then
return 1, i18n.translate("Subscription conversion: skip a malformed node.")
end
if not node.server or node.server == "" or not (node.port or (node.type == "hysteria2" and node.ports)) then
return 1, i18n.translatef("Subscription conversion: discard node %s, node information is incomplete.", tostring(node.name))
end
node = inherit(node)
local t = node.type
if t == "hysteria" or t == "hysteria2" then
local up, down = bandwidth_mbps(node.up), bandwidth_mbps(node.down)
if (node.up ~= nil and up == nil) or (node.down ~= nil and down == nil) then
return 1, i18n.translatef("Subscription conversion: discard node %s, invalid bandwidth format.", tostring(node.name))
end
node.up, node.down = up, down
end
node.server = domain_redir[node.server] or node.server
if t == "vless" then return encode_vless(node)
elseif t == "trojan" then return encode_trojan(node)
elseif t == "vmess" then return encode_vmess(node)
elseif t == "ss" then return encode_ss(node)
elseif t == "hysteria" then return encode_hysteria(node)
elseif t == "hysteria2" then return encode_hysteria2(node)
elseif t == "tuic" then return encode_tuic(node)
elseif t == "anytls" then return encode_anytls(node)
elseif t == "ssr" then return encode_ssr(node)
elseif t == "snell" then return encode_snell(node)
else api.log(2, i18n.translatef("Subscription conversion: discard unsupported node %s, node type: %s", node.name, t))
end
end
local function parse_dns_endpoint(value)
if type(value) ~= "string" then return nil end
value = api.trim(value)
-- Clash fragments and URL queries cannot be preserved by both node DNS backends.
if value == "" or value:find("[%s#?@,]") then return nil end
local url = value:find("://", 1, true) and value or "udp://" .. value
local ok, endpoint = pcall(api.parseURL, url)
if not ok or not endpoint then return nil end
local protocol = endpoint.protocol
if protocol ~= "udp" and protocol ~= "tcp" and protocol ~= "https" then return nil end
local host = endpoint.hostname
if not host or not (api.is_ip(host) or api.datatypes.hostname(host)) then return nil end
local bracketed = endpoint.host:sub(1, 1) == "["
local ipv6 = api.datatypes.ip6addr(host)
if bracketed and not ipv6 then return nil end
if protocol == "https" and ipv6 and not bracketed then return nil end
local port = endpoint.port or (protocol == "https" and 443 or 53)
if port < 1 or port > 65535 then return nil end
if protocol ~= "https" and endpoint.pathname then return nil end
return { protocol = protocol, host = host:lower():gsub("%.$", ""), port = port, url = url }
end
local function is_loopback_dns(host)
return host == "localhost" or (api.datatypes.ip4addr(host) and host:match("^127%.")) or host:match("^[0:]+:0*1$")
end
local function is_unspecified_dns(host)
return host == "0.0.0.0" or host:match("^[0:]+$")
end
local function extract_clash_dns(data)
local dns = data.dns
if type(dns) ~= "table" or not dns["proxy-server-nameserver"] then return nil end
-- A subscription-wide resolver cannot represent per-domain node DNS policies.
local node_policy = dns["proxy-server-nameserver-policy"]
if type(node_policy) == "table" and next(node_policy) then return nil end
local listener = parse_dns_endpoint(dns.listen)
local resolving = {}
local policy = dns["nameserver-policy"]
local has_policy = type(policy) == "table" and next(policy)
local function pick(candidates, depth)
if depth > 4 then return nil end
if type(candidates) == "string" then candidates = { candidates } end
if type(candidates) ~= "table" then return nil end
for _, value in ipairs(candidates) do
local endpoint = parse_dns_endpoint(value)
if endpoint and not is_unspecified_dns(endpoint.host) then
if is_loopback_dns(endpoint.host) then
local key = endpoint.protocol .. "://" .. endpoint.host .. ":" .. endpoint.port
local listen_matches = listener and listener.protocol ~= "https" and listener.port == endpoint.port and (
listener.host == endpoint.host or
(listener.host == "0.0.0.0" and not endpoint.host:find(":", 1, true)) or
(is_unspecified_dns(listener.host) and listener.host ~= "0.0.0.0" and endpoint.host:find(":", 1, true)) or
(endpoint.host == "localhost" and is_loopback_dns(listener.host)))
if endpoint.protocol ~= "https" and dns.enable == true and listen_matches and not resolving[key] and not has_policy then
resolving[key] = true
local resolver = pick(dns.nameserver, depth + 1)
resolving[key] = nil
if resolver then return resolver end
end
elseif endpoint.protocol == "https" then
return { domain_resolver = "https", domain_resolver_dns_https = endpoint.url }
elseif api.datatypes.ip4addr(endpoint.host) then
return { domain_resolver = endpoint.protocol, domain_resolver_dns = endpoint.host .. ":" .. endpoint.port }
end
end
end
end
local resolver = pick(dns["proxy-server-nameserver"], 0)
if resolver then
local address = resolver.domain_resolver_dns_https or resolver.domain_resolver .. "://" .. resolver.domain_resolver_dns
api.log(1, i18n.translatef("Subscription conversion: extracted node domain DNS: %s", address))
end
return resolver
end
function parseClashNode(raw, remark, auto_dns)
if not raw then return "" end
domain_redir = {}
local ok, lyaml = pcall(require, "lyaml")
if not ok then return raw end
local parsed, data = pcall(lyaml.load, raw)
if not parsed then
api.log(1, i18n.translatef("Subscription conversion: YAML parsing failed: %s", tostring(remark or "")))
return ""
end
if not data or type(data) ~= "table" then return raw end
if type(data.proxies) ~= "table" then return "" end
api.log(1, i18n.translate("Subscription conversion: Clash subscription detected, converting..."))
-- Some airports use hosts domain redirect to disguise the real node domain name.
for k, v in pairs(type(data.hosts) == "table" and data.hosts or {}) do
if type(k) == "string" and type(v) == "string" then
if api.datatypes.hostname(k) and api.datatypes.hostname(v) then
domain_redir[k] = v
end
end
end
local links = {}
for _, node in ipairs(data.proxies) do
local converted, err, link = pcall(encode_node, node)
if not converted then
local name = type(node) == "table" and node.name or ""
api.log(2, i18n.translatef("Subscription conversion: discard node %s, conversion failed.", tostring(name or "")))
elseif err == 1 and link then
api.log(2, link)
elseif link then
table.insert(links, link)
end
end
local resolver = #links > 0 and auto_dns and extract_clash_dns(data) or nil
return #links > 0 and table.concat(links, "\n") or "", resolver
end
function parse_clash_sub_info(headers)
local userinfo = headers:match("[Ss]ubscription%-userinfo:%s*([^\r\n]+)")
if not userinfo then return nil end
local upload = tonumber(userinfo:match("upload=(%d+)")) or 0
local download = tonumber(userinfo:match("download=(%d+)")) or 0
local total = tonumber(userinfo:match("total=(%d+)")) or 0
local expire = tonumber(userinfo:match("expire=(%d+)"))
local remain = total - (upload + download)
if remain < 0 then remain = 0 end
local function format_size(bytes)
local units = { "B", "KB", "MB", "GB", "TB", "PB" }
local i = 1
while bytes >= 1024 and i < #units do
bytes = bytes / 1024
i = i + 1
end
if bytes >= 100 then
return string.format("%.0f%s", bytes, units[i])
elseif bytes >= 10 then
return string.format("%.1f%s", bytes, units[i])
else
return string.format("%.2f%s", bytes, units[i])
end
end
local rem_traffic = format_size(remain)
local expired_date
if expire and expire > 0 then
local t = os.date("*t", expire)
expired_date = string.format("%d-%d-%d", t.year, t.month, t.day)
else
expired_date = i18n.translate("Never expires")
end
return {
rem_traffic = rem_traffic,
expired_date = expired_date
}
end
@@ -9,6 +9,8 @@ require 'luci.sys'
local api = require "luci.passwall2.api"
local 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")