From 87960c9c6c781a7353cd55952861cb95a77e5562 Mon Sep 17 00:00:00 2001 From: ushan0v Date: Wed, 27 May 2026 02:23:03 +0300 Subject: [PATCH] Replace runtime Lua and jq with ucode --- install.sh | 173 +- podkop/Makefile | 2 +- podkop/files/usr/bin/podkop | 724 +++---- podkop/files/usr/lib/byedpi.sh | 100 +- podkop/files/usr/lib/constants.sh | 1 - podkop/files/usr/lib/helpers.jq | 14 - podkop/files/usr/lib/helpers.sh | 14 +- podkop/files/usr/lib/json_utils.uc | 927 +++++++++ podkop/files/usr/lib/provider_status.uc | 199 ++ podkop/files/usr/lib/rulesets.sh | 20 +- podkop/files/usr/lib/rulesets.uc | 157 ++ .../files/usr/lib/sing_box_config_facade.sh | 499 ++--- .../files/usr/lib/sing_box_config_facade.uc | 516 +++++ .../files/usr/lib/sing_box_config_manager.sh | 1551 +------------- .../files/usr/lib/sing_box_config_manager.uc | 748 +++++++ podkop/files/usr/lib/subscription_cache.lua | 828 -------- podkop/files/usr/lib/subscription_cache.uc | 681 +++++++ podkop/files/usr/lib/subscription_parser.lua | 1781 ----------------- podkop/files/usr/lib/subscription_parser.sh | 766 +------ podkop/files/usr/lib/subscription_parser.uc | 1445 +++++++++++++ podkop/files/usr/lib/updater.sh | 293 +-- podkop/files/usr/lib/zapret.sh | 102 +- scripts/build-openwrt-packages-wsl.sh | 4 +- 23 files changed, 5610 insertions(+), 5935 deletions(-) delete mode 100644 podkop/files/usr/lib/helpers.jq create mode 100644 podkop/files/usr/lib/json_utils.uc create mode 100644 podkop/files/usr/lib/provider_status.uc create mode 100644 podkop/files/usr/lib/rulesets.uc create mode 100644 podkop/files/usr/lib/sing_box_config_facade.uc create mode 100644 podkop/files/usr/lib/sing_box_config_manager.uc delete mode 100644 podkop/files/usr/lib/subscription_cache.lua create mode 100644 podkop/files/usr/lib/subscription_cache.uc delete mode 100644 podkop/files/usr/lib/subscription_parser.lua create mode 100644 podkop/files/usr/lib/subscription_parser.uc diff --git a/install.sh b/install.sh index 510d0995..f1df9a0c 100755 --- a/install.sh +++ b/install.sh @@ -122,6 +122,154 @@ http_get() { esac } +install_json_helper_path() { + helper_path="$TMP_DIR/install-json.uc" + + if [ ! -s "$helper_path" ]; then + cat > "$helper_path" <<'EOF' +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_json_file(path) { + try { + return json(fs.readfile(path)); + } + catch (e) { + return null; + } +} + +function read_stdin_json() { + try { + return json(read_stdin()); + } + catch (e) { + return null; + } +} + +function starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function ends_with(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix; +} + +function asset_matches(name, kind, ext) { + let suffix = "." + ext; + if (!ends_with(name, suffix)) + return false; + + if (kind == "backend") + return starts_with(name, "podkop-plus_") || starts_with(name, "podkop-plus-"); + if (kind == "app") + return starts_with(name, "luci-app-podkop-plus_") || starts_with(name, "luci-app-podkop-plus-"); + if (kind == "i18n") + return starts_with(name, "luci-i18n-podkop-plus-ru_") || starts_with(name, "luci-i18n-podkop-plus-ru-"); + return false; +} + +function release_has_asset(release, kind, ext) { + if (type(release) != "object" || type(release.assets) != "array") + return false; + for (let asset in release.assets) + if (type(asset) == "object" && asset_matches(asset.name, kind, ext)) + return true; + return false; +} + +function github_message() { + let value = read_stdin_json(); + if (value == null) + exit(2); + if (type(value) == "object" && value.message != null) + print(as_string(value.message), "\n"); +} + +function release_tags(path, ext) { + for (let release in (read_json_file(path) || [])) { + if (type(release) != "object") + continue; + if (release.draft === true || release.prerelease === true) + continue; + if (!release_has_asset(release, "backend", ext) || !release_has_asset(release, "app", ext)) + continue; + let tag = as_string(release.tag_name || ""); + if (tag != "") + print(tag, "\n"); + } +} + +function release_by_tag(path, tag) { + for (let release in (read_json_file(path) || [])) { + if (type(release) == "object" && as_string(release.tag_name || "") == tag) { + print(sprintf("%J", release), "\n"); + return; + } + } +} + +function release_tag() { + let release = read_stdin_json(); + if (type(release) == "object" && release.tag_name != null) + print(as_string(release.tag_name), "\n"); +} + +function release_asset_url(kind, ext) { + let release = read_stdin_json(); + if (type(release) != "object" || type(release.assets) != "array") + return; + for (let asset in release.assets) { + if (type(asset) == "object" && asset_matches(asset.name, kind, ext)) { + print(as_string(asset.browser_download_url || ""), "\n"); + return; + } + } +} + +let mode = ARGV[0] || ""; + +if (mode == "github-message") + github_message(); +else if (mode == "release-tags") + release_tags(ARGV[1], ARGV[2]); +else if (mode == "release-by-tag") + release_by_tag(ARGV[1], ARGV[2]); +else if (mode == "release-tag") + release_tag(); +else if (mode == "release-asset-url") + release_asset_url(ARGV[1], ARGV[2]); +else + exit(1); +EOF + fi + + printf '%s\n' "$helper_path" +} + +install_json_ucode() { + ucode "$(install_json_helper_path)" "$@" +} + download_file_once() { case "$FETCHER" in wget) @@ -487,9 +635,8 @@ fetch_github_releases_json() { response="$(http_get "$url" 2>/dev/null || true)" [ -n "$response" ] || fail "Failed to query GitHub releases metadata for ${owner}/${repo}" - printf '%s' "$response" | jq -e . >/dev/null 2>&1 || fail "GitHub returned an invalid response for ${owner}/${repo}" - - message="$(printf '%s' "$response" | jq -r 'if type == "object" then (.message // empty) else empty end' 2>/dev/null)" + message="$(printf '%s' "$response" | install_json_ucode github-message 2>/dev/null)" || + fail "GitHub returned an invalid response for ${owner}/${repo}" case "$message" in *"API rate limit"*|*"rate limit exceeded"*) fail "GitHub API rate limit reached. Try again later." @@ -511,13 +658,7 @@ select_latest_podkop_plus_release_json() { releases_file="$(mktemp)" cat > "$releases_file" - for tag in $(jq -r --arg ext "$asset_ext" ' - .[] - | select(((.draft // false) | not) and ((.prerelease // false) | not)) - | select(any(.assets[]?; ((.name | startswith("podkop-plus_")) or (.name | startswith("podkop-plus-"))) and (.name | endswith("." + $ext)))) - | select(any(.assets[]?; ((.name | startswith("luci-app-podkop-plus_")) or (.name | startswith("luci-app-podkop-plus-"))) and (.name | endswith("." + $ext)))) - | .tag_name // empty - ' "$releases_file"); do + for tag in $(install_json_ucode release-tags "$releases_file" "$asset_ext"); do podkop_plus_release_version_parts "$tag" >/dev/null 2>&1 || continue if [ -z "$best_tag" ] || podkop_plus_release_version_lt "$best_tag" "$tag"; then @@ -526,7 +667,7 @@ select_latest_podkop_plus_release_json() { done if [ -n "$best_tag" ]; then - jq -c --arg tag "$best_tag" '.[] | select(.tag_name == $tag)' "$releases_file" | sed -n '1p' + install_json_ucode release-by-tag "$releases_file" "$best_tag" | sed -n '1p' fi rm -f "$releases_file" @@ -538,13 +679,13 @@ resolve_podkop_plus_release() { [ "$PKG_IS_APK" -eq 1 ] && asset_ext="apk" PODKOP_PLUS_RELEASE_JSON="$(fetch_github_releases_json "$REPO_OWNER" "$REPO_NAME" | select_latest_podkop_plus_release_json "$asset_ext")" - PODKOP_PLUS_RELEASE_TAG="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | jq -r '.tag_name // empty')" + PODKOP_PLUS_RELEASE_TAG="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | install_json_ucode release-tag 2>/dev/null)" [ -n "$PODKOP_PLUS_RELEASE_TAG" ] || fail "Failed to detect the Podkop Plus release tag" - PODKOP_PLUS_BACKEND_URL="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | jq -r --arg ext "$asset_ext" '.assets[] | select(((.name | startswith("podkop-plus_")) or (.name | startswith("podkop-plus-"))) and (.name | endswith("." + $ext))) | .browser_download_url' | sed -n '1p')" + PODKOP_PLUS_BACKEND_URL="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | install_json_ucode release-asset-url backend "$asset_ext" 2>/dev/null | sed -n '1p')" [ -n "$PODKOP_PLUS_BACKEND_URL" ] || fail "The Podkop Plus release does not contain a podkop-plus .$asset_ext package" - PODKOP_PLUS_APP_URL="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | jq -r --arg ext "$asset_ext" '.assets[] | select(((.name | startswith("luci-app-podkop-plus_")) or (.name | startswith("luci-app-podkop-plus-"))) and (.name | endswith("." + $ext))) | .browser_download_url' | sed -n '1p')" + PODKOP_PLUS_APP_URL="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | install_json_ucode release-asset-url app "$asset_ext" 2>/dev/null | sed -n '1p')" [ -n "$PODKOP_PLUS_APP_URL" ] || fail "The Podkop Plus release does not contain a luci-app-podkop-plus .$asset_ext package" PODKOP_PLUS_BACKEND_NAME="$(basename "$PODKOP_PLUS_BACKEND_URL")" @@ -555,7 +696,7 @@ resolve_podkop_plus_release() { PODKOP_PLUS_I18N_NAME="" if [ "$PODKOP_PLUS_I18N_REQUESTED" -eq 1 ]; then - PODKOP_PLUS_I18N_URL="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | jq -r --arg ext "$asset_ext" '.assets[] | select(((.name | startswith("luci-i18n-podkop-plus-ru_")) or (.name | startswith("luci-i18n-podkop-plus-ru-"))) and (.name | endswith("." + $ext))) | .browser_download_url' | sed -n '1p')" + PODKOP_PLUS_I18N_URL="$(printf '%s' "$PODKOP_PLUS_RELEASE_JSON" | install_json_ucode release-asset-url i18n "$asset_ext" 2>/dev/null | sed -n '1p')" [ -n "$PODKOP_PLUS_I18N_URL" ] || fail "The Podkop Plus release does not contain a luci-i18n-podkop-plus-ru .$asset_ext package" PODKOP_PLUS_I18N_NAME="$(basename "$PODKOP_PLUS_I18N_URL")" fi @@ -740,7 +881,7 @@ main() { deactivate_original_podkop_if_present pkg_list_update || fail "Failed to update package lists" - ensure_bootstrap_tool "jq" "jq" + ensure_bootstrap_tool "ucode" "ucode" resolve_podkop_plus_release remove_conflicting_dns_proxy diff --git a/podkop/Makefile b/podkop/Makefile index 563585d1..1915dfe3 100644 --- a/podkop/Makefile +++ b/podkop/Makefile @@ -21,7 +21,7 @@ include $(INCLUDE_DIR)/package.mk define Package/podkop-plus SECTION:=net CATEGORY:=Network - DEPENDS:=+sing-box +curl +jq +lua +luci-lib-jsonc +luci-lib-nixio +kmod-nft-tproxy +coreutils-base64 +coreutils-sort +bind-dig +nftables +kmod-nft-nat +kmod-nft-offload + DEPENDS:=+sing-box +curl +ucode +ucode-mod-fs +kmod-nft-tproxy +coreutils-base64 +coreutils-sort +bind-dig +nftables +kmod-nft-nat +kmod-nft-offload CONFLICTS:=https-dns-proxy nextdns luci-app-passwall luci-app-passwall2 TITLE:=Podkop Plus backend service URL:=https://github.com/ushan0v/podkop-plus diff --git a/podkop/files/usr/bin/podkop b/podkop/files/usr/bin/podkop index 1081800b..2b85f81e 100755 --- a/podkop/files/usr/bin/podkop +++ b/podkop/files/usr/bin/podkop @@ -16,14 +16,20 @@ check_required_file /lib/functions/network.sh check_required_file "$PODKOP_LIB/constants.sh" check_required_file "$PODKOP_LIB/nft.sh" check_required_file "$PODKOP_LIB/helpers.sh" +check_required_file "$PODKOP_LIB/json_utils.uc" +check_required_file "$PODKOP_LIB/provider_status.uc" check_required_file "$PODKOP_LIB/zapret.sh" check_required_file "$PODKOP_LIB/byedpi.sh" check_required_file "$PODKOP_LIB/sing_box_config_manager.sh" +check_required_file "$PODKOP_LIB/sing_box_config_manager.uc" check_required_file "$PODKOP_LIB/sing_box_config_facade.sh" -check_required_file "$PODKOP_LIB/subscription_cache.lua" +check_required_file "$PODKOP_LIB/sing_box_config_facade.uc" +check_required_file "$PODKOP_LIB/subscription_cache.uc" +check_required_file "$PODKOP_LIB/subscription_parser.uc" check_required_file "$PODKOP_LIB/subscription_parser.sh" check_required_file "$PODKOP_LIB/logging.sh" check_required_file "$PODKOP_LIB/rulesets.sh" +check_required_file "$PODKOP_LIB/rulesets.uc" check_required_file "$PODKOP_LIB/updater.sh" . /lib/functions.sh . /lib/config/uci.sh @@ -159,8 +165,8 @@ remove_legacy_server_country_cache() { rm -f "$PODKOP_RUNTIME_STATE_DIR/server-country-cache.json" } -subscription_cache_lua() { - lua "$PODKOP_LIB/subscription_cache.lua" "$@" +subscription_cache_ucode() { + ucode "$PODKOP_LIB/subscription_cache.uc" "$@" } subscription_cache_tmpfile() { @@ -195,7 +201,7 @@ write_subscription_outbound_link_cache() { return 1 } - subscription_cache_lua write-link-cache \ + subscription_cache_ucode write-link-cache \ "$PODKOP_SECTION_CACHE_DIR" "$PODKOP_RUNTIME_CACHE_FORMAT" "$section" \ "$links_tmp" "$link_refs_tmp" status=$? @@ -238,7 +244,7 @@ write_outbound_metadata() { return 1 } - subscription_cache_lua write-outbound-metadata \ + subscription_cache_ucode write-outbound-metadata \ "$PODKOP_SECTION_CACHE_DIR" "$PODKOP_RUNTIME_CACHE_FORMAT" "$section" \ "$names_tmp" "$countries_tmp" "$servers_tmp" status=$? @@ -255,7 +261,7 @@ subscription_metadata_append_file() { [ -n "$array_path" ] || return 0 [ -n "$metadata_path" ] || return 0 - subscription_cache_lua append-metadata-file "$array_path" "$metadata_path" "$source_index" "$source_section" || true + subscription_cache_ucode append-metadata-file "$array_path" "$metadata_path" "$source_index" "$source_section" || true } subscription_metadata_append_cached_source() { @@ -269,7 +275,7 @@ subscription_metadata_append_cached_source() { [ -n "$section" ] || return 0 legacy_target_path="$(get_subscription_metadata_path "$section")" - subscription_cache_lua append-cached-metadata \ + subscription_cache_ucode append-cached-metadata \ "$array_path" "$PODKOP_SECTION_CACHE_DIR" "$section" "$legacy_target_path" \ "$source_index" "$source_section" } @@ -281,7 +287,7 @@ write_subscription_metadata_json() { ensure_runtime_dirs [ -n "$metadata_json_path" ] || metadata_json_path="/dev/null" - subscription_cache_lua write-subscription-metadata \ + subscription_cache_ucode write-subscription-metadata \ "$PODKOP_SECTION_CACHE_DIR" "$PODKOP_RUNTIME_CACHE_FORMAT" "$section" "$metadata_json_path" } @@ -296,7 +302,7 @@ write_subscription_source_metadata_json() { [ -n "$metadata_json_path" ] || metadata_json_path="/dev/null" legacy_target_path="$(get_subscription_metadata_path "$section")" - subscription_cache_lua write-source-metadata \ + subscription_cache_ucode write-source-metadata \ "$PODKOP_SECTION_CACHE_DIR" "$PODKOP_RUNTIME_CACHE_FORMAT" "$section" \ "$source_index" "$source_section" "$metadata_json_path" "$legacy_target_path" } @@ -486,8 +492,7 @@ download_subscription_into_cache() { subscription_extract_ui_metadata "$headers_tmpfile" "$raw_tmpfile" "$metadata_tmpfile" >/dev/null 2>&1 || rm -f "$metadata_tmpfile" if [ -n "$metadata_output_path" ]; then - if [ -s "$metadata_tmpfile" ] && - jq -e 'type == "object" and (keys | length > 1)' "$metadata_tmpfile" >/dev/null 2>&1; then + if [ -s "$metadata_tmpfile" ] && json_utils_ucode object-has-extra-keys "$metadata_tmpfile" >/dev/null 2>&1; then cp "$metadata_tmpfile" "$metadata_output_path" || rm -f "$metadata_output_path" else rm -f "$metadata_output_path" @@ -697,7 +702,7 @@ prepare_subscription_cache_for_startup() { fi if [ -n "$metadata_tmpfile" ]; then - metadata_count="$(jq -r 'length' "$metadata_tmpfile" 2>/dev/null)" + metadata_count="$(json_utils_ucode length "$metadata_tmpfile" 2>/dev/null)" if [ -n "$metadata_count" ] && [ "$metadata_count" -gt 0 ]; then write_subscription_metadata_json "$section" "$metadata_tmpfile" else @@ -843,7 +848,7 @@ sing_box_service_pid() { local service_json='{"name":"sing-box"}' ubus call service list "$service_json" 2>/dev/null | - jq -r '.["sing-box"].instances // {} | to_entries[] | select((.value.running // false) == true and ((.value.pid // 0) > 0)) | .value.pid' 2>/dev/null | + json_utils_ucode sing-box-service-pid 2>/dev/null | sed -n '1p' } @@ -1726,7 +1731,7 @@ apply_urltest_selector_switch() { attempt=1 while [ "$attempt" -le 10 ]; do response="$(clash_api set_group_proxy "$selector_tag" "$urltest_tag" 2>/dev/null || true)" - if printf '%s' "$response" | jq -e '.success == true' >/dev/null 2>&1; then + if printf '%s' "$response" | json_utils_ucode response-success >/dev/null 2>&1; then log "Selected URLTest outbound '$urltest_tag' for selector '$selector_tag'" return 0 fi @@ -2232,9 +2237,8 @@ migrate_list_update_enabled() { check_requirements() { log "Check Requirements" - local sing_box_version jq_version coreutils_base64_version + local sing_box_version coreutils_base64_version sing_box_version="$(sing-box version | head -n1 | awk '{print $NF}')" - jq_version="$(jq --version | awk -F- '{print $2}')" coreutils_base64_version="$(base64 --version | head -n1 | awk '{print $4}')" if [ -z "$sing_box_version" ]; then @@ -2252,14 +2256,6 @@ check_requirements() { fi fi - if [ -z "$jq_version" ]; then - log "Package 'jq' is not installed. Aborted." "error" - exit 1 - elif ! is_min_package_version "$jq_version" "$JQ_REQUIRED_VERSION"; then - log "Package 'jq' version ($jq_version) is lower than the required minimum ($JQ_REQUIRED_VERSION). Aborted." "error" - exit 1 - fi - if [ -z "$coreutils_base64_version" ]; then log "Package 'coreutils-base64' is not installed. Aborted." "error" exit 1 @@ -2754,7 +2750,7 @@ uninstall() { rm -f /tmp/luci-indexcache* [ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload >/dev/null 2>&1 || true - echo '{"removed":true}' | jq . + printf '%s\n' '{"removed":true}' } # Migrations and validation funcs @@ -2933,14 +2929,7 @@ ruleset_kind_from_reference_hint() { json_ruleset_has_domain_matchers() { local filepath="$1" - jq -e ' - .. | objects | to_entries[]? | - select(.key == "domain" or .key == "domain_suffix" or .key == "domain_keyword" or .key == "domain_regex") | - .value | - if type == "array" then length > 0 - elif type == "string" then . != "" - else false end - ' "$filepath" > /dev/null 2>&1 + ucode "$PODKOP_LIB/rulesets.uc" has-domain-matchers "$filepath" >/dev/null 2>&1 } detect_local_binary_ruleset_kind() { @@ -3056,7 +3045,7 @@ validate_outbound_json_rule() { exit 1 fi - if ! printf '%s' "$outbound_json" | jq -e 'type == "object" and (.type? | type == "string")' >/dev/null 2>&1; then + if ! printf '%s' "$outbound_json" | json_utils_ucode valid-outbound >/dev/null 2>&1; then log "JSON outbound rule '$section' must contain a valid sing-box outbound JSON object with a type field. Aborted." "fatal" exit 1 fi @@ -4058,7 +4047,7 @@ subscription_update_section() { if [ -n "$metadata_tmpfile" ]; then if [ "$SUBSCRIPTION_SECTION_UPDATE_SUPERSEDED" -eq 0 ]; then - metadata_count="$(jq -r 'length' "$metadata_tmpfile" 2>/dev/null)" + metadata_count="$(json_utils_ucode length "$metadata_tmpfile" 2>/dev/null)" if [ -n "$metadata_count" ] && [ "$metadata_count" -gt 0 ]; then write_subscription_metadata_json "$section" "$metadata_tmpfile" else @@ -4501,7 +4490,7 @@ sing_box_configure_outbounds() { json_array_append_string() { local array_json="$1" local value="$2" - local array_tmp result jq_status + local array_tmp result status array_tmp="$(mktemp)" || return 1 printf '%s' "$array_json" > "$array_tmp" || { @@ -4509,12 +4498,11 @@ json_array_append_string() { return 1 } - result="$(jq -cn --slurpfile array "$array_tmp" --arg value "$value" \ - '($array[0] // []) + [$value]' 2>/dev/null)" - jq_status=$? + result="$(ucode "$PODKOP_LIB/json_utils.uc" array-append-string "$array_tmp" "$value" 2>/dev/null)" + status=$? rm -f "$array_tmp" - [ "$jq_status" -eq 0 ] || return 1 + [ "$status" -eq 0 ] || return 1 [ -n "$result" ] || return 1 printf '%s\n' "$result" } @@ -4522,7 +4510,7 @@ json_array_append_string() { json_arrays_concat() { local first_json="$1" local second_json="$2" - local first_tmp second_tmp result jq_status + local first_tmp second_tmp result status first_tmp="$(mktemp)" || return 1 second_tmp="$(mktemp)" || { @@ -4539,12 +4527,11 @@ json_arrays_concat() { return 1 } - result="$(jq -cn --slurpfile first "$first_tmp" --slurpfile second "$second_tmp" \ - '($first[0] // []) + ($second[0] // [])' 2>/dev/null)" - jq_status=$? + result="$(ucode "$PODKOP_LIB/json_utils.uc" arrays-concat "$first_tmp" "$second_tmp" 2>/dev/null)" + status=$? rm -f "$first_tmp" "$second_tmp" - [ "$jq_status" -eq 0 ] || return 1 + [ "$status" -eq 0 ] || return 1 [ -n "$result" ] || return 1 printf '%s\n' "$result" } @@ -4553,7 +4540,7 @@ json_object_set_string() { local object_json="$1" local key="$2" local value="$3" - local object_tmp result jq_status + local object_tmp result status object_tmp="$(mktemp)" || return 1 printf '%s' "$object_json" > "$object_tmp" || { @@ -4561,12 +4548,11 @@ json_object_set_string() { return 1 } - result="$(jq -cn --slurpfile object "$object_tmp" --arg key "$key" --arg value "$value" \ - '($object[0] // {}) + {($key): $value}' 2>/dev/null)" - jq_status=$? + result="$(ucode "$PODKOP_LIB/json_utils.uc" object-set-string "$object_tmp" "$key" "$value" 2>/dev/null)" + status=$? rm -f "$object_tmp" - [ "$jq_status" -eq 0 ] || return 1 + [ "$status" -eq 0 ] || return 1 [ -n "$result" ] || return 1 printf '%s\n' "$result" } @@ -4574,7 +4560,7 @@ json_object_set_string() { json_objects_merge() { local first_json="$1" local second_json="$2" - local first_tmp second_tmp result jq_status + local first_tmp second_tmp result status first_tmp="$(mktemp)" || return 1 second_tmp="$(mktemp)" || { @@ -4591,12 +4577,11 @@ json_objects_merge() { return 1 } - result="$(jq -cn --slurpfile first "$first_tmp" --slurpfile second "$second_tmp" \ - '($first[0] // {}) + ($second[0] // {})' 2>/dev/null)" - jq_status=$? + result="$(ucode "$PODKOP_LIB/json_utils.uc" objects-merge "$first_tmp" "$second_tmp" 2>/dev/null)" + status=$? rm -f "$first_tmp" "$second_tmp" - [ "$jq_status" -eq 0 ] || return 1 + [ "$status" -eq 0 ] || return 1 [ -n "$result" ] || return 1 printf '%s\n' "$result" } @@ -4623,8 +4608,8 @@ get_urltest_regex_matching_tags_json() { local tag_count regex_count index regex_index tag name regex result_json result_json="[]" - tag_count="$(printf '%s' "$tags_json" | jq -r 'length' 2>/dev/null)" - regex_count="$(printf '%s' "$regex_json" | jq -r 'length' 2>/dev/null)" + tag_count="$(printf '%s' "$tags_json" | json_utils_ucode stdin-length 2>/dev/null)" + regex_count="$(printf '%s' "$regex_json" | json_utils_ucode stdin-length 2>/dev/null)" case "$tag_count:$regex_count" in *[!0-9:]* | :* | *:) printf '[]\n' @@ -4639,16 +4624,16 @@ get_urltest_regex_matching_tags_json() { index=0 while [ "$index" -lt "$tag_count" ]; do - tag="$(printf '%s' "$tags_json" | jq -r --argjson index "$index" '.[$index] // empty' 2>/dev/null)" + tag="$(printf '%s' "$tags_json" | json_utils_ucode array-item "$index" 2>/dev/null)" index=$((index + 1)) [ -n "$tag" ] || continue - name="$(printf '%s' "$names_json" | jq -r --arg tag "$tag" '.[$tag] // $tag' 2>/dev/null)" + name="$(printf '%s' "$names_json" | json_utils_ucode object-get "$tag" 2>/dev/null)" [ -n "$name" ] || name="$tag" regex_index=0 while [ "$regex_index" -lt "$regex_count" ]; do - regex="$(printf '%s' "$regex_json" | jq -r --argjson index "$regex_index" '.[$index] // empty' 2>/dev/null)" + regex="$(printf '%s' "$regex_json" | json_utils_ucode array-item "$regex_index" 2>/dev/null)" regex_index=$((regex_index + 1)) [ -n "$regex" ] || continue @@ -4662,6 +4647,57 @@ get_urltest_regex_matching_tags_json() { printf '%s\n' "$result_json" } +urltest_filter_json() { + local mode="$1" + local tags_json="$2" + local names_json="$3" + local countries_json="$4" + local match_names_json="$5" + local regex_tags_json="$6" + local match_countries_json="$7" + local tags_tmp names_tmp countries_tmp match_names_tmp regex_tags_tmp match_countries_tmp result status + + tags_tmp="$(mktemp)" || return 1 + names_tmp="$(mktemp)" || { + rm -f "$tags_tmp" + return 1 + } + countries_tmp="$(mktemp)" || { + rm -f "$tags_tmp" "$names_tmp" + return 1 + } + match_names_tmp="$(mktemp)" || { + rm -f "$tags_tmp" "$names_tmp" "$countries_tmp" + return 1 + } + regex_tags_tmp="$(mktemp)" || { + rm -f "$tags_tmp" "$names_tmp" "$countries_tmp" "$match_names_tmp" + return 1 + } + match_countries_tmp="$(mktemp)" || { + rm -f "$tags_tmp" "$names_tmp" "$countries_tmp" "$match_names_tmp" "$regex_tags_tmp" + return 1 + } + + if ! printf '%s' "$tags_json" > "$tags_tmp" || + ! printf '%s' "$names_json" > "$names_tmp" || + ! printf '%s' "$countries_json" > "$countries_tmp" || + ! printf '%s' "$match_names_json" > "$match_names_tmp" || + ! printf '%s' "$regex_tags_json" > "$regex_tags_tmp" || + ! printf '%s' "$match_countries_json" > "$match_countries_tmp"; then + rm -f "$tags_tmp" "$names_tmp" "$countries_tmp" "$match_names_tmp" "$regex_tags_tmp" "$match_countries_tmp" + return 1 + fi + + result="$(json_utils_ucode urltest-filter "$mode" "$tags_tmp" "$names_tmp" "$countries_tmp" "$match_names_tmp" "$regex_tags_tmp" "$match_countries_tmp" 2>/dev/null)" + status=$? + rm -f "$tags_tmp" "$names_tmp" "$countries_tmp" "$match_names_tmp" "$regex_tags_tmp" "$match_countries_tmp" + + [ "$status" -eq 0 ] || return 1 + [ -n "$result" ] || result="[]" + printf '%s\n' "$result" +} + is_ipv6_literal() { local value="$1" @@ -4723,11 +4759,8 @@ read_section_outbound_country_cache() { esac cache_path="$(get_section_cache_path "$section")" - if [ -s "$cache_path" ] && jq -e 'type == "object"' "$cache_path" >/dev/null 2>&1; then - countries_json="$(jq -c ' - def object_or_empty: if type == "object" then . else {} end; - .outboundMetadata.countries | object_or_empty - ' "$cache_path" 2>/dev/null)" + if [ -s "$cache_path" ]; then + countries_json="$(json_utils_ucode section-countries "$cache_path" 2>/dev/null)" if [ -n "$countries_json" ]; then printf '%s\n' "$countries_json" return 0 @@ -4740,15 +4773,21 @@ read_section_outbound_country_cache() { country_is_lookup_ips() { local ips_json="$1" local ip_count start end batch_json body_tmp http_code body_error - local result_tmp result_json jq_status + local ips_tmp result_tmp result_json status + ips_tmp="$(mktemp)" || return 1 result_tmp="$(mktemp)" || return 1 : > "$result_tmp" - ip_count="$(printf '%s' "$ips_json" | jq -r 'length' 2>/dev/null)" + printf '%s' "$ips_json" > "$ips_tmp" || { + rm -f "$ips_tmp" "$result_tmp" + return 1 + } + + ip_count="$(printf '%s' "$ips_json" | json_utils_ucode stdin-length 2>/dev/null)" case "$ip_count" in '' | *[!0-9]*) - rm -f "$result_tmp" + rm -f "$ips_tmp" "$result_tmp" return 1 ;; esac @@ -4756,13 +4795,13 @@ country_is_lookup_ips() { start=0 while [ "$start" -lt "$ip_count" ]; do end=$((start + 100)) - batch_json="$(printf '%s' "$ips_json" | jq -c --argjson start "$start" --argjson end "$end" '.[$start:$end]' 2>/dev/null)" + batch_json="$(json_utils_ucode array-slice-file "$ips_tmp" "$start" "$end" 2>/dev/null)" if [ -z "$batch_json" ] || [ "$batch_json" = "[]" ]; then break fi body_tmp="$(mktemp)" || { - rm -f "$result_tmp" + rm -f "$ips_tmp" "$result_tmp" return 1 } @@ -4771,7 +4810,7 @@ country_is_lookup_ips() { -d "$batch_json" \ 'https://api.country.is/' 2>/dev/null || true)" - body_error="$(jq -r '.error.code // .code // .error // empty' "$body_tmp" 2>/dev/null)" + body_error="$(json_utils_ucode body-error "$body_tmp" 2>/dev/null)" if [ "$body_error" = "rate_limit" ]; then log "Server country lookup is rate-limited" "warn" rm -f "$body_tmp" @@ -4779,8 +4818,7 @@ country_is_lookup_ips() { fi if [ "$http_code" = "200" ]; then - jq -r '.[]? | select((.ip // "") != "" and (.country // "") != "") | [.ip, .country] | @tsv' \ - "$body_tmp" >> "$result_tmp" 2>/dev/null || true + json_utils_ucode ip-country-tsv "$body_tmp" >> "$result_tmp" 2>/dev/null || true elif [ "$http_code" = "429" ]; then log "Server country lookup is rate-limited" "warn" rm -f "$body_tmp" @@ -4794,17 +4832,11 @@ country_is_lookup_ips() { [ "$start" -lt "$ip_count" ] && sleep 1 done - result_json="$(jq -Rn ' - reduce inputs as $line ( - {}; - ($line | split("\t")) as $parts - | if ($parts | length) >= 2 then .[$parts[0]] = $parts[1] else . end - ) - ' < "$result_tmp" 2>/dev/null)" - jq_status=$? - rm -f "$result_tmp" + result_json="$(json_utils_ucode tsv-to-object "$result_tmp" 2>/dev/null)" + status=$? + rm -f "$ips_tmp" "$result_tmp" - [ "$jq_status" -eq 0 ] || return 1 + [ "$status" -eq 0 ] || return 1 [ -n "$result_json" ] || result_json="{}" printf '%s\n' "$result_json" } @@ -4812,11 +4844,15 @@ country_is_lookup_ips() { detect_server_countries_for_tags() { local servers_json="$1" local section="${2:-}" - local cache_json cached_countries_json missing_tmp resolved_tmp ips_json ip_country_json + local cache_tmp servers_tmp cache_json cached_countries_json missing_tmp resolved_tmp ips_json ip_country_json ip_country_tmp local resolved_countries_json tag server ip local server_count missing_count resolved_count unresolved_count log_prefix - if [ -z "$servers_json" ] || ! printf '%s' "$servers_json" | jq -e 'type == "object"' >/dev/null 2>&1; then + server_count="$(printf '%s' "$servers_json" | json_utils_ucode stdin-length 2>/dev/null)" + case "$server_count" in + '' | *[!0-9]*) server_count=0 ;; + esac + if [ -z "$servers_json" ] || [ "$server_count" -eq 0 ]; then printf '{}\n' return 0 fi @@ -4827,46 +4863,43 @@ detect_server_countries_for_tags() { log_prefix="current proxy rule" fi - server_count="$(printf '%s' "$servers_json" | jq -r 'length' 2>/dev/null)" - case "$server_count" in - '' | *[!0-9]*) server_count=0 ;; - esac - if [ "$server_count" -eq 0 ]; then - log "Server country detection skipped for $log_prefix" "debug" - printf '{}\n' - return 0 - fi - log "Detecting server countries for $log_prefix" "info" cache_json="$(read_section_outbound_country_cache "$section")" - cached_countries_json="$(jq -cn --argjson servers "$servers_json" --argjson cache "$cache_json" ' - reduce ($servers | to_entries[]) as $entry ( - {}; - ($cache[$entry.key] // "") as $country - | if $country != "" then .[$entry.key] = $country else . end - ) - ' 2>/dev/null)" + servers_tmp="$(mktemp)" || { + printf '{}\n' + return 0 + } + cache_tmp="$(mktemp)" || { + rm -f "$servers_tmp" + printf '{}\n' + return 0 + } + printf '%s' "$servers_json" > "$servers_tmp" || { + rm -f "$servers_tmp" "$cache_tmp" + printf '{}\n' + return 0 + } + printf '%s' "$cache_json" > "$cache_tmp" || { + rm -f "$servers_tmp" "$cache_tmp" + printf '{}\n' + return 0 + } + cached_countries_json="$(json_utils_ucode cached-countries-for-servers "$servers_tmp" "$cache_tmp" 2>/dev/null)" [ -n "$cached_countries_json" ] || cached_countries_json="{}" missing_tmp="$(mktemp)" || { + rm -f "$servers_tmp" "$cache_tmp" printf '%s\n' "$cached_countries_json" return 0 } resolved_tmp="$(mktemp)" || { - rm -f "$missing_tmp" + rm -f "$servers_tmp" "$cache_tmp" "$missing_tmp" printf '%s\n' "$cached_countries_json" return 0 } - jq -r --argjson cache "$cache_json" ' - to_entries[]? - | (.value | tostring | ascii_downcase) as $server - | select($server != "" and (($cache[.key] // "") == "")) - | [.key, .value] | @tsv - ' < "$missing_tmp" 2>/dev/null -$servers_json -EOF + json_utils_ucode missing-servers-tsv "$servers_tmp" "$cache_tmp" > "$missing_tmp" 2>/dev/null || true missing_count="$(wc -l < "$missing_tmp" 2>/dev/null | tr -d ' ')" case "$missing_count" in @@ -4888,7 +4921,7 @@ EOF if [ "$missing_count" -gt 0 ]; then log "Server country detection for $log_prefix could not resolve some servers" "warn" fi - rm -f "$missing_tmp" "$resolved_tmp" + rm -f "$servers_tmp" "$cache_tmp" "$missing_tmp" "$resolved_tmp" printf '%s\n' "$cached_countries_json" return 0 fi @@ -4896,31 +4929,30 @@ EOF unresolved_count=$((missing_count - resolved_count)) [ "$unresolved_count" -le 0 ] || log "Server country detection for $log_prefix could not resolve some servers" "warn" - ips_json="$(cut -f 2 "$resolved_tmp" | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null)" + ips_json="$(json_utils_ucode tsv-second-column-array "$resolved_tmp" 2>/dev/null)" [ -n "$ips_json" ] || ips_json="[]" ip_country_json="$(country_is_lookup_ips "$ips_json" 2>/dev/null || printf '{}')" [ -n "$ip_country_json" ] || ip_country_json="{}" - if ! printf '%s' "$ip_country_json" | jq -e 'length > 0' >/dev/null 2>&1; then + if ! printf '%s' "$ip_country_json" | json_utils_ucode object-nonempty >/dev/null 2>&1; then log "Server country detection for $log_prefix returned no countries" "warn" fi - resolved_countries_json="$(jq -Rn --argjson ip_country "$ip_country_json" ' - reduce inputs as $line ( - {}; - ($line | split("\t")) as $parts - | if ($parts | length) >= 2 then - ($ip_country[$parts[1]] // "") as $country - | if $country != "" then .[$parts[0]] = $country else . end - else - . - end - ) - ' < "$resolved_tmp" 2>/dev/null)" + ip_country_tmp="$(mktemp)" || { + rm -f "$servers_tmp" "$cache_tmp" "$missing_tmp" "$resolved_tmp" + printf '%s\n' "$cached_countries_json" + return 0 + } + printf '%s' "$ip_country_json" > "$ip_country_tmp" || { + rm -f "$servers_tmp" "$cache_tmp" "$missing_tmp" "$resolved_tmp" "$ip_country_tmp" + printf '%s\n' "$cached_countries_json" + return 0 + } + resolved_countries_json="$(json_utils_ucode resolved-countries-from-tsv "$resolved_tmp" "$ip_country_tmp" 2>/dev/null)" [ -n "$resolved_countries_json" ] || resolved_countries_json="{}" - rm -f "$missing_tmp" "$resolved_tmp" + rm -f "$servers_tmp" "$cache_tmp" "$missing_tmp" "$resolved_tmp" "$ip_country_tmp" log "Server country detection for $log_prefix completed" "info" - jq -cn --argjson cached "$cached_countries_json" --argjson resolved "$resolved_countries_json" '$cached + $resolved' 2>/dev/null || + json_objects_merge "$cached_countries_json" "$resolved_countries_json" 2>/dev/null || printf '%s\n' "$cached_countries_json" } @@ -4939,33 +4971,12 @@ select_urltest_included_outbounds() { include_countries_json="[]" if [ "$country_filter_enabled" -eq 1 ]; then include_countries_json="$(config_list_to_json "$section" "urltest_include_countries" | - jq -c 'map(tostring | ascii_upcase | select(length == 2))' 2>/dev/null)" + json_utils_ucode normalized-country-list 2>/dev/null)" [ -n "$include_countries_json" ] || include_countries_json="[]" fi - filtered_json="$(jq -cn \ - --argjson tags "$tags_json" \ - --argjson names "$names_json" \ - --argjson countries "$countries_json" \ - --argjson include_names "$include_names_json" \ - --argjson include_regex_tags "$include_regex_tags_json" \ - --argjson include_countries "$include_countries_json" ' - reduce ($tags // [])[] as $tag ( - []; - ($names[$tag] // $tag) as $name - | (($countries[$tag] // "") | ascii_upcase) as $country - | if - (($include_names | index($name)) != null) or - (($include_names | index($tag)) != null) or - ($country != "" and (($include_countries | index($country)) != null)) or - (($include_regex_tags | index($tag)) != null) - then - . + [$tag] - else - . - end - ) - ' 2>/dev/null)" + filtered_json="$(urltest_filter_json include "$tags_json" "$names_json" "$countries_json" \ + "$include_names_json" "$include_regex_tags_json" "$include_countries_json")" [ -n "$filtered_json" ] || filtered_json="[]" printf '%s\n' "$filtered_json" @@ -4986,33 +4997,12 @@ exclude_urltest_outbounds() { exclude_countries_json="[]" if [ "$country_filter_enabled" -eq 1 ]; then exclude_countries_json="$(config_list_to_json "$section" "urltest_exclude_countries" | - jq -c 'map(tostring | ascii_upcase | select(length == 2))' 2>/dev/null)" + json_utils_ucode normalized-country-list 2>/dev/null)" [ -n "$exclude_countries_json" ] || exclude_countries_json="[]" fi - filtered_json="$(jq -cn \ - --argjson tags "$tags_json" \ - --argjson names "$names_json" \ - --argjson countries "$countries_json" \ - --argjson exclude_names "$exclude_names_json" \ - --argjson exclude_regex_tags "$exclude_regex_tags_json" \ - --argjson exclude_countries "$exclude_countries_json" ' - reduce ($tags // [])[] as $tag ( - []; - ($names[$tag] // $tag) as $name - | (($countries[$tag] // "") | ascii_upcase) as $country - | if - (($exclude_names | index($name)) != null) or - (($exclude_names | index($tag)) != null) or - ($country != "" and (($exclude_countries | index($country)) != null)) or - (($exclude_regex_tags | index($tag)) != null) - then - . - else - . + [$tag] - end - ) - ' 2>/dev/null)" + filtered_json="$(urltest_filter_json exclude "$tags_json" "$names_json" "$countries_json" \ + "$exclude_names_json" "$exclude_regex_tags_json" "$exclude_countries_json")" [ -n "$filtered_json" ] || filtered_json="[]" printf '%s\n' "$filtered_json" @@ -5051,8 +5041,8 @@ filter_urltest_outbounds() { esac [ -n "$filtered_json" ] || filtered_json="$tags_json" - filtered_count="$(printf '%s' "$filtered_json" | jq -r 'length' 2>/dev/null)" - original_count="$(printf '%s' "$tags_json" | jq -r 'length' 2>/dev/null)" + filtered_count="$(printf '%s' "$filtered_json" | json_utils_ucode stdin-length 2>/dev/null)" + original_count="$(printf '%s' "$tags_json" | json_utils_ucode stdin-length 2>/dev/null)" if [ -z "$filtered_count" ] || [ "$filtered_count" -eq 0 ]; then if [ "$filter_mode" = "include" ]; then @@ -5156,7 +5146,7 @@ proxy_group_add_manual_link() { outbound_tag="$(get_outbound_tag_by_section "$section-$PROXY_GROUP_MANUAL_INDEX")" display_name="$(subscription_url_get_fragment "$link")" [ -n "$display_name" ] || display_name="$outbound_tag" - server="$(printf '%s' "$config" | jq -r --arg tag "$outbound_tag" '.outbounds[]? | select(.tag == $tag) | .server // empty' 2>/dev/null)" + server="$(printf '%s' "$config" | json_utils_ucode outbound-server-by-tag "$outbound_tag" 2>/dev/null)" proxy_group_append_outbound "$outbound_tag" "$link" "$display_name" "$server" } @@ -5309,7 +5299,7 @@ configure_outbound_handler() { SUBSCRIPTION_SECTION_METADATA_TMP="" if [ -n "$metadata_tmpfile" ]; then - metadata_count="$(jq -r 'length' "$metadata_tmpfile" 2>/dev/null)" + metadata_count="$(json_utils_ucode length "$metadata_tmpfile" 2>/dev/null)" if [ -n "$metadata_count" ] && [ "$metadata_count" -gt 0 ]; then write_subscription_metadata_json "$section" "$metadata_tmpfile" else @@ -5326,7 +5316,7 @@ configure_outbound_handler() { [ -n "$PROXY_GROUP_COUNTRIES_JSON" ] || PROXY_GROUP_COUNTRIES_JSON="{}" fi - outbounds_count="$(printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" | jq -r 'length' 2>/dev/null)" + outbounds_count="$(printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" | json_utils_ucode stdin-length 2>/dev/null)" if [ -z "$outbounds_count" ] || [ "$outbounds_count" -eq 0 ]; then log "Proxy rule '$section' has no usable proxy outbounds configured. Aborted." "fatal" exit 1 @@ -5343,7 +5333,7 @@ configure_outbound_handler() { urltest_outbounds="$(filter_urltest_outbounds "$section" "$PROXY_GROUP_OUTBOUND_TAGS_JSON" \ "$PROXY_GROUP_NAMES_JSON" "$PROXY_GROUP_COUNTRIES_JSON" "$detect_server_country")" [ -n "$urltest_outbounds" ] || urltest_outbounds="[]" - urltest_outbounds_count="$(printf '%s' "$urltest_outbounds" | jq -r 'length' 2>/dev/null)" + urltest_outbounds_count="$(printf '%s' "$urltest_outbounds" | json_utils_ucode stdin-length 2>/dev/null)" case "$urltest_outbounds_count" in '' | *[!0-9]*) urltest_outbounds_count=0 ;; esac @@ -5363,7 +5353,7 @@ configure_outbound_handler() { fi config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" "$urltest_tag" "true")" else - selector_default="$(printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" | jq -r '.[0] // empty' 2>/dev/null)" + selector_default="$(printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" | json_utils_ucode array-item 0 2>/dev/null)" if [ -z "$selector_default" ]; then log "Unable to determine default selector outbound for rule '$section'. Aborted." "fatal" exit 1 @@ -5372,7 +5362,7 @@ configure_outbound_handler() { config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$PROXY_GROUP_OUTBOUND_TAGS_JSON" "$selector_default" "true")" fi else - selector_default="$(printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" | jq -r '.[0] // empty' 2>/dev/null)" + selector_default="$(printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" | json_utils_ucode array-item 0 2>/dev/null)" if [ -z "$selector_default" ]; then log "Unable to determine default selector outbound for rule '$section'. Aborted." "fatal" exit 1 @@ -5547,10 +5537,7 @@ create_route_rule_for_action() { dns_route_rule_exists() { local tag="$1" - echo "$config" | jq -e \ - --arg service_tag "$SERVICE_TAG" \ - --arg tag "$tag" \ - '.dns.rules[]? | select(.[$service_tag] == $tag)' >/dev/null 2>&1 + printf '%s' "$config" | json_utils_ucode dns-route-rule-exists "$SERVICE_TAG" "$tag" >/dev/null 2>&1 } ensure_fakeip_dns_route_rule() { @@ -5567,18 +5554,7 @@ ensure_fakeip_dns_route_rule() { route_rule_has_resolve_matchers() { local tag="$1" - echo "$config" | jq -e \ - --arg service_tag "$SERVICE_TAG" \ - --arg tag "$tag" \ - '.route.rules[]? | - select(.[$service_tag] == $tag) | - ( - has("domain") or - has("domain_suffix") or - has("domain_keyword") or - has("domain_regex") or - has("rule_set") - )' >/dev/null 2>&1 + printf '%s' "$config" | json_utils_ucode route-rule-has-resolve-matchers "$SERVICE_TAG" "$tag" >/dev/null 2>&1 } configure_rule_set_reference_handler() { @@ -6580,30 +6556,7 @@ check_proxy() { return 1 fi - jq ' - walk( - if type == "object" then - with_entries( - if .key == "uuid" then - .value = "MASKED" - elif .key == "server" then - .value = "MASKED" - elif .key == "server_name" then - .value = "MASKED" - elif .key == "password" then - .value = "MASKED" - elif .key == "public_key" then - .value = "MASKED" - elif .key == "short_id" then - .value = "MASKED" - elif .key == "fingerprint" then - .value = "MASKED" - elif .key == "server_port" then - .value = "MASKED" - else . end - ) - else . end - )' "$sing_box_config_path" + json_utils_ucode mask-sing-box-config "$sing_box_config_path" nolog "Checking proxy connection..." @@ -6614,22 +6567,13 @@ check_proxy() { cleanup_check_proxy_dir "$check_proxy_dir" mkdir -p "$check_proxy_dir" - if ! jq --arg cache_path "$check_proxy_cache" ' - .inbounds = [] - | .services = [] - | (if .experimental.cache_file? then .experimental.cache_file.path = $cache_path else . end) - | del(.experimental.clash_api) - ' "$sing_box_config_path" > "$check_proxy_config"; then + if ! json_utils_ucode prepare-check-proxy-config "$sing_box_config_path" "$check_proxy_config" "$check_proxy_cache"; then nolog "Failed to prepare temporary configuration" cleanup_check_proxy_dir "$check_proxy_dir" return 1 fi - check_proxy_outbound_tag=$(jq -r --arg domain "$CHECK_PROXY_IP_DOMAIN" ' - .route.rules[]? - | select((.domain // []) | index($domain)) - | .outbound // empty - ' "$check_proxy_config" | sed -n '1p') + check_proxy_outbound_tag="$(json_utils_ucode check-proxy-outbound-tag "$check_proxy_config" "$CHECK_PROXY_IP_DOMAIN" 2>/dev/null | sed -n '1p')" for attempt in $(seq 1 5); do if [ -n "$check_proxy_outbound_tag" ]; then @@ -6793,30 +6737,7 @@ show_sing_box_config() { return 1 fi - jq ' - walk( - if type == "object" then - with_entries( - if .key == "uuid" then - .value = "MASKED" - elif .key == "server" then - .value = "MASKED" - elif .key == "server_name" then - .value = "MASKED" - elif .key == "password" then - .value = "MASKED" - elif .key == "public_key" then - .value = "MASKED" - elif .key == "short_id" then - .value = "MASKED" - elif .key == "fingerprint" then - .value = "MASKED" - elif .key == "server_port" then - .value = "MASKED" - else . end - ) - else . end - )' "$sing_box_config_path" + json_utils_ucode mask-sing-box-config "$sing_box_config_path" } show_config() { @@ -6857,25 +6778,22 @@ fetch_latest_podkop_version() { command -v curl >/dev/null 2>&1 || return 1 - if command -v jq >/dev/null 2>&1; then - releases_file="$(mktemp)" - if curl --connect-timeout 2 -m 5 -fsSL "https://api.github.com/repos/$PODKOP_RELEASE_REPO/releases?per_page=50" > "$releases_file" 2>/dev/null; then - best_tag="" - for tag in $(jq -r '.[] | select(((.draft // false) | not) and ((.prerelease // false) | not)) | .tag_name // empty' "$releases_file" 2>/dev/null); do - is_podkop_release_version "$tag" || continue + releases_file="$(mktemp)" + if curl --connect-timeout 2 -m 5 -fsSL "https://api.github.com/repos/$PODKOP_RELEASE_REPO/releases?per_page=50" > "$releases_file" 2>/dev/null; then + best_tag="" + for tag in $(json_utils_ucode github-release-tags "$releases_file" 2>/dev/null); do + is_podkop_release_version "$tag" || continue - if [ -z "$best_tag" ] || [ "$(podkop_release_version_compare "$best_tag" "$tag" 2>/dev/null)" -lt 0 ]; then - best_tag="$tag" - fi - done + if [ -z "$best_tag" ] || [ "$(podkop_release_version_compare "$best_tag" "$tag" 2>/dev/null)" -lt 0 ]; then + best_tag="$tag" + fi + done - rm -f "$releases_file" - [ -n "$best_tag" ] && echo "$best_tag" - else - rm -f "$releases_file" - fi + rm -f "$releases_file" + [ -n "$best_tag" ] && echo "$best_tag" return 0 fi + rm -f "$releases_file" curl --connect-timeout 2 -m 5 -fsSL "https://api.github.com/repos/$PODKOP_RELEASE_REPO/releases/latest" 2>/dev/null | sed -n 's/^[[:space:]]*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | @@ -6984,34 +6902,17 @@ get_luci_app_version() { } system_info_cache_is_valid() { - local now cached_at luci_app_version + local now luci_app_version [ -s "$PODKOP_SYSTEM_INFO_CACHE_FILE" ] || return 1 - jq -e . "$PODKOP_SYSTEM_INFO_CACHE_FILE" >/dev/null 2>&1 || return 1 now="$(date +%s 2>/dev/null)" case "$now" in '' | *[!0-9]*) now=0 ;; esac - cached_at="$(jq -r '.generated_at // 0' "$PODKOP_SYSTEM_INFO_CACHE_FILE" 2>/dev/null)" - case "$cached_at" in - '' | *[!0-9]*) cached_at=0 ;; - esac - - if [ "$now" -gt 0 ] && [ "$cached_at" -gt 0 ] && - [ $((now - cached_at)) -ge "$PODKOP_SYSTEM_INFO_CACHE_TTL" ]; then - return 1 - fi - luci_app_version="$(get_luci_app_version)" - jq -e \ - --arg podkop_version "$PODKOP_VERSION" \ - --arg luci_app_version "$luci_app_version" \ - '.podkop_version == $podkop_version and .luci_app_version == $luci_app_version' \ - "$PODKOP_SYSTEM_INFO_CACHE_FILE" >/dev/null 2>&1 || return 1 - - return 0 + json_utils_ucode system-info-cache-valid "$PODKOP_SYSTEM_INFO_CACHE_FILE" "$PODKOP_VERSION" "$luci_app_version" "$PODKOP_SYSTEM_INFO_CACHE_TTL" "$now" >/dev/null 2>&1 } write_system_info_cache() { @@ -7080,33 +6981,12 @@ build_system_info() { '' | *[!0-9]*) generated_at=0 ;; esac - jq -cn \ - --arg podkop_version "$podkop_version" \ - --arg podkop_latest_version "$podkop_latest_version" \ - --arg luci_app_version "$luci_app_version" \ - --arg sing_box_version "$sing_box_version" \ - --argjson sing_box_extended "$sing_box_extended" \ - --arg zapret_version "$zapret_version" \ - --argjson zapret_installed "$zapret_installed" \ - --arg byedpi_version "$byedpi_version" \ - --argjson byedpi_installed "$byedpi_installed" \ - --arg openwrt_version "$openwrt_version" \ - --arg device_model "$device_model" \ - --argjson generated_at "$generated_at" \ - '{ - podkop_version: $podkop_version, - podkop_latest_version: $podkop_latest_version, - luci_app_version: $luci_app_version, - sing_box_version: $sing_box_version, - sing_box_extended: $sing_box_extended, - zapret_version: $zapret_version, - zapret_installed: $zapret_installed, - byedpi_version: $byedpi_version, - byedpi_installed: $byedpi_installed, - openwrt_version: $openwrt_version, - device_model: $device_model, - generated_at: $generated_at - }' + json_utils_ucode system-info-json \ + "$podkop_version" "$podkop_latest_version" "$luci_app_version" \ + "$sing_box_version" "$sing_box_extended" \ + "$zapret_version" "$zapret_installed" \ + "$byedpi_version" "$byedpi_installed" \ + "$openwrt_version" "$device_model" "$generated_at" } refresh_system_info_cache() { @@ -7227,17 +7107,11 @@ get_status() { fi fi - jq -cn \ - --argjson running "$running" \ - --argjson enabled "$enabled" \ - --arg status "$status" \ - --argjson dns_configured "$dns_configured" \ - '{ - running: $running, - enabled: $enabled, - status: $status, - dns_configured: $dns_configured - }' + json_utils_ucode object-json \ + running n "$running" \ + enabled n "$enabled" \ + status s "$status" \ + dns_configured n "$dns_configured" } get_outbound_link() { @@ -7252,7 +7126,7 @@ get_outbound_link() { ;; esac - subscription_cache_lua get-link \ + subscription_cache_ucode get-link \ "$PODKOP_SECTION_CACHE_DIR" "$TMP_SUBSCRIPTION_FOLDER" "$section" "$outbound_tag" "$PODKOP_SUBSCRIPTION_LINKS_DIR" || printf '{"link":""}\n' } @@ -7268,7 +7142,7 @@ get_outbound_link_states() { ;; esac - subscription_cache_lua get-link-states "$PODKOP_SECTION_CACHE_DIR" "$section" "$PODKOP_SUBSCRIPTION_LINKS_DIR" || + subscription_cache_ucode get-link-states "$PODKOP_SECTION_CACHE_DIR" "$section" "$PODKOP_SUBSCRIPTION_LINKS_DIR" || printf '{}\n' } @@ -7285,7 +7159,7 @@ get_outbound_metadata() { esac metadata_path="$(get_outbound_metadata_path "$section")" - subscription_cache_lua get-outbound-metadata "$PODKOP_SECTION_CACHE_DIR" "$section" "$metadata_path" || + subscription_cache_ucode get-outbound-metadata "$PODKOP_SECTION_CACHE_DIR" "$section" "$metadata_path" || printf '{"names":{},"countries":{}}\n' } @@ -7303,7 +7177,7 @@ get_subscription_metadata() { esac metadata_path="$(get_subscription_metadata_path "$section")" - subscription_cache_lua get-subscription-metadata "$PODKOP_SECTION_CACHE_DIR" "$section" "$metadata_path" || + subscription_cache_ucode get-subscription-metadata "$PODKOP_SECTION_CACHE_DIR" "$section" "$metadata_path" || printf '{}\n' } @@ -7311,15 +7185,11 @@ validate_nfqws_strategy_json() { local raw_opt="${1:-}" if check_nfqws_strategy "$raw_opt"; then - jq -cn '{valid:true,message:"",needle:"",needles:[]}' + json_utils_ucode nfqws-strategy-validation true "" "" "" return 0 fi - jq -cn \ - --arg message "$NFQWS_VALIDATE_ERROR" \ - --arg needle "$NFQWS_VALIDATE_NEEDLE" \ - --arg needles "$NFQWS_VALIDATE_NEEDLES" \ - '{valid:false,message:$message,needle:$needle,needles:($needles | split("\n") | map(select(length > 0)))}' + json_utils_ucode nfqws-strategy-validation false "$NFQWS_VALIDATE_ERROR" "$NFQWS_VALIDATE_NEEDLE" "$NFQWS_VALIDATE_NEEDLES" } check_dns_available() { @@ -7385,7 +7255,14 @@ check_dns_available() { check_dhcp_has_podkop_dns config_load "$PODKOP_CONFIG_NAME" - echo "{\"dns_type\":\"$dns_type\",\"dns_server\":\"$display_dns_server\",\"dns_status\":$dns_status,\"dns_on_router\":$dns_on_router,\"bootstrap_dns_server\":\"$bootstrap_dns_server\",\"bootstrap_dns_status\":$bootstrap_dns_status,\"dhcp_config_status\":$dhcp_config_status}" | jq . + json_utils_ucode object-json \ + dns_type s "$dns_type" \ + dns_server s "$display_dns_server" \ + dns_status n "$dns_status" \ + dns_on_router n "$dns_on_router" \ + bootstrap_dns_server s "$bootstrap_dns_server" \ + bootstrap_dns_status n "$bootstrap_dns_status" \ + dhcp_config_status n "$dhcp_config_status" } check_dhcp_has_podkop_dns() { @@ -7517,7 +7394,15 @@ check_nft_rules() { rm -f /tmp/podkop_mark_check.$$ fi - echo "{\"table_exist\":$table_exist,\"rules_mangle_exist\":$rules_mangle_exist,\"rules_mangle_counters\":$rules_mangle_counters,\"rules_mangle_output_exist\":$rules_mangle_output_exist,\"rules_mangle_output_counters\":$rules_mangle_output_counters,\"rules_proxy_exist\":$rules_proxy_exist,\"rules_proxy_counters\":$rules_proxy_counters,\"rules_other_mark_exist\":$rules_other_mark_exist}" | jq . + json_utils_ucode object-json \ + table_exist n "$table_exist" \ + rules_mangle_exist n "$rules_mangle_exist" \ + rules_mangle_counters n "$rules_mangle_counters" \ + rules_mangle_output_exist n "$rules_mangle_output_exist" \ + rules_mangle_output_counters n "$rules_mangle_output_counters" \ + rules_proxy_exist n "$rules_proxy_exist" \ + rules_proxy_counters n "$rules_proxy_counters" \ + rules_other_mark_exist n "$rules_other_mark_exist" } check_sing_box() { @@ -7577,7 +7462,14 @@ check_sing_box() { sing_box_ports_listening=1 fi - echo "{\"sing_box_installed\":$sing_box_installed,\"sing_box_version_ok\":$sing_box_version_ok,\"sing_box_extended\":$sing_box_extended,\"sing_box_service_exist\":$sing_box_service_exist,\"sing_box_autostart_disabled\":$sing_box_autostart_disabled,\"sing_box_process_running\":$sing_box_process_running,\"sing_box_ports_listening\":$sing_box_ports_listening}" | jq . + json_utils_ucode object-json \ + sing_box_installed n "$sing_box_installed" \ + sing_box_version_ok n "$sing_box_version_ok" \ + sing_box_extended n "$sing_box_extended" \ + sing_box_service_exist n "$sing_box_service_exist" \ + sing_box_autostart_disabled n "$sing_box_autostart_disabled" \ + sing_box_process_running n "$sing_box_process_running" \ + sing_box_ports_listening n "$sing_box_ports_listening" } check_fakeip() { @@ -7593,8 +7485,7 @@ check_fakeip() { fakeip_status=true fi - jq -n --argjson fakeip "$fakeip_status" --arg IP "$fakeip_address" \ - '{fakeip: $fakeip, IP: $IP}' + json_utils_ucode object-json fakeip b "$fakeip_status" IP s "$fakeip_address" } ####################################### @@ -7617,7 +7508,7 @@ check_fakeip() { ####################################### clash_api_urlencode_path_segment() { - jq -nr --arg value "$1" '$value|@uri' + json_utils_ucode url-encode "$1" } clash_api() { @@ -7642,11 +7533,11 @@ clash_api() { case "$action" in get_proxies) - curl -s --header "$auth_header" "$CLASH_URL/proxies" | jq . + curl -s --header "$auth_header" "$CLASH_URL/proxies" | json_utils_ucode stdin-json ;; get_connections) - curl -s --header "$auth_header" "$CLASH_URL/connections" | jq . + curl -s --header "$auth_header" "$CLASH_URL/connections" | json_utils_ucode stdin-json ;; get_proxy_latency) @@ -7654,14 +7545,14 @@ clash_api() { local timeout="${3:-2000}" if [ -z "$proxy_tag" ]; then - echo '{"error":"proxy_tag required"}' | jq . + json_utils_ucode object-json error s "proxy_tag required" return 1 fi curl -G -s "$CLASH_URL/proxies/$proxy_tag/delay" \ --header "$auth_header" \ --data-urlencode "url=$TEST_URL" \ - --data-urlencode "timeout=$timeout" | jq . + --data-urlencode "timeout=$timeout" | json_utils_ucode stdin-json ;; get_group_latency) @@ -7669,14 +7560,14 @@ clash_api() { local timeout="${3:-5000}" if [ -z "$group_tag" ]; then - echo '{"error":"group_tag required"}' | jq . + json_utils_ucode object-json error s "group_tag required" return 1 fi curl -G -s "$CLASH_URL/group/$group_tag/delay" \ --header "$auth_header" \ --data-urlencode "url=$TEST_URL" \ - --data-urlencode "timeout=$timeout" | jq . + --data-urlencode "timeout=$timeout" | json_utils_ucode stdin-json ;; set_group_proxy) @@ -7684,7 +7575,7 @@ clash_api() { local proxy_tag="$3" if [ -z "$group_tag" ] || [ -z "$proxy_tag" ]; then - echo '{"error":"group_tag and proxy_tag required"}' | jq . + json_utils_ucode object-json error s "group_tag and proxy_tag required" return 1 fi @@ -7692,7 +7583,7 @@ clash_api() { response=$( curl -X PUT -s -w "\n%{http_code}" "$CLASH_URL/proxies/$group_tag" \ --header "$auth_header" \ - --data-raw "{\"name\":\"$proxy_tag\"}" + --data-raw "$(json_utils_ucode object-json name s "$proxy_tag")" ) local http_code @@ -7702,27 +7593,25 @@ clash_api() { case "$http_code" in 204) - echo "{\"success\":true,\"group\":\"$group_tag\",\"proxy\":\"$proxy_tag\"}" | jq . + json_utils_ucode object-json success b true group s "$group_tag" proxy s "$proxy_tag" ;; 404) - echo "{\"success\":false,\"error\":\"group_not_found\",\"message\":\"$group_tag does not exist\"}" | jq . + json_utils_ucode object-json success b false error s "group_not_found" message s "$group_tag does not exist" return 1 ;; 400) if echo "$body" | grep -q "not found"; then - echo "{\"success\":false,\"error\":\"proxy_not_found\",\"message\":\"$proxy_tag not found in group $group_tag\"}" | jq . + json_utils_ucode object-json success b false error s "proxy_not_found" message s "$proxy_tag not found in group $group_tag" else - echo '{"success":false,"error":"bad_request","message":"Invalid request"}' | jq . + json_utils_ucode object-json success b false error s "bad_request" message s "Invalid request" fi return 1 ;; *) if [ -n "$body" ]; then - local body_json - body_json=$(echo "$body" | jq -c .) - echo "{\"success\":false,\"http_code\":$http_code,\"body\":$body_json}" | jq . + json_utils_ucode object-json success b false http_code n "$http_code" body a "$body" else - echo "{\"success\":false,\"http_code\":$http_code}" | jq . + json_utils_ucode object-json success b false http_code n "$http_code" fi return 1 ;; @@ -7733,7 +7622,7 @@ clash_api() { local connection_id="$2" if [ -z "$connection_id" ]; then - echo '{"error":"connection_id required"}' | jq . + json_utils_ucode object-json error s "connection_id required" return 1 fi @@ -7748,21 +7637,17 @@ clash_api() { case "$http_code" in 200 | 204) - jq -n --arg connection_id "$connection_id" '{success:true, connection_id:$connection_id}' + json_utils_ucode object-json success b true connection_id s "$connection_id" ;; 404) - jq -n --arg connection_id "$connection_id" \ - '{success:false, error:"connection_not_found", connection_id:$connection_id}' + json_utils_ucode object-json success b false error s "connection_not_found" connection_id s "$connection_id" return 1 ;; *) if [ -n "$body" ]; then - local body_json - body_json=$(echo "$body" | jq -c . 2> /dev/null || jq -Rn --arg body "$body" '$body') - jq -n --arg http_code "$http_code" --argjson body "$body_json" \ - '{success:false, http_code:$http_code, body:$body}' + json_utils_ucode object-json success b false http_code n "$http_code" body a "$body" else - jq -n --arg http_code "$http_code" '{success:false, http_code:$http_code}' + json_utils_ucode object-json success b false http_code n "$http_code" fi return 1 ;; @@ -7780,16 +7665,13 @@ clash_api() { case "$http_code" in 200 | 204) - echo '{"success":true}' | jq . + json_utils_ucode object-json success b true ;; *) if [ -n "$body" ]; then - local body_json - body_json=$(echo "$body" | jq -c . 2> /dev/null || jq -Rn --arg body "$body" '$body') - jq -n --arg http_code "$http_code" --argjson body "$body_json" \ - '{success:false, http_code:$http_code, body:$body}' + json_utils_ucode object-json success b false http_code n "$http_code" body a "$body" else - jq -n --arg http_code "$http_code" '{success:false, http_code:$http_code}' + json_utils_ucode object-json success b false http_code n "$http_code" fi return 1 ;; @@ -7797,7 +7679,9 @@ clash_api() { ;; *) - echo '{"error":"unknown action","available":["get_proxies","get_connections","get_proxy_latency","get_group_latency","set_group_proxy","close_connection","close_all_connections"]}' | jq . + json_utils_ucode object-json \ + error s "unknown action" \ + available j '["get_proxies","get_connections","get_proxy_latency","get_group_latency","set_group_proxy","close_connection","close_all_connections"]' return 1 ;; esac @@ -7808,6 +7692,14 @@ print_global() { echo "$message" } +json_get_default() { + local json="$1" + local key="$2" + local fallback="$3" + + printf '%s' "$json" | json_utils_ucode object-get-default "$key" "$fallback" 2>/dev/null +} + global_check() { local PODKOP_LUCI_VERSION="Unknown" [ -n "$1" ] && PODKOP_LUCI_VERSION="$1" @@ -7822,17 +7714,17 @@ global_check() { if [ -n "$system_info_json" ]; then local podkop_version podkop_latest_version luci_app_version sing_box_version sing_box_extended zapret_version zapret_installed byedpi_version byedpi_installed openwrt_version device_model - podkop_version=$(echo "$system_info_json" | jq -r '.podkop_version // "unknown"') - podkop_latest_version=$(echo "$system_info_json" | jq -r '.podkop_latest_version // "unknown"') - luci_app_version=$(echo "$system_info_json" | jq -r '.luci_app_version // "unknown"') - sing_box_version=$(echo "$system_info_json" | jq -r '.sing_box_version // "unknown"') - sing_box_extended=$(echo "$system_info_json" | jq -r '.sing_box_extended // 0') - zapret_version=$(echo "$system_info_json" | jq -r '.zapret_version // "unknown"') - zapret_installed=$(echo "$system_info_json" | jq -r '.zapret_installed // 0') - byedpi_version=$(echo "$system_info_json" | jq -r '.byedpi_version // "unknown"') - byedpi_installed=$(echo "$system_info_json" | jq -r '.byedpi_installed // 0') - openwrt_version=$(echo "$system_info_json" | jq -r '.openwrt_version // "unknown"') - device_model=$(echo "$system_info_json" | jq -r '.device_model // "unknown"') + podkop_version=$(json_get_default "$system_info_json" podkop_version "unknown") + podkop_latest_version=$(json_get_default "$system_info_json" podkop_latest_version "unknown") + luci_app_version=$(json_get_default "$system_info_json" luci_app_version "unknown") + sing_box_version=$(json_get_default "$system_info_json" sing_box_version "unknown") + sing_box_extended=$(json_get_default "$system_info_json" sing_box_extended 0) + zapret_version=$(json_get_default "$system_info_json" zapret_version "unknown") + zapret_installed=$(json_get_default "$system_info_json" zapret_installed 0) + byedpi_version=$(json_get_default "$system_info_json" byedpi_version "unknown") + byedpi_installed=$(json_get_default "$system_info_json" byedpi_installed 0) + openwrt_version=$(json_get_default "$system_info_json" openwrt_version "unknown") + device_model=$(json_get_default "$system_info_json" device_model "unknown") if [ "$sing_box_extended" -eq 1 ]; then print_global "Sing-box core: extended" else @@ -7863,13 +7755,13 @@ global_check() { if [ -n "$dns_check_json" ]; then local dns_type dns_server dns_status dns_on_router bootstrap_dns_server bootstrap_dns_status dhcp_config_status - dns_type=$(echo "$dns_check_json" | jq -r '.dns_type // "unknown"') - dns_server=$(echo "$dns_check_json" | jq -r '.dns_server // "unknown"') - dns_status=$(echo "$dns_check_json" | jq -r '.dns_status // 0') - dns_on_router=$(echo "$dns_check_json" | jq -r '.dns_on_router // 0') - bootstrap_dns_server=$(echo "$dns_check_json" | jq -r '.bootstrap_dns_server // ""') - bootstrap_dns_status=$(echo "$dns_check_json" | jq -r '.bootstrap_dns_status // 0') - dhcp_config_status=$(echo "$dns_check_json" | jq -r '.dhcp_config_status // 0') + dns_type=$(json_get_default "$dns_check_json" dns_type "unknown") + dns_server=$(json_get_default "$dns_check_json" dns_server "unknown") + dns_status=$(json_get_default "$dns_check_json" dns_status 0) + dns_on_router=$(json_get_default "$dns_check_json" dns_on_router 0) + bootstrap_dns_server=$(json_get_default "$dns_check_json" bootstrap_dns_server "") + bootstrap_dns_status=$(json_get_default "$dns_check_json" bootstrap_dns_status 0) + dhcp_config_status=$(json_get_default "$dns_check_json" dhcp_config_status 0) # Bootstrap DNS if [ -n "$bootstrap_dns_server" ]; then @@ -7920,13 +7812,13 @@ global_check() { if [ -n "$singbox_check_json" ]; then local sing_box_installed sing_box_version_ok sing_box_extended sing_box_service_exist sing_box_autostart_disabled sing_box_process_running sing_box_ports_listening - sing_box_installed=$(echo "$singbox_check_json" | jq -r '.sing_box_installed // 0') - sing_box_version_ok=$(echo "$singbox_check_json" | jq -r '.sing_box_version_ok // 0') - sing_box_extended=$(echo "$singbox_check_json" | jq -r '.sing_box_extended // 0') - sing_box_service_exist=$(echo "$singbox_check_json" | jq -r '.sing_box_service_exist // 0') - sing_box_autostart_disabled=$(echo "$singbox_check_json" | jq -r '.sing_box_autostart_disabled // 0') - sing_box_process_running=$(echo "$singbox_check_json" | jq -r '.sing_box_process_running // 0') - sing_box_ports_listening=$(echo "$singbox_check_json" | jq -r '.sing_box_ports_listening // 0') + sing_box_installed=$(json_get_default "$singbox_check_json" sing_box_installed 0) + sing_box_version_ok=$(json_get_default "$singbox_check_json" sing_box_version_ok 0) + sing_box_extended=$(json_get_default "$singbox_check_json" sing_box_extended 0) + sing_box_service_exist=$(json_get_default "$singbox_check_json" sing_box_service_exist 0) + sing_box_autostart_disabled=$(json_get_default "$singbox_check_json" sing_box_autostart_disabled 0) + sing_box_process_running=$(json_get_default "$singbox_check_json" sing_box_process_running 0) + sing_box_ports_listening=$(json_get_default "$singbox_check_json" sing_box_ports_listening 0) if [ "$sing_box_installed" -eq 1 ]; then print_global "✅ Sing-box installed" @@ -7976,7 +7868,7 @@ global_check() { local zapret_check_json zapret_check_json=$(check_zapret_runtime) - if [ -n "$zapret_check_json" ] && [ "$(echo "$zapret_check_json" | jq -r '.zapret_installed // 0')" = "1" ]; then + if [ -n "$zapret_check_json" ] && [ "$(json_get_default "$zapret_check_json" zapret_installed 0)" = "1" ]; then print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" print_global "🧵 Zapret status" print_global "✅ Zapret provider available" @@ -7985,7 +7877,7 @@ global_check() { local byedpi_check_json byedpi_check_json=$(check_byedpi_runtime) - if [ -n "$byedpi_check_json" ] && [ "$(echo "$byedpi_check_json" | jq -r '.byedpi_installed // 0')" = "1" ]; then + if [ -n "$byedpi_check_json" ] && [ "$(json_get_default "$byedpi_check_json" byedpi_installed 0)" = "1" ]; then print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" print_global "🧵 ByeDPI status" print_global "✅ ByeDPI provider available" @@ -8000,14 +7892,14 @@ global_check() { if [ -n "$nft_check_json" ]; then local table_exist rules_mangle_exist rules_mangle_counters rules_mangle_output_exist rules_mangle_output_counters rules_proxy_exist rules_proxy_counters rules_other_mark_exist - table_exist=$(echo "$nft_check_json" | jq -r '.table_exist // 0') - rules_mangle_exist=$(echo "$nft_check_json" | jq -r '.rules_mangle_exist // 0') - rules_mangle_counters=$(echo "$nft_check_json" | jq -r '.rules_mangle_counters // 0') - rules_mangle_output_exist=$(echo "$nft_check_json" | jq -r '.rules_mangle_output_exist // 0') - rules_mangle_output_counters=$(echo "$nft_check_json" | jq -r '.rules_mangle_output_counters // 0') - rules_proxy_exist=$(echo "$nft_check_json" | jq -r '.rules_proxy_exist // 0') - rules_proxy_counters=$(echo "$nft_check_json" | jq -r '.rules_proxy_counters // 0') - rules_other_mark_exist=$(echo "$nft_check_json" | jq -r '.rules_other_mark_exist // 0') + table_exist=$(json_get_default "$nft_check_json" table_exist 0) + rules_mangle_exist=$(json_get_default "$nft_check_json" rules_mangle_exist 0) + rules_mangle_counters=$(json_get_default "$nft_check_json" rules_mangle_counters 0) + rules_mangle_output_exist=$(json_get_default "$nft_check_json" rules_mangle_output_exist 0) + rules_mangle_output_counters=$(json_get_default "$nft_check_json" rules_mangle_output_counters 0) + rules_proxy_exist=$(json_get_default "$nft_check_json" rules_proxy_exist 0) + rules_proxy_counters=$(json_get_default "$nft_check_json" rules_proxy_counters 0) + rules_other_mark_exist=$(json_get_default "$nft_check_json" rules_other_mark_exist 0) if [ "$table_exist" -eq 1 ]; then print_global "✅ Table exist" @@ -8149,8 +8041,8 @@ global_check() { if [ -n "$fakeip_check_json" ]; then local fakeip_status fakeip_address - fakeip_status=$(echo "$fakeip_check_json" | jq -r '.fakeip // false') - fakeip_address=$(echo "$fakeip_check_json" | jq -r '.IP // ""') + fakeip_status=$(json_get_default "$fakeip_check_json" fakeip false) + fakeip_address=$(json_get_default "$fakeip_check_json" IP "") if [ "$fakeip_status" = "true" ]; then print_global "✅ Sing-box FakeIP DNS works: $fakeip_address" diff --git a/podkop/files/usr/lib/byedpi.sh b/podkop/files/usr/lib/byedpi.sh index eb94d5b2..dc393414 100644 --- a/podkop/files/usr/lib/byedpi.sh +++ b/podkop/files/usr/lib/byedpi.sh @@ -428,14 +428,12 @@ validate_byedpi_strategy_json() { local raw_opt="$1" if check_byedpi_strategy "$raw_opt"; then - jq -cn '{valid: true, message: "", needle: "", needles: []}' + provider_status_ucode byedpi-validation 1 "" "" return 0 fi - printf '%s\n' "$BYEDPI_VALIDATE_NEEDLES" | jq -R . | jq -sc \ - --arg message "$BYEDPI_VALIDATE_ERROR" \ - --arg needle "$BYEDPI_VALIDATE_NEEDLE" \ - '{valid: false, message: $message, needle: $needle, needles: .}' + printf '%s\n' "$BYEDPI_VALIDATE_NEEDLES" | + provider_status_ucode byedpi-validation 0 "$BYEDPI_VALIDATE_ERROR" "$BYEDPI_VALIDATE_NEEDLE" } stop_byedpi_pidfile_process() { @@ -668,13 +666,7 @@ byedpi_rule_outbound_present() { outbound_tag="$(get_outbound_tag_by_section "$section")" - jq -e \ - --arg tag "$outbound_tag" \ - --arg address "$BYEDPI_LISTEN_ADDRESS" \ - --argjson port "$port" \ - '.outbounds[]? | - select(.type == "socks" and .tag == $tag and .server == $address and (.server_port // empty) == $port)' \ - "$BYEDPI_SINGBOX_CONFIG_PATH" >/dev/null 2>&1 + provider_status_ucode has-socks-outbound "$BYEDPI_SINGBOX_CONFIG_PATH" "$outbound_tag" "$BYEDPI_LISTEN_ADDRESS" "$port" >/dev/null 2>&1 } byedpi_rule_route_rule_present() { @@ -685,12 +677,7 @@ byedpi_rule_route_rule_present() { outbound_tag="$(get_outbound_tag_by_section "$section")" - jq -e \ - --arg inbound "$SB_TPROXY_INBOUND_TAG" \ - --arg outbound "$outbound_tag" \ - '.route.rules[]? | - select(.action == "route" and .inbound == $inbound and .outbound == $outbound)' \ - "$BYEDPI_SINGBOX_CONFIG_PATH" >/dev/null 2>&1 + provider_status_ucode has-route-rule "$BYEDPI_SINGBOX_CONFIG_PATH" "$SB_TPROXY_INBOUND_TAG" "$outbound_tag" >/dev/null 2>&1 } _collect_byedpi_runtime_status_handler() { @@ -816,51 +803,28 @@ get_byedpi_status_json() { status_message="byedpi provider status is normal" fi - jq -cn \ - --arg version "$version" \ - --arg provider_path "$BYEDPI_BIN" \ - --arg listen_address "$BYEDPI_LISTEN_ADDRESS" \ - --arg status_message "$status_message" \ - --argjson installed "$installed" \ - --argjson package_installed "$package_installed" \ - --argjson provider_available "$provider_available" \ - --argjson configured "$configured" \ - --argjson enabled_rule_count "${enabled_rule_count:-0}" \ - --argjson expected_process_count "${expected_process_count:-0}" \ - --argjson running_process_count "${running_process_count:-0}" \ - --argjson supervisor_process_count "${supervisor_process_count:-0}" \ - --argjson restart_count "${restart_count:-0}" \ - --argjson runtime_unstable "$runtime_unstable" \ - --argjson standalone_service_enabled "$standalone_service_enabled" \ - --argjson standalone_service_running "$standalone_service_running" \ - --argjson port_base "$BYEDPI_PORT_BASE" \ - --argjson outbounds_configured "$outbounds_configured" \ - --argjson routes_configured "$routes_configured" \ - --argjson ready "$ready" \ - --argjson conflict "$conflict" \ - '{ - installed: $installed, - package_installed: $package_installed, - provider_available: $provider_available, - provider_path: $provider_path, - version: $version, - configured: $configured, - enabled_rule_count: $enabled_rule_count, - expected_process_count: $expected_process_count, - running_process_count: $running_process_count, - supervisor_process_count: $supervisor_process_count, - restart_count: $restart_count, - runtime_unstable: $runtime_unstable, - standalone_service_enabled: $standalone_service_enabled, - standalone_service_running: $standalone_service_running, - listen_address: $listen_address, - port_base: $port_base, - outbounds_configured: $outbounds_configured, - routes_configured: $routes_configured, - ready: $ready, - conflict: $conflict, - status_message: $status_message - }' + provider_status_ucode byedpi-status \ + "$installed" \ + "$package_installed" \ + "$provider_available" \ + "$BYEDPI_BIN" \ + "$version" \ + "$configured" \ + "${enabled_rule_count:-0}" \ + "${expected_process_count:-0}" \ + "${running_process_count:-0}" \ + "${supervisor_process_count:-0}" \ + "${restart_count:-0}" \ + "$runtime_unstable" \ + "$standalone_service_enabled" \ + "$standalone_service_running" \ + "$BYEDPI_LISTEN_ADDRESS" \ + "$BYEDPI_PORT_BASE" \ + "$outbounds_configured" \ + "$routes_configured" \ + "$ready" \ + "$conflict" \ + "$status_message" } check_byedpi_runtime_json() { @@ -874,13 +838,5 @@ check_byedpi_runtime_json() { package_installed=1 fi - jq -cn \ - --argjson byedpi_installed "$installed" \ - --argjson byedpi_package_installed "$package_installed" \ - --arg byedpi_provider_path "$BYEDPI_BIN" \ - '{ - byedpi_installed: $byedpi_installed, - byedpi_package_installed: $byedpi_package_installed, - byedpi_provider_path: $byedpi_provider_path - }' + provider_status_ucode byedpi-check "$installed" "$package_installed" "$BYEDPI_BIN" } diff --git a/podkop/files/usr/lib/constants.sh b/podkop/files/usr/lib/constants.sh index 5c1bf986..47b52e01 100644 --- a/podkop/files/usr/lib/constants.sh +++ b/podkop/files/usr/lib/constants.sh @@ -19,7 +19,6 @@ TMP_SING_BOX_FOLDER="/tmp/sing-box" TMP_RULESET_FOLDER="$TMP_SING_BOX_FOLDER/rulesets" TMP_SUBSCRIPTION_FOLDER="$TMP_SING_BOX_FOLDER/subscriptions" CLOUDFLARE_OCTETS="8.47 162.159 188.114" # Endpoints https://github.com/ampetelin/warp-endpoint-checker -JQ_REQUIRED_VERSION="1.7.1" COREUTILS_BASE64_REQUIRED_VERSION="9.7" RT_TABLE_NAME="podkopplus" diff --git a/podkop/files/usr/lib/helpers.jq b/podkop/files/usr/lib/helpers.jq deleted file mode 100644 index d914febd..00000000 --- a/podkop/files/usr/lib/helpers.jq +++ /dev/null @@ -1,14 +0,0 @@ -def extend_key_value(current_value; new_value): - if (current_value | type) == "array" then - if (new_value | type) == "array" then - current_value + new_value - else - current_value + [new_value] - end - else - if (new_value | type) == "array" then - [current_value] + new_value - else - [current_value, new_value] - end - end; \ No newline at end of file diff --git a/podkop/files/usr/lib/helpers.sh b/podkop/files/usr/lib/helpers.sh index 730091c1..e006ef8a 100644 --- a/podkop/files/usr/lib/helpers.sh +++ b/podkop/files/usr/lib/helpers.sh @@ -425,16 +425,20 @@ download_subscription() { return 1 } +json_utils_ucode() { + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/json_utils.uc" "$@" +} + validate_subscription_file() { local filepath="$1" [ -s "$filepath" ] || return 1 - jq -e ' - type == "object" and - (.outbounds | type == "array") and - ((.outbounds | length) > 0) - ' "$filepath" > /dev/null 2>&1 + json_utils_ucode validate-subscription "$filepath" >/dev/null 2>&1 +} + +provider_status_ucode() { + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/provider_status.uc" "$@" } # Converts Windows-style line endings (CRLF) to Unix-style (LF) diff --git a/podkop/files/usr/lib/json_utils.uc b/podkop/files/usr/lib/json_utils.uc new file mode 100644 index 00000000..3f3bc2a6 --- /dev/null +++ b/podkop/files/usr/lib/json_utils.uc @@ -0,0 +1,927 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_stdin_json() { + let data = read_stdin(); + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function write_json_file(path, value) { + return fs.writefile(path, sprintf("%J", value) + "\n"); +} + +function str_contains(haystack, needle) { + return index(as_string(haystack), as_string(needle)) >= 0; +} + +function str_startswith(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function str_endswith(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function validate_subscription(path) { + let value = read_json_file(path); + return type(value) == "object" && type(value.outbounds) == "array" && length(value.outbounds) > 0; +} + +function object_has_extra_keys(path) { + let value = read_json_file(path); + return type(value) == "object" && length(keys(value)) > 1; +} + +function value_length(path) { + let value = read_json_file(path); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function response_success() { + let value = read_stdin_json(); + return type(value) == "object" && value.success === true; +} + +function valid_outbound() { + let value = read_stdin_json(); + return type(value) == "object" && type(value.type) == "string"; +} + +function stdin_length() { + let value = read_stdin_json(); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function array_item(index) { + let value = read_stdin_json(); + index = int(index || 0); + if (type(value) == "array" && index >= 0 && index < length(value) && value[index] != null) + print(as_string(value[index]), "\n"); +} + +function object_get(key) { + let value = read_stdin_json(); + if (type(value) == "object" && value[key] != null) + print(as_string(value[key]), "\n"); +} + +function object_get_default(key, fallback) { + let value = read_stdin_json(); + if (type(value) == "object" && value[key] != null) + print(as_string(value[key]), "\n"); + else + print(as_string(fallback), "\n"); +} + +function parse_json_or_null(value) { + try { + return json(value); + } + catch (e) { + return null; + } +} + +function arg_bool(value) { + return value === true || value == "true" || value == "1" || value == 1; +} + +function arg_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9-]/)) + return 0; + return int(value); +} + +function arg_optional_number(value) { + value = as_string(value); + if (value == "" || value == "null" || match(value, /[^0-9-]/)) + return null; + return int(value); +} + +function object_json(start) { + let result = {}; + + for (let i = start; i + 2 < length(ARGV); i += 3) { + let key = as_string(ARGV[i]); + let kind = as_string(ARGV[i + 1]); + let value = ARGV[i + 2]; + + if (kind == "n") + result[key] = arg_number(value); + else if (kind == "b") + result[key] = arg_bool(value); + else if (kind == "j") + result[key] = parse_json_or_null(value); + else if (kind == "a") { + let parsed = parse_json_or_null(value); + result[key] = parsed != null ? parsed : as_string(value); + } + else + result[key] = as_string(value); + } + + write_json(result); +} + +function subscription_ui_metadata() { + let title = as_string(ARGV[1]); + let web_page_url = as_string(ARGV[2]); + let support_url = as_string(ARGV[3]); + let announce = as_string(ARGV[4]); + let announce_url = as_string(ARGV[5]); + let file_name = as_string(ARGV[6]); + let has_traffic = arg_bool(ARGV[7]); + let upload = arg_optional_number(ARGV[8]); + let download = arg_optional_number(ARGV[9]); + let used = arg_number(ARGV[10]); + let total = arg_optional_number(ARGV[11]); + let remaining = arg_optional_number(ARGV[12]); + let is_unlimited = arg_bool(ARGV[13]); + let expire = arg_optional_number(ARGV[14]); + let refill_date = arg_optional_number(ARGV[15]); + + let result = { version: 1 }; + if (title != "") + result.title = title; + if (has_traffic) { + let traffic = { + used: used, + isUnlimited: is_unlimited + }; + if (upload != null) + traffic.upload = upload; + if (download != null) + traffic.download = download; + if (total != null && total > 0) { + traffic.total = total; + traffic.remaining = remaining != null ? remaining : 0; + } + result.traffic = traffic; + } + if (expire != null && expire > 0) + result.expire = expire; + if (refill_date != null && refill_date > 0) + result.refillDate = refill_date; + if (web_page_url != "") + result.webPageUrl = web_page_url; + if (support_url != "") + result.supportUrl = support_url; + if (announce != "") + result.announce = announce; + if (announce_url != "") + result.announceUrl = announce_url; + if (file_name != "") + result.fileName = file_name; + + if (length(keys(result)) > 1) + write_json(result); +} + +function stdin_json() { + let value = read_stdin_json(); + if (value == null) + exit(1); + write_json(value); +} + +function file_json_valid(path) { + return read_json_file(path) != null; +} + +function json_file_field(path, key, fallback) { + let value = read_json_file(path); + if (type(value) == "object" && value[key] != null) + print(as_string(value[key]), "\n"); + else + print(as_string(fallback), "\n"); +} + +function github_release_tags(path) { + for (let release in array_or_empty(read_json_file(path))) { + if (type(release) != "object") + continue; + if (release.draft === true || release.prerelease === true) + continue; + let tag = as_string(release.tag_name || ""); + if (tag != "") + print(tag, "\n"); + } +} + +function github_response_ok() { + let response = read_stdin_json(); + if (response == null) + return false; + + if (type(response) == "object") { + let message = as_string(response.message || ""); + if (match(message, /API rate limit/) || match(message, /rate limit exceeded/) || message == "Not Found") + return false; + } + + return true; +} + +function release_by_tag(tag) { + for (let release in array_or_empty(read_stdin_json())) { + if (type(release) != "object") + continue; + if (release.draft === true || release.prerelease === true) + continue; + if (as_string(release.tag_name || "") == tag) { + write_json(release); + return; + } + } +} + +function release_asset_name(prefix, ext) { + let release = object_or_empty(read_stdin_json()); + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + let name = as_string(asset.name || ""); + if ((str_startswith(name, prefix + "_") || str_startswith(name, prefix + "-")) && + str_endswith(name, "." + ext)) { + print(name, "\n"); + return; + } + } +} + +function release_asset_url(name) { + let release = object_or_empty(read_stdin_json()); + for (let asset in array_or_empty(release.assets)) { + if (type(asset) == "object" && as_string(asset.name || "") == name) { + print(as_string(asset.browser_download_url || ""), "\n"); + return; + } + } +} + +function release_asset_name_by_suffix(suffix) { + let release = object_or_empty(read_stdin_json()); + for (let asset in array_or_empty(release.assets)) { + let name = type(asset) == "object" ? as_string(asset.name || "") : ""; + if (str_endswith(name, suffix)) { + print(name, "\n"); + return; + } + } +} + +function release_asset_url_by_suffix(suffix) { + let release = object_or_empty(read_stdin_json()); + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + let name = as_string(asset.name || ""); + if (str_endswith(name, suffix)) { + print(as_string(asset.browser_download_url || ""), "\n"); + return; + } + } +} + +function byedpi_asset_matches(name, arch, ext) { + return (str_startswith(name, "byedpi_") || str_startswith(name, "byedpi-")) && + str_endswith(name, "." + ext) && + (str_contains(name, "_" + arch + "." + ext) || str_contains(name, "-" + arch + "." + ext)); +} + +function select_byedpi_asset_from_release(release, asset_ext, arch_candidates) { + for (let arch in split(as_string(arch_candidates), " ")) { + if (arch == "") + continue; + for (let asset in array_or_empty(release.assets)) { + if (type(asset) != "object") + continue; + let name = as_string(asset.name || ""); + let url = as_string(asset.browser_download_url || ""); + if (url != "" && byedpi_asset_matches(name, arch, asset_ext)) { + print(arch, "\t", name, "\t", url, "\n"); + return true; + } + } + } + + return false; +} + +function byedpi_select_asset(series, asset_ext, arch_candidates) { + let releases = array_or_empty(read_stdin_json()); + + for (let pass = 0; pass < 2; pass++) { + if (pass == 0 && as_string(series) == "") + continue; + + for (let release in releases) { + if (type(release) != "object") + continue; + if (release.draft === true || release.prerelease === true) + continue; + if (pass == 0) { + let tag = as_string(release.tag_name || ""); + let name = as_string(release.name || ""); + if (!str_contains(tag, series) && !str_contains(name, series)) + continue; + } + if (select_byedpi_asset_from_release(release, asset_ext, arch_candidates)) + return; + } + } +} + +function sing_box_extended_release_tag() { + for (let release in array_or_empty(read_stdin_json())) { + if (type(release) != "object") + continue; + if (release.draft === true || release.prerelease === true) + continue; + let tag = as_string(release.tag_name || ""); + let lowered = lc(tag); + if (tag != "" && !str_contains(lowered, "alpha") && !str_contains(lowered, "beta") && !str_contains(lowered, "rc")) { + print(tag, "\n"); + return; + } + } +} + +function system_info_cache_valid(path, podkop_version, luci_app_version, ttl, now) { + let cache = read_json_file(path); + if (type(cache) != "object") + return false; + + now = arg_number(now); + ttl = arg_number(ttl); + let cached_at = arg_number(cache.generated_at || 0); + + if (now > 0 && cached_at > 0 && ttl > 0 && now - cached_at >= ttl) + return false; + + return cache.podkop_version == podkop_version && cache.luci_app_version == luci_app_version; +} + +function system_info_json() { + write_json({ + podkop_version: as_string(ARGV[1]), + podkop_latest_version: as_string(ARGV[2]), + luci_app_version: as_string(ARGV[3]), + sing_box_version: as_string(ARGV[4]), + sing_box_extended: arg_number(ARGV[5]), + zapret_version: as_string(ARGV[6]), + zapret_installed: arg_number(ARGV[7]), + byedpi_version: as_string(ARGV[8]), + byedpi_installed: arg_number(ARGV[9]), + openwrt_version: as_string(ARGV[10]), + device_model: as_string(ARGV[11]), + generated_at: arg_number(ARGV[12]) + }); +} + +function nfqws_strategy_validation(valid, message, needle, needles) { + let result = []; + for (let item in split(as_string(needles), "\n")) { + if (item != "") + push(result, item); + } + + write_json({ + valid: arg_bool(valid), + message: as_string(message), + needle: as_string(needle), + needles: result + }); +} + +function url_encode(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) { + let c = substr(value, i, 1); + let code = ord(c); + if ((code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + c == "-" || c == "_" || c == "." || c == "~") + print(c); + else + print(sprintf("%%%02X", code)); + } + print("\n"); +} + +function job_running_is(path, expected) { + let value = read_json_file(path); + let running = type(value) == "object" && value.running === true; + return running == arg_bool(expected); +} + +function updates_set_running_job_pid(path, pid) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) + value.pid = as_string(pid); + write_json(value); +} + +function updates_mark_stale_job_state(path) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.success = false; + value.running = false; + value.message = "Component action job is stale or the worker process exited unexpectedly"; + value.changed = 0; + value.status = ""; + value.exit_code = null; + } + write_json(value); +} + +function updates_finish_job_state(path, exit_code, updated_at) { + let value = read_json_file(path); + if (value == null) + exit(1); + + value.running = false; + value.exit_code = arg_number(exit_code); + value.updated_at = arg_number(updated_at); + write_json(value); +} + +function updates_fallback_job_state(component, action, message, exit_code, updated_at) { + write_json({ + success: false, + running: false, + component: as_string(component), + action: as_string(action), + message: as_string(message), + current_version: "", + latest_version: "", + changed: 0, + status: "", + exit_code: arg_number(exit_code), + updated_at: arg_number(updated_at) + }); +} + +function sing_box_service_pid() { + let value = read_stdin_json(); + let service = type(value) == "object" ? value["sing-box"] : null; + let instances = service && type(service.instances) == "object" ? service.instances : {}; + + for (let _, instance in instances) { + if (type(instance) == "object" && instance.running === true && int(instance.pid || 0) > 0) { + print(instance.pid, "\n"); + return; + } + } +} + +function array_append_string(path, value) { + let result = array_or_empty(read_json_file(path)); + push(result, as_string(value)); + write_json(result); +} + +function arrays_concat(first_path, second_path) { + let result = array_or_empty(read_json_file(first_path)); + for (let value in array_or_empty(read_json_file(second_path))) + push(result, value); + write_json(result); +} + +function object_set_string(path, key, value) { + let result = object_or_empty(read_json_file(path)); + result[as_string(key)] = as_string(value); + write_json(result); +} + +function objects_merge(first_path, second_path) { + let result = object_or_empty(read_json_file(first_path)); + for (let key, value in object_or_empty(read_json_file(second_path))) + result[key] = value; + write_json(result); +} + +function normalized_country_list() { + let result = []; + for (let value in array_or_empty(read_stdin_json())) { + value = uc(as_string(value)); + if (length(value) == 2) + push(result, value); + } + write_json(result); +} + +function contains(values, needle) { + for (let value in values) { + if (value == needle) + return true; + } + return false; +} + +function urltest_filter(mode, tags_path, names_path, countries_path, names_filter_path, regex_tags_path, countries_filter_path) { + let tags = array_or_empty(read_json_file(tags_path)); + let names = object_or_empty(read_json_file(names_path)); + let countries = object_or_empty(read_json_file(countries_path)); + let name_filter = array_or_empty(read_json_file(names_filter_path)); + let regex_tags = array_or_empty(read_json_file(regex_tags_path)); + let country_filter = array_or_empty(read_json_file(countries_filter_path)); + let result = []; + + for (let tag in tags) { + let name = as_string(names[tag] || tag); + let country = uc(as_string(countries[tag] || "")); + let matched = contains(name_filter, name) || + contains(name_filter, tag) || + (country != "" && contains(country_filter, country)) || + contains(regex_tags, tag); + + if ((mode == "include" && matched) || (mode == "exclude" && !matched)) + push(result, tag); + } + + write_json(result); +} + +function section_countries(path) { + let cache = object_or_empty(read_json_file(path)); + write_json(object_or_empty(cache.outboundMetadata && cache.outboundMetadata.countries)); +} + +function cached_countries_for_servers(servers_path, cache_path) { + let servers = object_or_empty(read_json_file(servers_path)); + let cache = object_or_empty(read_json_file(cache_path)); + let result = {}; + + for (let tag, _ in servers) { + let country = as_string(cache[tag] || ""); + if (country != "") + result[tag] = country; + } + + write_json(result); +} + +function missing_servers_tsv(servers_path, cache_path) { + let servers = object_or_empty(read_json_file(servers_path)); + let cache = object_or_empty(read_json_file(cache_path)); + + for (let tag, server in servers) { + server = as_string(server); + if (server != "" && as_string(cache[tag] || "") == "") + print(tag, "\t", server, "\n"); + } +} + +function body_error(path) { + let body = read_json_file(path); + let result = ""; + + if (type(body) == "object") + result = as_string((body.error && body.error.code) || body.code || body.error || ""); + + print(result, "\n"); +} + +function ip_country_tsv(path) { + for (let item in array_or_empty(read_json_file(path))) { + if (type(item) != "object") + continue; + let ip = as_string(item.ip || ""); + let country = as_string(item.country || ""); + if (ip != "" && country != "") + print(ip, "\t", country, "\n"); + } +} + +function tsv_to_object(path) { + let result = {}; + let data = fs.readfile(path); + if (data != null) { + for (let line in split(data, "\n")) { + if (line == "") + continue; + let parts = split(line, "\t"); + if (length(parts) >= 2) + result[parts[0]] = parts[1]; + } + } + write_json(result); +} + +function tsv_second_column_array(path) { + let seen = {}; + let result = []; + let data = fs.readfile(path); + + if (data != null) { + for (let line in split(data, "\n")) { + if (line == "") + continue; + let parts = split(line, "\t"); + if (length(parts) >= 2 && parts[1] != "" && !seen[parts[1]]) { + seen[parts[1]] = true; + push(result, parts[1]); + } + } + } + + sort(result, function(first, second) { + return first == second ? 0 : (first < second ? -1 : 1); + }); + write_json(result); +} + +function array_slice_file(path, start, end) { + write_json(slice(array_or_empty(read_json_file(path)), int(start || 0), int(end || 0))); +} + +function object_nonempty_stdin() { + let value = read_stdin_json(); + return (type(value) == "array" || type(value) == "object") && length(value) > 0; +} + +function resolved_countries_from_tsv(resolved_path, ip_country_path) { + let ip_country = object_or_empty(read_json_file(ip_country_path)); + let result = {}; + let data = fs.readfile(resolved_path); + + if (data != null) { + for (let line in split(data, "\n")) { + if (line == "") + continue; + let parts = split(line, "\t"); + if (length(parts) < 2) + continue; + let country = as_string(ip_country[parts[1]] || ""); + if (country != "") + result[parts[0]] = country; + } + } + + write_json(result); +} + +function outbound_server_by_tag(tag) { + let config = object_or_empty(read_stdin_json()); + for (let outbound in array_or_empty(config.outbounds)) { + if (type(outbound) == "object" && outbound.tag == tag) { + print(as_string(outbound.server || ""), "\n"); + return; + } + } +} + +let masked_sing_box_keys = { + uuid: true, + server: true, + server_name: true, + password: true, + public_key: true, + short_id: true, + fingerprint: true, + server_port: true +}; + +function mask_sing_box_value(value) { + if (type(value) == "array") { + let result = []; + for (let item in value) + push(result, mask_sing_box_value(item)); + return result; + } + + if (type(value) == "object") { + let result = {}; + for (let key, item in value) + result[key] = masked_sing_box_keys[key] ? "MASKED" : mask_sing_box_value(item); + return result; + } + + return value; +} + +function mask_sing_box_config(path) { + write_json(mask_sing_box_value(read_json_file(path))); +} + +function prepare_check_proxy_config(input_path, output_path, cache_path) { + let config = object_or_empty(read_json_file(input_path)); + config.inbounds = []; + config.services = []; + if (type(config.experimental) == "object") { + if (type(config.experimental.cache_file) == "object") + config.experimental.cache_file.path = as_string(cache_path); + delete config.experimental.clash_api; + } + + if (!write_json_file(output_path, config)) + exit(1); +} + +function check_proxy_outbound_tag(config_path, domain) { + let config = object_or_empty(read_json_file(config_path)); + for (let rule in array_or_empty(config.route && config.route.rules)) { + if (type(rule) != "object") + continue; + if (contains(array_or_empty(rule.domain), domain)) { + print(as_string(rule.outbound || ""), "\n"); + return; + } + } +} + +function dns_route_rule_exists(service_tag, tag) { + let config = object_or_empty(read_stdin_json()); + for (let rule in array_or_empty(config.dns && config.dns.rules)) { + if (type(rule) == "object" && rule[service_tag] == tag) + return true; + } + return false; +} + +function route_rule_has_resolve_matchers(service_tag, tag) { + let config = object_or_empty(read_stdin_json()); + for (let rule in array_or_empty(config.route && config.route.rules)) { + if (type(rule) != "object" || rule[service_tag] != tag) + continue; + if (rule.domain != null || rule.domain_suffix != null || rule.domain_keyword != null || + rule.domain_regex != null || rule.rule_set != null) + return true; + } + return false; +} + +let mode = ARGV[0] || ""; + +if (mode == "validate-subscription") + exit(validate_subscription(ARGV[1]) ? 0 : 1); +else if (mode == "object-has-extra-keys") + exit(object_has_extra_keys(ARGV[1]) ? 0 : 1); +else if (mode == "length") + value_length(ARGV[1]); +else if (mode == "response-success") + exit(response_success() ? 0 : 1); +else if (mode == "valid-outbound") + exit(valid_outbound() ? 0 : 1); +else if (mode == "stdin-length") + stdin_length(); +else if (mode == "array-item") + array_item(ARGV[1]); +else if (mode == "object-get") + object_get(ARGV[1]); +else if (mode == "object-get-default") + object_get_default(ARGV[1], ARGV[2]); +else if (mode == "object-json") + object_json(1); +else if (mode == "subscription-ui-metadata") + subscription_ui_metadata(); +else if (mode == "stdin-json") + stdin_json(); +else if (mode == "file-json-valid") + exit(file_json_valid(ARGV[1]) ? 0 : 1); +else if (mode == "json-file-field") + json_file_field(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "github-release-tags") + github_release_tags(ARGV[1]); +else if (mode == "github-response-ok") + exit(github_response_ok() ? 0 : 1); +else if (mode == "release-by-tag") + release_by_tag(ARGV[1]); +else if (mode == "release-asset-name") + release_asset_name(ARGV[1], ARGV[2]); +else if (mode == "release-asset-url") + release_asset_url(ARGV[1]); +else if (mode == "release-asset-name-by-suffix") + release_asset_name_by_suffix(ARGV[1]); +else if (mode == "release-asset-url-by-suffix") + release_asset_url_by_suffix(ARGV[1]); +else if (mode == "byedpi-select-asset") + byedpi_select_asset(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "sing-box-extended-release-tag") + sing_box_extended_release_tag(); +else if (mode == "system-info-cache-valid") + exit(system_info_cache_valid(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +else if (mode == "system-info-json") + system_info_json(); +else if (mode == "nfqws-strategy-validation") + nfqws_strategy_validation(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "url-encode") + url_encode(ARGV[1]); +else if (mode == "job-running-is") + exit(job_running_is(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "updates-set-running-job-pid") + updates_set_running_job_pid(ARGV[1], ARGV[2]); +else if (mode == "updates-mark-stale-job-state") + updates_mark_stale_job_state(ARGV[1]); +else if (mode == "updates-finish-job-state") + updates_finish_job_state(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "updates-fallback-job-state") + updates_fallback_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "sing-box-service-pid") + sing_box_service_pid(); +else if (mode == "array-append-string") + array_append_string(ARGV[1], ARGV[2]); +else if (mode == "arrays-concat") + arrays_concat(ARGV[1], ARGV[2]); +else if (mode == "object-set-string") + object_set_string(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "objects-merge") + objects_merge(ARGV[1], ARGV[2]); +else if (mode == "normalized-country-list") + normalized_country_list(); +else if (mode == "urltest-filter") + urltest_filter(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +else if (mode == "section-countries") + section_countries(ARGV[1]); +else if (mode == "cached-countries-for-servers") + cached_countries_for_servers(ARGV[1], ARGV[2]); +else if (mode == "missing-servers-tsv") + missing_servers_tsv(ARGV[1], ARGV[2]); +else if (mode == "body-error") + body_error(ARGV[1]); +else if (mode == "ip-country-tsv") + ip_country_tsv(ARGV[1]); +else if (mode == "tsv-to-object") + tsv_to_object(ARGV[1]); +else if (mode == "tsv-second-column-array") + tsv_second_column_array(ARGV[1]); +else if (mode == "array-slice-file") + array_slice_file(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "object-nonempty") + exit(object_nonempty_stdin() ? 0 : 1); +else if (mode == "resolved-countries-from-tsv") + resolved_countries_from_tsv(ARGV[1], ARGV[2]); +else if (mode == "outbound-server-by-tag") + outbound_server_by_tag(ARGV[1]); +else if (mode == "mask-sing-box-config") + mask_sing_box_config(ARGV[1]); +else if (mode == "prepare-check-proxy-config") + prepare_check_proxy_config(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "check-proxy-outbound-tag") + check_proxy_outbound_tag(ARGV[1], ARGV[2]); +else if (mode == "dns-route-rule-exists") + exit(dns_route_rule_exists(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "route-rule-has-resolve-matchers") + exit(route_rule_has_resolve_matchers(ARGV[1], ARGV[2]) ? 0 : 1); +else { + warn("Usage: json_utils.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/provider_status.uc b/podkop/files/usr/lib/provider_status.uc new file mode 100644 index 00000000..542af9f7 --- /dev/null +++ b/podkop/files/usr/lib/provider_status.uc @@ -0,0 +1,199 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function int_arg(value) { + value = as_string(value); + return value == "" ? 0 : int(value, 10); +} + +function bool_arg(value) { + value = as_string(value); + return value == "1" || value == "true"; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function byedpi_validation_result(valid, message, needle) { + let needles = []; + if (!bool_arg(valid)) { + for (let line in split(read_stdin(), "\n")) { + if (line != "") + push(needles, line); + } + } + + write_json({ + valid: bool_arg(valid), + message: as_string(message), + needle: as_string(needle), + needles + }); +} + +function has_socks_outbound(config_path, tag, address, port) { + let config = read_json_file(config_path); + port = int_arg(port); + for (let outbound in array_or_empty(config && config.outbounds)) { + if (type(outbound) == "object" && + outbound.type == "socks" && + outbound.tag == tag && + outbound.server == address && + int(outbound.server_port || 0) == port) + return true; + } + return false; +} + +function has_direct_mark_outbound(config_path, tag, routing_mark) { + let config = read_json_file(config_path); + routing_mark = int_arg(routing_mark); + for (let outbound in array_or_empty(config && config.outbounds)) { + if (type(outbound) == "object" && + outbound.type == "direct" && + outbound.tag == tag && + int(outbound.routing_mark || 0) == routing_mark) + return true; + } + return false; +} + +function has_route_rule(config_path, inbound, outbound_tag) { + let config = read_json_file(config_path); + for (let rule in array_or_empty(config && config.route && config.route.rules)) { + if (type(rule) == "object" && + rule.action == "route" && + rule.inbound == inbound && + rule.outbound == outbound_tag) + return true; + } + return false; +} + +function byedpi_status(args) { + write_json({ + installed: bool_arg(args[0]), + package_installed: bool_arg(args[1]), + provider_available: bool_arg(args[2]), + provider_path: as_string(args[3]), + version: as_string(args[4]), + configured: bool_arg(args[5]), + enabled_rule_count: int_arg(args[6]), + expected_process_count: int_arg(args[7]), + running_process_count: int_arg(args[8]), + supervisor_process_count: int_arg(args[9]), + restart_count: int_arg(args[10]), + runtime_unstable: bool_arg(args[11]), + standalone_service_enabled: bool_arg(args[12]), + standalone_service_running: bool_arg(args[13]), + listen_address: as_string(args[14]), + port_base: int_arg(args[15]), + outbounds_configured: bool_arg(args[16]), + routes_configured: bool_arg(args[17]), + ready: bool_arg(args[18]), + conflict: bool_arg(args[19]), + status_message: as_string(args[20]) + }); +} + +function byedpi_check(installed, package_installed, provider_path) { + write_json({ + byedpi_installed: bool_arg(installed), + byedpi_package_installed: bool_arg(package_installed), + byedpi_provider_path: as_string(provider_path) + }); +} + +function zapret_status(args) { + write_json({ + installed: bool_arg(args[0]), + package_installed: bool_arg(args[1]), + provider_available: bool_arg(args[2]), + provider_path: as_string(args[3]), + files_available: bool_arg(args[4]), + ipset_available: bool_arg(args[5]), + version: as_string(args[6]), + configured: bool_arg(args[7]), + enabled_rule_count: int_arg(args[8]), + expected_process_count: int_arg(args[9]), + running_process_count: int_arg(args[10]), + supervisor_process_count: int_arg(args[11]), + standalone_service_enabled: bool_arg(args[12]), + standalone_service_running: bool_arg(args[13]), + standalone_config_present: bool_arg(args[14]), + standalone_conflict: bool_arg(args[15]), + luci_app_installed: bool_arg(args[16]), + queue_base: int_arg(args[17]), + queue_range_end: int_arg(args[18]), + queue_overlap: bool_arg(args[19]), + legacy_runtime_present: bool_arg(args[20]), + ready: bool_arg(args[21]), + conflict: bool_arg(args[22]), + outbounds_configured: bool_arg(args[23]), + routes_configured: bool_arg(args[24]), + status_message: as_string(args[25]) + }); +} + +function zapret_check(installed, package_installed, provider_path) { + write_json({ + zapret_installed: bool_arg(installed), + zapret_package_installed: bool_arg(package_installed), + zapret_provider_path: as_string(provider_path) + }); +} + +let mode = ARGV[0] || ""; + +if (mode == "byedpi-validation") + byedpi_validation_result(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "has-socks-outbound") + exit(has_socks_outbound(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +else if (mode == "has-direct-mark-outbound") + exit(has_direct_mark_outbound(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "has-route-rule") + exit(has_route_rule(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "byedpi-status") + byedpi_status(slice(ARGV, 1)); +else if (mode == "byedpi-check") + byedpi_check(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "zapret-status") + zapret_status(slice(ARGV, 1)); +else if (mode == "zapret-check") + zapret_check(ARGV[1], ARGV[2], ARGV[3]); +else { + warn("Usage: provider_status.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/rulesets.sh b/podkop/files/usr/lib/rulesets.sh index 575667f7..f1a03a1b 100644 --- a/podkop/files/usr/lib/rulesets.sh +++ b/podkop/files/usr/lib/rulesets.sh @@ -20,7 +20,7 @@ create_source_rule_set() { return 3 fi - jq -n '{version: 3, rules: []}' > "$ruleset_filepath" + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" create-source "$ruleset_filepath" } ####################################### @@ -38,17 +38,15 @@ patch_source_ruleset_rules() { local key="$2" local value="$3" - local tmpfile=$(mktemp) + local tmpfile + tmpfile="$(mktemp)" || return 1 - jq --arg key "$key" --argjson value "$value" \ - '( .rules | map(has($key)) | index(true) ) as $idx | - if $idx != null then - .rules[$idx][$key] = (.rules[$idx][$key] + $value | unique) - else - .rules += [{ ($key): $value }] - end' "$filepath" > "$tmpfile" + cp "$filepath" "$tmpfile" || { + rm -f "$tmpfile" + return 1 + } - if [ $? -ne 0 ]; then + if ! ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" patch-source "$tmpfile" "$key" "$value"; then rm -f "$tmpfile" return 1 fi @@ -159,5 +157,5 @@ extract_ip_cidr_from_json_ruleset_to_file() { local output_file="$2" log "Extracting ip_cidr entries from $json_file to $output_file" "debug" - jq -r '.rules[]? | select(has("ip_cidr")) | .ip_cidr[]?' "$json_file" > "$output_file" + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" extract-ip-cidr "$json_file" "$output_file" } diff --git a/podkop/files/usr/lib/rulesets.uc b/podkop/files/usr/lib/rulesets.uc new file mode 100644 index 00000000..4665cfb1 --- /dev/null +++ b/podkop/files/usr/lib/rulesets.uc @@ -0,0 +1,157 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function json_decode_text(text) { + try { + return json(as_string(text)); + } + catch (e) { + return null; + } +} + +function read_json_file(path) { + let data = fs.readfile(path); + return data == null ? null : json_decode_text(data); +} + +function write_json_file(path, value) { + return fs.writefile(path, sprintf("%J", value) + "\n"); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function sort_values(values) { + sort(values, function(first, second) { + first = sprintf("%J", first); + second = sprintf("%J", second); + return first == second ? 0 : (first < second ? -1 : 1); + }); + return values; +} + +function unique_values(values) { + values = sort_values(values); + let result = []; + let previous = null; + let has_previous = false; + + for (let value in values) { + let encoded = sprintf("%J", value); + if (!has_previous || encoded != previous) { + push(result, value); + previous = encoded; + has_previous = true; + } + } + + return result; +} + +function create_source(path) { + if (!write_json_file(path, { version: 3, rules: [] })) + exit(1); +} + +function patch_source(path, key, value_text) { + let ruleset = object_or_empty(read_json_file(path)); + let values = array_or_empty(json_decode_text(value_text)); + + if (type(ruleset.rules) != "array") + ruleset.rules = []; + + let found = false; + for (let rule in ruleset.rules) { + if (type(rule) == "object" && rule[key] != null) { + let merged = []; + for (let item in array_or_empty(rule[key])) + push(merged, item); + for (let item in values) + push(merged, item); + rule[key] = unique_values(merged); + found = true; + break; + } + } + + if (!found) { + let rule = {}; + rule[key] = values; + push(ruleset.rules, rule); + } + + if (!write_json_file(path, ruleset)) + exit(1); +} + +function extract_ip_cidr(json_path, output_path) { + let ruleset = object_or_empty(read_json_file(json_path)); + let lines = []; + + for (let rule in array_or_empty(ruleset.rules)) { + if (type(rule) != "object") + continue; + for (let ip in array_or_empty(rule.ip_cidr)) + push(lines, as_string(ip)); + } + + if (!fs.writefile(output_path, length(lines) > 0 ? join("\n", lines) + "\n" : "")) + exit(1); +} + +function value_has_domain_matchers(value) { + if (type(value) == "array") { + for (let item in value) { + if (value_has_domain_matchers(item)) + return true; + } + return false; + } + + if (type(value) != "object") + return false; + + for (let key, item in value) { + if (key == "domain" || key == "domain_suffix" || key == "domain_keyword" || key == "domain_regex") { + if (type(item) == "array" && length(item) > 0) + return true; + if (type(item) == "string" && item != "") + return true; + } + + if (value_has_domain_matchers(item)) + return true; + } + + return false; +} + +function has_domain_matchers(path) { + return value_has_domain_matchers(read_json_file(path)); +} + +let mode = ARGV[0] || ""; + +if (mode == "create-source") + create_source(ARGV[1]); +else if (mode == "patch-source") + patch_source(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "extract-ip-cidr") + extract_ip_cidr(ARGV[1], ARGV[2]); +else if (mode == "has-domain-matchers") + exit(has_domain_matchers(ARGV[1]) ? 0 : 1); +else { + warn("Usage: rulesets.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/sing_box_config_facade.sh b/podkop/files/usr/lib/sing_box_config_facade.sh index 68941a54..8a05439f 100644 --- a/podkop/files/usr/lib/sing_box_config_facade.sh +++ b/podkop/files/usr/lib/sing_box_config_facade.sh @@ -1,7 +1,98 @@ -PODKOP_LIB="/usr/lib/podkop-plus" +PODKOP_LIB="${PODKOP_LIB:-/usr/lib/podkop-plus}" . "$PODKOP_LIB/helpers.sh" . "$PODKOP_LIB/sing_box_config_manager.sh" +sing_box_cf_ucode() { + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/sing_box_config_facade.uc" "$@" +} + +sing_box_cf_ucode_input() { + local operation="$1" + local input="$2" + shift 2 + + printf '%s' "$input" | + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/sing_box_config_facade.uc" "$operation" "$@" +} + +sing_box_cf_tags_json_object() { + printf '{"tags":%s}' "${1:-[]}" +} + +sing_box_cf_tags_json_count() { + sing_box_cf_ucode_input prepared-length "$(sing_box_cf_tags_json_object "$1")" tags 2>/dev/null +} + +sing_box_cf_tags_json_csv() { + sing_box_cf_ucode_input prepared-tags-csv "$(sing_box_cf_tags_json_object "$1")" 2>/dev/null +} + +sing_box_cf_outbounds_json_object() { + printf '{"outbounds":%s}' "${1:-[]}" +} + +sing_box_cf_outbounds_json_count() { + sing_box_cf_ucode_input prepared-length "$(sing_box_cf_outbounds_json_object "$1")" outbounds 2>/dev/null +} + +sing_box_cf_json_arrays_concat() { + local first_json="$1" + local second_json="$2" + local first_tmp second_tmp result status + + first_tmp="$(mktemp)" || return 1 + second_tmp="$(mktemp)" || { + rm -f "$first_tmp" + return 1 + } + + printf '%s' "$first_json" > "$first_tmp" || { + rm -f "$first_tmp" "$second_tmp" + return 1 + } + printf '%s' "$second_json" > "$second_tmp" || { + rm -f "$first_tmp" "$second_tmp" + return 1 + } + + result="$(ucode "$PODKOP_LIB/json_utils.uc" arrays-concat "$first_tmp" "$second_tmp" 2>/dev/null)" + status=$? + rm -f "$first_tmp" "$second_tmp" + + [ "$status" -eq 0 ] || return 1 + [ -n "$result" ] || return 1 + printf '%s\n' "$result" +} + +sing_box_cf_json_objects_merge() { + local first_json="$1" + local second_json="$2" + local first_tmp second_tmp result status + + first_tmp="$(mktemp)" || return 1 + second_tmp="$(mktemp)" || { + rm -f "$first_tmp" + return 1 + } + + printf '%s' "$first_json" > "$first_tmp" || { + rm -f "$first_tmp" "$second_tmp" + return 1 + } + printf '%s' "$second_json" > "$second_tmp" || { + rm -f "$first_tmp" "$second_tmp" + return 1 + } + + result="$(ucode "$PODKOP_LIB/json_utils.uc" objects-merge "$first_tmp" "$second_tmp" 2>/dev/null)" + status=$? + rm -f "$first_tmp" "$second_tmp" + + [ "$status" -eq 0 ] || return 1 + [ -n "$result" ] || return 1 + printf '%s\n' "$result" +} + sing_box_cf_add_dns_server() { local config="$1" local type="$2" @@ -362,14 +453,7 @@ sing_box_cf_add_single_key_reject_rule() { sing_box_cf_subscription_candidate_outbounds() { local subscription_json_path="$1" - jq -c '[.outbounds[]? | select( - type == "object" and - .type != "selector" and - .type != "urltest" and - .type != "direct" and - .type != "dns" and - .type != "block" - )]' "$subscription_json_path" 2>/dev/null + sing_box_cf_ucode candidate-outbounds "$subscription_json_path" 2>/dev/null } sing_box_cf_log_subscription_skips() { @@ -377,19 +461,13 @@ sing_box_cf_log_subscription_skips() { local context="$2" local skipped_count reason_summary - skipped_count="$(printf '%s' "$prepared_json" | jq -r '.skipped // 0' 2>/dev/null)" + skipped_count="$(sing_box_cf_ucode_input skip-count "$prepared_json" 2>/dev/null)" case "$skipped_count" in '' | *[!0-9]*) return 0 ;; esac [ "$skipped_count" -gt 0 ] || return 0 - reason_summary="$(printf '%s' "$prepared_json" | jq -r ' - (.skipped_reason_counts // {}) - | to_entries - | sort_by([-.value, .key]) - | map("\(.value)x \(.key)") - | join("; ") - ' 2>/dev/null)" + reason_summary="$(sing_box_cf_ucode_input skip-summary "$prepared_json" 2>/dev/null)" if [ -n "$reason_summary" ]; then log "Skipped $skipped_count subscription outbounds $context: $reason_summary" "warn" @@ -400,43 +478,21 @@ sing_box_cf_log_subscription_skips() { sing_box_cf_subscription_plugin_supports() { local outbounds_json="$1" - local plugins_tmp records_tmp plugin_name plugin_supports_json supported + local records_tmp plugin_name plugin_supports_json supported - plugins_tmp="$(mktemp)" || return 1 - records_tmp="$(mktemp)" || { - rm -f "$plugins_tmp" - return 1 - } - - printf '%s' "$outbounds_json" | jq -r ' - .[]? - | select(.type == "shadowsocks") - | (.plugin // empty) - | tostring - | split(";")[0] - | select(. != "") - ' 2>/dev/null | sort -u > "$plugins_tmp" + records_tmp="$(mktemp)" || return 1 : > "$records_tmp" + sing_box_cf_ucode_input plugin-names "$outbounds_json" 2>/dev/null | while IFS= read -r plugin_name || [ -n "$plugin_name" ]; do [ -n "$plugin_name" ] || continue command -v "$plugin_name" >/dev/null 2>&1 && supported=true || supported=false printf '%s\t%s\n' "$plugin_name" "$supported" >> "$records_tmp" - done < "$plugins_tmp" + done - plugin_supports_json="$(jq -Rn ' - reduce inputs as $line ( - {}; - ($line | split("\t")) as $parts - | if ($parts | length) >= 2 and $parts[0] != "" then - .[$parts[0]] = ($parts[1] == "true") - else - . - end - ) - ' < "$records_tmp" 2>/dev/null)" + plugin_supports_json="$(sing_box_cf_ucode plugin-supports-from-records "$records_tmp" 2>/dev/null)" - rm -f "$plugins_tmp" "$records_tmp" + rm -f "$records_tmp" [ -n "$plugin_supports_json" ] || plugin_supports_json="{}" printf '%s\n' "$plugin_supports_json" } @@ -444,8 +500,7 @@ sing_box_cf_subscription_plugin_supports() { sing_box_cf_prepare_subscription_batch() { local config="$1" local outbounds_json="$2" - local existing_tags_json existing_tags_tmp outbounds_tmp prepared_json jq_status supports_xhttp plugin_supports_json \ - parser_path config_tmp plugin_supports_tmp prepared_tmp + local outbounds_tmp prepared_json supports_xhttp plugin_supports_json config_tmp plugin_supports_tmp prepared_tmp supports_xhttp=false if is_sing_box_extended; then @@ -454,189 +509,42 @@ sing_box_cf_prepare_subscription_batch() { plugin_supports_json="$(sing_box_cf_subscription_plugin_supports "$outbounds_json")" [ -n "$plugin_supports_json" ] || plugin_supports_json="{}" - existing_tags_json="$(printf '%s' "$config" | jq -c '[.outbounds[]?.tag // empty]' 2>/dev/null)" - [ -n "$existing_tags_json" ] || existing_tags_json="[]" - - existing_tags_tmp="$(mktemp)" || return 1 - outbounds_tmp="$(mktemp)" || { - rm -f "$existing_tags_tmp" - return 1 - } - - printf '%s' "$existing_tags_json" > "$existing_tags_tmp" || { - rm -f "$existing_tags_tmp" "$outbounds_tmp" - return 1 - } + outbounds_tmp="$(mktemp)" || return 1 printf '%s' "$outbounds_json" > "$outbounds_tmp" || { - rm -f "$existing_tags_tmp" "$outbounds_tmp" + rm -f "$outbounds_tmp" return 1 } - parser_path="${PODKOP_LIB:-/usr/lib/podkop-plus}/subscription_parser.lua" - if command -v lua >/dev/null 2>&1 && [ -r "$parser_path" ]; then - config_tmp="$(mktemp)" || { - rm -f "$existing_tags_tmp" "$outbounds_tmp" - return 1 - } - plugin_supports_tmp="$(mktemp)" || { - rm -f "$existing_tags_tmp" "$outbounds_tmp" "$config_tmp" - return 1 - } - prepared_tmp="$(mktemp)" || { - rm -f "$existing_tags_tmp" "$outbounds_tmp" "$config_tmp" "$plugin_supports_tmp" - return 1 - } + command -v ucode >/dev/null 2>&1 || { + rm -f "$outbounds_tmp" + return 1 + } - if printf '%s' "$config" > "$config_tmp" && - printf '%s' "$plugin_supports_json" > "$plugin_supports_tmp" && - lua "$parser_path" prepare "$config_tmp" "$outbounds_tmp" "$prepared_tmp" "$supports_xhttp" "$plugin_supports_tmp" && - prepared_json="$(cat "$prepared_tmp" 2>/dev/null)" && - [ -n "$prepared_json" ]; then - rm -f "$existing_tags_tmp" "$outbounds_tmp" "$config_tmp" "$plugin_supports_tmp" "$prepared_tmp" - printf '%s\n' "$prepared_json" - return 0 - fi + config_tmp="$(mktemp)" || { + rm -f "$outbounds_tmp" + return 1 + } + plugin_supports_tmp="$(mktemp)" || { + rm -f "$outbounds_tmp" "$config_tmp" + return 1 + } + prepared_tmp="$(mktemp)" || { + rm -f "$outbounds_tmp" "$config_tmp" "$plugin_supports_tmp" + return 1 + } - rm -f "$config_tmp" "$plugin_supports_tmp" "$prepared_tmp" - log "Lua subscription batch preparation failed; falling back to jq preparation" "warn" + if printf '%s' "$config" > "$config_tmp" && + printf '%s' "$plugin_supports_json" > "$plugin_supports_tmp" && + sing_box_cf_ucode prepare-subscription "$config_tmp" "$outbounds_tmp" "$prepared_tmp" "$supports_xhttp" "$plugin_supports_tmp" && + prepared_json="$(cat "$prepared_tmp" 2>/dev/null)" && + [ -n "$prepared_json" ]; then + rm -f "$outbounds_tmp" "$config_tmp" "$plugin_supports_tmp" "$prepared_tmp" + printf '%s\n' "$prepared_json" + return 0 fi - prepared_json="$(jq -c --slurpfile existing_tags "$existing_tags_tmp" \ - --argjson supports_xhttp "$supports_xhttp" \ - --argjson plugin_supports "$plugin_supports_json" ' - ($existing_tags[0] // []) as $existing_tags - | - def safe_string($value; $fallback): - (($value // $fallback) | tostring) as $result - | if $result == "" or $result == "null" then $fallback else $result end; - - def non_empty_string($value): - ($value | type) == "string" and $value != ""; - - def valid_server($outbound): - non_empty_string($outbound.server // null); - - def valid_server_port($outbound): - ($outbound.server_port | type) == "number" and - (($outbound.server_port | floor) == $outbound.server_port) and - $outbound.server_port >= 1 and - $outbound.server_port <= 65535; - - def type_requires_server($type): - ["vless", "vmess", "trojan", "shadowsocks", "socks", "hysteria2"] | index($type) != null; - - def supported_flow($flow): - ($flow == null) or ($flow == "") or ($flow == "xtls-rprx-vision"); - - def supported_transport_type($transport): - ["http", "ws", "quic", "grpc", "httpupgrade", "xhttp", "kcp"] | index($transport) != null; - - def supported_shadowsocks_method($method): - [ - "none", - "aes-128-gcm", "aes-192-gcm", "aes-256-gcm", - "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305", - "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305", - "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", - "aes-128-ctr", "aes-192-ctr", "aes-256-ctr", - "chacha20", "chacha20-ietf", "xchacha20", - "salsa20", "rc4-md5" - ] | index($method) != null; - - def reality_enabled($outbound): - ($outbound.tls.reality? | type) == "object" and (($outbound.tls.reality.enabled // true) == true); - - def plugin_name($plugin): - ($plugin | tostring | split(";")[0]); - - def prefilter_skip_reason($outbound): - ($outbound.type // "" | tostring) as $type - | if $type == "" then - "missing outbound type" - elif ($outbound.type != ($outbound.type | tostring)) then - "outbound type must be a string" - elif type_requires_server($type) and (valid_server($outbound) | not) then - "missing or empty server" - elif type_requires_server($type) and (valid_server_port($outbound) | not) then - "missing or invalid server_port" - elif ($type == "vless" or $type == "vmess") and (non_empty_string($outbound.uuid // null) | not) then - "missing uuid" - elif ($type == "trojan" or $type == "hysteria2") and (non_empty_string($outbound.password // null) | not) then - "missing password" - elif $type == "shadowsocks" and (non_empty_string($outbound.method // null) | not) then - "missing shadowsocks method" - elif $type == "shadowsocks" and (non_empty_string($outbound.password // null) | not) then - "missing shadowsocks password" - elif $type == "shadowsocks" and (supported_shadowsocks_method($outbound.method) | not) then - "unsupported shadowsocks method: \($outbound.method)" - elif $type == "shadowsocks" and non_empty_string($outbound.plugin // null) and (($plugin_supports[plugin_name($outbound.plugin)] // false) | not) then - "shadowsocks plugin is not installed: \(plugin_name($outbound.plugin))" - elif reality_enabled($outbound) and (non_empty_string($outbound.tls.reality.public_key // null) | not) then - "reality public_key is missing" - elif ($type == "vless") and (supported_flow($outbound.flow // "") | not) then - "unsupported vless flow: \($outbound.flow)" - elif (($outbound.transport? | type) == "object") and (($outbound.transport.type // "" | tostring) == "") then - "missing transport type" - elif (($outbound.transport? | type) == "object") and (supported_transport_type(($outbound.transport.type // "") | tostring) | not) then - "unknown transport type: \($outbound.transport.type)" - elif (($outbound.transport? | type) == "object") and (($outbound.transport.type // "" | tostring) == "xhttp") and ($supports_xhttp | not) then - "transport xhttp requires sing-box-extended" - elif ($type == "shadowsocks" and (($outbound.tls.enabled // false) == true)) then - "shadowsocks with TLS is not supported" - else - "" - end; - - def unique_tag($base; $taken): - if (($taken[$base] // false) | not) then - $base - else - first(range(1; 100000) as $n - | "\($base)-\($n)" - | select((($taken[.] // false) | not))) - end; - - . as $subscription_outbounds | - reduce range(0; ($subscription_outbounds | length)) as $source_index ( - { - outbounds: [], - tags: [], - names: [], - servers: [], - links: [], - source_indices: [], - skipped: 0, - skipped_reason_counts: {}, - taken: (reduce $existing_tags[] as $tag ({}; .[$tag] = true)) - }; - $subscription_outbounds[$source_index] as $outbound - | - ((.outbounds | length) + 1) as $index - | safe_string($outbound.remark // $outbound.tag; "server-\($index)") as $display_name - | prefilter_skip_reason($outbound) as $skip_reason - | if $skip_reason != "" then - .skipped += 1 - | .skipped_reason_counts[$skip_reason] = ((.skipped_reason_counts[$skip_reason] // 0) + 1) - else - safe_string($outbound.tag // $outbound.remark; "server-\($index)") as $base_tag - | (.taken as $taken | unique_tag($base_tag; $taken)) as $tag - | .outbounds += [($outbound | del(.tag, .remark, .share_link) + {tag: $tag})] - | .tags += [$tag] - | .names += [$display_name] - | .servers += [($outbound.server // "")] - | .links += [($outbound.share_link // "")] - | .source_indices += [($source_index + 1)] - | .taken[$tag] = true - end - ) - | del(.taken) - ' "$outbounds_tmp" 2>/dev/null)" - jq_status=$? - rm -f "$existing_tags_tmp" "$outbounds_tmp" - - [ "$jq_status" -eq 0 ] || return 1 - [ -n "$prepared_json" ] || return 1 - printf '%s\n' "$prepared_json" + rm -f "$outbounds_tmp" "$config_tmp" "$plugin_supports_tmp" "$prepared_tmp" + return 1 } sing_box_cf_validation_error_summary() { @@ -654,52 +562,47 @@ sing_box_cf_validation_error_summary() { sing_box_cf_try_subscription_outbounds_batch() { local config="$1" local new_outbounds="$2" - local new_outbounds_tmp config_tmp validation_tmp validation_config updated_config jq_status check_output + local new_outbounds_tmp updated_tmp validation_tmp check_tmp validation_config check_output SING_BOX_CF_VALIDATED_CONFIG="" SING_BOX_CF_VALIDATION_ERROR="" new_outbounds_tmp="$(mktemp)" || return 1 - config_tmp="$(mktemp)" || { + updated_tmp="$(mktemp)" || { rm -f "$new_outbounds_tmp" return 1 } - - printf '%s' "$new_outbounds" > "$new_outbounds_tmp" || { - rm -f "$new_outbounds_tmp" "$config_tmp" + validation_tmp="$(mktemp)" || { + rm -f "$new_outbounds_tmp" "$updated_tmp" return 1 } - printf '%s' "$config" > "$config_tmp" || { - rm -f "$new_outbounds_tmp" "$config_tmp" + check_tmp="$(mktemp)" || { + rm -f "$new_outbounds_tmp" "$updated_tmp" "$validation_tmp" return 1 } - updated_config="$(jq -c --slurpfile new_outbounds "$new_outbounds_tmp" \ - '.outbounds += ($new_outbounds[0] // [])' "$config_tmp" 2>/dev/null)" - jq_status=$? - rm -f "$new_outbounds_tmp" "$config_tmp" - - [ "$jq_status" -eq 0 ] || return 1 - [ -n "$updated_config" ] || return 1 - updated_config="$(printf '%s' "$updated_config" | jq -c 'del(.outbounds[]?.share_link, .outbounds[]?.remark)' 2>/dev/null)" || return 1 - [ -n "$updated_config" ] || return 1 - - validation_config="$(printf '%s' "$updated_config" | jq -c ' - (first(.outbounds[]? | select(.type == "direct") | .tag) // "direct-out") as $direct - | .route = ((.route // {}) + {rules: [], rule_set: [], final: $direct}) - ' 2>/dev/null)" || return 1 - [ -n "$validation_config" ] || return 1 - - validation_tmp="$(mktemp)" || return 1 - sing_box_cm_save_config_to_file "$validation_config" "$validation_tmp" - if ! check_output="$(sing-box -c "$validation_tmp" check 2>&1)"; then - SING_BOX_CF_VALIDATION_ERROR="$(sing_box_cf_validation_error_summary "$check_output")" - rm -f "$validation_tmp" + if ! printf '%s' "$new_outbounds" > "$new_outbounds_tmp" || + ! sing_box_cf_ucode_input prepare-validation "$config" "$new_outbounds_tmp" "$updated_tmp" "$validation_tmp"; then + rm -f "$new_outbounds_tmp" "$updated_tmp" "$validation_tmp" "$check_tmp" return 1 fi - rm -f "$validation_tmp" - SING_BOX_CF_VALIDATED_CONFIG="$updated_config" + validation_config="$(cat "$validation_tmp" 2>/dev/null)" || validation_config="" + [ -n "$validation_config" ] || { + rm -f "$new_outbounds_tmp" "$updated_tmp" "$validation_tmp" "$check_tmp" + return 1 + } + + sing_box_cm_save_config_to_file "$validation_config" "$check_tmp" + if ! check_output="$(sing-box -c "$check_tmp" check 2>&1)"; then + SING_BOX_CF_VALIDATION_ERROR="$(sing_box_cf_validation_error_summary "$check_output")" + rm -f "$new_outbounds_tmp" "$updated_tmp" "$validation_tmp" "$check_tmp" + return 1 + fi + + SING_BOX_CF_VALIDATED_CONFIG="$(cat "$updated_tmp" 2>/dev/null)" + rm -f "$new_outbounds_tmp" "$updated_tmp" "$validation_tmp" "$check_tmp" + [ -n "$SING_BOX_CF_VALIDATED_CONFIG" ] || return 1 return 0 } @@ -707,55 +610,25 @@ sing_box_cf_prepared_link_refs_json() { local prepared_json="$1" local source_section="${2:-}" - printf '%s' "$prepared_json" | jq -c --arg source_section "$source_section" ' - (.tags // []) as $tags - | (.source_indices // []) as $source_indices - | $source_section as $source_section - | reduce range(0; ($tags | length)) as $index ( - {}; - if $source_section != "" then - .[$tags[$index]] = { - sourceSection: $source_section, - sourceIndex: (($source_indices[$index] // ($index + 1)) | tonumber) - } - else - . - end - ) - ' 2>/dev/null + sing_box_cf_ucode_input prepared-link-refs "$prepared_json" "$source_section" 2>/dev/null } sing_box_cf_prepared_names_json() { local prepared_json="$1" - printf '%s' "$prepared_json" | jq -c ' - (.tags // []) as $tags - | (.names // []) as $names - | reduce range(0; ($tags | length)) as $index ( - {}; - .[$tags[$index]] = (($names[$index] // $tags[$index]) | tostring) - ) - ' 2>/dev/null + sing_box_cf_ucode_input prepared-names "$prepared_json" 2>/dev/null } sing_box_cf_prepared_servers_json() { local prepared_json="$1" - printf '%s' "$prepared_json" | jq -c ' - (.tags // []) as $tags - | (.servers // []) as $servers - | reduce range(0; ($tags | length)) as $index ( - {}; - (($servers[$index] // "") | tostring) as $server - | if $server != "" then .[$tags[$index]] = $server else . end - ) - ' 2>/dev/null + sing_box_cf_ucode_input prepared-servers "$prepared_json" 2>/dev/null } sing_box_cf_prepared_names_lines() { local prepared_json="$1" - printf '%s' "$prepared_json" | jq -r '.names[]?' 2>/dev/null + sing_box_cf_ucode_input prepared-names-lines "$prepared_json" 2>/dev/null } sing_box_cf_subscription_prepared_slice() { @@ -763,25 +636,14 @@ sing_box_cf_subscription_prepared_slice() { local start="$2" local end="$3" - printf '%s' "$prepared_json" | jq -c --argjson start "$start" --argjson end "$end" ' - . + { - outbounds: ((.outbounds // [])[$start:$end]), - tags: ((.tags // [])[$start:$end]), - names: ((.names // [])[$start:$end]), - servers: ((.servers // [])[$start:$end]), - links: ((.links // [])[$start:$end]), - source_indices: ((.source_indices // [])[$start:$end]), - skipped: 0, - skipped_reason_counts: {} - } - ' 2>/dev/null + sing_box_cf_ucode_input prepared-slice "$prepared_json" "$start" "$end" 2>/dev/null } sing_box_cf_append_subscription_prepared_metadata() { local prepared_json="$1" local tags_json link_refs_json names_json servers_json names - tags_json="$(printf '%s' "$prepared_json" | jq -c '.tags // []' 2>/dev/null)" + tags_json="$(sing_box_cf_ucode_input prepared-field "$prepared_json" tags 2>/dev/null)" [ -n "$tags_json" ] || tags_json="[]" link_refs_json="$(sing_box_cf_prepared_link_refs_json "$prepared_json" "$SING_BOX_CF_SOURCE_SECTION")" @@ -791,20 +653,16 @@ sing_box_cf_append_subscription_prepared_metadata() { servers_json="$(sing_box_cf_prepared_servers_json "$prepared_json")" [ -n "$servers_json" ] || servers_json="{}" - SUBSCRIPTION_OUTBOUND_TAGS_JSON="$(printf '%s' "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" | - jq -c --argjson tags "$tags_json" '. + $tags' 2>/dev/null)" + SUBSCRIPTION_OUTBOUND_TAGS_JSON="$(sing_box_cf_json_arrays_concat "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" "$tags_json" 2>/dev/null)" [ -n "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" ] || SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="$(printf '%s' "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" | - jq -c --argjson link_refs "$link_refs_json" '. + $link_refs' 2>/dev/null)" + SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="$(sing_box_cf_json_objects_merge "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" "$link_refs_json" 2>/dev/null)" [ -n "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" ] || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" - SUBSCRIPTION_OUTBOUND_NAMES_JSON="$(printf '%s' "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" | - jq -c --argjson names "$names_json" '. + $names' 2>/dev/null)" + SUBSCRIPTION_OUTBOUND_NAMES_JSON="$(sing_box_cf_json_objects_merge "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" "$names_json" 2>/dev/null)" [ -n "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" ] || SUBSCRIPTION_OUTBOUND_NAMES_JSON="{}" - SUBSCRIPTION_OUTBOUND_SERVERS_JSON="$(printf '%s' "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" | - jq -c --argjson servers "$servers_json" '. + $servers' 2>/dev/null)" + SUBSCRIPTION_OUTBOUND_SERVERS_JSON="$(sing_box_cf_json_objects_merge "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" "$servers_json" 2>/dev/null)" [ -n "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" ] || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="{}" names="$(sing_box_cf_prepared_names_lines "$prepared_json")" @@ -823,9 +681,9 @@ sing_box_cf_apply_subscription_batch() { local prepared_json="$2" local new_outbounds new_outbounds_count - new_outbounds="$(printf '%s' "$prepared_json" | jq -c '.outbounds // []' 2>/dev/null)" + new_outbounds="$(sing_box_cf_ucode_input prepared-field "$prepared_json" outbounds 2>/dev/null)" [ -n "$new_outbounds" ] || return 1 - new_outbounds_count="$(printf '%s' "$new_outbounds" | jq -r 'length' 2>/dev/null)" + new_outbounds_count="$(sing_box_cf_ucode_input prepared-length "$prepared_json" outbounds 2>/dev/null)" [ -n "$new_outbounds_count" ] || return 1 [ "$new_outbounds_count" -gt 0 ] || return 1 @@ -833,9 +691,9 @@ sing_box_cf_apply_subscription_batch() { return 1 fi - SUBSCRIPTION_OUTBOUND_TAGS_JSON="$(printf '%s' "$prepared_json" | jq -c '.tags // []' 2>/dev/null)" + SUBSCRIPTION_OUTBOUND_TAGS_JSON="$(sing_box_cf_ucode_input prepared-field "$prepared_json" tags 2>/dev/null)" [ -n "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" ] || SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - SUBSCRIPTION_OUTBOUND_TAGS="$(printf '%s' "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" | jq -r 'join(",")' 2>/dev/null)" + SUBSCRIPTION_OUTBOUND_TAGS="$(sing_box_cf_ucode_input prepared-tags-csv "$prepared_json" 2>/dev/null)" SUBSCRIPTION_OUTBOUND_NAMES="$(sing_box_cf_prepared_names_lines "$prepared_json")" SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="$(sing_box_cf_prepared_link_refs_json "$prepared_json" "$SING_BOX_CF_SOURCE_SECTION")" [ -n "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" ] || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" @@ -861,7 +719,7 @@ sing_box_cf_apply_subscription_outbounds_range() { return 0 } - outbounds_json="$(printf '%s' "$chunk" | jq -c '.outbounds // []' 2>/dev/null)" + outbounds_json="$(sing_box_cf_ucode_input prepared-field "$chunk" outbounds 2>/dev/null)" [ -n "$outbounds_json" ] || { SING_BOX_CF_FALLBACK_SKIPPED_COUNT=$((SING_BOX_CF_FALLBACK_SKIPPED_COUNT + count)) return 0 @@ -875,10 +733,7 @@ sing_box_cf_apply_subscription_outbounds_range() { fi if [ "$count" -eq 1 ]; then - display_name="$(printf '%s' "$chunk" | - jq -r '(.names[0] // .outbounds[0].tag // "unknown") | tostring' 2>/dev/null | - tr '\r\n\t' ' ' | - sed 's/[[:space:]][[:space:]]*/ /g;s/^[[:space:]]*//;s/[[:space:]]*$//')" + display_name="$(sing_box_cf_ucode_input prepared-display-name "$chunk" 2>/dev/null)" [ -n "$display_name" ] || display_name="unknown" [ -n "$SING_BOX_CF_VALIDATION_ERROR" ] || SING_BOX_CF_VALIDATION_ERROR="sing-box check failed" log "Skipped unsupported subscription outbound '$display_name': $SING_BOX_CF_VALIDATION_ERROR" "warn" @@ -908,7 +763,7 @@ sing_box_cf_apply_subscription_outbounds_chunked() { local prepared_json="$2" local outbounds_count half rest - outbounds_count="$(printf '%s' "$prepared_json" | jq -r '(.outbounds // []) | length' 2>/dev/null)" + outbounds_count="$(sing_box_cf_ucode_input prepared-length "$prepared_json" outbounds 2>/dev/null)" [ -n "$outbounds_count" ] || return 1 [ "$outbounds_count" -gt 0 ] || return 1 @@ -940,7 +795,7 @@ sing_box_cf_apply_subscription_outbounds_chunked() { log "Skipped $SING_BOX_CF_FALLBACK_SKIPPED_COUNT unsupported subscription outbounds during chunked fallback validation" "warn" fi - SUBSCRIPTION_OUTBOUND_TAGS="$(printf '%s' "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" | jq -r 'join(",")' 2>/dev/null)" + SUBSCRIPTION_OUTBOUND_TAGS="$(sing_box_cf_tags_json_csv "$SUBSCRIPTION_OUTBOUND_TAGS_JSON")" SING_BOX_CF_LAST_CONFIG="$SING_BOX_CF_FALLBACK_WORKING_CONFIG" return 0 @@ -969,7 +824,7 @@ sing_box_cf_add_subscription_outbounds() { fi outbounds_json="$(sing_box_cf_subscription_candidate_outbounds "$subscription_json_path")" - outbounds_count="$(printf '%s' "$outbounds_json" | jq -r 'length' 2>/dev/null)" + outbounds_count="$(sing_box_cf_outbounds_json_count "$outbounds_json")" if [ -z "$outbounds_count" ] || [ "$outbounds_count" -eq 0 ]; then log "No proxy outbounds found in subscription JSON" "error" @@ -981,13 +836,13 @@ sing_box_cf_add_subscription_outbounds() { prepared_json="$(sing_box_cf_prepare_subscription_batch "$config" "$outbounds_json")" if [ -n "$prepared_json" ]; then - skipped_count="$(printf '%s' "$prepared_json" | jq -r '.skipped // 0' 2>/dev/null)" + skipped_count="$(sing_box_cf_ucode_input skip-count "$prepared_json" 2>/dev/null)" if [ "${skipped_count:-0}" -gt 0 ]; then sing_box_cf_log_subscription_skips "$prepared_json" "before validation" fi if sing_box_cf_apply_subscription_batch "$config" "$prepared_json"; then - log "Added $(printf '%s' "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" | jq -r 'length' 2>/dev/null) subscription outbounds for rule '$section'" "info" + log "Added $(sing_box_cf_tags_json_count "$SUBSCRIPTION_OUTBOUND_TAGS_JSON") subscription outbounds for rule '$section'" "info" echo "$SING_BOX_CF_LAST_CONFIG" return 0 fi @@ -995,7 +850,7 @@ sing_box_cf_add_subscription_outbounds() { log "Batch subscription validation failed for rule '$section', trying chunked fallback validation" "warn" if [ -n "$prepared_json" ] && sing_box_cf_apply_subscription_outbounds_chunked "$config" "$prepared_json"; then - log "Added $(printf '%s' "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" | jq -r 'length' 2>/dev/null) subscription outbounds for rule '$section'" "info" + log "Added $(sing_box_cf_tags_json_count "$SUBSCRIPTION_OUTBOUND_TAGS_JSON") subscription outbounds for rule '$section'" "info" echo "$SING_BOX_CF_LAST_CONFIG" return 0 fi diff --git a/podkop/files/usr/lib/sing_box_config_facade.uc b/podkop/files/usr/lib/sing_box_config_facade.uc new file mode 100644 index 00000000..fbfbf6bc --- /dev/null +++ b/podkop/files/usr/lib/sing_box_config_facade.uc @@ -0,0 +1,516 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function json_decode_text(text) { + try { + return json(as_string(text)); + } + catch (e) { + return null; + } +} + +function read_stdin_json() { + return json_decode_text(read_stdin()); +} + +function read_json_file(path) { + let data = fs.readfile(path); + return data == null ? null : json_decode_text(data); +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function write_file_json(path, value) { + return fs.writefile(path, sprintf("%J", value) + "\n"); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function sort_strings(values) { + sort(values, function(first, second) { + return first == second ? 0 : (first < second ? -1 : 1); + }); + return values; +} + +function clone(value) { + return json_decode_text(sprintf("%J", value)); +} + +function non_empty_string(value) { + return type(value) == "string" && value != ""; +} + +function valid_server_port(value) { + return type(value) == "int" && value >= 1 && value <= 65535; +} + +function type_requires_server(proxy_type) { + return proxy_type == "vless" || proxy_type == "vmess" || proxy_type == "trojan" || + proxy_type == "shadowsocks" || proxy_type == "socks" || proxy_type == "hysteria2"; +} + +function supported_flow(flow) { + return flow == null || flow == "" || flow == "xtls-rprx-vision"; +} + +function supported_transport_type(transport) { + return transport == "http" || transport == "ws" || transport == "quic" || transport == "grpc" || + transport == "httpupgrade" || transport == "xhttp" || transport == "kcp"; +} + +let supported_shadowsocks_methods = { + "none": true, + "aes-128-gcm": true, + "aes-192-gcm": true, + "aes-256-gcm": true, + "chacha20-ietf-poly1305": true, + "xchacha20-ietf-poly1305": true, + "2022-blake3-aes-128-gcm": true, + "2022-blake3-aes-256-gcm": true, + "2022-blake3-chacha20-poly1305": true, + "aes-128-cfb": true, + "aes-192-cfb": true, + "aes-256-cfb": true, + "aes-128-ctr": true, + "aes-192-ctr": true, + "aes-256-ctr": true, + "chacha20": true, + "chacha20-ietf": true, + "xchacha20": true, + "salsa20": true, + "rc4-md5": true +}; + +function plugin_name(plugin) { + let m = match(as_string(plugin), /^([^;]*)/); + return m ? m[1] : ""; +} + +function reality_enabled(outbound) { + return type(outbound.tls) == "object" && type(outbound.tls.reality) == "object" && + (outbound.tls.reality.enabled == null || outbound.tls.reality.enabled === true); +} + +function prefilter_skip_reason(outbound, supports_xhttp, plugin_supports) { + if (type(outbound) != "object") + return "not an object"; + + let proxy_type = as_string(outbound.type || ""); + if (proxy_type == "") + return "missing type"; + if (proxy_type == "direct" || proxy_type == "selector" || proxy_type == "urltest" || + proxy_type == "dns" || proxy_type == "block") + return "non-proxy outbound type: " + proxy_type; + if (proxy_type != "vless" && proxy_type != "vmess" && proxy_type != "trojan" && + proxy_type != "shadowsocks" && proxy_type != "socks" && proxy_type != "hysteria2") + return "unsupported type: " + proxy_type; + if (type_requires_server(proxy_type) && !non_empty_string(outbound.server)) + return "missing or empty server"; + if (type_requires_server(proxy_type) && !valid_server_port(outbound.server_port)) + return "missing or invalid server_port"; + if ((proxy_type == "vless" || proxy_type == "vmess") && !non_empty_string(outbound.uuid)) + return "missing uuid"; + if ((proxy_type == "trojan" || proxy_type == "hysteria2") && !non_empty_string(outbound.password)) + return "missing password"; + if (proxy_type == "shadowsocks" && !non_empty_string(outbound.method)) + return "missing shadowsocks method"; + if (proxy_type == "shadowsocks" && !non_empty_string(outbound.password)) + return "missing shadowsocks password"; + if (proxy_type == "shadowsocks" && !supported_shadowsocks_methods[outbound.method]) + return "unsupported shadowsocks method: " + as_string(outbound.method); + if (proxy_type == "shadowsocks" && non_empty_string(outbound.plugin) && !plugin_supports[plugin_name(outbound.plugin)]) + return "shadowsocks plugin is not installed: " + plugin_name(outbound.plugin); + if (reality_enabled(outbound) && !non_empty_string(outbound.tls.reality.public_key)) + return "reality public_key is missing"; + if (proxy_type == "vless" && !supported_flow(outbound.flow || "")) + return "unsupported vless flow: " + as_string(outbound.flow); + if (type(outbound.transport) == "object" && as_string(outbound.transport.type || "") == "") + return "missing transport type"; + if (type(outbound.transport) == "object" && !supported_transport_type(as_string(outbound.transport.type || ""))) + return "unknown transport type: " + as_string(outbound.transport.type); + if (type(outbound.transport) == "object" && as_string(outbound.transport.type || "") == "xhttp" && !supports_xhttp) + return "transport xhttp requires sing-box-extended"; + if (proxy_type == "shadowsocks" && type(outbound.tls) == "object" && outbound.tls.enabled === true) + return "shadowsocks with TLS is not supported"; + + return ""; +} + +function safe_string(value, fallback) { + let result = value == null ? fallback : as_string(value); + return result == "" || result == "null" ? fallback : result; +} + +function unique_tag(base, taken) { + if (!taken[base]) + return base; + + for (let suffix = 1; suffix < 100000; suffix++) { + let candidate = base + "-" + suffix; + if (!taken[candidate]) + return candidate; + } + + return base + "-overflow"; +} + +function copy_outbound(outbound) { + let copy = {}; + for (let key, value in outbound) { + if (key != "tag" && key != "remark" && key != "share_link") + copy[key] = value; + } + return copy; +} + +function candidate_outbounds(path) { + let subscription = object_or_empty(read_json_file(path)); + let result = []; + let skipped_types = { + selector: true, + urltest: true, + direct: true, + dns: true, + block: true + }; + + for (let outbound in array_or_empty(subscription.outbounds)) { + if (type(outbound) == "object" && !skipped_types[outbound.type]) + push(result, outbound); + } + + write_json(result); +} + +function prepare_subscription(config_path, outbounds_path, output_path, supports_xhttp_arg, plugin_supports_path) { + let config = read_json_file(config_path); + let outbounds = read_json_file(outbounds_path); + let plugin_supports = object_or_empty(read_json_file(plugin_supports_path)); + + if (type(config) != "object" || type(outbounds) != "array") + exit(1); + + let supports_xhttp = supports_xhttp_arg == "true" || supports_xhttp_arg == "1"; + let taken = {}; + for (let outbound in array_or_empty(config.outbounds)) { + if (type(outbound) == "object" && non_empty_string(outbound.tag)) + taken[outbound.tag] = true; + } + + let tags = []; + let names = []; + let servers = []; + let links = []; + let source_indices = []; + let skipped = 0; + let skipped_reason_counts = {}; + let prepared = []; + + for (let i = 0; i < length(outbounds); i++) { + let outbound = outbounds[i]; + let index = length(prepared) + 1; + let display_name = safe_string(outbound && (outbound.remark || outbound.tag), "server-" + index); + let skip_reason = prefilter_skip_reason(outbound, supports_xhttp, plugin_supports); + + if (skip_reason != "") { + skipped++; + skipped_reason_counts[skip_reason] = (skipped_reason_counts[skip_reason] || 0) + 1; + continue; + } + + let base_tag = safe_string(outbound.tag || outbound.remark, "server-" + index); + let tag = unique_tag(base_tag, taken); + let outbound_copy = copy_outbound(outbound); + outbound_copy.tag = tag; + + push(prepared, outbound_copy); + push(tags, tag); + push(names, display_name); + push(servers, outbound.server || ""); + push(links, outbound.share_link || ""); + push(source_indices, i + 1); + taken[tag] = true; + } + + if (!write_file_json(output_path, { + outbounds: prepared, + links, + source_indices, + names, + servers, + skipped, + skipped_reason_counts: length(keys(skipped_reason_counts)) > 0 ? skipped_reason_counts : [], + tags + })) + exit(1); +} + +function skip_count() { + let prepared = object_or_empty(read_stdin_json()); + print(as_string(prepared.skipped || 0), "\n"); +} + +function skip_summary() { + let prepared = object_or_empty(read_stdin_json()); + let counts = object_or_empty(prepared.skipped_reason_counts); + let entries = []; + + for (let reason, count in counts) { + push(entries, { reason, count: int(count || 0) }); + } + + sort(entries, function(first, second) { + if (first.count != second.count) + return second.count - first.count; + return first.reason == second.reason ? 0 : (first.reason < second.reason ? -1 : 1); + }); + + let parts = []; + for (let entry in entries) + push(parts, entry.count + "x " + entry.reason); + print(join("; ", parts), "\n"); +} + +function plugin_names() { + let outbounds = array_or_empty(read_stdin_json()); + let seen = {}; + for (let outbound in outbounds) { + if (type(outbound) != "object" || outbound.type != "shadowsocks") + continue; + let plugin = as_string(outbound.plugin); + let semicolon = index(plugin, ";"); + if (semicolon >= 0) + plugin = substr(plugin, 0, semicolon); + if (plugin != "") + seen[plugin] = true; + } + + for (let name in sort_strings(keys(seen))) + print(name, "\n"); +} + +function plugin_supports_from_records(path) { + let result = {}; + let data = fs.readfile(path); + if (data != null) { + for (let line in split(data, "\n")) { + if (line == "") + continue; + let parts = split(line, "\t"); + if (length(parts) >= 2 && parts[0] != "") + result[parts[0]] = parts[1] == "true"; + } + } + write_json(result); +} + +function first_direct_tag(config) { + for (let outbound in array_or_empty(config.outbounds)) { + if (type(outbound) == "object" && outbound.type == "direct" && as_string(outbound.tag) != "") + return outbound.tag; + } + return "direct-out"; +} + +function strip_runtime_fields(config) { + for (let outbound in array_or_empty(config.outbounds)) { + if (type(outbound) == "object") { + delete outbound.share_link; + delete outbound.remark; + } + } +} + +function prepare_validation(new_outbounds_path, updated_path, validation_path) { + let config = object_or_empty(read_stdin_json()); + let new_outbounds = array_or_empty(read_json_file(new_outbounds_path)); + if (type(config.outbounds) != "array") + config.outbounds = []; + + for (let outbound in new_outbounds) + push(config.outbounds, outbound); + strip_runtime_fields(config); + + let validation = clone(config); + let route = type(validation.route) == "object" ? validation.route : {}; + route.rules = []; + route.rule_set = []; + route.final = first_direct_tag(config); + validation.route = route; + + if (!write_file_json(updated_path, config)) + exit(1); + if (!write_file_json(validation_path, validation)) + exit(1); +} + +function prepared_link_refs(prepared, source_section) { + let tags = array_or_empty(prepared.tags); + let source_indices = array_or_empty(prepared.source_indices); + let result = {}; + + if (source_section == "") + return result; + + for (let i = 0; i < length(tags); i++) { + result[tags[i]] = { + sourceSection: source_section, + sourceIndex: int(source_indices[i] || (i + 1)) + }; + } + return result; +} + +function prepared_names(prepared) { + let tags = array_or_empty(prepared.tags); + let names = array_or_empty(prepared.names); + let result = {}; + for (let i = 0; i < length(tags); i++) + result[tags[i]] = as_string(names[i] || tags[i]); + return result; +} + +function prepared_servers(prepared) { + let tags = array_or_empty(prepared.tags); + let servers = array_or_empty(prepared.servers); + let result = {}; + for (let i = 0; i < length(tags); i++) { + let server = as_string(servers[i]); + if (server != "") + result[tags[i]] = server; + } + return result; +} + +function prepared_names_lines(prepared) { + for (let name in array_or_empty(prepared.names)) + print(as_string(name), "\n"); +} + +function prepared_slice(start, end) { + let prepared = object_or_empty(read_stdin_json()); + start = int(start || 0); + end = int(end || start); + prepared.outbounds = slice(array_or_empty(prepared.outbounds), start, end); + prepared.tags = slice(array_or_empty(prepared.tags), start, end); + prepared.names = slice(array_or_empty(prepared.names), start, end); + prepared.servers = slice(array_or_empty(prepared.servers), start, end); + prepared.links = slice(array_or_empty(prepared.links), start, end); + prepared.source_indices = slice(array_or_empty(prepared.source_indices), start, end); + prepared.skipped = 0; + prepared.skipped_reason_counts = {}; + write_json(prepared); +} + +function field_json(field) { + let value = object_or_empty(read_stdin_json())[field]; + if (field == "outbounds" || field == "tags" || field == "names" || field == "servers" || field == "links" || field == "source_indices") + value = array_or_empty(value); + write_json(value == null ? [] : value); +} + +function field_length(field) { + let value = object_or_empty(read_stdin_json())[field]; + if (type(value) != "array" && type(value) != "object") + print("0\n"); + else + print(length(value), "\n"); +} + +function tags_csv() { + let prepared = object_or_empty(read_stdin_json()); + let tags = []; + for (let tag in array_or_empty(prepared.tags)) + push(tags, as_string(tag)); + print(join(",", tags), "\n"); +} + +function metadata_command(kind, source_section) { + let prepared = object_or_empty(read_stdin_json()); + if (kind == "link-refs") + write_json(prepared_link_refs(prepared, source_section)); + else if (kind == "names") + write_json(prepared_names(prepared)); + else if (kind == "servers") + write_json(prepared_servers(prepared)); + else if (kind == "names-lines") + prepared_names_lines(prepared); +} + +function display_name() { + let prepared = object_or_empty(read_stdin_json()); + let names = array_or_empty(prepared.names); + let outbounds = array_or_empty(prepared.outbounds); + let result = as_string(names[0]); + if (result == "" && length(outbounds) > 0 && type(outbounds[0]) == "object") + result = as_string(outbounds[0].tag); + if (result == "") + result = "unknown"; + result = replace(result, /[\r\n\t]/g, " "); + result = replace(result, /[ ]+/g, " "); + result = trim(result); + print(result != "" ? result : "unknown", "\n"); +} + +let mode = ARGV[0] || ""; + +if (mode == "candidate-outbounds") + candidate_outbounds(ARGV[1]); +else if (mode == "skip-count") + skip_count(); +else if (mode == "skip-summary") + skip_summary(); +else if (mode == "plugin-names") + plugin_names(); +else if (mode == "plugin-supports-from-records") + plugin_supports_from_records(ARGV[1]); +else if (mode == "prepare-subscription") + prepare_subscription(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "prepare-validation") + prepare_validation(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "prepared-link-refs") + metadata_command("link-refs", as_string(ARGV[1])); +else if (mode == "prepared-names") + metadata_command("names", ""); +else if (mode == "prepared-servers") + metadata_command("servers", ""); +else if (mode == "prepared-names-lines") + metadata_command("names-lines", ""); +else if (mode == "prepared-slice") + prepared_slice(ARGV[1], ARGV[2]); +else if (mode == "prepared-field") + field_json(ARGV[1]); +else if (mode == "prepared-length") + field_length(ARGV[1]); +else if (mode == "prepared-tags-csv") + tags_csv(); +else if (mode == "prepared-display-name") + display_name(); +else { + warn("Usage: sing_box_config_facade.uc [args...]\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/sing_box_config_manager.sh b/podkop/files/usr/lib/sing_box_config_manager.sh index 9ed41d23..7c38aab0 100644 --- a/podkop/files/usr/lib/sing_box_config_manager.sh +++ b/podkop/files/usr/lib/sing_box_config_manager.sh @@ -2,1085 +2,125 @@ # Module: sing_box_config_manager.sh # # Purpose: -# This script provides a set of helper functions to simplify creation and management -# of sing-box configuration. +# Thin ash wrappers for sing_box_config_manager.uc. Shell remains the +# orchestration layer; JSON configuration mutations happen in ucode. # -# Conventions: -# - All functions are prefixed with: sing_box_cm_* -# - "cm" stands for "config manager", shortened by convention -# -# Usage: -# Include this script in your ash script with: -# . /usr/lib/podkop-plus/sing_box_config_manager.sh -# -# After that, sing_box_cm_* functions are available for generating -# and modifying sing-box JSON configuration. SERVICE_TAG="__service_tag" -####################################### -# Configure the logging section of a sing-box JSON configuration. -# Arguments: -# config: string, JSON configuration -# disabled: boolean, true to disable logging -# level: string, log level. One of: trace debug info warn error fatal panic. -# timestamp: boolean, true to include timestamps -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_configure_log "$CONFIG" false "info" true) -####################################### +sing_box_cm_ucode() { + local operation="$1" + local config="$2" + shift 2 + + printf '%s' "$config" | + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/sing_box_config_manager.uc" "$operation" "$@" +} + sing_box_cm_configure_log() { - local config="$1" - local disabled="$2" - local level="$3" - local timestamp="$4" - - echo "$config" | jq \ - --argjson disabled "$disabled" \ - --arg level "$level" \ - --argjson timestamp "$timestamp" \ - '.log = { - disabled: $disabled, - level: $level, - timestamp: $timestamp - }' + sing_box_cm_ucode configure-log "$1" "$2" "$3" "$4" } -####################################### -# Configure the DNS section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# final: string, default dns server tag -# strategy: string, default domain strategy for resolving the domain names -# independent_cache: boolean, whether to use an independent DNS cache -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_configure_dns "$CONFIG" "direct-out" "ipv4_only" true) -####################################### sing_box_cm_configure_dns() { - local config="$1" - local final="$2" - local strategy="$3" - local independent_cache="$4" - - echo "$config" | jq \ - --arg final "$final" \ - --arg strategy "$strategy" \ - --argjson independent_cache "$independent_cache" \ - '.dns = { - servers: (.dns.servers // []), - rules: (.dns.rules // []), - final: $final, - strategy: $strategy, - independent_cache: $independent_cache - }' - + sing_box_cm_ucode configure-dns "$1" "$2" "$3" "$4" } -####################################### -# Add a UDP DNS server to the DNS section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the DNS server -# server_address: string, IP address or hostname of the DNS server -# server_port: string or integer, port of the DNS server -# domain_resolver: string, domain resolver to use for resolving domain names (optional) -# detour: string, tag of the upstream outbound (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_udp_dns_server "$CONFIG" "udp-server" "8.8.8.8" 53) -####################################### sing_box_cm_add_udp_dns_server() { - local config="$1" - local tag="$2" - local server_address="$3" - local server_port="$4" - local domain_resolver="$5" - local detour="$6" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_address "$server_address" \ - --arg server_port "$server_port" \ - --arg domain_resolver "$domain_resolver" \ - --arg detour "$detour" \ - '.dns.servers += [( - { - type: "udp", - tag: $tag, - server: $server_address, - server_port: ($server_port | tonumber) - } - + (if $detour != "" then { detour: $detour } else {} end) - + (if $domain_resolver != "" then { domain_resolver: $domain_resolver } else {} end) - )]' + sing_box_cm_ucode add-udp-dns-server "$1" "$2" "$3" "$4" "$5" "$6" } -####################################### -# Add a TLS DNS server to the DNS section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the DNS server -# server_address: string, IP address or hostname of the DNS server -# server_port: string or integer, port of the DNS server -# domain_resolver: string, domain resolver to use for resolving domain names (optional) -# detour: string, tag of the upstream outbound (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_tls_dns_server "$CONFIG" "dot-server" "1.1.1.1" 853) -####################################### sing_box_cm_add_tls_dns_server() { - local config="$1" - local tag="$2" - local server_address="$3" - local server_port="$4" - local domain_resolver="$5" - local detour="$6" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_address "$server_address" \ - --arg server_port "$server_port" \ - --arg domain_resolver "$domain_resolver" \ - --arg detour "$detour" \ - '.dns.servers += [( - { - type: "tls", - tag: $tag, - server: $server_address, - server_port: ($server_port | tonumber) - } - + (if $detour != "" then { detour: $detour } else {} end) - + (if $domain_resolver != "" then { domain_resolver: $domain_resolver } else {} end) - )]' + sing_box_cm_ucode add-tls-dns-server "$1" "$2" "$3" "$4" "$5" "$6" } -####################################### -# Add an HTTPS DNS server to the DNS section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the DNS server -# server_address: string, IP address or hostname of the DNS server -# server_port: string or integer, port of the DNS server -# path: string, URL path for HTTPS DNS requests (optional) -# headers: string, additional headers for HTTPS DNS requests (optional) -# domain_resolver: string, domain resolver to use for resolving domain names (optional) -# detour: string, tag of the upstream outbound (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_https_dns_server "$CONFIG" "doh-server" "1.1.1.1" 443) -####################################### sing_box_cm_add_https_dns_server() { - local config="$1" - local tag="$2" - local server_address="$3" - local server_port="$4" - local path="$5" - local headers="$6" - local domain_resolver="$7" - local detour="$8" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_address "$server_address" \ - --arg server_port "$server_port" \ - --arg path "$path" \ - --arg headers "$headers" \ - --arg domain_resolver "$domain_resolver" \ - --arg detour "$detour" \ - '.dns.servers += [( - { - type: "https", - tag: $tag, - server: $server_address, - server_port: ($server_port |tonumber) - } - + (if $path != "" then { path: $path } else {} end) - + (if $headers != "" then { headers: $headers } else {} end) - + (if $detour != "" then { detour: $detour } else {} end) - + (if $domain_resolver != "" then { domain_resolver: $domain_resolver } else {} end) - )]' + sing_box_cm_ucode add-https-dns-server "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" } -####################################### -# Add a FakeIP DNS server to the DNS section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the DNS server -# inet4_range: string, IPv4 range used for fake IP mapping -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_fakeip_dns_server "$CONFIG" "fakeip-server" "198.18.0.0/15") -####################################### sing_box_cm_add_fakeip_dns_server() { - local config="$1" - local tag="$2" - local inet4_range="$3" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg inet4_range "$inet4_range" \ - '.dns.servers += [{ - type: "fakeip", - tag: $tag, - inet4_range: $inet4_range, - }]' + sing_box_cm_ucode add-fakeip-dns-server "$1" "$2" "$3" } -####################################### -# Add a DNS routing rule to the DNS section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# server: string, target DNS server for the rule -# tag: string, identifier for the route rule -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_dns_route_rule "$CONFIG" "fakeip-server" "fakeip-dns-rule-id") -####################################### sing_box_cm_add_dns_route_rule() { - local config="$1" - local server="$2" - local tag="$3" - - echo "$config" | jq \ - --arg server "$server" \ - --arg service_tag "$SERVICE_TAG" \ - --arg tag "$tag" \ - '.dns.rules += [{ - action: "route", - server: $server, - $service_tag: $tag - }]' + sing_box_cm_ucode add-dns-route-rule "$1" "$2" "$3" } -####################################### -# Patch a DNS routing rule in the DNS section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the rule to patch -# key: string, the key in the rule to update or add -# value: string, JSON value to assign to the key -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_patch_dns_route_rule "$CONFIG" "fakeip-dns-rule-id" "rule_set" "main") -# CONFIG=$(sing_box_cm_patch_dns_route_rule "$CONFIG" "fakeip-dns-rule-id" "rule_set" '["main","second"]') -# CONFIG=$(sing_box_cm_patch_dns_route_rule "$CONFIG" "fakeip-dns-rule-id" "domain" "example.com") -####################################### sing_box_cm_patch_dns_route_rule() { - local config="$1" - local tag="$2" - local key="$3" - local value="$4" - - value=$(_normalize_arg "$value") - - echo "$config" | jq \ - --arg service_tag "$SERVICE_TAG" \ - --arg tag "$tag" \ - --arg key "$key" \ - --argjson value "$value" \ -'import "helpers" as h {"search": "/usr/lib/podkop-plus"}; - .dns.rules |= map( - if .[$service_tag] == $tag then - if has($key) then - .[$key] |= h::extend_key_value(.; $value) - else - . + { ($key): $value } - end - else - . - end - )' + sing_box_cm_ucode patch-dns-route-rule "$1" "$2" "$3" "$4" } -####################################### -# Add a DNS reject rule to the DNS section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# key: string, the key to set for the reject rule -# value: string, JSON value to assign to the key -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_dns_reject_rule "$CONFIG" "query_type" "HTTPS") -####################################### sing_box_cm_add_dns_reject_rule() { - local config="$1" - local key="$2" - local value="$3" - - value=$(_normalize_arg "$value") - - echo "$config" | jq \ - --arg key "$key" \ - --argjson value "$value" \ - '.dns.rules += [{ - action: "reject", - ($key): $value - }]' + sing_box_cm_ucode add-dns-reject-rule "$1" "$2" "$3" } -####################################### -# Add a TProxy inbound to the inbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the inbound -# listen_address: string, IP address to listen on -# listen_port: integer, port to listen on -# tcp_fast_open: boolean, enable or disable TCP Fast Open -# udp_fragment: boolean, enable or disable UDP fragmentation -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_tproxy_inbound "$CONFIG" "tproxy-in" "127.0.0.1" 6969 true true) -####################################### sing_box_cm_add_tproxy_inbound() { - local config="$1" - local tag="$2" - local listen_address="$3" - local listen_port="$4" - local tcp_fast_open="$5" - local udp_fragment="$6" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg listen_address "$listen_address" \ - --argjson listen_port "$listen_port" \ - --argjson tcp_fast_open "$tcp_fast_open" \ - --argjson udp_fragment "$udp_fragment" \ - '.inbounds += [{ - type: "tproxy", - tag: $tag, - listen: $listen_address, - listen_port: $listen_port, - tcp_fast_open: $tcp_fast_open, - udp_fragment: $udp_fragment - }]' + sing_box_cm_ucode add-tproxy-inbound "$1" "$2" "$3" "$4" "$5" "$6" } -####################################### -# Add a Direct inbound to the inbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the inbound -# listen_address: string, IP address to listen on -# listen_port: integer, port to listen on -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_direct_inbound "$CONFIG" "dns-in" "127.0.0.42" 53) -####################################### sing_box_cm_add_direct_inbound() { - local config="$1" - local tag="$2" - local listen_address="$3" - local listen_port="$4" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg listen_address "$listen_address" \ - --argjson listen_port "$listen_port" \ - '.inbounds += [{ - type: "direct", - tag: $tag, - listen: $listen_address, - listen_port: $listen_port, - }]' + sing_box_cm_ucode add-direct-inbound "$1" "$2" "$3" "$4" } -####################################### -# Add a Mixed inbound to the inbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the inbound -# listen_address: string, IP address to listen on -# listen_port: integer, port to listen on -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_mixed_inbound "$CONFIG" "tproxy-in" "192.168.1.1" 2080) -####################################### sing_box_cm_add_mixed_inbound() { - local config="$1" - local tag="$2" - local listen_address="$3" - local listen_port="$4" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg listen_address "$listen_address" \ - --argjson listen_port "$listen_port" \ - '.inbounds += [{ - type: "mixed", - tag: $tag, - listen: $listen_address, - listen_port: $listen_port, - }]' + sing_box_cm_ucode add-mixed-inbound "$1" "$2" "$3" "$4" } -####################################### -# Add a Direct outbound to the outbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the outbound -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_direct_outbound "$CONFIG" "direct-out") -####################################### sing_box_cm_add_direct_outbound() { - local config="$1" - local tag="$2" - local routing_mark="$3" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg routing_mark "$routing_mark" \ - '.outbounds += [( - { - type: "direct", - tag: $tag - } - + (if $routing_mark != "" then { routing_mark: ($routing_mark | tonumber) } else {} end) - )]' + sing_box_cm_ucode add-direct-outbound "$1" "$2" "$3" } -####################################### -# Add a SOCKS outbound to the outbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the outbound -# server_address: string, IP address or hostname of the SOCKS server -# server_port: integer, port of the SOCKS server -# version: string, SOCKS version (optional) -# username: string, username for authentication (optional) -# password: string, password for authentication (optional) -# network: string, network type (e.g., "tcp") (optional) -# udp_over_tcp: integer, version for UDP over TCP (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_socks_outbound "$CONFIG" "socks5-out" "192.168.1.10" 1080) -####################################### sing_box_cm_add_socks_outbound() { - local config="$1" - local tag="$2" - local server_address="$3" - local server_port="$4" - local version="$5" - local username="$6" - local password="$7" - local network="$8" - local udp_over_tcp="$9" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_address "$server_address" \ - --arg server_port "$server_port" \ - --arg version "$version" \ - --arg username "$username" \ - --arg password "$password" \ - --arg network "$network" \ - --arg udp_over_tcp "$udp_over_tcp" \ - '.outbounds += [( - { - type: "socks", - tag: $tag, - server: $server_address, - server_port: ($server_port | tonumber) - } - + (if $version != "" then {version: $version} else {} end) - + (if $username != "" then {username: $username} else {} end) - + (if $password != "" then {password: $password} else {} end) - + (if $network != "" then {network: $network} else {} end) - + (if $udp_over_tcp != "" then { - udp_over_tcp: { - enabled: true, - version: ($udp_over_tcp | tonumber) - } - } else {} end) - - )]' + sing_box_cm_ucode add-socks-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" } -####################################### -# Add a Shadowsocks outbound to the outbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the outbound -# server_address: string, IP address or hostname of the Shadowsocks server -# server_port: integer, port of the Shadowsocks server -# method: string, encryption method -# password: string, password for encryption -# network: string, network type (e.g., "tcp") (optional) -# udp_over_tcp: integer, version for UDP over TCP (optional) -# plugin: string, plugin name (optional) -# plugin_opts: string, plugin options (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$( -# sing_box_cm_add_shadowsocks_outbound "$CONFIG" "ss-out" "127.0.0.1" 443 \ -# "2022-blake3-aes-128-gcm" "8JCsPssfgS8tiRwiMlhARg==" -# ) -####################################### sing_box_cm_add_shadowsocks_outbound() { - local config="$1" - local tag="$2" - local server_address="$3" - local server_port="$4" - local method="$5" - local password="$6" - local network="$7" - local udp_over_tcp="$8" - local plugin="$9" - local plugin_opts="${10}" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_address "$server_address" \ - --arg server_port "$server_port" \ - --arg method "$method" \ - --arg password "$password" \ - --arg plugin "$plugin" \ - --arg plugin_opts "$plugin_opts" \ - --arg network "$network" \ - --arg udp_over_tcp "$udp_over_tcp" \ - '.outbounds += [( - { - type: "shadowsocks", - tag: $tag, - server: $server_address, - server_port: ($server_port | tonumber), - method: $method, - password: $password - } - + (if $plugin != "" then {plugin: $plugin} else {} end) - + (if $plugin_opts != "" then {plugin_opts: $plugin_opts} else {} end) - + (if $network != "" then {network: $network} else {} end) - + (if $udp_over_tcp != "" then { - udp_over_tcp: { - enabled: true, - version: ($udp_over_tcp | tonumber) - } - } else {} end) - )]' + sing_box_cm_ucode add-shadowsocks-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" } -####################################### -# Add a VLESS outbound to the outbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the outbound -# server_address: string, IP address or hostname of the VLESS server -# server_port: integer, port of the VLESS server -# uuid: string, user UUID -# flow: string, flow setting (optional) -# network: string, network type (e.g., "tcp") (optional) -# packet_encoding: string, packet encoding method (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$( -# sing_box_cm_add_vless_outbound "$CONFIG" "vless-reality-out" "example.com" 443 \ -# "bf000d23-0752-40b4-affe-68f7707a9661" "xtls-rprx-vision" -# ) -####################################### sing_box_cm_add_vless_outbound() { - local config="$1" - local tag="$2" - local server_address="$3" - local server_port="$4" - local uuid="$5" - local flow="$6" - local network="$7" - local packet_encoding="$8" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_address "$server_address" \ - --arg server_port "$server_port" \ - --arg uuid "$uuid" \ - --arg flow "$flow" \ - --arg network "$network" \ - --arg packet_encoding "$packet_encoding" \ - '.outbounds += [( - { - type: "vless", - tag: $tag, - server: $server_address, - server_port: ($server_port | tonumber), - uuid: $uuid - } - + (if $flow != "" then {flow: $flow} else {} end) - + (if $network != "" then {network: $network} else {} end) - + (if $packet_encoding != "" then {packet_encoding: $packet_encoding} else {} end) - )]' + sing_box_cm_ucode add-vless-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" } -####################################### -# Add a Trojan outbound to the outbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the outbound -# server_address: string, IP address or hostname of the Trojan server -# server_port: integer, port of the Trojan server -# password: string, password for authentication -# network: string, network type (e.g., "tcp") (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_trojan_outbound "$CONFIG" "trojan-out" "example.com" 443 "supersecretpassword" "tcp") -####################################### sing_box_cm_add_trojan_outbound() { - local config="$1" - local tag="$2" - local server_address="$3" - local server_port="$4" - local password="$5" - local network="$6" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_address "$server_address" \ - --arg server_port "$server_port" \ - --arg password "$password" \ - --arg network "$network" \ - '.outbounds += [( - { - type: "trojan", - tag: $tag, - server: $server_address, - server_port: ($server_port | tonumber), - password: $password - } - + (if $network != "" then {network: $network} else {} end) - )]' + sing_box_cm_ucode add-trojan-outbound "$1" "$2" "$3" "$4" "$5" "$6" } -####################################### -# Add a Hysteria2 outbound to the outbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the outbound -# server_address: string, IP address or hostname of the Hysteria2 server -# server_port: integer, port of the Hysteria2 server -# password: string, password for authentication -# obfuscator_type: string, obfuscation type (optional) -# obfuscator_password: string, obfuscation password (optional) -# upload_mbps: integer, upload bandwidth limit in Mbps (optional) -# download_mbps: integer, download bandwidth limit in Mbps (optional) -# network: string, network type (e.g., "udp") (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_hysteria2_outbound "$CONFIG" "hysteria2-out" "example.com" 443 "supersecret" \ -# "salamander" "obfs-pass" "50" "200" "udp") -####################################### sing_box_cm_add_hysteria2_outbound() { - local config="$1" - local tag="$2" - local server_address="$3" - local server_port="$4" - local password="$5" - local obfuscator_type="$6" - local obfuscator_password="$7" - local upload_mbps="$8" - local download_mbps="$9" - local network="${10}" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_address "$server_address" \ - --arg server_port "$server_port" \ - --arg password "$password" \ - --arg obfuscator_type "$obfuscator_type" \ - --arg obfuscator_password "$obfuscator_password" \ - --arg upload_mbps "$upload_mbps" \ - --arg download_mbps "$download_mbps" \ - --arg network "$network" \ - '.outbounds += [( - { - type: "hysteria2", - tag: $tag, - server: $server_address, - server_port: ($server_port | tonumber), - password: $password - } - + (if $obfuscator_type != "" and $obfuscator_password != "" then { - obfs: { - type: $obfuscator_type, - password: $obfuscator_password - } - } else {} end) - + (if $upload_mbps != "" then {up_mbps: ($upload_mbps | tonumber)} else {} end) - + (if $download_mbps != "" then {down_mbps: ($download_mbps | tonumber)} else {} end) - + (if $network != "" then {network: $network} else {} end) - )]' + sing_box_cm_ucode add-hysteria2-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" } -####################################### -# Set gRPC transport settings for an outbound in a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the outbound to modify -# service_name: string, gRPC service name (optional) -# idle_timeout: string or integer, idle timeout (optional) -# ping_timeout: string or integer, ping timeout (optional) -# permit_without_stream: boolean, flag for permitting without stream (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_set_grpc_transport_for_outbound "$CONFIG" "vless-tls-grpc-out") -####################################### sing_box_cm_set_grpc_transport_for_outbound() { - local config="$1" - local tag="$2" - local service_name="$3" - local idle_timeout="$4" - local ping_timeout="$5" - local permit_without_stream="$6" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg service_name "$service_name" \ - --arg idle_timeout "$idle_timeout" \ - --arg ping_timeout "$ping_timeout" \ - --arg permit_without_stream "$permit_without_stream" \ - '.outbounds |= map( - if .tag == $tag then - . + { - transport: ( - { type: "grpc" } - + (if $service_name != "" then {service_name: $service_name} else {} end) - + (if $idle_timeout != "" then {idle_timeout: $idle_timeout} else {} end) - + (if $ping_timeout != "" then {ping_timeout: $ping_timeout} else {} end) - + (if $permit_without_stream != "" then {permit_without_stream: $permit_without_stream} else {} end) - ) - } - else - . - end - )' + sing_box_cm_ucode set-grpc-transport "$1" "$2" "$3" "$4" "$5" "$6" } -####################################### -# Set WebSocket transport settings for an outbound in a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the outbound to modify -# path: string, WebSocket path -# host: string, Host header for WebSocket (optional) -# max_early_data: integer, maximum early data (optional) -# early_data_header_name: string, header name for early data (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_set_ws_transport_for_outbound "$CONFIG" "vless-tls-ws-out" "/path" "example.com") -####################################### sing_box_cm_set_ws_transport_for_outbound() { - local config="$1" - local tag="$2" - local path="$3" - local host="$4" - local max_early_data="$5" - local early_data_header_name="$6" - - # Not sure if the "Host" parameter in headers is correct — needs verification - echo "$config" | jq \ - --arg tag "$tag" \ - --arg path "$path" \ - --arg host "$host" \ - --arg max_early_data "$max_early_data" \ - --arg early_data_header_name "$early_data_header_name" \ - '.outbounds |= map( - if .tag == $tag then - . + { - transport: ( - { - type: "ws", - path: $path - } - + (if $host != "" then {headers: {Host: $host}} else {} end) - + (if $max_early_data != "" then {max_early_data: $max_early_data | tonumber} else {} end) - + (if $early_data_header_name != "" then - {early_data_header_name: $early_data_header_name} - else {} end) - ) - } - else - . - end - )' + sing_box_cm_ucode set-ws-transport "$1" "$2" "$3" "$4" "$5" "$6" } -####################################### -# Set HTTP transport settings for an outbound in a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the outbound to modify -# path: string, HTTP request path (optional) -# hosts: string, JSON array of host domains (optional) -# Outputs: -# Writes updated JSON configuration to stdout -####################################### sing_box_cm_set_http_transport_for_outbound() { - local config="$1" - local tag="$2" - local path="$3" - local hosts="$4" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg path "$path" \ - --argjson hosts "$hosts" \ - '.outbounds |= map( - if .tag == $tag then - . + { - transport: ( - { type: "http" } - + (if $path != "" then {path: $path} else {} end) - + (if ($hosts | length) > 0 then {host: $hosts} else {} end) - ) - } - else - . - end - )' + sing_box_cm_ucode set-http-transport "$1" "$2" "$3" "$4" } -####################################### -# Set HTTPUpgrade transport settings for an outbound in a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the outbound to modify -# path: string, HTTPUpgrade request path (optional) -# host: string, HTTPUpgrade host (optional) -# Outputs: -# Writes updated JSON configuration to stdout -####################################### sing_box_cm_set_httpupgrade_transport_for_outbound() { - local config="$1" - local tag="$2" - local path="$3" - local host="$4" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg path "$path" \ - --arg host "$host" \ - '.outbounds |= map( - if .tag == $tag then - . + { - transport: ( - { type: "httpupgrade" } - + (if $path != "" then {path: $path} else {} end) - + (if $host != "" then {host: $host} else {} end) - ) - } - else - . - end - )' + sing_box_cm_ucode set-httpupgrade-transport "$1" "$2" "$3" "$4" } -####################################### -# Set XHTTP transport settings for an outbound in a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the outbound to modify -# path: string, XHTTP request path (optional, defaults to /) -# host: string, XHTTP host (optional) -# mode: string, XHTTP mode (auto, packet-up, stream-up, stream-one) -# Outputs: -# Writes updated JSON configuration to stdout -####################################### sing_box_cm_set_xhttp_transport_for_outbound() { - local config="$1" - local tag="$2" - local path="$3" - local host="$4" - local mode="$5" - - [ -n "$path" ] || path="/" - case "$mode" in - auto | packet-up | stream-up | stream-one) ;; - *) mode="auto" ;; - esac - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg path "$path" \ - --arg host "$host" \ - --arg mode "$mode" \ - '.outbounds |= map( - if .tag == $tag then - . + { - transport: ( - { - type: "xhttp", - mode: $mode, - path: $path, - x_padding_bytes: "100-1000", - no_grpc_header: false, - sc_max_each_post_bytes: 1000000, - sc_min_posts_interval_ms: 30 - } - + (if $host != "" then {host: $host} else {} end) - ) - } - else - . - end - )' + sing_box_cm_ucode set-xhttp-transport "$1" "$2" "$3" "$4" "$5" } -####################################### -# Set TLS settings for an outbound in a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the outbound to modify -# server_name: string, used to verify the hostname on the returned certificates (optional) -# insecure: boolean, accept any server certificate (optional) -# alpn: string, JSON value, supported application level protocols (optional) -# utls_fingerprint: string, uTLS fingerprint (optional) -# reality_public_key: string, Reality public key (optional) -# reality_short_id: string, Reality short ID (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$( -# sing_box_cm_set_tls_for_outbound "$CONFIG" "vless-reality-out" "example.com" false null "chrome" \ -# "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0" "0123456789abcdef" -# ) -####################################### sing_box_cm_set_tls_for_outbound() { - local config="$1" - local tag="$2" - local server_name="$3" - local insecure="$4" - local alpn="$5" - local utls_fingerprint="$6" - local reality_public_key="$7" - local reality_short_id="$8" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg server_name "$server_name" \ - --arg insecure "$insecure" \ - --argjson alpn "$alpn" \ - --arg utls_fingerprint "$utls_fingerprint" \ - --arg reality_public_key "$reality_public_key" \ - --arg reality_short_id "$reality_short_id" \ - '.outbounds |= map( - if .tag == $tag then - . + { - tls: ( - { enabled: true } - + (if $server_name != "" then {server_name: $server_name} else {} end) - + (if $insecure == "true" then {insecure: true} else {} end) - + (if $alpn != null then {alpn: $alpn} else {} end) - + (if $utls_fingerprint != "" then { - utls: { - enabled: true, - fingerprint: $utls_fingerprint - } - } else {} end) - + (if $reality_public_key != "" then { - reality: { - enabled: true, - public_key: $reality_public_key, - short_id: $reality_short_id - } - } else {} end) - ) - } - else - . - end - )' + sing_box_cm_ucode set-tls "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" } -####################################### -# Add a Direct outbound with a specific network interface to a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the outbound -# interface: string, network interface to bind the outbound -# domain_resolver: string, tag of the domain resolver to be used for this outbound (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_interface_outbound "$CONFIG" "warp-out" "awg0") -####################################### sing_box_cm_add_interface_outbound() { - local config="$1" - local tag="$2" - local interface="$3" - local domain_resolver="$4" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg interface "$interface" \ - --arg domain_resolver "$domain_resolver" \ - '.outbounds += [ - { - type: "direct", - tag: $tag, - bind_interface: $interface - } - + (if $domain_resolver != "" then {domain_resolver: $domain_resolver} else {} end) - ]' + sing_box_cm_ucode add-interface-outbound "$1" "$2" "$3" "$4" } -####################################### -# Add a raw outbound JSON object to the outbounds section of a sing-box configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the outbound -# raw_outbound: string, JSON object, the raw outbound configuration to add -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_raw_outbound "$CONFIG" "raw-out" '{"type":"trojan","tag":"trojan-out","server":"127.0.0.1","server_port":1080,"password":"8JCsPssfgS8tiRwiMlhARg==","network":"tcp"}') -####################################### sing_box_cm_add_raw_outbound() { - local config="$1" - local tag="$2" - local raw_outbound="$3" - - echo "$config" | jq \ - --arg tag "$tag" \ - --argjson raw_outbound "$raw_outbound" \ - '.outbounds += [( - $raw_outbound + {tag: $tag} - )]' + sing_box_cm_ucode add-raw-outbound "$1" "$2" "$3" } -####################################### -# Add a URLTest outbound to the outbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the URLTest outbound -# outbounds: string, JSON array of outbound tags to test -# url: string, URL to probe (optional) -# interval: string, test interval (e.g., "10s") (optional) -# tolerance: string or integer, max latency difference tolerated (optional) -# idle_timeout: string or integer, idle timeout duration (optional) -# interrupt_exist_connections: boolean, flag to interrupt existing connections ("true"/"false") (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_urltest_outbound "$CONFIG" "auto-select" '["proxy1","proxy2"]') -####################################### sing_box_cm_add_urltest_outbound() { local config="$1" local tag="$2" @@ -1089,8 +129,8 @@ sing_box_cm_add_urltest_outbound() { local interval="$5" local tolerance="$6" local idle_timeout="$7" - local interrupt_exist_connections="$8" - local outbounds_tmp jq_status + local interrupt="$8" + local outbounds_tmp result status outbounds_tmp="$(mktemp)" || return 1 printf '%s' "$outbounds" > "$outbounds_tmp" || { @@ -1098,51 +138,23 @@ sing_box_cm_add_urltest_outbound() { return 1 } - printf '%s' "$config" | jq \ - --arg tag "$tag" \ - --slurpfile outbounds "$outbounds_tmp" \ - --arg url "$url" \ - --arg interval "$interval" \ - --arg tolerance "$tolerance" \ - --arg idle_timeout "$idle_timeout" \ - --arg interrupt_exist_connections "$interrupt_exist_connections" \ - '.outbounds += [ - { - type: "urltest", - tag: $tag, - outbounds: ($outbounds[0] // []) - } - + (if $url != "" then {url: $url} else {} end) - + (if $interval != "" then {interval: $interval} else {} end) - + (if $tolerance != "" then {tolerance: ($tolerance | tonumber)} else {} end) - + (if $idle_timeout != "" then {idle_timeout: $idle_timeout} else {} end) - + (if $interrupt_exist_connections == "true" then {interrupt_exist_connections: true} else {} end) - ]' - jq_status=$? + result="$(sing_box_cm_ucode add-urltest-outbound-file "$config" "$tag" "$outbounds_tmp" \ + "$url" "$interval" "$tolerance" "$idle_timeout" "$interrupt")" + status=$? rm -f "$outbounds_tmp" - return "$jq_status" + + [ "$status" -eq 0 ] || return 1 + [ -n "$result" ] || return 1 + printf '%s\n' "$result" } -####################################### -# Add a Selector outbound to the outbounds section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the Selector outbound -# outbounds: string (JSON), array of outbound tags to choose from -# default: string, default outbound tag if none selected -# interrupt_exist_connections: boolean, flag to interrupt existing connections ("true"/"false") (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_selector_outbound "$CONFIG" "select-proxy" '["proxy1","proxy2"]') -####################################### sing_box_cm_add_selector_outbound() { local config="$1" local tag="$2" local outbounds="$3" local default="$4" - local interrupt_exist_connections="$5" - local outbounds_tmp jq_status + local interrupt="$5" + local outbounds_tmp result status outbounds_tmp="$(mktemp)" || return 1 printf '%s' "$outbounds" > "$outbounds_tmp" || { @@ -1150,500 +162,79 @@ sing_box_cm_add_selector_outbound() { return 1 } - printf '%s' "$config" | jq \ - --arg tag "$tag" \ - --slurpfile outbounds "$outbounds_tmp" \ - --arg default "$default" \ - --arg interrupt_exist_connections "$interrupt_exist_connections" \ - '.outbounds += [ - { - type: "selector", - tag: $tag, - outbounds: ($outbounds[0] // []), - default: $default - } - + (if $interrupt_exist_connections == "true" then {interrupt_exist_connections: true} else {} end) - ]' - jq_status=$? + result="$(sing_box_cm_ucode add-selector-outbound-file "$config" "$tag" "$outbounds_tmp" "$default" "$interrupt")" + status=$? rm -f "$outbounds_tmp" - return "$jq_status" + + [ "$status" -eq 0 ] || return 1 + [ -n "$result" ] || return 1 + printf '%s\n' "$result" } -####################################### -# Configure the route section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# final: string, final outbound tag for unmatched traffic -# auto_detect_interface: boolean, enable or disable automatic interface detection -# default_domain_resolver: string, default DNS resolver for domain-based routing -# default_interface: string, default network interface to use when auto detection is disabled (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_configure_route "$CONFIG" "direct-out" true "udp-server") -####################################### sing_box_cm_configure_route() { - local config="$1" - local final="$2" - local auto_detect_interface="$3" - local default_domain_resolver="$4" - local default_interface="$5" - - echo "$config" | jq \ - --arg final "$final" \ - --argjson auto_detect_interface "$auto_detect_interface" \ - --arg default_domain_resolver "$default_domain_resolver" \ - --arg default_interface "$default_interface" \ - '.route = { - rules: (.route.rules // []), - rule_set: (.route.rule_set // []), - final: $final, - auto_detect_interface: $auto_detect_interface, - default_domain_resolver: $default_domain_resolver - } - + (if $default_interface != "" then { default_interface: $default_interface } else {} end) - ' + sing_box_cm_ucode configure-route "$1" "$2" "$3" "$4" "$5" } -####################################### -# Add a routing rule to the route section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the route rule -# inbound: string, inbound tag to match -# outbound: string, outbound tag to route matched traffic to -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_route_rule "$CONFIG" "main-route-rule" "tproxy-in" "main") -####################################### sing_box_cm_add_route_rule() { - local config="$1" - local tag="$2" - local inbound="$3" - local outbound="$4" - - echo "$config" | jq \ - --arg service_tag "$SERVICE_TAG" \ - --arg tag "$tag" \ - --arg inbound "$inbound" \ - --arg outbound "$outbound" \ - '.route.rules += [{ - action: "route", - inbound: $inbound, - outbound: $outbound, - $service_tag: $tag - }]' + sing_box_cm_ucode add-route-rule "$1" "$2" "$3" "$4" } -####################################### -# Insert a resolve rule immediately before a tagged route rule. -# Copies the domain/rule-set matchers from the target route rule so sing-box can -# resolve a domain destination before the final route decision. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# route_rule_tag: string, tag of the route rule to precede -# resolve_rule_tag: string, tag for the new resolve rule -# server: string, DNS server tag (optional, default: "dns-server") -# Outputs: -# Writes updated JSON configuration to stdout -####################################### sing_box_cm_add_resolve_rule() { - local config="$1" - local route_rule_tag="$2" - local resolve_rule_tag="$3" - local server="${4:-dns-server}" - - echo "$config" | jq \ - --arg service_tag "$SERVICE_TAG" \ - --arg route_tag "$route_rule_tag" \ - --arg resolve_tag "$resolve_rule_tag" \ - --arg server "$server" \ - '.route.rules |= [ - .[] | - if .[$service_tag] == $route_tag then - ( - {} - + (if has("inbound") then { inbound: .inbound } else {} end) - + (if has("source_ip_cidr") then { source_ip_cidr: .source_ip_cidr } else {} end) - + (if has("domain") then { domain: .domain } else {} end) - + (if has("domain_suffix") then { domain_suffix: .domain_suffix } else {} end) - + (if has("domain_keyword") then { domain_keyword: .domain_keyword } else {} end) - + (if has("domain_regex") then { domain_regex: .domain_regex } else {} end) - + (if has("rule_set") then { rule_set: .rule_set } else {} end) - + { - action: "resolve", - server: $server, - ($service_tag): $resolve_tag - } - ), . - else - . - end - ]' + sing_box_cm_ucode add-resolve-rule "$1" "$2" "$3" "${4:-dns-server}" } -####################################### -# Patch a routing rule in the route section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the route rule to patch -# key: string, the key in the rule to update or add -# value: string (JSON), value to assign to the key -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_patch_route_rule "$CONFIG" "main-route-rule" "rule_set" "inline-ruleset") -####################################### sing_box_cm_patch_route_rule() { - local config="$1" - local tag="$2" - local key="$3" - local value="$4" - - value=$(_normalize_arg "$value") - - echo "$config" | jq \ - --arg service_tag "$SERVICE_TAG" \ - --arg tag "$tag" \ - --arg key "$key" \ - --argjson value "$value" \ -'import "helpers" as h {"search": "/usr/lib/podkop-plus"}; - .route.rules |= map( - if .[$service_tag] == $tag then - if has($key) then - .[$key] |= h::extend_key_value(.; $value) - else - . + { ($key): $value } - end - else - . - end - )' + sing_box_cm_ucode patch-route-rule "$1" "$2" "$3" "$4" } -####################################### -# Add a reject rule to the route section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# key: string, the key to set for the reject rule -# value: string (JSON), value to assign to the key -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_reject_route_rule "$CONFIG" "reject-rule-tag") -####################################### sing_box_cm_add_reject_route_rule() { - local config="$1" - local tag="$2" - local inbound="$3" - - echo "$config" | jq \ - --arg service_tag "$SERVICE_TAG" \ - --arg tag "$tag" \ - --arg inbound "$inbound" \ - '.route.rules += [{ - action: "reject", - inbound: $inbound, - $service_tag: $tag - }]' + sing_box_cm_ucode add-reject-route-rule "$1" "$2" "$3" } -####################################### -# Add a hijack-dns rule to the route section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# key: string, the key to set for the hijack-dns rule -# value: string (JSON), value to assign to the key -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_hijack_dns_route_rule "$CONFIG" "protocol" "dns") -####################################### sing_box_cm_add_hijack_dns_route_rule() { - local config="$1" - local key="$2" - local value="$3" - - value=$(_normalize_arg "$value") - - echo "$config" | jq \ - --arg key "$key" \ - --argjson value "$value" \ - '.route.rules += [{ - action: "hijack-dns", - ($key): $value - }]' + sing_box_cm_ucode add-hijack-dns-route-rule "$1" "$2" "$3" } -####################################### -# Add a route-options rule to the route section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the route-options rule -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_options_route_rule "$CONFIG" "override-fakeip-port") -####################################### sing_box_cm_add_options_route_rule() { - local config="$1" - local tag="$2" - - echo "$config" | jq \ - --arg service_tag "$SERVICE_TAG" \ - --arg tag "$tag" \ - '.route.rules += [{ - action: "route-options", - $service_tag: $tag - }]' + sing_box_cm_ucode add-options-route-rule "$1" "$2" } -####################################### -# Add a sniff rule to the route section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# key: string, the key to set for the sniff rule -# value: string (JSON), value to assign to the key -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_sniff_route_rule "$CONFIG" "inbound" "tproxy-in") -# CONFIG=$(sing_box_cm_sniff_route_rule "$CONFIG" "inbound" '["tproxy-in","dns-in"]') -####################################### sing_box_cm_sniff_route_rule() { - local config="$1" - local key="$2" - local value="$3" - - value=$(_normalize_arg "$value") - - echo "$config" | jq \ - --arg key "$key" \ - --argjson value "$value" \ - '.route.rules += [{ - action: "sniff", - ($key): $value - }]' + sing_box_cm_ucode sniff-route-rule "$1" "$2" "$3" } -####################################### -# Add an inline ruleset to the route.rule_set section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the inline ruleset -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_inline_ruleset "$CONFIG" "inline-ruleset") -####################################### sing_box_cm_add_inline_ruleset() { - local config="$1" - local tag="$2" - - echo "$config" | jq \ - --arg tag "$tag" \ - '.route.rule_set += [{ - type: "inline", - tag: $tag - }]' + sing_box_cm_ucode add-inline-ruleset "$1" "$2" } -####################################### -# Add or update a rule in an inline ruleset within the route.rule_set section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier of the inline ruleset -# key: string, the key in the ruleset to update or add -# value: string (JSON), value to assign to the key -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_inline_ruleset_rule "$CONFIG" "inline-ruleset" "domain_suffix" "telegram.org") -# CONFIG=$(sing_box_cm_add_inline_ruleset_rule "$CONFIG" "inline-ruleset" "domain_suffix" "discord.com") -# CONFIG=$(sing_box_cm_add_inline_ruleset_rule "$CONFIG" "inline-ruleset" "ip_cidr" "111.111.111.111/32") -####################################### sing_box_cm_add_inline_ruleset_rule() { - local config="$1" - local tag="$2" - local key="$3" - local value="$4" - - value=$(_normalize_arg "$value") - - echo "$config" | jq -L /usr/lib/podkop-plus \ - --arg tag "$tag" \ - --arg key "$key" \ - --argjson value "$value" \ -'import "helpers" as h {"search": "/usr/lib/podkop-plus"}; - .route.rule_set |= map( - if .tag == $tag then - if has($key) then - .[$key] |= h::extend_key_value(.; $value) - else - . + { ($key): $value } - end - else - . - end - )' + sing_box_cm_ucode add-inline-ruleset-rule "$1" "$2" "$3" "$4" } -####################################### -# Add a local ruleset to the route.rule_set section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the local ruleset -# format: string, format of the local ruleset ("source" or "binary") -# path: string, file path to the local ruleset -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_local_ruleset "$CONFIG" "local-source-ruleset" "source" "/tmp/local-ruleset.json") -# CONFIG=$(sing_box_cm_add_local_ruleset "$CONFIG" "local-binary-ruleset" "binary" "/tmp/local-ruleset.srs") -####################################### sing_box_cm_add_local_ruleset() { - local config="$1" - local tag="$2" - local format="$3" - local path="$4" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg format "$format" \ - --arg path "$path" \ - '.route.rule_set += [{ - type: "local", - tag: $tag, - format: $format, - path: $path - }]' + sing_box_cm_ucode add-local-ruleset "$1" "$2" "$3" "$4" } -####################################### -# Add a remote ruleset to the route.rule_set section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# tag: string, identifier for the remote ruleset -# format: string, format of the remote ruleset ("source" or "binary") -# url: string, URL to download the ruleset from -# download_detour: string, detour tag for downloading (optional) -# update_interval: string, update interval for the ruleset (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_add_remote_ruleset "$CONFIG" "remote-source-ruleset" "source" "https://example.com/telegram.json") -####################################### sing_box_cm_add_remote_ruleset() { - local config="$1" - local tag="$2" - local format="$3" - local url="$4" - local download_detour="$5" - local update_interval="$6" - - echo "$config" | jq \ - --arg tag "$tag" \ - --arg format "$format" \ - --arg url "$url" \ - --arg download_detour "$download_detour" \ - --arg update_interval "$update_interval" \ - '.route.rule_set += [( - { - type: "remote", - tag: $tag, - format: $format, - url: $url - } - + (if $download_detour != "" then { download_detour: $download_detour } else {} end) - + (if $update_interval != "" then { update_interval: $update_interval } else {} end) - )]' - + sing_box_cm_ucode add-remote-ruleset "$1" "$2" "$3" "$4" "$5" "$6" } -####################################### -# Configure the experimental cache_file section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# enabled: boolean, enable or disable file caching -# path: string, file path for cache storage -# store_fakeip: boolean, whether to store fake IPs in the cache -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_configure_cache_file "$CONFIG" true "/tmp/cache.db" true) -####################################### sing_box_cm_configure_cache_file() { - local config="$1" - local enabled="$2" - local path="$3" - local store_fakeip="$4" - - echo "$config" | jq \ - --argjson enabled "$enabled" \ - --arg path "$path" \ - --argjson store_fakeip "$store_fakeip" \ - '.experimental.cache_file = { - enabled: $enabled, - path: $path, - store_fakeip: $store_fakeip - }' + sing_box_cm_ucode configure-cache-file "$1" "$2" "$3" "$4" } -####################################### -# Configure the experimental clash_api section of a sing-box JSON configuration. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# external_controller: string, API listening address; Clash API will be disabled if empty -# external_ui: string, path to static web resources to serve at http://{{external-controller}}/ui (optional) -# secret: string, secret for the RESTful API Authenticate by specifying HTTP header (optional) -# Outputs: -# Writes updated JSON configuration to stdout -# Example: -# CONFIG=$(sing_box_cm_configure_clash_api "$CONFIG" "192.168.1.1:9090" "ui") -####################################### sing_box_cm_configure_clash_api() { - local config="$1" - local external_controller="$2" - local external_ui="$3" - local secret="$4" - - echo "$config" | jq \ - --arg external_controller "$external_controller" \ - --arg external_ui "$external_ui" \ - --arg secret "$secret" \ - '.experimental.clash_api = { - external_controller: $external_controller, - } - + (if $external_ui != "" then { external_ui: $external_ui } else {} end) - + (if $secret != "" then { secret: $secret } else {} end)' + sing_box_cm_ucode configure-clash-api "$1" "$2" "$3" "$4" } -####################################### -# Save a sing-box JSON configuration to a file, removing service-specific tags. -# Arguments: -# config: string (JSON), sing-box configuration to modify -# file_path: string, path to save the configuration file -# Outputs: -# Writes the cleaned JSON configuration to the specified file -# Example: -# sing_box_cm_save_config_to_file "$CONFIG" "/tmp/sing-box-config.json" -####################################### sing_box_cm_save_config_to_file() { local config="$1" local filepath="$2" - echo "$config" | jq \ - --arg tag "$SERVICE_TAG" \ - 'walk(if type == "object" then del(.[$tag]) else . end)' \ - > "$filepath" + printf '%s' "$config" | + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/sing_box_config_manager.uc" save-config "$filepath" } _normalize_arg() { - local value="$1" - if echo "$value" | jq -e . > /dev/null 2>&1; then - printf '%s' "$value" - else - printf '%s' "$value" | jq -R . - fi + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/sing_box_config_manager.uc" normalize-arg "$1" } diff --git a/podkop/files/usr/lib/sing_box_config_manager.uc b/podkop/files/usr/lib/sing_box_config_manager.uc new file mode 100644 index 00000000..9e263974 --- /dev/null +++ b/podkop/files/usr/lib/sing_box_config_manager.uc @@ -0,0 +1,748 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +let SERVICE_TAG = "__service_tag"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : data; +} + +function read_config() { + try { + let config = json(read_stdin()); + return type(config) == "object" ? config : {}; + } + catch (e) { + return {}; + } +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function bool_arg(value) { + return as_string(value) == "true"; +} + +function number_arg(value) { + value = as_string(value); + return value == "" ? null : int(value, 10); +} + +function json_arg(value) { + value = as_string(value); + try { + return json(value); + } + catch (e) { + return value; + } +} + +function array_arg(value) { + let parsed = json_arg(value); + return type(parsed) == "array" ? parsed : []; +} + +function array_file_arg(path) { + let parsed = read_json_file(path); + return type(parsed) == "array" ? parsed : []; +} + +function optional_string(object, key, value) { + value = as_string(value); + if (value != "") + object[key] = value; +} + +function optional_number(object, key, value) { + let parsed = number_arg(value); + if (parsed != null) + object[key] = parsed; +} + +function ensure_object(config, key) { + if (type(config[key]) != "object") + config[key] = {}; + return config[key]; +} + +function ensure_array(object, key) { + if (type(object[key]) != "array") + object[key] = []; + return object[key]; +} + +function extend_key_value(current_value, new_value) { + let result = []; + + if (type(current_value) == "array") { + for (let item in current_value) + push(result, item); + } + else { + push(result, current_value); + } + + if (type(new_value) == "array") { + for (let item in new_value) + push(result, item); + } + else { + push(result, new_value); + } + + return result; +} + +function patch_tagged_rule(rules, tag, key, value) { + for (let rule in rules) { + if (type(rule) == "object" && rule[SERVICE_TAG] == tag) { + if (rule[key] != null) + rule[key] = extend_key_value(rule[key], value); + else + rule[key] = value; + } + } +} + +function patch_outbound(config, tag, patch) { + for (let outbound in ensure_array(config, "outbounds")) { + if (type(outbound) == "object" && outbound.tag == tag) { + for (let key, value in patch) + outbound[key] = value; + } + } +} + +function add_dns_server(config, server) { + push(ensure_array(ensure_object(config, "dns"), "servers"), server); +} + +function add_outbound(config, outbound) { + push(ensure_array(config, "outbounds"), outbound); +} + +function add_rule(config, section, rule) { + push(ensure_array(ensure_object(config, section), "rules"), rule); +} + +function add_rule_set(config, rule_set) { + push(ensure_array(ensure_object(config, "route"), "rule_set"), rule_set); +} + +function clean_service_tags(value) { + if (type(value) == "array") { + for (let i = 0; i < length(value); i++) + value[i] = clean_service_tags(value[i]); + return value; + } + + if (type(value) == "object") { + delete value[SERVICE_TAG]; + for (let key, item in value) + value[key] = clean_service_tags(item); + } + + return value; +} + +function configure_log(config, args) { + config.log = { + disabled: bool_arg(args[0]), + level: as_string(args[1]), + timestamp: bool_arg(args[2]) + }; +} + +function configure_dns(config, args) { + let current = type(config.dns) == "object" ? config.dns : {}; + config.dns = { + servers: type(current.servers) == "array" ? current.servers : [], + rules: type(current.rules) == "array" ? current.rules : [], + final: as_string(args[0]), + strategy: as_string(args[1]), + independent_cache: bool_arg(args[2]) + }; +} + +function add_udp_dns_server(config, args) { + let server = { + type: "udp", + tag: as_string(args[0]), + server: as_string(args[1]), + server_port: number_arg(args[2]) + }; + optional_string(server, "domain_resolver", args[3]); + optional_string(server, "detour", args[4]); + add_dns_server(config, server); +} + +function add_tls_dns_server(config, args) { + let server = { + type: "tls", + tag: as_string(args[0]), + server: as_string(args[1]), + server_port: number_arg(args[2]) + }; + optional_string(server, "domain_resolver", args[3]); + optional_string(server, "detour", args[4]); + add_dns_server(config, server); +} + +function add_https_dns_server(config, args) { + let server = { + type: "https", + tag: as_string(args[0]), + server: as_string(args[1]), + server_port: number_arg(args[2]) + }; + optional_string(server, "path", args[3]); + optional_string(server, "headers", args[4]); + optional_string(server, "domain_resolver", args[5]); + optional_string(server, "detour", args[6]); + add_dns_server(config, server); +} + +function add_fakeip_dns_server(config, args) { + add_dns_server(config, { + type: "fakeip", + tag: as_string(args[0]), + inet4_range: as_string(args[1]) + }); +} + +function add_dns_route_rule(config, args) { + let rule = { + action: "route", + server: as_string(args[0]) + }; + rule[SERVICE_TAG] = as_string(args[1]); + add_rule(config, "dns", rule); +} + +function patch_dns_route_rule(config, args) { + patch_tagged_rule(ensure_array(ensure_object(config, "dns"), "rules"), as_string(args[0]), as_string(args[1]), json_arg(args[2])); +} + +function add_dns_reject_rule(config, args) { + let rule = { action: "reject" }; + rule[as_string(args[0])] = json_arg(args[1]); + add_rule(config, "dns", rule); +} + +function add_tproxy_inbound(config, args) { + push(ensure_array(config, "inbounds"), { + type: "tproxy", + tag: as_string(args[0]), + listen: as_string(args[1]), + listen_port: number_arg(args[2]), + tcp_fast_open: bool_arg(args[3]), + udp_fragment: bool_arg(args[4]) + }); +} + +function add_direct_inbound(config, args) { + push(ensure_array(config, "inbounds"), { + type: "direct", + tag: as_string(args[0]), + listen: as_string(args[1]), + listen_port: number_arg(args[2]) + }); +} + +function add_mixed_inbound(config, args) { + push(ensure_array(config, "inbounds"), { + type: "mixed", + tag: as_string(args[0]), + listen: as_string(args[1]), + listen_port: number_arg(args[2]) + }); +} + +function add_direct_outbound(config, args) { + let outbound = { + type: "direct", + tag: as_string(args[0]) + }; + optional_number(outbound, "routing_mark", args[1]); + add_outbound(config, outbound); +} + +function add_socks_outbound(config, args) { + let outbound = { + type: "socks", + tag: as_string(args[0]), + server: as_string(args[1]), + server_port: number_arg(args[2]) + }; + optional_string(outbound, "version", args[3]); + optional_string(outbound, "username", args[4]); + optional_string(outbound, "password", args[5]); + optional_string(outbound, "network", args[6]); + if (as_string(args[7]) != "") { + outbound.udp_over_tcp = { + enabled: true, + version: number_arg(args[7]) + }; + } + add_outbound(config, outbound); +} + +function add_shadowsocks_outbound(config, args) { + let outbound = { + type: "shadowsocks", + tag: as_string(args[0]), + server: as_string(args[1]), + server_port: number_arg(args[2]), + method: as_string(args[3]), + password: as_string(args[4]) + }; + optional_string(outbound, "network", args[5]); + if (as_string(args[6]) != "") { + outbound.udp_over_tcp = { + enabled: true, + version: number_arg(args[6]) + }; + } + optional_string(outbound, "plugin", args[7]); + optional_string(outbound, "plugin_opts", args[8]); + add_outbound(config, outbound); +} + +function add_vless_outbound(config, args) { + let outbound = { + type: "vless", + tag: as_string(args[0]), + server: as_string(args[1]), + server_port: number_arg(args[2]), + uuid: as_string(args[3]) + }; + optional_string(outbound, "flow", args[4]); + optional_string(outbound, "network", args[5]); + optional_string(outbound, "packet_encoding", args[6]); + add_outbound(config, outbound); +} + +function add_trojan_outbound(config, args) { + let outbound = { + type: "trojan", + tag: as_string(args[0]), + server: as_string(args[1]), + server_port: number_arg(args[2]), + password: as_string(args[3]) + }; + optional_string(outbound, "network", args[4]); + add_outbound(config, outbound); +} + +function add_hysteria2_outbound(config, args) { + let outbound = { + type: "hysteria2", + tag: as_string(args[0]), + server: as_string(args[1]), + server_port: number_arg(args[2]), + password: as_string(args[3]) + }; + if (as_string(args[4]) != "" && as_string(args[5]) != "") { + outbound.obfs = { + type: as_string(args[4]), + password: as_string(args[5]) + }; + } + optional_number(outbound, "up_mbps", args[6]); + optional_number(outbound, "down_mbps", args[7]); + optional_string(outbound, "network", args[8]); + add_outbound(config, outbound); +} + +function set_grpc_transport(config, args) { + let transport = { type: "grpc" }; + optional_string(transport, "service_name", args[1]); + optional_string(transport, "idle_timeout", args[2]); + optional_string(transport, "ping_timeout", args[3]); + optional_string(transport, "permit_without_stream", args[4]); + patch_outbound(config, as_string(args[0]), { transport }); +} + +function set_ws_transport(config, args) { + let transport = { + type: "ws", + path: as_string(args[1]) + }; + if (as_string(args[2]) != "") + transport.headers = { Host: as_string(args[2]) }; + optional_number(transport, "max_early_data", args[3]); + optional_string(transport, "early_data_header_name", args[4]); + patch_outbound(config, as_string(args[0]), { transport }); +} + +function set_http_transport(config, args) { + let transport = { type: "http" }; + optional_string(transport, "path", args[1]); + let hosts = array_arg(args[2]); + if (length(hosts) > 0) + transport.host = hosts; + patch_outbound(config, as_string(args[0]), { transport }); +} + +function set_httpupgrade_transport(config, args) { + let transport = { type: "httpupgrade" }; + optional_string(transport, "path", args[1]); + optional_string(transport, "host", args[2]); + patch_outbound(config, as_string(args[0]), { transport }); +} + +function set_xhttp_transport(config, args) { + let mode = as_string(args[3]); + if (mode != "auto" && mode != "packet-up" && mode != "stream-up" && mode != "stream-one") + mode = "auto"; + + let transport = { + type: "xhttp", + mode, + path: as_string(args[1]) != "" ? as_string(args[1]) : "/", + x_padding_bytes: "100-1000", + no_grpc_header: false, + sc_max_each_post_bytes: 1000000, + sc_min_posts_interval_ms: 30 + }; + optional_string(transport, "host", args[2]); + patch_outbound(config, as_string(args[0]), { transport }); +} + +function set_tls(config, args) { + let tls = { enabled: true }; + optional_string(tls, "server_name", args[1]); + if (bool_arg(args[2])) + tls.insecure = true; + + let alpn = json_arg(args[3]); + if (alpn != null) + tls.alpn = alpn; + + if (as_string(args[4]) != "") { + tls.utls = { + enabled: true, + fingerprint: as_string(args[4]) + }; + } + if (as_string(args[5]) != "") { + tls.reality = { + enabled: true, + public_key: as_string(args[5]), + short_id: as_string(args[6]) + }; + } + patch_outbound(config, as_string(args[0]), { tls }); +} + +function add_interface_outbound(config, args) { + let outbound = { + type: "direct", + tag: as_string(args[0]), + bind_interface: as_string(args[1]) + }; + optional_string(outbound, "domain_resolver", args[2]); + add_outbound(config, outbound); +} + +function add_raw_outbound(config, args) { + let outbound = json_arg(args[1]); + if (type(outbound) != "object") + outbound = {}; + outbound.tag = as_string(args[0]); + add_outbound(config, outbound); +} + +function add_urltest_outbound(config, args) { + let outbound = { + type: "urltest", + tag: as_string(args[0]), + outbounds: array_arg(args[1]) + }; + optional_string(outbound, "url", args[2]); + optional_string(outbound, "interval", args[3]); + optional_number(outbound, "tolerance", args[4]); + optional_string(outbound, "idle_timeout", args[5]); + if (bool_arg(args[6])) + outbound.interrupt_exist_connections = true; + add_outbound(config, outbound); +} + +function add_urltest_outbound_file(config, args) { + let outbound = { + type: "urltest", + tag: as_string(args[0]), + outbounds: array_file_arg(args[1]) + }; + optional_string(outbound, "url", args[2]); + optional_string(outbound, "interval", args[3]); + optional_number(outbound, "tolerance", args[4]); + optional_string(outbound, "idle_timeout", args[5]); + if (bool_arg(args[6])) + outbound.interrupt_exist_connections = true; + add_outbound(config, outbound); +} + +function add_selector_outbound(config, args) { + let outbound = { + type: "selector", + tag: as_string(args[0]), + outbounds: array_arg(args[1]), + default: as_string(args[2]) + }; + if (bool_arg(args[3])) + outbound.interrupt_exist_connections = true; + add_outbound(config, outbound); +} + +function add_selector_outbound_file(config, args) { + let outbound = { + type: "selector", + tag: as_string(args[0]), + outbounds: array_file_arg(args[1]), + default: as_string(args[2]) + }; + if (bool_arg(args[3])) + outbound.interrupt_exist_connections = true; + add_outbound(config, outbound); +} + +function configure_route(config, args) { + let current = type(config.route) == "object" ? config.route : {}; + config.route = { + rules: type(current.rules) == "array" ? current.rules : [], + rule_set: type(current.rule_set) == "array" ? current.rule_set : [], + final: as_string(args[0]), + auto_detect_interface: bool_arg(args[1]), + default_domain_resolver: as_string(args[2]) + }; + optional_string(config.route, "default_interface", args[3]); +} + +function add_route_rule(config, args) { + let rule = { + action: "route", + inbound: as_string(args[1]), + outbound: as_string(args[2]) + }; + rule[SERVICE_TAG] = as_string(args[0]); + add_rule(config, "route", rule); +} + +function copy_rule_matchers(rule) { + let copied = {}; + for (let key in ["inbound", "source_ip_cidr", "domain", "domain_suffix", "domain_keyword", "domain_regex", "rule_set"]) { + if (rule[key] != null) + copied[key] = rule[key]; + } + return copied; +} + +function add_resolve_rule(config, args) { + let route = ensure_object(config, "route"); + let rules = ensure_array(route, "rules"); + let updated = []; + + for (let rule in rules) { + if (type(rule) == "object" && rule[SERVICE_TAG] == as_string(args[0])) { + let resolve = copy_rule_matchers(rule); + resolve.action = "resolve"; + resolve.server = as_string(args[2]) != "" ? as_string(args[2]) : "dns-server"; + resolve[SERVICE_TAG] = as_string(args[1]); + push(updated, resolve); + } + push(updated, rule); + } + + route.rules = updated; +} + +function patch_route_rule(config, args) { + patch_tagged_rule(ensure_array(ensure_object(config, "route"), "rules"), as_string(args[0]), as_string(args[1]), json_arg(args[2])); +} + +function add_reject_route_rule(config, args) { + let rule = { + action: "reject", + inbound: as_string(args[1]) + }; + rule[SERVICE_TAG] = as_string(args[0]); + add_rule(config, "route", rule); +} + +function add_hijack_dns_route_rule(config, args) { + let rule = { action: "hijack-dns" }; + rule[as_string(args[0])] = json_arg(args[1]); + add_rule(config, "route", rule); +} + +function add_options_route_rule(config, args) { + let rule = { action: "route-options" }; + rule[SERVICE_TAG] = as_string(args[0]); + add_rule(config, "route", rule); +} + +function sniff_route_rule(config, args) { + let rule = { action: "sniff" }; + rule[as_string(args[0])] = json_arg(args[1]); + add_rule(config, "route", rule); +} + +function add_inline_ruleset(config, args) { + add_rule_set(config, { + type: "inline", + tag: as_string(args[0]) + }); +} + +function add_inline_ruleset_rule(config, args) { + let rule_sets = ensure_array(ensure_object(config, "route"), "rule_set"); + let tag = as_string(args[0]); + let key = as_string(args[1]); + let value = json_arg(args[2]); + for (let rule_set in rule_sets) { + if (type(rule_set) == "object" && rule_set.tag == tag) { + if (rule_set[key] != null) + rule_set[key] = extend_key_value(rule_set[key], value); + else + rule_set[key] = value; + } + } +} + +function add_local_ruleset(config, args) { + add_rule_set(config, { + type: "local", + tag: as_string(args[0]), + format: as_string(args[1]), + path: as_string(args[2]) + }); +} + +function add_remote_ruleset(config, args) { + let rule_set = { + type: "remote", + tag: as_string(args[0]), + format: as_string(args[1]), + url: as_string(args[2]) + }; + optional_string(rule_set, "download_detour", args[3]); + optional_string(rule_set, "update_interval", args[4]); + add_rule_set(config, rule_set); +} + +function configure_cache_file(config, args) { + let experimental = ensure_object(config, "experimental"); + experimental.cache_file = { + enabled: bool_arg(args[0]), + path: as_string(args[1]), + store_fakeip: bool_arg(args[2]) + }; +} + +function configure_clash_api(config, args) { + let experimental = ensure_object(config, "experimental"); + experimental.clash_api = { + external_controller: as_string(args[0]) + }; + optional_string(experimental.clash_api, "external_ui", args[1]); + optional_string(experimental.clash_api, "secret", args[2]); +} + +let handlers = { + "configure-log": configure_log, + "configure-dns": configure_dns, + "add-udp-dns-server": add_udp_dns_server, + "add-tls-dns-server": add_tls_dns_server, + "add-https-dns-server": add_https_dns_server, + "add-fakeip-dns-server": add_fakeip_dns_server, + "add-dns-route-rule": add_dns_route_rule, + "patch-dns-route-rule": patch_dns_route_rule, + "add-dns-reject-rule": add_dns_reject_rule, + "add-tproxy-inbound": add_tproxy_inbound, + "add-direct-inbound": add_direct_inbound, + "add-mixed-inbound": add_mixed_inbound, + "add-direct-outbound": add_direct_outbound, + "add-socks-outbound": add_socks_outbound, + "add-shadowsocks-outbound": add_shadowsocks_outbound, + "add-vless-outbound": add_vless_outbound, + "add-trojan-outbound": add_trojan_outbound, + "add-hysteria2-outbound": add_hysteria2_outbound, + "set-grpc-transport": set_grpc_transport, + "set-ws-transport": set_ws_transport, + "set-http-transport": set_http_transport, + "set-httpupgrade-transport": set_httpupgrade_transport, + "set-xhttp-transport": set_xhttp_transport, + "set-tls": set_tls, + "add-interface-outbound": add_interface_outbound, + "add-raw-outbound": add_raw_outbound, + "add-urltest-outbound": add_urltest_outbound, + "add-urltest-outbound-file": add_urltest_outbound_file, + "add-selector-outbound": add_selector_outbound, + "add-selector-outbound-file": add_selector_outbound_file, + "configure-route": configure_route, + "add-route-rule": add_route_rule, + "add-resolve-rule": add_resolve_rule, + "patch-route-rule": patch_route_rule, + "add-reject-route-rule": add_reject_route_rule, + "add-hijack-dns-route-rule": add_hijack_dns_route_rule, + "add-options-route-rule": add_options_route_rule, + "sniff-route-rule": sniff_route_rule, + "add-inline-ruleset": add_inline_ruleset, + "add-inline-ruleset-rule": add_inline_ruleset_rule, + "add-local-ruleset": add_local_ruleset, + "add-remote-ruleset": add_remote_ruleset, + "configure-cache-file": configure_cache_file, + "configure-clash-api": configure_clash_api +}; + +let mode = ARGV[0] || ""; + +if (mode == "normalize-arg") { + write_json(json_arg(ARGV[1])); + exit(0); +} + +let config = read_config(); + +if (mode == "save-config") { + let path = ARGV[1] || ""; + if (path == "" || !fs.writefile(path, sprintf("%J", clean_service_tags(config)) + "\n")) + exit(1); + exit(0); +} + +let handler = handlers[mode]; +if (!handler) { + warn("Usage: sing_box_config_manager.uc [args...]\n"); + exit(1); +} + +handler(config, slice(ARGV, 1)); +write_json(config); diff --git a/podkop/files/usr/lib/subscription_cache.lua b/podkop/files/usr/lib/subscription_cache.lua deleted file mode 100644 index f4d0c238..00000000 --- a/podkop/files/usr/lib/subscription_cache.lua +++ /dev/null @@ -1,828 +0,0 @@ -#!/usr/bin/env lua - -local jsonc_ok, jsonc = pcall(require, "luci.jsonc") -local nixio_ok, nixio = pcall(require, "nixio") - -local JSON_ARRAY_MT = { __jsontype = "array" } - -local function json_array(value) - return setmetatable(value or {}, JSON_ARRAY_MT) -end - -local function is_json_array(value) - return type(value) == "table" and getmetatable(value) == JSON_ARRAY_MT -end - -local function json_escape(value) - value = tostring(value or "") - value = value:gsub("\\", "\\\\") - value = value:gsub("\"", "\\\"") - value = value:gsub("\b", "\\b") - value = value:gsub("\f", "\\f") - value = value:gsub("\n", "\\n") - value = value:gsub("\r", "\\r") - value = value:gsub("\t", "\\t") - value = value:gsub("[%z\1-\31]", function(char) - return string.format("\\u%04x", char:byte()) - end) - return value -end - -local function table_is_array(value) - if is_json_array(value) then - return true - end - - local max = 0 - local count = 0 - for key, _ in pairs(value) do - if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then - return false - end - if key > max then - max = key - end - count = count + 1 - end - - return count > 0 and max == count -end - -local function json_encode(value) - local value_type = type(value) - if value == nil then - return "null" - elseif value_type == "string" then - return "\"" .. json_escape(value) .. "\"" - elseif value_type == "number" then - return tostring(value) - elseif value_type == "boolean" then - return value and "true" or "false" - elseif value_type ~= "table" then - return "null" - end - - if table_is_array(value) then - local parts = {} - for index = 1, #value do - parts[#parts + 1] = json_encode(value[index]) - end - return "[" .. table.concat(parts, ",") .. "]" - end - - local keys = {} - for key, _ in pairs(value) do - if type(key) == "string" then - keys[#keys + 1] = key - end - end - table.sort(keys) - - local parts = {} - for _, key in ipairs(keys) do - parts[#parts + 1] = "\"" .. json_escape(key) .. "\":" .. json_encode(value[key]) - end - return "{" .. table.concat(parts, ",") .. "}" -end - -local function read_file(path) - if not path or path == "" or path == "-" then - return nil - end - - local file = io.open(path, "rb") - if not file then - return nil - end - - local data = file:read("*a") - file:close() - return data -end - -local function write_file(path, data) - local file = assert(io.open(path, "wb")) - file:write(data) - file:close() -end - -local function json_decode_text(text) - if not text or text == "" then - return nil - end - - if jsonc_ok then - return jsonc.parse(text) - end - - return nil -end - -local function read_json(path) - return json_decode_text(read_file(path)) -end - -local function is_array(value) - return type(value) == "table" and table_is_array(value) -end - -local function has_string_key(value) - if type(value) ~= "table" then - return false - end - - for key, _ in pairs(value) do - if type(key) == "string" then - return true - end - end - return false -end - -local function object_or_empty(value) - if type(value) ~= "table" then - return {} - end - if is_array(value) and not has_string_key(value) then - return {} - end - return value -end - -local function array_or_empty(value) - if type(value) ~= "table" then - return json_array() - end - if is_array(value) then - return value - end - return json_array() -end - -local function object_key_count(value) - if type(value) ~= "table" then - return 0 - end - - local count = 0 - for key, _ in pairs(value) do - if type(key) == "string" then - count = count + 1 - end - end - return count -end - -local function valid_metadata_object(value) - return type(value) == "table" and object_key_count(value) > 1 -end - -local function safe_section(section) - return type(section) == "string" and section:match("^[A-Za-z0-9_-]+$") ~= nil -end - -local function cache_path(cache_dir, section) - return cache_dir .. "/" .. section .. ".json" -end - -local function load_cache(cache_dir, section) - local parsed = read_json(cache_path(cache_dir, section)) - if type(parsed) == "table" and not is_array(parsed) then - return parsed - end - return {} -end - -local function normalize_cache(cache, section, format_version) - cache.version = tonumber(format_version) or format_version - cache.section = section - cache.links = object_or_empty(cache.links) - cache.linkRefs = object_or_empty(cache.linkRefs) - cache.outboundMetadata = object_or_empty(cache.outboundMetadata) - cache.outboundMetadata.names = object_or_empty(cache.outboundMetadata.names) - cache.outboundMetadata.countries = object_or_empty(cache.outboundMetadata.countries) - cache.servers = object_or_empty(cache.servers) - cache.subscriptionMetadata = array_or_empty(cache.subscriptionMetadata) - return cache -end - -local function save_cache(cache_dir, section, format_version, cache) - normalize_cache(cache, section, format_version) - - local path = cache_path(cache_dir, section) - local tmp_path = path .. "." .. tostring(os.time()) .. "." .. tostring(math.random(1000000)) .. ".tmp" - write_file(tmp_path, json_encode(cache) .. "\n") - assert(os.rename(tmp_path, path)) -end - -local function write_link_cache(cache_dir, format_version, section, links_path, link_refs_path) - local cache = load_cache(cache_dir, section) - cache.links = object_or_empty(read_json(links_path)) - cache.linkRefs = object_or_empty(read_json(link_refs_path)) - save_cache(cache_dir, section, format_version, cache) -end - -local function write_outbound_metadata(cache_dir, format_version, section, names_path, countries_path, servers_path) - local cache = load_cache(cache_dir, section) - cache.outboundMetadata = { - names = object_or_empty(read_json(names_path)), - countries = object_or_empty(read_json(countries_path)) - } - cache.servers = object_or_empty(read_json(servers_path)) - save_cache(cache_dir, section, format_version, cache) -end - -local function metadata_array_from_file(metadata_path) - local metadata = read_json(metadata_path) - local result = json_array() - - if is_array(metadata) then - for _, item in ipairs(metadata) do - if valid_metadata_object(item) then - result[#result + 1] = item - end - end - elseif valid_metadata_object(metadata) then - result[#result + 1] = metadata - end - - return result -end - -local function write_subscription_metadata(cache_dir, format_version, section, metadata_path) - local cache = load_cache(cache_dir, section) - cache.subscriptionMetadata = metadata_array_from_file(metadata_path) - save_cache(cache_dir, section, format_version, cache) -end - -local function read_metadata_items_from_cache(cache_dir, section, legacy_path) - local cache = load_cache(cache_dir, section) - local metadata = cache.subscriptionMetadata - - if type(metadata) ~= "table" then - metadata = read_json(legacy_path) - end - - local result = json_array() - if is_array(metadata) then - for _, item in ipairs(metadata) do - if valid_metadata_object(item) then - result[#result + 1] = item - end - end - elseif valid_metadata_object(metadata) then - result[#result + 1] = metadata - end - - return result -end - -local function metadata_source_index(item) - return tonumber(item.sourceIndex or item.source_index) -end - -local function metadata_source_section(item) - return tostring(item.sourceSection or item.source_section or "") -end - -local function metadata_items_have_source_markers(items) - for _, item in ipairs(items) do - if metadata_source_index(item) ~= nil or metadata_source_section(item) ~= "" then - return true - end - end - return false -end - -local function metadata_matches_source(item, source_index, source_section, has_source_markers) - if has_source_markers then - local item_section = metadata_source_section(item) - local item_index = metadata_source_index(item) - return (item_section ~= "" and item_section == source_section) or - (item_section == "" and item_index == source_index) - end - return false -end - -local function attach_source_metadata(item, source_index, source_section) - item.sourceIndex = source_index - item.sourceSection = source_section - return item -end - -local function append_metadata_file(array_path, metadata_path, source_index, source_section) - if not array_path or array_path == "" then - return - end - - local array = array_or_empty(read_json(array_path)) - local metadata = read_json(metadata_path) - source_index = tonumber(source_index) or 0 - source_section = tostring(source_section or "") - - if valid_metadata_object(metadata) then - array[#array + 1] = attach_source_metadata(metadata, source_index, source_section) - write_file(array_path, json_encode(array) .. "\n") - end -end - -local function append_cached_metadata(array_path, cache_dir, section, legacy_path, source_index, source_section) - if not array_path or array_path == "" then - return - end - - local array = array_or_empty(read_json(array_path)) - local items = read_metadata_items_from_cache(cache_dir, section, legacy_path) - source_index = tonumber(source_index) or 0 - source_section = tostring(source_section or "") - - local has_source_markers = metadata_items_have_source_markers(items) - local selected = nil - if has_source_markers then - for _, item in ipairs(items) do - if metadata_matches_source(item, source_index, source_section, true) then - selected = item - break - end - end - else - selected = items[source_index] - end - - if valid_metadata_object(selected) then - array[#array + 1] = attach_source_metadata(selected, source_index, source_section) - write_file(array_path, json_encode(array) .. "\n") - end -end - -local function write_source_metadata(cache_dir, format_version, section, source_index, source_section, metadata_path, legacy_path) - local cache = load_cache(cache_dir, section) - local items = read_metadata_items_from_cache(cache_dir, section, legacy_path) - local kept = json_array() - local has_source_markers = metadata_items_have_source_markers(items) - source_index = tonumber(source_index) or 0 - source_section = tostring(source_section or "") - - for index, item in ipairs(items) do - local keep - if has_source_markers then - keep = not metadata_matches_source(item, source_index, source_section, true) - else - keep = index ~= source_index - end - if keep and valid_metadata_object(item) then - kept[#kept + 1] = item - end - end - - local metadata = read_json(metadata_path) - if valid_metadata_object(metadata) then - kept[#kept + 1] = attach_source_metadata(metadata, source_index, source_section) - end - - table.sort(kept, function(first, second) - return (metadata_source_index(first) or 999999) < (metadata_source_index(second) or 999999) - end) - - cache.subscriptionMetadata = kept - save_cache(cache_dir, section, format_version, cache) -end - -local function starts_with(value, prefix) - return value:sub(1, #prefix) == prefix -end - -local function uri_encode(value) - return tostring(value or ""):gsub("([^A-Za-z0-9%-%._~])", function(char) - return string.format("%%%02X", char:byte()) - end) -end - -local function base64_encode(value) - if nixio_ok and nixio.bin and nixio.bin.b64encode then - return (nixio.bin.b64encode(tostring(value or "")):gsub("=+$", "")) - end - return "" -end - -local function host_port(server, port) - server = tostring(server or "") - if server:find(":", 1, true) and not starts_with(server, "[") then - server = "[" .. server .. "]" - end - return server .. ":" .. tostring(port or "") -end - -local function add_query(params, key, value) - value = tostring(value or "") - if value ~= "" then - params[#params + 1] = uri_encode(key) .. "=" .. uri_encode(value) - end -end - -local function add_tls_query(params, outbound, trojan_default_tls) - local tls = type(outbound.tls) == "table" and outbound.tls or nil - if not tls or tls.enabled == false then - if trojan_default_tls then - add_query(params, "security", "tls") - end - return - end - - local reality = type(tls.reality) == "table" and tls.reality or nil - if reality and reality.enabled ~= false then - add_query(params, "security", "reality") - add_query(params, "pbk", reality.public_key) - add_query(params, "sid", reality.short_id) - else - add_query(params, "security", "tls") - end - - add_query(params, "sni", tls.server_name) - if tls.insecure == true then - add_query(params, "allowInsecure", "1") - end - if type(tls.utls) == "table" and tls.utls.enabled ~= false then - add_query(params, "fp", tls.utls.fingerprint) - end - if type(tls.alpn) == "table" and #tls.alpn > 0 then - add_query(params, "alpn", table.concat(tls.alpn, ",")) - end -end - -local function add_transport_query(params, outbound) - local transport = type(outbound.transport) == "table" and outbound.transport or nil - if not transport then - add_query(params, "type", "tcp") - return - end - - local transport_type = tostring(transport.type or "") - add_query(params, "type", transport_type ~= "" and transport_type or "tcp") - - if transport_type == "ws" then - add_query(params, "path", transport.path) - if type(transport.headers) == "table" then - add_query(params, "host", transport.headers.Host or transport.headers.host) - end - elseif transport_type == "grpc" then - add_query(params, "serviceName", transport.service_name) - elseif transport_type == "http" then - add_query(params, "path", transport.path) - if type(transport.host) == "table" and #transport.host > 0 then - add_query(params, "host", table.concat(transport.host, ",")) - else - add_query(params, "host", transport.host) - end - elseif transport_type == "xhttp" then - add_query(params, "path", transport.path) - add_query(params, "host", transport.host) - add_query(params, "mode", transport.mode) - end -end - -local function query_string(params) - if #params == 0 then - return "" - end - return "?" .. table.concat(params, "&") -end - -local function fragment(outbound) - local tag = tostring(outbound.tag or "") - if tag == "" then - return "" - end - return "#" .. uri_encode(tag) -end - -local function serialize_vless(outbound) - if tostring(outbound.uuid or "") == "" or tostring(outbound.server or "") == "" or not outbound.server_port then - return "" - end - - local params = {} - add_tls_query(params, outbound, false) - add_transport_query(params, outbound) - add_query(params, "flow", outbound.flow) - add_query(params, "packetEncoding", outbound.packet_encoding) - - return "vless://" .. uri_encode(outbound.uuid) .. "@" .. - host_port(outbound.server, outbound.server_port) .. query_string(params) .. fragment(outbound) -end - -local function serialize_trojan(outbound) - if tostring(outbound.password or "") == "" or tostring(outbound.server or "") == "" or not outbound.server_port then - return "" - end - - local params = {} - add_tls_query(params, outbound, true) - add_transport_query(params, outbound) - - return "trojan://" .. uri_encode(outbound.password) .. "@" .. - host_port(outbound.server, outbound.server_port) .. query_string(params) .. fragment(outbound) -end - -local function serialize_shadowsocks(outbound) - if tostring(outbound.method or "") == "" or tostring(outbound.password or "") == "" or - tostring(outbound.server or "") == "" or not outbound.server_port then - return "" - end - - local userinfo = base64_encode(tostring(outbound.method) .. ":" .. tostring(outbound.password)) - if userinfo == "" then - return "" - end - - return "ss://" .. userinfo .. "@" .. host_port(outbound.server, outbound.server_port) .. fragment(outbound) -end - -local function serialize_socks(outbound) - if tostring(outbound.server or "") == "" or not outbound.server_port then - return "" - end - - local scheme = "socks" .. tostring(outbound.version or "5") - local auth = "" - if tostring(outbound.username or "") ~= "" then - auth = uri_encode(outbound.username) - if tostring(outbound.password or "") ~= "" then - auth = auth .. ":" .. uri_encode(outbound.password) - end - auth = auth .. "@" - end - - return scheme .. "://" .. auth .. host_port(outbound.server, outbound.server_port) .. fragment(outbound) -end - -local function serialize_hysteria2(outbound) - if tostring(outbound.password or "") == "" or tostring(outbound.server or "") == "" or not outbound.server_port then - return "" - end - - local params = {} - local tls = type(outbound.tls) == "table" and outbound.tls or nil - if tls then - add_query(params, "sni", tls.server_name) - if tls.insecure == true then - add_query(params, "insecure", "1") - end - if type(tls.alpn) == "table" and #tls.alpn > 0 then - add_query(params, "alpn", table.concat(tls.alpn, ",")) - end - end - if type(outbound.obfs) == "table" then - add_query(params, "obfs", outbound.obfs.type) - add_query(params, "obfs-password", outbound.obfs.password) - end - - return "hysteria2://" .. uri_encode(outbound.password) .. "@" .. - host_port(outbound.server, outbound.server_port) .. query_string(params) .. fragment(outbound) -end - -local function serialize_vmess(outbound) - if tostring(outbound.uuid or "") == "" or tostring(outbound.server or "") == "" or not outbound.server_port then - return "" - end - - local vmess = { - v = "2", - ps = tostring(outbound.tag or ""), - add = tostring(outbound.server or ""), - port = tostring(outbound.server_port or ""), - id = tostring(outbound.uuid or ""), - aid = tostring(outbound.alter_id or 0), - scy = tostring(outbound.security or "auto"), - net = "tcp", - type = "none", - host = "", - path = "", - tls = "", - sni = "" - } - - if type(outbound.tls) == "table" and outbound.tls.enabled ~= false then - vmess.tls = "tls" - vmess.sni = tostring(outbound.tls.server_name or "") - if type(outbound.tls.utls) == "table" then - vmess.fp = tostring(outbound.tls.utls.fingerprint or "") - end - end - - if type(outbound.transport) == "table" then - vmess.net = tostring(outbound.transport.type or "tcp") - if vmess.net == "ws" then - vmess.path = tostring(outbound.transport.path or "") - if type(outbound.transport.headers) == "table" then - vmess.host = tostring(outbound.transport.headers.Host or outbound.transport.headers.host or "") - end - elseif vmess.net == "grpc" then - vmess.path = tostring(outbound.transport.service_name or "") - elseif vmess.net == "http" then - vmess.path = tostring(outbound.transport.path or "") - if type(outbound.transport.host) == "table" and #outbound.transport.host > 0 then - vmess.host = table.concat(outbound.transport.host, ",") - else - vmess.host = tostring(outbound.transport.host or "") - end - end - end - - local encoded = base64_encode(json_encode(vmess)) - if encoded == "" then - return "" - end - return "vmess://" .. encoded -end - -local function serialize_outbound_link(outbound) - if type(outbound) ~= "table" then - return "" - end - - local outbound_type = tostring(outbound.type or "") - if outbound_type == "vless" then - return serialize_vless(outbound) - elseif outbound_type == "trojan" then - return serialize_trojan(outbound) - elseif outbound_type == "shadowsocks" then - return serialize_shadowsocks(outbound) - elseif outbound_type == "socks" then - return serialize_socks(outbound) - elseif outbound_type == "hysteria2" then - return serialize_hysteria2(outbound) - elseif outbound_type == "vmess" then - return serialize_vmess(outbound) - end - - return "" -end - -local function is_copyable_link(value) - value = tostring(value or ""):lower() - local prefixes = { - "vless://", "vmess://", "trojan://", "ss://", "ssr://", - "hysteria2://", "hy2://", "tuic://", - "socks4://", "socks4a://", "socks5://" - } - for _, prefix in ipairs(prefixes) do - if starts_with(value, prefix) then - return true - end - end - return false -end - -local function get_source_link(subscription_dir, ref) - if type(ref) ~= "table" then - return "" - end - - local source_section = tostring(ref.sourceSection or ref.source_section or "") - local source_index = tonumber(ref.sourceIndex or ref.source_index) - if not safe_section(source_section) or not source_index or source_index < 1 then - return "" - end - - local source = read_json(subscription_dir .. "/" .. source_section .. ".json") - if type(source) ~= "table" or type(source.outbounds) ~= "table" then - return "" - end - - local outbound = source.outbounds[source_index] - if type(outbound) ~= "table" then - return "" - end - - local link = tostring(outbound.share_link or "") - if link == "" then - link = serialize_outbound_link(outbound) - end - if is_copyable_link(link) then - return link - end - return "" -end - -local function get_link(cache_dir, subscription_dir, section, tag, legacy_links_dir) - local cache = load_cache(cache_dir, section) - local links = object_or_empty(cache.links) - local link_refs = object_or_empty(cache.linkRefs) - local link = tostring(links[tag] or "") - - if not is_copyable_link(link) then - link = get_source_link(subscription_dir, link_refs[tag]) - end - - if not is_copyable_link(link) and legacy_links_dir and legacy_links_dir ~= "" then - local legacy = object_or_empty(read_json(legacy_links_dir .. "/" .. section .. ".json")) - link = tostring(legacy[tag] or "") - end - - if not is_copyable_link(link) then - link = "" - end - - print(json_encode({ link = link })) -end - -local function get_link_states(cache_dir, section, legacy_links_dir) - local cache = load_cache(cache_dir, section) - local result = {} - - for tag, link in pairs(object_or_empty(cache.links)) do - result[tag] = is_copyable_link(link) - end - for tag, _ in pairs(object_or_empty(cache.linkRefs)) do - result[tag] = true - end - - if next(result) == nil and legacy_links_dir and legacy_links_dir ~= "" then - local legacy = object_or_empty(read_json(legacy_links_dir .. "/" .. section .. ".json")) - for tag, link in pairs(legacy) do - result[tag] = is_copyable_link(link) - end - end - - print(json_encode(result)) -end - -local function get_outbound_metadata(cache_dir, section, legacy_path) - local cache = load_cache(cache_dir, section) - local metadata = cache.outboundMetadata - - if type(metadata) ~= "table" then - metadata = read_json(legacy_path) - end - metadata = object_or_empty(metadata) - - print(json_encode({ - names = object_or_empty(metadata.names), - countries = object_or_empty(metadata.countries) - })) -end - -local function get_subscription_metadata(cache_dir, section, legacy_path) - local items = read_metadata_items_from_cache(cache_dir, section, legacy_path) - if #items > 0 then - print(json_encode(items)) - else - print("{}") - end -end - -local mode = arg[1] or "" - -if mode == "write-link-cache" then - local cache_dir, format_version, section = arg[2], arg[3], arg[4] - if not safe_section(section) then os.exit(1) end - write_link_cache(cache_dir, format_version, section, arg[5], arg[6]) -elseif mode == "write-outbound-metadata" then - local cache_dir, format_version, section = arg[2], arg[3], arg[4] - if not safe_section(section) then os.exit(1) end - write_outbound_metadata(cache_dir, format_version, section, arg[5], arg[6], arg[7]) -elseif mode == "write-subscription-metadata" then - local cache_dir, format_version, section = arg[2], arg[3], arg[4] - if not safe_section(section) then os.exit(1) end - write_subscription_metadata(cache_dir, format_version, section, arg[5]) -elseif mode == "append-metadata-file" then - append_metadata_file(arg[2], arg[3], arg[4], arg[5]) -elseif mode == "append-cached-metadata" then - append_cached_metadata(arg[2], arg[3], arg[4], arg[5], arg[6], arg[7]) -elseif mode == "write-source-metadata" then - local cache_dir, format_version, section = arg[2], arg[3], arg[4] - if not safe_section(section) then os.exit(1) end - write_source_metadata(cache_dir, format_version, section, arg[5], arg[6], arg[7], arg[8]) -elseif mode == "get-link" then - local cache_dir, subscription_dir, section = arg[2], arg[3], arg[4] - if not safe_section(section) then - print("{\"link\":\"\"}") - else - get_link(cache_dir, subscription_dir, section, arg[5] or "", arg[6] or "") - end -elseif mode == "get-link-states" then - local cache_dir, section = arg[2], arg[3] - if not safe_section(section) then - print("{}") - else - get_link_states(cache_dir, section, arg[4] or "") - end -elseif mode == "get-outbound-metadata" then - local cache_dir, section = arg[2], arg[3] - if not safe_section(section) then - print("{\"names\":{},\"countries\":{}}") - else - get_outbound_metadata(cache_dir, section, arg[4]) - end -elseif mode == "get-subscription-metadata" then - local cache_dir, section = arg[2], arg[3] - if not safe_section(section) then - print("{}") - else - get_subscription_metadata(cache_dir, section, arg[4]) - end -else - io.stderr:write("Usage: subscription_cache.lua ...\n") - os.exit(1) -end diff --git a/podkop/files/usr/lib/subscription_cache.uc b/podkop/files/usr/lib/subscription_cache.uc new file mode 100644 index 00000000..9c40a0b7 --- /dev/null +++ b/podkop/files/usr/lib/subscription_cache.uc @@ -0,0 +1,681 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_json(path) { + if (path == null || path == "" || path == "-") + return null; + + let data = fs.readfile(path); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function write_file(path, value) { + return fs.writefile(path, value); +} + +function write_json(path, value) { + return write_file(path, sprintf("%J", value) + "\n"); +} + +function write_stdout_json(value) { + print(sprintf("%J", value), "\n"); +} + +function is_array(value) { + return type(value) == "array"; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function object_key_count(value) { + return type(value) == "object" ? length(keys(value)) : 0; +} + +function valid_metadata_object(value) { + return type(value) == "object" && object_key_count(value) > 1; +} + +function safe_section(section) { + return type(section) == "string" && match(section, /^[A-Za-z0-9_-]+$/); +} + +function cache_path(cache_dir, section) { + return cache_dir + "/" + section + ".json"; +} + +function load_cache(cache_dir, section) { + return object_or_empty(read_json(cache_path(cache_dir, section))); +} + +function normalize_cache(cache, section, format_version) { + cache.version = int(format_version || 0); + cache.section = as_string(section); + cache.links = object_or_empty(cache.links); + cache.linkRefs = object_or_empty(cache.linkRefs); + cache.outboundMetadata = object_or_empty(cache.outboundMetadata); + cache.outboundMetadata.names = object_or_empty(cache.outboundMetadata.names); + cache.outboundMetadata.countries = object_or_empty(cache.outboundMetadata.countries); + cache.servers = object_or_empty(cache.servers); + cache.subscriptionMetadata = array_or_empty(cache.subscriptionMetadata); + return cache; +} + +function save_cache(cache_dir, section, format_version, cache) { + cache = normalize_cache(cache, section, format_version); + let path = cache_path(cache_dir, section); + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + + if (!write_json(tmp_path, cache)) + exit(1); + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + exit(1); + } +} + +function write_link_cache(cache_dir, format_version, section, links_path, link_refs_path) { + let cache = load_cache(cache_dir, section); + cache.links = object_or_empty(read_json(links_path)); + cache.linkRefs = object_or_empty(read_json(link_refs_path)); + save_cache(cache_dir, section, format_version, cache); +} + +function write_outbound_metadata(cache_dir, format_version, section, names_path, countries_path, servers_path) { + let cache = load_cache(cache_dir, section); + cache.outboundMetadata = { + names: object_or_empty(read_json(names_path)), + countries: object_or_empty(read_json(countries_path)) + }; + cache.servers = object_or_empty(read_json(servers_path)); + save_cache(cache_dir, section, format_version, cache); +} + +function metadata_array_from_file(metadata_path) { + let metadata = read_json(metadata_path); + let result = []; + + if (is_array(metadata)) { + for (let item in metadata) { + if (valid_metadata_object(item)) + push(result, item); + } + } + else if (valid_metadata_object(metadata)) { + push(result, metadata); + } + + return result; +} + +function write_subscription_metadata(cache_dir, format_version, section, metadata_path) { + let cache = load_cache(cache_dir, section); + cache.subscriptionMetadata = metadata_array_from_file(metadata_path); + save_cache(cache_dir, section, format_version, cache); +} + +function read_metadata_items_from_cache(cache_dir, section, legacy_path) { + let cache = load_cache(cache_dir, section); + let metadata = cache.subscriptionMetadata; + + if (type(metadata) != "array") + metadata = read_json(legacy_path); + + let result = []; + if (is_array(metadata)) { + for (let item in metadata) { + if (valid_metadata_object(item)) + push(result, item); + } + } + else if (valid_metadata_object(metadata)) { + push(result, metadata); + } + + return result; +} + +function metadata_source_index(item) { + if (type(item) != "object") + return null; + let value = item.sourceIndex != null ? item.sourceIndex : item.source_index; + if (value == null || as_string(value) == "") + return null; + return int(value); +} + +function metadata_source_section(item) { + if (type(item) != "object") + return ""; + return as_string(item.sourceSection != null ? item.sourceSection : item.source_section); +} + +function metadata_items_have_source_markers(items) { + for (let item in items) { + if (metadata_source_index(item) != null || metadata_source_section(item) != "") + return true; + } + return false; +} + +function metadata_matches_source(item, source_index, source_section, has_source_markers) { + if (!has_source_markers) + return false; + + let item_section = metadata_source_section(item); + let item_index = metadata_source_index(item); + return (item_section != "" && item_section == source_section) || + (item_section == "" && item_index == source_index); +} + +function attach_source_metadata(item, source_index, source_section) { + if (type(item) != "object") + item = {}; + item.sourceIndex = source_index; + item.sourceSection = as_string(source_section); + return item; +} + +function append_metadata_file(array_path, metadata_path, source_index, source_section) { + if (array_path == null || array_path == "") + return; + + let array = array_or_empty(read_json(array_path)); + let metadata = read_json(metadata_path); + source_index = int(source_index || 0); + source_section = as_string(source_section); + + if (valid_metadata_object(metadata)) { + push(array, attach_source_metadata(metadata, source_index, source_section)); + write_json(array_path, array); + } +} + +function append_cached_metadata(array_path, cache_dir, section, legacy_path, source_index, source_section) { + if (array_path == null || array_path == "") + return; + + let array = array_or_empty(read_json(array_path)); + let items = read_metadata_items_from_cache(cache_dir, section, legacy_path); + source_index = int(source_index || 0); + source_section = as_string(source_section); + + let has_source_markers = metadata_items_have_source_markers(items); + let selected = null; + if (has_source_markers) { + for (let item in items) { + if (metadata_matches_source(item, source_index, source_section, true)) { + selected = item; + break; + } + } + } + else if (source_index > 0 && source_index <= length(items)) { + selected = items[source_index - 1]; + } + + if (valid_metadata_object(selected)) { + push(array, attach_source_metadata(selected, source_index, source_section)); + write_json(array_path, array); + } +} + +function write_source_metadata(cache_dir, format_version, section, source_index, source_section, metadata_path, legacy_path) { + let cache = load_cache(cache_dir, section); + let items = read_metadata_items_from_cache(cache_dir, section, legacy_path); + let kept = []; + let has_source_markers = metadata_items_have_source_markers(items); + source_index = int(source_index || 0); + source_section = as_string(source_section); + + for (let index, item in items) { + let keep = has_source_markers ? + !metadata_matches_source(item, source_index, source_section, true) : + (index + 1) != source_index; + if (keep && valid_metadata_object(item)) + push(kept, item); + } + + let metadata = read_json(metadata_path); + if (valid_metadata_object(metadata)) + push(kept, attach_source_metadata(metadata, source_index, source_section)); + + sort(kept, function(first, second) { + let first_index = metadata_source_index(first) || 999999; + let second_index = metadata_source_index(second) || 999999; + return first_index == second_index ? 0 : (first_index < second_index ? -1 : 1); + }); + + cache.subscriptionMetadata = kept; + save_cache(cache_dir, section, format_version, cache); +} + +function starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function uri_encode(value) { + value = as_string(value); + let result = ""; + for (let i = 0; i < length(value); i++) { + let char = substr(value, i, 1); + let code = ord(char); + if ((code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + char == "-" || char == "_" || char == "." || char == "~") + result += char; + else + result += sprintf("%%%02X", code); + } + return result; +} + +function base64_encode(value) { + let encoded = b64enc(as_string(value)); + while (length(encoded) > 0 && substr(encoded, length(encoded) - 1) == "=") + encoded = substr(encoded, 0, length(encoded) - 1); + return encoded; +} + +function host_port(server, port) { + server = as_string(server); + if (index(server, ":") >= 0 && !starts_with(server, "[")) + server = "[" + server + "]"; + return server + ":" + as_string(port); +} + +function add_query(params, key, value) { + value = as_string(value); + if (value != "") + push(params, uri_encode(key) + "=" + uri_encode(value)); +} + +function add_tls_query(params, outbound, trojan_default_tls) { + let tls = type(outbound.tls) == "object" ? outbound.tls : null; + if (!tls || tls.enabled === false) { + if (trojan_default_tls) + add_query(params, "security", "tls"); + return; + } + + let reality = type(tls.reality) == "object" ? tls.reality : null; + if (reality && reality.enabled !== false) { + add_query(params, "security", "reality"); + add_query(params, "pbk", reality.public_key); + add_query(params, "sid", reality.short_id); + } + else { + add_query(params, "security", "tls"); + } + + add_query(params, "sni", tls.server_name); + if (tls.insecure === true) + add_query(params, "allowInsecure", "1"); + if (type(tls.utls) == "object" && tls.utls.enabled !== false) + add_query(params, "fp", tls.utls.fingerprint); + if (type(tls.alpn) == "array" && length(tls.alpn) > 0) + add_query(params, "alpn", join(",", tls.alpn)); +} + +function add_transport_query(params, outbound) { + let transport = type(outbound.transport) == "object" ? outbound.transport : null; + if (!transport) { + add_query(params, "type", "tcp"); + return; + } + + let transport_type = as_string(transport.type); + add_query(params, "type", transport_type != "" ? transport_type : "tcp"); + + if (transport_type == "ws") { + add_query(params, "path", transport.path); + if (type(transport.headers) == "object") + add_query(params, "host", transport.headers.Host || transport.headers.host); + } + else if (transport_type == "grpc") { + add_query(params, "serviceName", transport.service_name); + } + else if (transport_type == "http") { + add_query(params, "path", transport.path); + if (type(transport.host) == "array" && length(transport.host) > 0) + add_query(params, "host", join(",", transport.host)); + else + add_query(params, "host", transport.host); + } + else if (transport_type == "xhttp") { + add_query(params, "path", transport.path); + add_query(params, "host", transport.host); + add_query(params, "mode", transport.mode); + } +} + +function query_string(params) { + return length(params) == 0 ? "" : "?" + join("&", params); +} + +function fragment(outbound) { + let tag = as_string(outbound.tag); + return tag == "" ? "" : "#" + uri_encode(tag); +} + +function serialize_vless(outbound) { + if (as_string(outbound.uuid) == "" || as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + let params = []; + add_tls_query(params, outbound, false); + add_transport_query(params, outbound); + add_query(params, "flow", outbound.flow); + add_query(params, "packetEncoding", outbound.packet_encoding); + return "vless://" + uri_encode(outbound.uuid) + "@" + + host_port(outbound.server, outbound.server_port) + query_string(params) + fragment(outbound); +} + +function serialize_trojan(outbound) { + if (as_string(outbound.password) == "" || as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + let params = []; + add_tls_query(params, outbound, true); + add_transport_query(params, outbound); + return "trojan://" + uri_encode(outbound.password) + "@" + + host_port(outbound.server, outbound.server_port) + query_string(params) + fragment(outbound); +} + +function serialize_shadowsocks(outbound) { + if (as_string(outbound.method) == "" || as_string(outbound.password) == "" || + as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + let userinfo = base64_encode(as_string(outbound.method) + ":" + as_string(outbound.password)); + return userinfo == "" ? "" : + "ss://" + userinfo + "@" + host_port(outbound.server, outbound.server_port) + fragment(outbound); +} + +function serialize_socks(outbound) { + if (as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + + let scheme = "socks" + as_string(outbound.version || "5"); + let auth = ""; + if (as_string(outbound.username) != "") { + auth = uri_encode(outbound.username); + if (as_string(outbound.password) != "") + auth += ":" + uri_encode(outbound.password); + auth += "@"; + } + + return scheme + "://" + auth + host_port(outbound.server, outbound.server_port) + fragment(outbound); +} + +function serialize_hysteria2(outbound) { + if (as_string(outbound.password) == "" || as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + + let params = []; + let tls = type(outbound.tls) == "object" ? outbound.tls : null; + if (tls) { + add_query(params, "sni", tls.server_name); + if (tls.insecure === true) + add_query(params, "insecure", "1"); + if (type(tls.alpn) == "array" && length(tls.alpn) > 0) + add_query(params, "alpn", join(",", tls.alpn)); + } + if (type(outbound.obfs) == "object") { + add_query(params, "obfs", outbound.obfs.type); + add_query(params, "obfs-password", outbound.obfs.password); + } + + return "hysteria2://" + uri_encode(outbound.password) + "@" + + host_port(outbound.server, outbound.server_port) + query_string(params) + fragment(outbound); +} + +function serialize_vmess(outbound) { + if (as_string(outbound.uuid) == "" || as_string(outbound.server) == "" || outbound.server_port == null) + return ""; + + let vmess = { + v: "2", + ps: as_string(outbound.tag), + add: as_string(outbound.server), + port: as_string(outbound.server_port), + id: as_string(outbound.uuid), + aid: as_string(outbound.alter_id || 0), + scy: as_string(outbound.security || "auto"), + net: "tcp", + type: "none", + host: "", + path: "", + tls: "", + sni: "" + }; + + if (type(outbound.tls) == "object" && outbound.tls.enabled !== false) { + vmess.tls = "tls"; + vmess.sni = as_string(outbound.tls.server_name); + if (type(outbound.tls.utls) == "object") + vmess.fp = as_string(outbound.tls.utls.fingerprint); + } + + if (type(outbound.transport) == "object") { + vmess.net = as_string(outbound.transport.type || "tcp"); + if (vmess.net == "ws") { + vmess.path = as_string(outbound.transport.path); + if (type(outbound.transport.headers) == "object") + vmess.host = as_string(outbound.transport.headers.Host || outbound.transport.headers.host); + } + else if (vmess.net == "grpc") { + vmess.path = as_string(outbound.transport.service_name); + } + else if (vmess.net == "http") { + vmess.path = as_string(outbound.transport.path); + if (type(outbound.transport.host) == "array" && length(outbound.transport.host) > 0) + vmess.host = join(",", outbound.transport.host); + else + vmess.host = as_string(outbound.transport.host); + } + } + + let encoded = base64_encode(sprintf("%J", vmess)); + return encoded == "" ? "" : "vmess://" + encoded; +} + +function serialize_outbound_link(outbound) { + if (type(outbound) != "object") + return ""; + + let outbound_type = as_string(outbound.type); + if (outbound_type == "vless") + return serialize_vless(outbound); + if (outbound_type == "trojan") + return serialize_trojan(outbound); + if (outbound_type == "shadowsocks") + return serialize_shadowsocks(outbound); + if (outbound_type == "socks") + return serialize_socks(outbound); + if (outbound_type == "hysteria2") + return serialize_hysteria2(outbound); + if (outbound_type == "vmess") + return serialize_vmess(outbound); + return ""; +} + +function is_copyable_link(value) { + value = lc(as_string(value)); + let prefixes = [ + "vless://", "vmess://", "trojan://", "ss://", "ssr://", + "hysteria2://", "hy2://", "tuic://", + "socks4://", "socks4a://", "socks5://" + ]; + for (let prefix in prefixes) { + if (starts_with(value, prefix)) + return true; + } + return false; +} + +function get_source_link(subscription_dir, ref) { + if (type(ref) != "object") + return ""; + + let source_section = as_string(ref.sourceSection || ref.source_section); + let source_index = int(ref.sourceIndex || ref.source_index || 0); + if (!safe_section(source_section) || source_index < 1) + return ""; + + let source = read_json(subscription_dir + "/" + source_section + ".json"); + if (type(source) != "object" || type(source.outbounds) != "array") + return ""; + + let outbound = source.outbounds[source_index - 1]; + if (type(outbound) != "object") + return ""; + + let link = as_string(outbound.share_link); + if (link == "") + link = serialize_outbound_link(outbound); + return is_copyable_link(link) ? link : ""; +} + +function get_link(cache_dir, subscription_dir, section, tag, legacy_links_dir) { + let cache = load_cache(cache_dir, section); + let links = object_or_empty(cache.links); + let link_refs = object_or_empty(cache.linkRefs); + let link = as_string(links[tag]); + + if (!is_copyable_link(link)) + link = get_source_link(subscription_dir, link_refs[tag]); + + if (!is_copyable_link(link) && legacy_links_dir != null && legacy_links_dir != "") { + let legacy = object_or_empty(read_json(legacy_links_dir + "/" + section + ".json")); + link = as_string(legacy[tag]); + } + + if (!is_copyable_link(link)) + link = ""; + + write_stdout_json({ link: link }); +} + +function get_link_states(cache_dir, section, legacy_links_dir) { + let cache = load_cache(cache_dir, section); + let result = {}; + + for (let tag, link in object_or_empty(cache.links)) + result[tag] = is_copyable_link(link); + for (let tag, _ in object_or_empty(cache.linkRefs)) + result[tag] = true; + + if (length(keys(result)) == 0 && legacy_links_dir != null && legacy_links_dir != "") { + let legacy = object_or_empty(read_json(legacy_links_dir + "/" + section + ".json")); + for (let tag, link in legacy) + result[tag] = is_copyable_link(link); + } + + write_stdout_json(result); +} + +function get_outbound_metadata(cache_dir, section, legacy_path) { + let cache = load_cache(cache_dir, section); + let metadata = cache.outboundMetadata; + if (type(metadata) != "object") + metadata = read_json(legacy_path); + metadata = object_or_empty(metadata); + + write_stdout_json({ + names: object_or_empty(metadata.names), + countries: object_or_empty(metadata.countries) + }); +} + +function get_subscription_metadata(cache_dir, section, legacy_path) { + let items = read_metadata_items_from_cache(cache_dir, section, legacy_path); + if (length(items) > 0) + write_stdout_json(items); + else + print("{}\n"); +} + +let mode = ARGV[0] || ""; + +if (mode == "write-link-cache") { + let cache_dir = ARGV[1], format_version = ARGV[2], section = ARGV[3]; + if (!safe_section(section)) + exit(1); + write_link_cache(cache_dir, format_version, section, ARGV[4], ARGV[5]); +} +else if (mode == "write-outbound-metadata") { + let cache_dir = ARGV[1], format_version = ARGV[2], section = ARGV[3]; + if (!safe_section(section)) + exit(1); + write_outbound_metadata(cache_dir, format_version, section, ARGV[4], ARGV[5], ARGV[6]); +} +else if (mode == "write-subscription-metadata") { + let cache_dir = ARGV[1], format_version = ARGV[2], section = ARGV[3]; + if (!safe_section(section)) + exit(1); + write_subscription_metadata(cache_dir, format_version, section, ARGV[4]); +} +else if (mode == "append-metadata-file") { + append_metadata_file(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +} +else if (mode == "append-cached-metadata") { + append_cached_metadata(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); +} +else if (mode == "write-source-metadata") { + let cache_dir = ARGV[1], format_version = ARGV[2], section = ARGV[3]; + if (!safe_section(section)) + exit(1); + write_source_metadata(cache_dir, format_version, section, ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +} +else if (mode == "get-link") { + let cache_dir = ARGV[1], subscription_dir = ARGV[2], section = ARGV[3]; + if (!safe_section(section)) + print("{\"link\":\"\"}\n"); + else + get_link(cache_dir, subscription_dir, section, ARGV[4] || "", ARGV[5] || ""); +} +else if (mode == "get-link-states") { + let cache_dir = ARGV[1], section = ARGV[2]; + if (!safe_section(section)) + print("{}\n"); + else + get_link_states(cache_dir, section, ARGV[3] || ""); +} +else if (mode == "get-outbound-metadata") { + let cache_dir = ARGV[1], section = ARGV[2]; + if (!safe_section(section)) + print("{\"names\":{},\"countries\":{}}\n"); + else + get_outbound_metadata(cache_dir, section, ARGV[3]); +} +else if (mode == "get-subscription-metadata") { + let cache_dir = ARGV[1], section = ARGV[2]; + if (!safe_section(section)) + print("{}\n"); + else + get_subscription_metadata(cache_dir, section, ARGV[3]); +} +else { + warn("Usage: subscription_cache.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/subscription_parser.lua b/podkop/files/usr/lib/subscription_parser.lua deleted file mode 100644 index 9683b5e2..00000000 --- a/podkop/files/usr/lib/subscription_parser.lua +++ /dev/null @@ -1,1781 +0,0 @@ -#!/usr/bin/env lua - -local jsonc_ok, jsonc = pcall(require, "luci.jsonc") -local nixio_ok, nixio = pcall(require, "nixio") - -local JSON_ARRAY_MT = { __jsontype = "array" } - -local function json_array(t) - return setmetatable(t or {}, JSON_ARRAY_MT) -end - -local function is_json_array(t) - return type(t) == "table" and getmetatable(t) == JSON_ARRAY_MT -end - -local function trim(value) - return (tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")) -end - -local function starts_with(value, prefix) - return value:sub(1, #prefix) == prefix -end - -local function split_csv(value) - local result = json_array() - value = tostring(value or "") - for item in (value .. ","):gmatch("(.-),") do - if item ~= "" then - result[#result + 1] = item - end - end - return result -end - -local function is_true(value) - if value == nil then - return false - end - - local normalized = tostring(value):lower() - return normalized == "1" or normalized == "true" or normalized == "yes" or normalized == "on" -end - -local function is_integer_string(value) - return type(value) == "string" and value:match("^%d+$") ~= nil -end - -local function json_escape(value) - value = tostring(value or "") - value = value:gsub("\\", "\\\\") - value = value:gsub("\"", "\\\"") - value = value:gsub("\b", "\\b") - value = value:gsub("\f", "\\f") - value = value:gsub("\n", "\\n") - value = value:gsub("\r", "\\r") - value = value:gsub("\t", "\\t") - value = value:gsub("[%z\1-\31]", function(c) - return string.format("\\u%04x", c:byte()) - end) - return value -end - -local function table_is_array(value) - if is_json_array(value) then - return true - end - - local max = 0 - local count = 0 - for key, _ in pairs(value) do - if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then - return false - end - if key > max then - max = key - end - count = count + 1 - end - - return max == count and count > 0 -end - -local function json_encode(value) - if jsonc_ok then - local encoded = jsonc.stringify(value) - if encoded then - return encoded - end - end - - local value_type = type(value) - if value == nil then - return "null" - elseif value_type == "string" then - return "\"" .. json_escape(value) .. "\"" - elseif value_type == "number" then - return tostring(value) - elseif value_type == "boolean" then - return value and "true" or "false" - elseif value_type ~= "table" then - return "null" - end - - if table_is_array(value) then - local parts = {} - for index = 1, #value do - parts[#parts + 1] = json_encode(value[index]) - end - return "[" .. table.concat(parts, ",") .. "]" - end - - local keys = {} - for key, _ in pairs(value) do - if type(key) == "string" then - keys[#keys + 1] = key - end - end - table.sort(keys) - - local parts = {} - for _, key in ipairs(keys) do - parts[#parts + 1] = "\"" .. json_escape(key) .. "\":" .. json_encode(value[key]) - end - return "{" .. table.concat(parts, ",") .. "}" -end - -local function utf8_char(codepoint) - if codepoint <= 0x7f then - return string.char(codepoint) - elseif codepoint <= 0x7ff then - return string.char( - 0xc0 + math.floor(codepoint / 0x40), - 0x80 + (codepoint % 0x40) - ) - elseif codepoint <= 0xffff then - return string.char( - 0xe0 + math.floor(codepoint / 0x1000), - 0x80 + (math.floor(codepoint / 0x40) % 0x40), - 0x80 + (codepoint % 0x40) - ) - end - - return string.char( - 0xf0 + math.floor(codepoint / 0x40000), - 0x80 + (math.floor(codepoint / 0x1000) % 0x40), - 0x80 + (math.floor(codepoint / 0x40) % 0x40), - 0x80 + (codepoint % 0x40) - ) -end - -local function json_decode(input) - if jsonc_ok then - return jsonc.parse(tostring(input or "")) - end - - local text = tostring(input or "") - local pos = 1 - - local function skip_ws() - while true do - local char = text:sub(pos, pos) - if char == " " or char == "\n" or char == "\r" or char == "\t" then - pos = pos + 1 - else - break - end - end - end - - local parse_value - - local function parse_string() - if text:sub(pos, pos) ~= "\"" then - error("expected string") - end - pos = pos + 1 - local result = {} - - while pos <= #text do - local char = text:sub(pos, pos) - if char == "\"" then - pos = pos + 1 - return table.concat(result) - elseif char == "\\" then - local escape = text:sub(pos + 1, pos + 1) - pos = pos + 2 - if escape == "\"" or escape == "\\" or escape == "/" then - result[#result + 1] = escape - elseif escape == "b" then - result[#result + 1] = "\b" - elseif escape == "f" then - result[#result + 1] = "\f" - elseif escape == "n" then - result[#result + 1] = "\n" - elseif escape == "r" then - result[#result + 1] = "\r" - elseif escape == "t" then - result[#result + 1] = "\t" - elseif escape == "u" then - local hex = text:sub(pos, pos + 3) - local codepoint = tonumber(hex, 16) - if not codepoint then - error("invalid unicode escape") - end - pos = pos + 4 - if codepoint >= 0xd800 and codepoint <= 0xdbff and text:sub(pos, pos + 1) == "\\u" then - local low = tonumber(text:sub(pos + 2, pos + 5), 16) - if low and low >= 0xdc00 and low <= 0xdfff then - codepoint = 0x10000 + ((codepoint - 0xd800) * 0x400) + (low - 0xdc00) - pos = pos + 6 - end - end - result[#result + 1] = utf8_char(codepoint) - else - error("invalid string escape") - end - else - result[#result + 1] = char - pos = pos + 1 - end - end - - error("unterminated string") - end - - local function parse_number() - local start = pos - local char = text:sub(pos, pos) - if char == "-" then - pos = pos + 1 - end - while text:sub(pos, pos):match("%d") do - pos = pos + 1 - end - if text:sub(pos, pos) == "." then - pos = pos + 1 - while text:sub(pos, pos):match("%d") do - pos = pos + 1 - end - end - char = text:sub(pos, pos) - if char == "e" or char == "E" then - pos = pos + 1 - char = text:sub(pos, pos) - if char == "+" or char == "-" then - pos = pos + 1 - end - while text:sub(pos, pos):match("%d") do - pos = pos + 1 - end - end - - local number = tonumber(text:sub(start, pos - 1)) - if number == nil then - error("invalid number") - end - return number - end - - local function parse_array() - pos = pos + 1 - skip_ws() - local result = json_array() - if text:sub(pos, pos) == "]" then - pos = pos + 1 - return result - end - - while true do - result[#result + 1] = parse_value() - skip_ws() - local char = text:sub(pos, pos) - if char == "]" then - pos = pos + 1 - return result - elseif char ~= "," then - error("expected array separator") - end - pos = pos + 1 - skip_ws() - end - end - - local function parse_object() - pos = pos + 1 - skip_ws() - local result = {} - if text:sub(pos, pos) == "}" then - pos = pos + 1 - return result - end - - while true do - skip_ws() - local key = parse_string() - skip_ws() - if text:sub(pos, pos) ~= ":" then - error("expected object separator") - end - pos = pos + 1 - skip_ws() - result[key] = parse_value() - skip_ws() - local char = text:sub(pos, pos) - if char == "}" then - pos = pos + 1 - return result - elseif char ~= "," then - error("expected object comma") - end - pos = pos + 1 - end - end - - function parse_value() - skip_ws() - local char = text:sub(pos, pos) - if char == "\"" then - return parse_string() - elseif char == "{" then - return parse_object() - elseif char == "[" then - return parse_array() - elseif char == "-" or char:match("%d") then - return parse_number() - elseif text:sub(pos, pos + 3) == "true" then - pos = pos + 4 - return true - elseif text:sub(pos, pos + 4) == "false" then - pos = pos + 5 - return false - elseif text:sub(pos, pos + 3) == "null" then - pos = pos + 4 - return nil - end - - error("unexpected JSON token") - end - - local ok, result = pcall(function() - local value = parse_value() - skip_ws() - if pos <= #text then - error("trailing JSON input") - end - return value - end) - - if ok then - return result - end - return nil, result -end - -local base64_map = {} -do - local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - for index = 1, #alphabet do - base64_map[alphabet:sub(index, index)] = index - 1 - end -end - -local function base64_decode(value) - value = tostring(value or ""):gsub("%s+", ""):gsub("-", "+"):gsub("_", "/") - if value == "" then - return nil - end - - local remainder = #value % 4 - if remainder == 1 then - return nil - elseif remainder > 1 then - value = value .. string.rep("=", 4 - remainder) - end - - if nixio_ok and nixio.bin and nixio.bin.b64decode then - local decoded = nixio.bin.b64decode(value) - if decoded then - return decoded - end - end - - local output = {} - local buffer = 0 - local bits = 0 - for index = 1, #value do - local char = value:sub(index, index) - if char == "=" then - break - end - local decoded = base64_map[char] - if decoded == nil then - return nil - end - - buffer = buffer * 64 + decoded - bits = bits + 6 - while bits >= 8 do - bits = bits - 8 - local factor = 2 ^ bits - local byte = math.floor(buffer / factor) - buffer = buffer % factor - output[#output + 1] = string.char(byte) - end - end - - return table.concat(output) -end - -local function urldecode(value) - value = tostring(value or ""):gsub("+", " ") - return (value:gsub("%%(%x%x)", function(hex) - return string.char(tonumber(hex, 16)) - end)) -end - -local function parse_query(query) - local params = {} - query = tostring(query or "") - if query == "" then - return params - end - - for pair in (query .. "&"):gmatch("(.-)&") do - if pair ~= "" then - local key, value = pair:match("^([^=]*)=(.*)$") - if not key then - key = pair - value = "" - end - key = urldecode(key) - if key ~= "" then - params[key] = urldecode(value or "") - end - end - end - - return params -end - -local function parse_host_port(value) - value = tostring(value or "") - value = value:gsub("/$", "") - if starts_with(value, "[") then - local host, port = value:match("^%[([^%]]+)%]:(%d+)$") - return host, tonumber(port) - end - - local host, port = value:match("^(.-):(%d+)$") - return host, tonumber(port) -end - -local function parse_url(url) - local scheme, rest = url:match("^([%w%+%.%-]+)://(.*)$") - if not scheme then - return nil - end - - local fragment = "" - local hash = rest:find("#", 1, true) - if hash then - fragment = urldecode(rest:sub(hash + 1)) - rest = rest:sub(1, hash - 1) - end - - local query = "" - local question = rest:find("?", 1, true) - if question then - query = rest:sub(question + 1) - rest = rest:sub(1, question - 1) - end - - local authority = rest:match("^([^/]*)") or rest - local userinfo = "" - local hostport = authority - local at = authority:match("^.*()@") - if at then - userinfo = urldecode(authority:sub(1, at - 1)) - hostport = authority:sub(at + 1) - end - - local host, port = parse_host_port(hostport) - return { - scheme = scheme:lower(), - userinfo = userinfo, - host = host or "", - port = port, - query = parse_query(query), - fragment = fragment - } -end - -local function normalize_utls_fingerprint(value) - local allowed = { - [""] = true, - chrome = true, - firefox = true, - edge = true, - safari = true, - ["360"] = true, - ios = true, - android = true, - randomized = true, - randomizedalpn = true, - randomizednoalpn = true - } - - value = tostring(value or "") - if allowed[value] then - return value - end - return "chrome" -end - -local function add_tls(url, security, default_tls) - local query = url.query or {} - local sni = query.sni or query.peer or "" - local insecure = query.allowInsecure or query.insecure or "" - local alpn = query.alpn or "" - local transport = query.type or "" - if transport == "xhttp" and alpn == "" then - alpn = "h2,http/1.1" - end - - local fingerprint = normalize_utls_fingerprint(query.fp or "") - local public_key = query.pbk or "" - local short_id = query.sid or "" - - if security == "reality" and public_key == "" then - return nil, false - end - if (security == "reality" or public_key ~= "") and fingerprint == "" then - fingerprint = "chrome" - end - - local tls_enabled = false - if security == "tls" or security == "xtls" or security == "reality" then - tls_enabled = true - elseif security == nil or security == "" then - tls_enabled = default_tls or sni ~= "" or alpn ~= "" or fingerprint ~= "" or public_key ~= "" - end - - if not tls_enabled then - return nil, true - end - - local tls = { enabled = true } - if sni ~= "" then - tls.server_name = sni - end - if is_true(insecure) then - tls.insecure = true - end - if alpn ~= "" then - tls.alpn = split_csv(alpn) - end - if fingerprint ~= "" then - tls.utls = { enabled = true, fingerprint = fingerprint } - end - if security == "reality" or public_key ~= "" then - tls.reality = { enabled = true } - if public_key ~= "" then - tls.reality.public_key = public_key - end - if short_id ~= "" then - tls.reality.short_id = short_id - end - end - - return tls, true -end - -local function add_transport(url) - local query = url.query or {} - local transport = query.type or "" - local path = query.path or "" - local host = query.host or "" - local early_data = query.ed or "" - local grpc_service_name = query.serviceName or "" - local xhttp_mode = query.mode or "auto" - local sni = query.sni or "" - - if xhttp_mode ~= "auto" and xhttp_mode ~= "packet-up" and xhttp_mode ~= "stream-up" and xhttp_mode ~= "stream-one" then - xhttp_mode = "auto" - end - - if transport == "ws" then - local result = { type = "ws", path = path ~= "" and path or "/" } - if host ~= "" then - result.headers = { Host = host } - end - if is_integer_string(early_data) then - result.max_early_data = tonumber(early_data) - end - return result - elseif transport == "grpc" then - local result = { type = "grpc" } - if grpc_service_name ~= "" then - result.service_name = grpc_service_name - end - return result - elseif transport == "http" or transport == "h2" then - local result = { type = "http" } - if path ~= "" then - result.path = path - end - if host ~= "" then - result.host = split_csv(host) - end - return result - elseif transport == "httpupgrade" then - local result = { type = "httpupgrade" } - if path ~= "" then - result.path = path - end - if host ~= "" then - result.host = host - end - return result - elseif transport == "xhttp" then - if path == "" then - path = "/" - end - if host == "" then - host = sni - end - - local result = { - type = "xhttp", - mode = xhttp_mode, - path = path, - x_padding_bytes = "100-1000", - no_grpc_header = false, - sc_max_each_post_bytes = 1000000, - sc_min_posts_interval_ms = 30 - } - if host ~= "" then - result.host = host - end - return result - end - - return nil -end - -local function valid_port(port) - return type(port) == "number" and port >= 1 and port <= 65535 and math.floor(port) == port -end - -local function process_vless(raw, url) - if url.host == "" or not valid_port(url.port) or url.userinfo == "" then - return nil - end - - local flow = url.query.flow or "" - if flow ~= "" and flow ~= "xtls-rprx-vision" then - return nil - end - - local packet_encoding = url.query.packetEncoding or "" - if packet_encoding ~= "xudp" and packet_encoding ~= "packetaddr" then - packet_encoding = "" - end - - local outbound = { - type = "vless", - tag = url.fragment ~= "" and url.fragment or (url.host .. ":" .. tostring(url.port)), - share_link = raw, - server = url.host, - server_port = url.port, - uuid = url.userinfo - } - if flow ~= "" then - outbound.flow = flow - end - if packet_encoding ~= "" then - outbound.packet_encoding = packet_encoding - end - - local tls, ok = add_tls(url, url.query.security or "", false) - if not ok then - return nil - end - if tls then - outbound.tls = tls - end - - local transport = add_transport(url) - if transport then - outbound.transport = transport - end - return outbound -end - -local function process_trojan(raw, url) - if url.host == "" or not valid_port(url.port) or url.userinfo == "" then - return nil - end - - local outbound = { - type = "trojan", - tag = url.fragment ~= "" and url.fragment or (url.host .. ":" .. tostring(url.port)), - share_link = raw, - server = url.host, - server_port = url.port, - password = url.userinfo - } - - local tls, ok = add_tls(url, url.query.security or "", true) - if not ok then - return nil - end - if tls then - outbound.tls = tls - end - - local transport = add_transport(url) - if transport then - outbound.transport = transport - end - return outbound -end - -local function process_socks(raw, url) - if url.host == "" or not valid_port(url.port) then - return nil - end - - local username = "" - local password = "" - if url.userinfo ~= "" then - local colon = url.userinfo:find(":", 1, true) - if colon then - username = urldecode(url.userinfo:sub(1, colon - 1)) - password = urldecode(url.userinfo:sub(colon + 1)) - if username == password then - password = "" - end - else - username = urldecode(url.userinfo) - end - end - - local outbound = { - type = "socks", - tag = url.fragment ~= "" and url.fragment or (url.host .. ":" .. tostring(url.port)), - share_link = raw, - server = url.host, - server_port = url.port - } - local version = url.scheme:sub(6) - if version ~= "" then - outbound.version = version - end - if username ~= "" then - outbound.username = username - end - if password ~= "" then - outbound.password = password - end - return outbound -end - -local function is_shadowsocks_userinfo_format(value) - return type(value) == "string" and value:match("^[^:]+:[^:]+:?[^:]*$") ~= nil -end - -local function process_shadowsocks(raw) - local body = raw:match("^ss://(.*)$") - if not body then - return nil - end - - local fragment = "" - local hash = body:find("#", 1, true) - if hash then - fragment = urldecode(body:sub(hash + 1)) - body = body:sub(1, hash - 1) - end - - local query = "" - local question = body:find("?", 1, true) - if question then - query = body:sub(question + 1) - body = body:sub(1, question - 1) - end - - local userinfo - local hostport - local at = body:match("^.*()@") - if at then - userinfo = body:sub(1, at - 1) - hostport = body:sub(at + 1) - else - local decoded = base64_decode(body) - if not decoded then - return nil - end - at = decoded:match("^.*()@") - if not at then - return nil - end - userinfo = decoded:sub(1, at - 1) - hostport = decoded:sub(at + 1) - end - - userinfo = urldecode(userinfo) - if not is_shadowsocks_userinfo_format(userinfo) then - local decoded = base64_decode(userinfo) - if not decoded then - return nil - end - userinfo = decoded - end - - local method, password = userinfo:match("^([^:]+):(.*)$") - local host, port = parse_host_port(hostport) - if not method or method == "" or method == "ss" or not password or password == "" or host == "" or not valid_port(port) then - return nil - end - - local params = parse_query(query) - local plugin = params.plugin or "" - local plugin_opts = params["plugin-opts"] or "" - if plugin ~= "" and plugin_opts == "" then - local parsed_plugin, parsed_opts = plugin:match("^([^;]+);(.*)$") - if parsed_plugin then - plugin = parsed_plugin - plugin_opts = parsed_opts - end - end - - local outbound = { - type = "shadowsocks", - tag = fragment ~= "" and fragment or (host .. ":" .. tostring(port)), - share_link = raw, - server = host, - server_port = port, - method = method, - password = password - } - if plugin ~= "" then - outbound.plugin = plugin - end - if plugin_opts ~= "" then - outbound.plugin_opts = plugin_opts - end - return outbound -end - -local function process_hysteria2(raw, url) - if url.host == "" or not valid_port(url.port) or url.userinfo == "" then - return nil - end - - local password = url.userinfo - local colon = password:find(":", 1, true) - if colon then - password = password:sub(colon + 1) - end - if password == "" then - return nil - end - - local tls = { enabled = true } - if (url.query.sni or "") ~= "" then - tls.server_name = url.query.sni - end - if is_true(url.query.insecure) then - tls.insecure = true - end - if (url.query.alpn or "") ~= "" then - tls.alpn = split_csv(url.query.alpn) - end - - local outbound = { - type = "hysteria2", - tag = url.fragment ~= "" and url.fragment or (url.host .. ":" .. tostring(url.port)), - share_link = raw, - server = url.host, - server_port = url.port, - password = password, - tls = tls - } - if (url.query.network or "") ~= "" then - outbound.network = url.query.network - end - if is_integer_string(url.query.upmbps or "") then - outbound.up_mbps = tonumber(url.query.upmbps) - end - if is_integer_string(url.query.downmbps or "") then - outbound.down_mbps = tonumber(url.query.downmbps) - end - if (url.query.obfs or "") ~= "" and url.query.obfs ~= "none" then - outbound.obfs = { type = url.query.obfs } - if (url.query["obfs-password"] or "") ~= "" then - outbound.obfs.password = url.query["obfs-password"] - end - end - return outbound -end - -local function string_value(value) - if value == nil then - return "" - end - return tostring(value) -end - -local function process_vmess_json(raw, decoded) - local vmess = json_decode(decoded) - if type(vmess) ~= "table" then - return nil - end - - local server = string_value(vmess.add) - local port = tonumber(vmess.port) - local uuid = string_value(vmess.id) - if server == "" or not valid_port(port) or uuid == "" then - return nil - end - - local outbound = { - type = "vmess", - tag = string_value(vmess.ps) ~= "" and string_value(vmess.ps) or (server .. ":" .. tostring(port)), - share_link = raw, - server = server, - server_port = port, - uuid = uuid, - security = string_value(vmess.scy) ~= "" and string_value(vmess.scy) or "auto" - } - - local alter_id = tonumber(vmess.aid) - if alter_id then - outbound.alter_id = alter_id - end - - if vmess.tls == true or vmess.tls == "tls" or vmess.tls == "true" then - local fingerprint = normalize_utls_fingerprint(string_value(vmess.fp)) - local tls = { enabled = true } - if string_value(vmess.sni) ~= "" then - tls.server_name = string_value(vmess.sni) - end - if string_value(vmess.alpn) ~= "" then - tls.alpn = split_csv(string_value(vmess.alpn)) - end - if fingerprint ~= "" then - tls.utls = { enabled = true, fingerprint = fingerprint } - end - outbound.tls = tls - end - - local network = string_value(vmess.net) - if network == "ws" then - outbound.transport = { - type = "ws", - path = string_value(vmess.path) ~= "" and string_value(vmess.path) or "/" - } - if string_value(vmess.host) ~= "" then - outbound.transport.headers = { Host = string_value(vmess.host) } - end - elseif network == "grpc" then - outbound.transport = { type = "grpc" } - if string_value(vmess.path) ~= "" then - outbound.transport.service_name = string_value(vmess.path) - end - elseif network == "http" or network == "h2" then - outbound.transport = { type = "http" } - if string_value(vmess.path) ~= "" then - outbound.transport.path = string_value(vmess.path) - end - if string_value(vmess.host) ~= "" then - outbound.transport.host = split_csv(string_value(vmess.host)) - end - end - - return outbound -end - -local function process_vmess(raw) - local encoded = raw:match("^vmess://(.*)$") - if not encoded then - return nil - end - - encoded = encoded:gsub("#.*$", "") - local decoded = base64_decode(encoded) - if not decoded then - return nil - end - decoded = decoded:gsub("[\r\n]", "") - if not decoded:match("^%s*{.*}%s*$") then - return nil - end - return process_vmess_json(raw, decoded) -end - -local function parse_share_link(line) - if starts_with(line, "vmess://") then - return process_vmess(line) - elseif starts_with(line, "ss://") then - return process_shadowsocks(line) - end - - local url = parse_url(line) - if not url then - return nil - end - - if url.scheme == "vless" then - return process_vless(line, url) - elseif url.scheme == "trojan" then - return process_trojan(line, url) - elseif url.scheme == "hysteria2" or url.scheme == "hy2" then - return process_hysteria2(line, url) - elseif url.scheme:match("^socks") then - return process_socks(line, url) - end - - return nil -end - -local function clean_scalar(value) - value = trim(value) - local first = value:sub(1, 1) - local last = value:sub(-1) - if #value >= 2 and ((first == "\"" and last == "\"") or (first == "'" and last == "'")) then - return value:sub(2, -2) - end - return value -end - -local function leading_indent(value) - local prefix = tostring(value or ""):match("^[ \t]*") or "" - return #prefix -end - -local function find_top_level_colon(value) - local depth = 0 - local quote = "" - local escaped = false - - for index = 1, #value do - local char = value:sub(index, index) - if quote ~= "" then - if escaped then - escaped = false - elseif char == "\\" then - escaped = true - elseif char == quote then - quote = "" - end - elseif char == "\"" or char == "'" then - quote = char - elseif char == "{" or char == "[" then - depth = depth + 1 - elseif char == "}" or char == "]" then - depth = depth - 1 - elseif char == ":" and depth == 0 then - return index - end - end - - return nil -end - -local function split_top_level(value, separator) - local result = {} - local depth = 0 - local quote = "" - local escaped = false - local start = 1 - - for index = 1, #value do - local char = value:sub(index, index) - if quote ~= "" then - if escaped then - escaped = false - elseif char == "\\" then - escaped = true - elseif char == quote then - quote = "" - end - elseif char == "\"" or char == "'" then - quote = char - elseif char == "{" or char == "[" then - depth = depth + 1 - elseif char == "}" or char == "]" then - depth = depth - 1 - elseif char == separator and depth == 0 then - result[#result + 1] = value:sub(start, index - 1) - start = index + 1 - end - end - - result[#result + 1] = value:sub(start) - return result -end - -local function set_record_field(record, key, value) - key = clean_scalar(key) - value = clean_scalar(value) - if key == "" then - return - end - - if record.fields[key] == nil then - record.keys[#record.keys + 1] = key - end - record.fields[key] = value -end - -local parse_clash_map - -local function parse_clash_pair(record, part, prefix) - part = trim(part) - local colon = find_top_level_colon(part) - if not colon then - return - end - - local key = clean_scalar(part:sub(1, colon - 1)) - local value = trim(part:sub(colon + 1)) - if value:sub(1, 1) == "{" and value:sub(-1) == "}" then - parse_clash_map(record, value, prefix .. key .. ".") - else - set_record_field(record, prefix .. key, value) - end -end - -parse_clash_map = function(record, value, prefix) - value = trim(value) - if value:sub(1, 1) == "{" then - value = value:sub(2) - end - if value:sub(-1) == "}" then - value = value:sub(1, -2) - end - - for _, part in ipairs(split_top_level(value, ",")) do - parse_clash_pair(record, part, prefix) - end -end - -local function empty_clash_record() - return { - fields = {}, - keys = {}, - context = "", - context_indent = -1 - } -end - -local function clash_record_has_fields(record) - return #record.keys > 0 -end - -local function emit_clash_record(records, record) - if not clash_record_has_fields(record) then - return empty_clash_record() - end - - local item = {} - for _, key in ipairs(record.keys) do - if record.fields[key] ~= nil then - item[key] = record.fields[key] - end - end - records[#records + 1] = item - return empty_clash_record() -end - -local clash_nested_keys = { - ["ws-opts"] = true, - ["grpc-opts"] = true, - ["reality-opts"] = true, - ["obfs-opts"] = true, - headers = true -} - -local function parse_clash_block_line(record, line) - local indent = leading_indent(line) - local text = trim(line) - if text == "" then - return - end - - local colon = text:find(":", 1, true) - if not colon then - return - end - - local key = clean_scalar(text:sub(1, colon - 1)) - local value = trim(text:sub(colon + 1)) - - if record.context ~= "" and indent <= record.context_indent then - record.context = "" - record.context_indent = -1 - end - - if value == "" then - if clash_nested_keys[key] then - if record.context ~= "" and indent > record.context_indent then - record.context = record.context .. "." .. key - else - record.context = key - end - record.context_indent = indent - end - return - end - - local full_key = key - if record.context ~= "" and indent > record.context_indent then - full_key = record.context .. "." .. key - end - set_record_field(record, full_key, value) -end - -local function is_clash_proxies_header(line) - local text = trim(line) - return text == "proxies:" or text:match("^proxies:%s*#") ~= nil -end - -local function clash_yaml_records(input_file) - local input = assert(io.open(input_file, "r")) - local records = {} - local record = empty_clash_record() - local in_proxies = false - - for line in input:lines() do - local proxies_header = is_clash_proxies_header(line) - - if in_proxies and line:match("^[^ \t]") and not proxies_header and not line:match("^%-[ \t]") then - record = emit_clash_record(records, record) - in_proxies = false - end - - if proxies_header then - record = emit_clash_record(records, record) - in_proxies = true - elseif in_proxies then - if line:match("^[ \t]*%-[ \t]*%{") then - record = emit_clash_record(records, record) - record = empty_clash_record() - local map_value = line:gsub("^[ \t]*%-[ \t]*", "", 1) - parse_clash_map(record, map_value, "") - record = emit_clash_record(records, record) - elseif line:match("^[ \t]*%-[ \t]*") then - record = emit_clash_record(records, record) - record = empty_clash_record() - local rest = line:gsub("^[ \t]*%-[ \t]*", "", 1) - if trim(rest) ~= "" then - parse_clash_block_line(record, rest) - end - elseif clash_record_has_fields(record) then - parse_clash_block_line(record, line) - end - end - end - - input:close() - emit_clash_record(records, record) - return records -end - -local function lower(value) - return tostring(value or ""):lower() -end - -local function normalize_packet_encoding(value) - value = tostring(value or "") - if value == "xudp" or value == "packetaddr" then - return value - end - return "" -end - -local function clash_vless_flow_supported(flow) - return flow == nil or flow == "" or flow == "xtls-rprx-vision" -end - -local function normalized_clash_alpn(value) - value = tostring(value or ""):gsub("^%[", ""):gsub("%]$", ""):gsub("%s+", "") - return value -end - -local function add_clash_tls(outbound, options) - local enabled = options.always or is_true(options.tls) or options.sni ~= "" or - options.alpn ~= "" or options.fingerprint ~= "" or options.reality_public_key ~= "" - - if not enabled then - return - end - - local tls = { enabled = true } - if options.sni ~= "" then - tls.server_name = options.sni - end - if is_true(options.skip_verify) then - tls.insecure = true - end - if options.alpn ~= "" then - tls.alpn = split_csv(options.alpn) - end - - if options.reality_public_key ~= "" then - tls.utls = { - enabled = true, - fingerprint = options.fingerprint ~= "" and options.fingerprint or "chrome" - } - tls.reality = { enabled = true, public_key = options.reality_public_key } - if options.reality_short_id ~= "" then - tls.reality.short_id = options.reality_short_id - end - elseif options.fingerprint ~= "" then - tls.utls = { enabled = true, fingerprint = options.fingerprint } - end - - outbound.tls = tls -end - -local function add_clash_transport(outbound, options) - if options.network == "ws" then - outbound.transport = { - type = "ws", - path = options.ws_path ~= "" and options.ws_path or "/" - } - if options.ws_host ~= "" then - outbound.transport.headers = { Host = options.ws_host } - end - elseif options.network == "grpc" then - outbound.transport = { type = "grpc" } - if options.grpc_service_name ~= "" then - outbound.transport.service_name = options.grpc_service_name - end - end -end - -local function parse_clash_record(record) - local proxy_type = lower(record.type) - local name = tostring(record.name or "") - local server = tostring(record.server or "") - local port = tonumber(record.port) - if name == "" then - name = server .. ":" .. tostring(record.port or "") - end - if proxy_type == "" or server == "" or not valid_port(port) then - return nil - end - - local options = { - tls = tostring(record.tls or ""), - skip_verify = tostring(record["skip-cert-verify"] or ""), - sni = tostring(record.sni or record.servername or ""), - network = lower(record.network), - ws_path = tostring(record["ws-opts.path"] or ""), - ws_host = tostring(record["ws-opts.headers.Host"] or ""), - grpc_service_name = tostring(record["grpc-opts.grpc-service-name"] or ""), - reality_public_key = tostring(record["reality-opts.public-key"] or ""), - reality_short_id = tostring(record["reality-opts.short-id"] or ""), - alpn = normalized_clash_alpn(record.alpn or ""), - fingerprint = normalize_utls_fingerprint(tostring(record["client-fingerprint"] or record.fingerprint or "")) - } - - if proxy_type == "ss" or proxy_type == "shadowsocks" then - local method = tostring(record.cipher or "") - local password = tostring(record.password or "") - if method == "" or method == "ss" or password == "" then - return nil - end - return { - type = "shadowsocks", - tag = name, - server = server, - server_port = port, - method = method, - password = password - } - elseif proxy_type == "vmess" then - local uuid = tostring(record.uuid or "") - if uuid == "" then - return nil - end - local outbound = { - type = "vmess", - tag = name, - server = server, - server_port = port, - uuid = uuid, - security = tostring(record.cipher or "") ~= "" and tostring(record.cipher) or "auto" - } - local alter_id = tonumber(record.alterId or record["alter-id"]) - if alter_id then - outbound.alter_id = alter_id - end - add_clash_tls(outbound, options) - add_clash_transport(outbound, options) - return outbound - elseif proxy_type == "vless" then - local uuid = tostring(record.uuid or "") - local flow = tostring(record.flow or "") - local packet_encoding = normalize_packet_encoding(record["packet-encoding"] or record.packetEncoding or "") - if uuid == "" or not clash_vless_flow_supported(flow) then - return nil - end - local outbound = { - type = "vless", - tag = name, - server = server, - server_port = port, - uuid = uuid - } - if flow ~= "" then - outbound.flow = flow - end - if packet_encoding ~= "" then - outbound.packet_encoding = packet_encoding - end - add_clash_tls(outbound, options) - add_clash_transport(outbound, options) - return outbound - elseif proxy_type == "trojan" then - local password = tostring(record.password or "") - if password == "" then - return nil - end - local outbound = { - type = "trojan", - tag = name, - server = server, - server_port = port, - password = password - } - options.always = true - add_clash_tls(outbound, options) - add_clash_transport(outbound, options) - return outbound - elseif proxy_type == "hysteria2" or proxy_type == "hy2" then - local password = tostring(record.password or "") - if password == "" then - return nil - end - local outbound = { - type = "hysteria2", - tag = name, - server = server, - server_port = port, - password = password, - tls = { enabled = true } - } - if options.sni ~= "" then - outbound.tls.server_name = options.sni - end - if is_true(options.skip_verify) then - outbound.tls.insecure = true - end - if options.alpn ~= "" then - outbound.tls.alpn = split_csv(options.alpn) - end - local obfs = tostring(record.obfs or "") - local obfs_password = tostring(record["obfs-password"] or record["obfs-opts.password"] or "") - if obfs ~= "" and obfs ~= "none" then - outbound.obfs = { type = obfs } - if obfs_password ~= "" then - outbound.obfs.password = obfs_password - end - end - return outbound - elseif proxy_type == "socks5" or proxy_type == "socks" then - local outbound = { - type = "socks", - tag = name, - server = server, - server_port = port, - version = "5" - } - if tostring(record.username or "") ~= "" then - outbound.username = tostring(record.username) - end - if tostring(record.password or "") ~= "" then - outbound.password = tostring(record.password) - end - return outbound - end - - return nil -end - -local function normalize_clash_yaml(input_file, output_file) - local outbounds = json_array() - local skipped = 0 - - for _, record in ipairs(clash_yaml_records(input_file)) do - local outbound = parse_clash_record(record) - if outbound then - outbounds[#outbounds + 1] = outbound - else - skipped = skipped + 1 - end - end - - if #outbounds == 0 then - os.remove(output_file) - return false - end - - local output = assert(io.open(output_file, "w")) - output:write(json_encode({ - version = 1, - format = "clash-yaml", - skipped = skipped, - outbounds = outbounds - }), "\n") - output:close() - return true -end - -local function normalize_uri_list(input_file, output_file) - local input = assert(io.open(input_file, "r")) - local output = assert(io.open(output_file, "w")) - local added = 0 - local skipped = 0 - - output:write("{\"version\":1,\"format\":\"uri-list\",\"outbounds\":[") - - for line in input:lines() do - line = trim(line:gsub("\r", "")) - if line ~= "" and not starts_with(line, "#") then - local outbound = parse_share_link(line) - if outbound then - if added > 0 then - output:write(",") - end - output:write(json_encode(outbound)) - added = added + 1 - else - skipped = skipped + 1 - end - end - end - input:close() - output:write("],\"skipped\":", tostring(skipped), "}\n") - output:close() - - if added == 0 then - os.remove(output_file) - return false - end - return true -end - -local function read_file(path) - local file = assert(io.open(path, "r")) - local data = file:read("*a") - file:close() - return data -end - -local function non_empty_string(value) - return type(value) == "string" and value ~= "" -end - -local function valid_server_port(value) - return type(value) == "number" and value >= 1 and value <= 65535 and math.floor(value) == value -end - -local function type_requires_server(proxy_type) - return proxy_type == "vless" or proxy_type == "vmess" or proxy_type == "trojan" or - proxy_type == "shadowsocks" or proxy_type == "socks" or proxy_type == "hysteria2" -end - -local function supported_flow(flow) - return flow == nil or flow == "" or flow == "xtls-rprx-vision" -end - -local function supported_transport_type(transport) - return transport == "http" or transport == "ws" or transport == "quic" or transport == "grpc" or - transport == "httpupgrade" or transport == "xhttp" or transport == "kcp" -end - -local supported_shadowsocks_methods = { - ["none"] = true, - ["aes-128-gcm"] = true, - ["aes-192-gcm"] = true, - ["aes-256-gcm"] = true, - ["chacha20-ietf-poly1305"] = true, - ["xchacha20-ietf-poly1305"] = true, - ["2022-blake3-aes-128-gcm"] = true, - ["2022-blake3-aes-256-gcm"] = true, - ["2022-blake3-chacha20-poly1305"] = true, - ["aes-128-cfb"] = true, - ["aes-192-cfb"] = true, - ["aes-256-cfb"] = true, - ["aes-128-ctr"] = true, - ["aes-192-ctr"] = true, - ["aes-256-ctr"] = true, - ["chacha20"] = true, - ["chacha20-ietf"] = true, - ["xchacha20"] = true, - ["salsa20"] = true, - ["rc4-md5"] = true -} - -local function plugin_name(plugin) - return tostring(plugin or ""):match("^([^;]*)") -end - -local function reality_enabled(outbound) - return type(outbound.tls) == "table" and type(outbound.tls.reality) == "table" and - (outbound.tls.reality.enabled == nil or outbound.tls.reality.enabled == true) -end - -local function prefilter_skip_reason(outbound, supports_xhttp, plugin_supports) - if type(outbound) ~= "table" then - return "outbound must be an object" - end - - local proxy_type = outbound.type - if proxy_type == nil or tostring(proxy_type) == "" then - return "missing outbound type" - elseif type(proxy_type) ~= "string" then - return "outbound type must be a string" - elseif type_requires_server(proxy_type) and not non_empty_string(outbound.server) then - return "missing or empty server" - elseif type_requires_server(proxy_type) and not valid_server_port(outbound.server_port) then - return "missing or invalid server_port" - elseif (proxy_type == "vless" or proxy_type == "vmess") and not non_empty_string(outbound.uuid) then - return "missing uuid" - elseif (proxy_type == "trojan" or proxy_type == "hysteria2") and not non_empty_string(outbound.password) then - return "missing password" - elseif proxy_type == "shadowsocks" and not non_empty_string(outbound.method) then - return "missing shadowsocks method" - elseif proxy_type == "shadowsocks" and not non_empty_string(outbound.password) then - return "missing shadowsocks password" - elseif proxy_type == "shadowsocks" and not supported_shadowsocks_methods[outbound.method] then - return "unsupported shadowsocks method: " .. tostring(outbound.method) - elseif proxy_type == "shadowsocks" and non_empty_string(outbound.plugin) and not plugin_supports[plugin_name(outbound.plugin)] then - return "shadowsocks plugin is not installed: " .. plugin_name(outbound.plugin) - elseif reality_enabled(outbound) and not non_empty_string(outbound.tls.reality.public_key) then - return "reality public_key is missing" - elseif proxy_type == "vless" and not supported_flow(outbound.flow or "") then - return "unsupported vless flow: " .. tostring(outbound.flow) - elseif type(outbound.transport) == "table" and tostring(outbound.transport.type or "") == "" then - return "missing transport type" - elseif type(outbound.transport) == "table" and not supported_transport_type(tostring(outbound.transport.type or "")) then - return "unknown transport type: " .. tostring(outbound.transport.type) - elseif type(outbound.transport) == "table" and tostring(outbound.transport.type or "") == "xhttp" and not supports_xhttp then - return "transport xhttp requires sing-box-extended" - elseif proxy_type == "shadowsocks" and type(outbound.tls) == "table" and outbound.tls.enabled == true then - return "shadowsocks with TLS is not supported" - end - - return "" -end - -local function safe_string(value, fallback) - local result = value == nil and fallback or tostring(value) - if result == "" or result == "null" then - return fallback - end - return result -end - -local function unique_tag(base, taken) - if not taken[base] then - return base - end - - for suffix = 1, 99999 do - local candidate = base .. "-" .. tostring(suffix) - if not taken[candidate] then - return candidate - end - end - return base .. "-overflow" -end - -local function copy_outbound(outbound) - local copy = {} - for key, value in pairs(outbound) do - if key ~= "tag" and key ~= "remark" and key ~= "share_link" then - copy[key] = value - end - end - return copy -end - -local function prepare_subscription(config_file, outbounds_file, output_file, supports_xhttp_arg, plugin_supports_file) - local config = json_decode(read_file(config_file)) - local outbounds = json_decode(read_file(outbounds_file)) - local plugin_supports = {} - if plugin_supports_file and plugin_supports_file ~= "" then - plugin_supports = json_decode(read_file(plugin_supports_file)) or {} - end - - if type(config) ~= "table" or type(outbounds) ~= "table" then - return false - end - - local supports_xhttp = supports_xhttp_arg == "true" or supports_xhttp_arg == "1" - local taken = {} - if type(config.outbounds) == "table" then - for _, outbound in ipairs(config.outbounds) do - if type(outbound) == "table" and non_empty_string(outbound.tag) then - taken[outbound.tag] = true - end - end - end - - local tags = json_array() - local names = json_array() - local servers = json_array() - local links = json_array() - local source_indices = json_array() - local skipped = 0 - local skipped_reason_counts = {} - local output = assert(io.open(output_file, "w")) - local added = 0 - - output:write("{\"outbounds\":[") - - for source_index, outbound in ipairs(outbounds) do - local index = added + 1 - local display_name = safe_string(outbound and (outbound.remark or outbound.tag), "server-" .. tostring(index)) - local skip_reason = prefilter_skip_reason(outbound, supports_xhttp, plugin_supports) - if skip_reason ~= "" then - skipped = skipped + 1 - skipped_reason_counts[skip_reason] = (skipped_reason_counts[skip_reason] or 0) + 1 - else - local base_tag = safe_string(outbound.tag or outbound.remark, "server-" .. tostring(index)) - local tag = unique_tag(base_tag, taken) - local outbound_copy = copy_outbound(outbound) - outbound_copy.tag = tag - if added > 0 then - output:write(",") - end - output:write(json_encode(outbound_copy)) - added = added + 1 - tags[#tags + 1] = tag - names[#names + 1] = display_name - servers[#servers + 1] = outbound.server or "" - links[#links + 1] = outbound.share_link or "" - source_indices[#source_indices + 1] = source_index - taken[tag] = true - end - end - - output:write("],\"links\":", json_encode(links)) - output:write(",\"source_indices\":", json_encode(source_indices)) - output:write(",\"names\":", json_encode(names)) - output:write(",\"servers\":", json_encode(servers)) - output:write(",\"skipped\":", tostring(skipped)) - output:write(",\"skipped_reason_counts\":", json_encode(skipped_reason_counts)) - output:write(",\"tags\":", json_encode(tags)) - output:write("}\n") - output:close() - return true -end - -local mode = arg[1] -local ok = false - -if mode == "normalize-uri-list" then - ok = normalize_uri_list(arg[2], arg[3]) -elseif mode == "normalize-clash-yaml" then - ok = normalize_clash_yaml(arg[2], arg[3]) -elseif mode == "prepare" then - ok = prepare_subscription(arg[2], arg[3], arg[4], arg[5], arg[6]) -elseif arg[1] and arg[2] then - ok = normalize_uri_list(arg[1], arg[2]) -else - io.stderr:write("Usage: subscription_parser.lua normalize-uri-list \n") - io.stderr:write(" subscription_parser.lua normalize-clash-yaml \n") - io.stderr:write(" subscription_parser.lua prepare \n") - os.exit(2) -end - -if not ok then - os.exit(1) -end diff --git a/podkop/files/usr/lib/subscription_parser.sh b/podkop/files/usr/lib/subscription_parser.sh index 175825cd..b4322e62 100644 --- a/podkop/files/usr/lib/subscription_parser.sh +++ b/podkop/files/usr/lib/subscription_parser.sh @@ -1,13 +1,5 @@ # shellcheck shell=ash -subscription_content_has_share_links() { - grep -Eq '^[[:space:]]*(ss|vmess|vless|trojan|hysteria2|hy2|socks|socks4|socks4a|socks5)://' "$1" -} - -subscription_content_is_clash_yaml() { - grep -Eq '^[[:space:]]*proxies:[[:space:]]*($|#)' "$1" -} - subscription_url_decode() { case "$1" in *%* | *+*) url_decode "$1" ;; @@ -24,35 +16,6 @@ subscription_url_get_fragment() { esac } -subscription_bool_is_true() { - case "$1" in - 1 | true | TRUE | True | yes | YES | on | ON) - return 0 - ;; - esac - - return 1 -} - -subscription_bool_json() { - if subscription_bool_is_true "$1"; then - printf 'true' - else - printf 'false' - fi -} - -subscription_normalize_utls_fingerprint() { - case "$1" in - "" | chrome | firefox | edge | safari | 360 | ios | android | randomized | randomizedalpn | randomizednoalpn) - printf '%s' "$1" - ;; - *) - printf 'chrome' - ;; - esac -} - subscription_base64_decode_string() { local raw="$1" local normalized remainder padding @@ -350,7 +313,7 @@ subscription_metadata_raw_value() { local raw_json="$1" local key="$2" - printf '%s\n' "$raw_json" | jq -r --arg key "$key" '.[$key] // empty' 2>/dev/null + printf '%s\n' "$raw_json" | subscription_json_utils_ucode object-get "$key" 2>/dev/null } subscription_normalize_ui_metadata_json() { @@ -413,64 +376,60 @@ EOF remaining_json="$remaining" fi - jq -cn \ - --arg title "$title" \ - --arg webPageUrl "$web_page_url" \ - --arg supportUrl "$support_url" \ - --arg announce "$announce" \ - --arg announceUrl "$announce_url" \ - --arg fileName "$file_name" \ - --argjson hasTraffic "$has_traffic" \ - --argjson upload "$upload_json" \ - --argjson download "$download_json" \ - --argjson used "$used" \ - --argjson total "$total_json" \ - --argjson remaining "$remaining_json" \ - --argjson isUnlimited "$is_unlimited" \ - --argjson expire "$expire_json" \ - --argjson refillDate "$refill_date_json" ' - { - version: 1 - } - + (if $title != "" then {title: $title} else {} end) - + (if $hasTraffic then - { - traffic: ( - {} - + (if $upload != null then {upload: $upload} else {} end) - + (if $download != null then {download: $download} else {} end) - + {used: $used, isUnlimited: $isUnlimited} - + (if $total != null and $total > 0 then {total: $total, remaining: $remaining} else {} end) - ) - } - else {} end) - + (if $expire != null and $expire > 0 then {expire: $expire} else {} end) - + (if $refillDate != null and $refillDate > 0 then {refillDate: $refillDate} else {} end) - + (if $webPageUrl != "" then {webPageUrl: $webPageUrl} else {} end) - + (if $supportUrl != "" then {supportUrl: $supportUrl} else {} end) - + (if $announce != "" then {announce: $announce} else {} end) - + (if $announceUrl != "" then {announceUrl: $announceUrl} else {} end) - + (if $fileName != "" then {fileName: $fileName} else {} end) - | if (keys | length) > 1 then . else empty end - ' + subscription_json_utils_ucode subscription-ui-metadata \ + "$title" \ + "$web_page_url" \ + "$support_url" \ + "$announce" \ + "$announce_url" \ + "$file_name" \ + "$has_traffic" \ + "$upload_json" \ + "$download_json" \ + "$used" \ + "$total_json" \ + "$remaining_json" \ + "$is_unlimited" \ + "$expire_json" \ + "$refill_date_json" } subscription_extract_ui_metadata() { local headers_file="$1" local body_file="$2" local output="$3" - local headers_json body_json raw_json metadata_json + local headers_json body_json raw_json metadata_json headers_tmp body_tmp status headers_json="$(subscription_metadata_headers_json "$headers_file")" || headers_json="{}" body_json="$(subscription_metadata_body_json "$body_file")" || body_json="{}" - raw_json="$(jq -cn --argjson body "$body_json" --argjson headers "$headers_json" '$body + $headers')" || { + body_tmp="$(mktemp)" || { rm -f "$output" return 1 } + headers_tmp="$(mktemp)" || { + rm -f "$body_tmp" "$output" + return 1 + } + printf '%s' "$body_json" > "$body_tmp" || { + rm -f "$body_tmp" "$headers_tmp" "$output" + return 1 + } + printf '%s' "$headers_json" > "$headers_tmp" || { + rm -f "$body_tmp" "$headers_tmp" "$output" + return 1 + } + + raw_json="$(subscription_json_utils_ucode objects-merge "$body_tmp" "$headers_tmp" 2>/dev/null)" + status=$? + rm -f "$body_tmp" "$headers_tmp" + if [ "$status" -ne 0 ] || [ -z "$raw_json" ]; then + rm -f "$output" + return 1 + fi metadata_json="$(subscription_normalize_ui_metadata_json "$raw_json")" || metadata_json="" - if [ -n "$metadata_json" ] && printf '%s\n' "$metadata_json" | jq -e 'type == "object" and (keys | length > 1)' >/dev/null 2>&1; then + if [ -n "$metadata_json" ]; then printf '%s\n' "$metadata_json" > "$output" return 0 fi @@ -479,653 +438,52 @@ subscription_extract_ui_metadata() { return 1 } -subscription_strip_metadata_preamble_file() { - local input="$1" - local tmpfile +subscription_parser_ucode() { + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/subscription_parser.uc" "$@" +} - [ -s "$input" ] || return 0 - - tmpfile="$(mktemp)" || return 0 - awk ' - function allowed(k) { - return k == "profile-title" || - k == "subscription-userinfo" || - k == "profile-web-page-url" || - k == "support-url" || - k == "announce" || - k == "announce-url" || - k == "subscription-refill-date" || - k == "content-disposition" - } - NR <= 20 { - line = $0 - sub(/\r$/, "", line) - gsub(/^[ \t]+|[ \t]+$/, "", line) - if (line ~ /^(#|\/\/)[ \t]*[A-Za-z0-9][A-Za-z0-9_-]*[ \t]*:/) { - key = line - sub(/^(#|\/\/)[ \t]*/, "", key) - sub(/[ \t]*:.*/, "", key) - key = tolower(key) - if (!allowed(key)) { - print - next - } - next - } - } - { - print - } - ' "$input" > "$tmpfile" && mv "$tmpfile" "$input" || rm -f "$tmpfile" +subscription_json_utils_ucode() { + ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/json_utils.uc" "$@" } subscription_validate_normalized_file() { local output="$1" - jq -e ' - type == "object" and - (.outbounds | type == "array") and - ((.outbounds | length) > 0) - ' "$output" >/dev/null 2>&1 + subscription_json_utils_ucode validate-subscription "$output" >/dev/null 2>&1 } -subscription_normalize_sing_box_json_file() { - local input="$1" - local output="$2" +subscription_log_normalized_skipped() { + local output="$1" + local skipped format message - jq -ce ' - def candidate_outbounds: - if type == "object" and (.outbounds | type == "array") then - .outbounds - elif type == "array" then - . - elif type == "object" and (.type? | type == "string") then - [.] - else - [] - end; + skipped="$(subscription_json_utils_ucode json-file-field "$output" "skipped" "0" 2>/dev/null)" + case "$skipped" in + '' | *[!0-9]*) skipped=0 ;; + esac + [ "$skipped" -gt 0 ] || return 0 - { - version: 1, - format: "sing-box-json", - outbounds: (candidate_outbounds | map(select(type == "object"))) - } - ' "$input" > "$output" 2>/dev/null -} - -subscription_normalize_uri_list_file() { - local input="$1" - local output="$2" - - subscription_normalize_uri_list_file_lua "$input" "$output" -} - -subscription_normalize_uri_list_file_lua() { - local input="$1" - local output="$2" - local parser_path="${PODKOP_LIB:-/usr/lib/podkop-plus}/subscription_parser.lua" - local tmp_output skipped - - command -v lua >/dev/null 2>&1 || { - log "Lua interpreter is required to parse URI subscription links" "error" - return 1 - } - [ -r "$parser_path" ] || { - log "Lua subscription parser not found: $parser_path" "error" - return 1 - } - - tmp_output="$(mktemp)" || return 1 - if lua "$parser_path" normalize-uri-list "$input" "$tmp_output"; then - skipped="$(jq -r '.skipped // 0' "$tmp_output" 2>/dev/null)" - case "$skipped" in - '' | *[!0-9]*) skipped=0 ;; - esac - if [ "$skipped" -gt 0 ]; then - log "Skipped $skipped invalid or unsupported subscription links" "warn" - fi - mv "$tmp_output" "$output" - return 0 - fi - - rm -f "$tmp_output" - log "Lua subscription parser failed" "error" - return 1 -} - -subscription_normalize_clash_yaml_file_lua() { - local input="$1" - local output="$2" - local parser_path="${PODKOP_LIB:-/usr/lib/podkop-plus}/subscription_parser.lua" - local tmp_output skipped - - command -v lua >/dev/null 2>&1 || { - log "Lua interpreter is required to parse Clash YAML subscriptions" "error" - return 1 - } - [ -r "$parser_path" ] || { - log "Lua subscription parser not found: $parser_path" "error" - return 1 - } - - tmp_output="$(mktemp)" || return 1 - if lua "$parser_path" normalize-clash-yaml "$input" "$tmp_output"; then - skipped="$(jq -r '.skipped // 0' "$tmp_output" 2>/dev/null)" - case "$skipped" in - '' | *[!0-9]*) skipped=0 ;; - esac - if [ "$skipped" -gt 0 ]; then - log "Skipped $skipped invalid or unsupported Clash proxy entries" "warn" - fi - mv "$tmp_output" "$output" - return 0 - fi - - rm -f "$tmp_output" - log "Lua Clash YAML parser failed" "error" - return 1 -} - -subscription_clash_yaml_records() { - local input="$1" - - awk ' - function trim(s) { - gsub(/^[ \t\r\n]+/, "", s) - gsub(/[ \t\r\n]+$/, "", s) - return s - } - function json_escape(s) { - gsub(/\\/, "\\\\", s) - gsub(/"/, "\\\"", s) - gsub(/\t/, "\\t", s) - gsub(/\r/, "", s) - return s - } - function clean_scalar(s) { - s = trim(s) - if ((substr(s, 1, 1) == "\"" && substr(s, length(s), 1) == "\"") || - (substr(s, 1, 1) == "'"'"'" && substr(s, length(s), 1) == "'"'"'")) { - s = substr(s, 2, length(s) - 2) - } - return s - } - function clear_fields( k) { - for (k in fields) { - delete fields[k] - } - field_count = 0 - ctx = "" - ctx_indent = -1 - } - function set_field(k, v) { - k = clean_scalar(k) - v = clean_scalar(v) - if (k == "") { - return - } - if (!(k in fields)) { - keys[++field_count] = k - } - fields[k] = v - } - function emit_record( i,k,sep) { - if (field_count <= 0) { - return - } - printf "{" - sep = "" - for (i = 1; i <= field_count; i++) { - k = keys[i] - if (k in fields) { - printf "%s\"%s\":\"%s\"", sep, json_escape(k), json_escape(fields[k]) - sep = "," - } - } - printf "}\n" - clear_fields() - } - function parse_pair(part, prefix, i,c,depth,quote,esc,colon,key,value) { - part = trim(part) - depth = 0 - quote = "" - esc = 0 - colon = 0 - for (i = 1; i <= length(part); i++) { - c = substr(part, i, 1) - if (quote != "") { - if (esc) { - esc = 0 - } else if (c == "\\") { - esc = 1 - } else if (c == quote) { - quote = "" - } - continue - } - if (c == "\"" || c == "'"'"'") { - quote = c - continue - } - if (c == "{" || c == "[") { - depth++ - } else if (c == "}" || c == "]") { - depth-- - } else if (c == ":" && depth == 0) { - colon = i - break - } - } - if (colon <= 0) { - return - } - key = clean_scalar(substr(part, 1, colon - 1)) - value = trim(substr(part, colon + 1)) - if (substr(value, 1, 1) == "{" && substr(value, length(value), 1) == "}") { - parse_map(value, prefix key ".") - } else { - set_field(prefix key, value) - } - } - function parse_map(s, prefix, i,c,depth,quote,esc,start,part) { - s = trim(s) - if (substr(s, 1, 1) == "{") { - s = substr(s, 2) - } - if (substr(s, length(s), 1) == "}") { - s = substr(s, 1, length(s) - 1) - } - depth = 0 - quote = "" - esc = 0 - start = 1 - for (i = 1; i <= length(s); i++) { - c = substr(s, i, 1) - if (quote != "") { - if (esc) { - esc = 0 - } else if (c == "\\") { - esc = 1 - } else if (c == quote) { - quote = "" - } - continue - } - if (c == "\"" || c == "'"'"'") { - quote = c - continue - } - if (c == "{" || c == "[") { - depth++ - } else if (c == "}" || c == "]") { - depth-- - } else if (c == "," && depth == 0) { - part = substr(s, start, i - start) - parse_pair(part, prefix) - start = i + 1 - } - } - part = substr(s, start) - parse_pair(part, prefix) - } - function leading_indent(s, i,c) { - for (i = 1; i <= length(s); i++) { - c = substr(s, i, 1) - if (c != " " && c != "\t") { - return i - 1 - } - } - return length(s) - } - function parse_block_line(line, indent,t,key,value,colon,fullkey) { - indent = leading_indent(line) - t = trim(line) - if (t == "") { - return - } - colon = index(t, ":") - if (colon <= 0) { - return - } - key = clean_scalar(substr(t, 1, colon - 1)) - value = trim(substr(t, colon + 1)) - - if (ctx != "" && indent <= ctx_indent) { - ctx = "" - ctx_indent = -1 - } - - if (value == "") { - if (key == "ws-opts" || key == "grpc-opts" || key == "reality-opts" || key == "obfs-opts" || key == "headers") { - if (ctx != "" && indent > ctx_indent) { - ctx = ctx "." key - } else { - ctx = key - } - ctx_indent = indent - } - return - } - - fullkey = key - if (ctx != "" && indent > ctx_indent) { - fullkey = ctx "." key - } - set_field(fullkey, value) - } - BEGIN { - in_proxies = 0 - clear_fields() - } - /^[^ \t]/ { - if (in_proxies && $0 !~ /^proxies:[ \t]*($|#)/ && $0 !~ /^-[ \t]*/) { - emit_record() - in_proxies = 0 - } - } - /^[ \t]*proxies:[ \t]*($|#)/ { - emit_record() - in_proxies = 1 - next - } - { - if (!in_proxies) { - next - } - line = $0 - if (line ~ /^[ \t]*-[ \t]*\{/) { - emit_record() - clear_fields() - sub(/^[ \t]*-[ \t]*/, "", line) - parse_map(line, "") - emit_record() - next - } - if (line ~ /^[ \t]*-[ \t]*/) { - emit_record() - clear_fields() - sub(/^[ \t]*-[ \t]*/, "", line) - if (trim(line) != "") { - parse_block_line(line) - } - next - } - if (field_count > 0) { - parse_block_line(line) - } - } - END { - emit_record() - } - ' "$input" -} - -subscription_clash_value() { - local record="$1" - local key="$2" - local rest value - - case "$record" in - *\"$key\":\"*) - rest="${record#*\"$key\":\"}" - value="${rest%%\"*}" - value="$(printf '%s' "$value" | sed 's/\\"/"/g;s/\\\\/\\/g')" - printf '%s\n' "$value" + format="$(subscription_json_utils_ucode json-file-field "$output" "format" "" 2>/dev/null)" + case "$format" in + clash-yaml) + message="Skipped $skipped invalid or unsupported Clash proxy entries" ;; *) - printf '\n' + message="Skipped $skipped invalid or unsupported subscription links" ;; esac -} - -subscription_parse_clash_record() { - local record="$1" - local type name server port tls skip_verify sni network ws_path ws_host grpc_service_name \ - reality_public_key reality_short_id alpn fingerprint password uuid cipher alter_id flow packet_encoding \ - obfs obfs_password username - - type="$(subscription_clash_value "$record" "type" | tr '[:upper:]' '[:lower:]')" - name="$(subscription_clash_value "$record" "name")" - server="$(subscription_clash_value "$record" "server")" - port="$(subscription_clash_value "$record" "port")" - tls="$(subscription_clash_value "$record" "tls")" - skip_verify="$(subscription_clash_value "$record" "skip-cert-verify")" - sni="$(subscription_clash_value "$record" "sni")" - [ -n "$sni" ] || sni="$(subscription_clash_value "$record" "servername")" - network="$(subscription_clash_value "$record" "network" | tr '[:upper:]' '[:lower:]')" - ws_path="$(subscription_clash_value "$record" "ws-opts.path")" - ws_host="$(subscription_clash_value "$record" "ws-opts.headers.Host")" - grpc_service_name="$(subscription_clash_value "$record" "grpc-opts.grpc-service-name")" - reality_public_key="$(subscription_clash_value "$record" "reality-opts.public-key")" - reality_short_id="$(subscription_clash_value "$record" "reality-opts.short-id")" - alpn="$(subscription_clash_value "$record" "alpn" | sed 's/^\[//;s/\]$//;s/[[:space:]]//g')" - fingerprint="$(subscription_clash_value "$record" "client-fingerprint")" - [ -n "$fingerprint" ] || fingerprint="$(subscription_clash_value "$record" "fingerprint")" - fingerprint="$(subscription_normalize_utls_fingerprint "$fingerprint")" - [ -n "$name" ] || name="$server:$port" - - [ -n "$type" ] && [ -n "$server" ] && [ -n "$port" ] || return 1 - - case "$type" in - ss | shadowsocks) - cipher="$(subscription_clash_value "$record" "cipher")" - password="$(subscription_clash_value "$record" "password")" - [ -n "$cipher" ] && [ -n "$password" ] || return 1 - [ "$cipher" = "ss" ] && return 1 - jq -cn --arg tag "$name" --arg server "$server" --arg port "$port" --arg method "$cipher" --arg password "$password" ' - {type:"shadowsocks", tag:$tag, server:$server, server_port:($port|tonumber), method:$method, password:$password}' - ;; - vmess) - uuid="$(subscription_clash_value "$record" "uuid")" - alter_id="$(subscription_clash_value "$record" "alterId")" - [ -n "$alter_id" ] || alter_id="$(subscription_clash_value "$record" "alter-id")" - cipher="$(subscription_clash_value "$record" "cipher")" - [ -n "$cipher" ] || cipher="auto" - [ -n "$uuid" ] || return 1 - jq -cn \ - --arg type "vmess" --arg tag "$name" --arg server "$server" --arg port "$port" \ - --arg uuid "$uuid" --arg alter_id "$alter_id" --arg security "$cipher" \ - --arg tls "$(subscription_bool_json "$tls")" \ - --arg skip_verify "$(subscription_bool_json "$skip_verify")" \ - --arg sni "$sni" --arg alpn "$alpn" --arg fingerprint "$fingerprint" \ - --arg network "$network" --arg ws_path "$ws_path" --arg ws_host "$ws_host" --arg grpc_service_name "$grpc_service_name" ' - { - type:$type, tag:$tag, server:$server, server_port:($port|tonumber), uuid:$uuid, security:$security - } - + (if $alter_id != "" then {alter_id:($alter_id|tonumber)} else {} end) - + (if $tls == "true" or $sni != "" or $fingerprint != "" then - {tls: ({enabled:true} - + (if $sni != "" then {server_name:$sni} else {} end) - + (if $skip_verify == "true" then {insecure:true} else {} end) - + (if $alpn != "" then {alpn:($alpn|split(","))} else {} end) - + (if $fingerprint != "" then {utls:{enabled:true,fingerprint:$fingerprint}} else {} end))} - else {} end) - + (if $network == "ws" then {transport:({type:"ws", path:(if $ws_path != "" then $ws_path else "/" end)} + (if $ws_host != "" then {headers:{Host:$ws_host}} else {} end))} - elif $network == "grpc" then {transport:({type:"grpc"} + (if $grpc_service_name != "" then {service_name:$grpc_service_name} else {} end))} - else {} end)' - ;; - vless) - uuid="$(subscription_clash_value "$record" "uuid")" - flow="$(subscription_clash_value "$record" "flow")" - packet_encoding="$(subscription_clash_value "$record" "packet-encoding")" - [ -n "$packet_encoding" ] || packet_encoding="$(subscription_clash_value "$record" "packetEncoding")" - packet_encoding="$(subscription_normalize_packet_encoding "$packet_encoding")" - [ -n "$uuid" ] || return 1 - subscription_vless_flow_is_supported "$flow" || return 1 - jq -cn \ - --arg tag "$name" --arg server "$server" --arg port "$port" --arg uuid "$uuid" \ - --arg flow "$flow" --arg packet_encoding "$packet_encoding" \ - --arg tls "$(subscription_bool_json "$tls")" \ - --arg skip_verify "$(subscription_bool_json "$skip_verify")" \ - --arg sni "$sni" --arg alpn "$alpn" --arg fingerprint "$fingerprint" \ - --arg reality_public_key "$reality_public_key" --arg reality_short_id "$reality_short_id" \ - --arg network "$network" --arg ws_path "$ws_path" --arg ws_host "$ws_host" --arg grpc_service_name "$grpc_service_name" ' - { - type:"vless", tag:$tag, server:$server, server_port:($port|tonumber), uuid:$uuid - } - + (if $flow != "" then {flow:$flow} else {} end) - + (if $packet_encoding != "" then {packet_encoding:$packet_encoding} else {} end) - + (if $tls == "true" or $sni != "" or $fingerprint != "" or $reality_public_key != "" then - {tls: ({enabled:true} - + (if $sni != "" then {server_name:$sni} else {} end) - + (if $skip_verify == "true" then {insecure:true} else {} end) - + (if $alpn != "" then {alpn:($alpn|split(","))} else {} end) - + (if $reality_public_key != "" then - {utls:{enabled:true,fingerprint:(if $fingerprint != "" then $fingerprint else "chrome" end)}} - elif $fingerprint != "" then - {utls:{enabled:true,fingerprint:$fingerprint}} - else {} end) - + (if $reality_public_key != "" then {reality:({enabled:true, public_key:$reality_public_key} + (if $reality_short_id != "" then {short_id:$reality_short_id} else {} end))} else {} end))} - else {} end) - + (if $network == "ws" then {transport:({type:"ws", path:(if $ws_path != "" then $ws_path else "/" end)} + (if $ws_host != "" then {headers:{Host:$ws_host}} else {} end))} - elif $network == "grpc" then {transport:({type:"grpc"} + (if $grpc_service_name != "" then {service_name:$grpc_service_name} else {} end))} - else {} end)' - ;; - trojan) - password="$(subscription_clash_value "$record" "password")" - [ -n "$password" ] || return 1 - jq -cn \ - --arg tag "$name" --arg server "$server" --arg port "$port" --arg password "$password" \ - --arg tls "$(subscription_bool_json "$tls")" \ - --arg skip_verify "$(subscription_bool_json "$skip_verify")" \ - --arg sni "$sni" --arg alpn "$alpn" --arg fingerprint "$fingerprint" \ - --arg reality_public_key "$reality_public_key" --arg reality_short_id "$reality_short_id" \ - --arg network "$network" --arg ws_path "$ws_path" --arg ws_host "$ws_host" --arg grpc_service_name "$grpc_service_name" ' - { - type:"trojan", tag:$tag, server:$server, server_port:($port|tonumber), password:$password - } - + {tls: ({enabled:true} - + (if $sni != "" then {server_name:$sni} else {} end) - + (if $skip_verify == "true" then {insecure:true} else {} end) - + (if $alpn != "" then {alpn:($alpn|split(","))} else {} end) - + (if $reality_public_key != "" then - {utls:{enabled:true,fingerprint:(if $fingerprint != "" then $fingerprint else "chrome" end)}} - elif $fingerprint != "" then - {utls:{enabled:true,fingerprint:$fingerprint}} - else {} end) - + (if $reality_public_key != "" then {reality:({enabled:true, public_key:$reality_public_key} + (if $reality_short_id != "" then {short_id:$reality_short_id} else {} end))} else {} end))} - + (if $network == "ws" then {transport:({type:"ws", path:(if $ws_path != "" then $ws_path else "/" end)} + (if $ws_host != "" then {headers:{Host:$ws_host}} else {} end))} - elif $network == "grpc" then {transport:({type:"grpc"} + (if $grpc_service_name != "" then {service_name:$grpc_service_name} else {} end))} - else {} end)' - ;; - hysteria2 | hy2) - password="$(subscription_clash_value "$record" "password")" - obfs="$(subscription_clash_value "$record" "obfs")" - obfs_password="$(subscription_clash_value "$record" "obfs-password")" - [ -n "$obfs_password" ] || obfs_password="$(subscription_clash_value "$record" "obfs-opts.password")" - [ -n "$password" ] || return 1 - jq -cn \ - --arg tag "$name" --arg server "$server" --arg port "$port" --arg password "$password" \ - --arg skip_verify "$(subscription_bool_json "$skip_verify")" \ - --arg sni "$sni" --arg alpn "$alpn" --arg obfs "$obfs" --arg obfs_password "$obfs_password" ' - { - type:"hysteria2", tag:$tag, server:$server, server_port:($port|tonumber), password:$password, - tls: ({enabled:true} - + (if $sni != "" then {server_name:$sni} else {} end) - + (if $skip_verify == "true" then {insecure:true} else {} end) - + (if $alpn != "" then {alpn:($alpn|split(","))} else {} end)) - } - + (if $obfs != "" and $obfs != "none" then {obfs:({type:$obfs} + (if $obfs_password != "" then {password:$obfs_password} else {} end))} else {} end)' - ;; - socks5 | socks) - username="$(subscription_clash_value "$record" "username")" - password="$(subscription_clash_value "$record" "password")" - jq -cn --arg tag "$name" --arg server "$server" --arg port "$port" --arg username "$username" --arg password "$password" ' - {type:"socks", tag:$tag, server:$server, server_port:($port|tonumber), version:"5"} - + (if $username != "" then {username:$username} else {} end) - + (if $password != "" then {password:$password} else {} end)' - ;; - *) - return 1 - ;; - esac -} - -subscription_normalize_clash_yaml_file() { - local input="$1" - local output="$2" - local records jsonl record added skipped - - if subscription_normalize_clash_yaml_file_lua "$input" "$output"; then - return 0 - fi - - return 1 - - records="$(mktemp)" || return 1 - jsonl="$(mktemp)" || { - rm -f "$records" - return 1 - } - - subscription_clash_yaml_records "$input" > "$records" - - added=0 - skipped=0 - while IFS= read -r record || [ -n "$record" ]; do - [ -n "$record" ] || continue - if subscription_parse_clash_record "$record" >> "$jsonl"; then - added=$((added + 1)) - else - skipped=$((skipped + 1)) - log "Skip unsupported or invalid Clash proxy entry" "warn" - fi - done < "$records" - - rm -f "$records" - - if [ "$added" -eq 0 ]; then - rm -f "$jsonl" - return 1 - fi - - jq -s --argjson skipped "$skipped" '{ - version: 1, - format: "clash-yaml", - skipped: $skipped, - outbounds: . - }' "$jsonl" > "$output" - rm -f "$jsonl" + log "$message" "warn" } subscription_normalize_content_file() { local input="$1" local output="$2" - local depth="${3:-0}" - local decoded_tmp - convert_crlf_to_lf "$input" - subscription_strip_metadata_preamble_file "$input" - - if jq -e . "$input" >/dev/null 2>&1; then - subscription_normalize_sing_box_json_file "$input" "$output" || return 1 + if subscription_parser_ucode normalize-content "$input" "$output"; then + subscription_log_normalized_skipped "$output" subscription_validate_normalized_file "$output" return $? fi - if subscription_content_is_clash_yaml "$input"; then - subscription_normalize_clash_yaml_file "$input" "$output" || return 1 - subscription_validate_normalized_file "$output" - return $? - fi - - if subscription_content_has_share_links "$input"; then - subscription_normalize_uri_list_file "$input" "$output" || return 1 - subscription_validate_normalized_file "$output" - return $? - fi - - if [ "$depth" -lt 1 ]; then - decoded_tmp="$(mktemp)" || return 1 - if subscription_try_base64_decode_file "$input" "$decoded_tmp"; then - if subscription_normalize_content_file "$decoded_tmp" "$output" $((depth + 1)); then - rm -f "$decoded_tmp" - return 0 - fi - fi - rm -f "$decoded_tmp" - fi - return 1 } diff --git a/podkop/files/usr/lib/subscription_parser.uc b/podkop/files/usr/lib/subscription_parser.uc new file mode 100644 index 00000000..07926e63 --- /dev/null +++ b/podkop/files/usr/lib/subscription_parser.uc @@ -0,0 +1,1445 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function trim(value) { + value = as_string(value); + let start = 0; + let end = length(value); + + while (start < end) { + let c = substr(value, start, 1); + if (c != " " && c != "\t" && c != "\r" && c != "\n") + break; + start++; + } + + while (end > start) { + let c = substr(value, end - 1, 1); + if (c != " " && c != "\t" && c != "\r" && c != "\n") + break; + end--; + } + + return substr(value, start, end - start); +} + +function first_non_ws_char(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) { + let c = substr(value, i, 1); + if (c != " " && c != "\t" && c != "\r" && c != "\n") + return c; + } + return ""; +} + +function starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function is_supported_share_link(line) { + return starts_with(line, "ss://") || + starts_with(line, "vmess://") || + starts_with(line, "vless://") || + starts_with(line, "trojan://") || + starts_with(line, "hysteria2://") || + starts_with(line, "hy2://") || + starts_with(line, "socks://") || + starts_with(line, "socks4://") || + starts_with(line, "socks4a://") || + starts_with(line, "socks5://"); +} + +function split_csv(value) { + let result = []; + for (let item in split(as_string(value), ",")) { + if (item != "") + push(result, item); + } + return result; +} + +function is_true(value) { + if (value == null || value == "") + return false; + let normalized = lc(as_string(value)); + return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "on"; +} + +function is_integer_string(value) { + if (type(value) != "string" || value == "") + return false; + for (let i = 0; i < length(value); i++) { + let code = ord(substr(value, i, 1)); + if (code < 48 || code > 57) + return false; + } + return true; +} + +function json_decode_text(text) { + try { + return json(as_string(text)); + } + catch (e) { + return null; + } +} + +function read_file(path) { + return fs.readfile(path); +} + +function read_json_file(path) { + let data = read_file(path); + return data == null ? null : json_decode_text(data); +} + +function write_json_file(path, value) { + return fs.writefile(path, sprintf("%J", value) + "\n"); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function base64_decode(value) { + value = as_string(value); + if (index(value, "\n") >= 0 || index(value, "\r") >= 0 || index(value, "\t") >= 0 || index(value, " ") >= 0) + value = replace(value, /\s+/g, ""); + if (index(value, "-") >= 0) + value = replace(value, /-/g, "+"); + if (index(value, "_") >= 0) + value = replace(value, /_/g, "/"); + if (value == "") + return null; + + let remainder = length(value) % 4; + if (remainder == 1) + return null; + if (remainder > 1) { + for (let i = 0; i < 4 - remainder; i++) + value += "="; + } + + return b64dec(value); +} + +function urldecode(value) { + value = as_string(value); + if (index(value, "%") < 0 && index(value, "+") < 0) + return value; + + if (index(value, "+") >= 0) + value = replace(value, /\+/g, " "); + if (index(value, "%") < 0) + return value; + return replace(value, /%([0-9A-Fa-f][0-9A-Fa-f])/g, function(all, hex_value) { + return chr(hex(hex_value)); + }); +} + +let fragment_prefix_decode_cache = {}; +let fragment_decode_cache = {}; +let query_parse_cache = {}; + +function urldecode_fragment(value) { + value = as_string(value); + if (index(value, "%") < 0 && index(value, "+") < 0) + return value; + if (value in fragment_decode_cache) + return fragment_decode_cache[value]; + + let marker = rindex(value, "%23"); + if (marker >= 0) { + let suffix = substr(value, marker + 3); + if (is_integer_string(suffix)) { + let prefix = substr(value, 0, marker + 3); + if (!(prefix in fragment_prefix_decode_cache)) + fragment_prefix_decode_cache[prefix] = urldecode(prefix); + let decoded = fragment_prefix_decode_cache[prefix] + suffix; + fragment_decode_cache[value] = decoded; + return decoded; + } + } + + let decoded = urldecode(value); + fragment_decode_cache[value] = decoded; + return decoded; +} + +function parse_query(query) { + let params = {}; + query = as_string(query); + if (query == "") + return params; + if (query in query_parse_cache) + return query_parse_cache[query]; + + for (let pair in split(query, "&")) { + if (pair == "") + continue; + let equals = index(pair, "="); + let key = equals >= 0 ? substr(pair, 0, equals) : pair; + let value = equals >= 0 ? substr(pair, equals + 1) : ""; + key = urldecode(key); + if (key != "") + params[key] = urldecode(value); + } + + query_parse_cache[query] = params; + return params; +} + +function parse_host_port(value) { + value = as_string(value); + if (length(value) > 0 && substr(value, length(value) - 1) == "/") + value = substr(value, 0, length(value) - 1); + if (starts_with(value, "[")) { + let m = match(value, /^\[([^\]]+)\]:(\d+)$/); + return m ? [m[1], int(m[2])] : ["", null]; + } + + let colon = rindex(value, ":"); + if (colon < 0) + return ["", null]; + + let host = substr(value, 0, colon); + let port = substr(value, colon + 1); + if (!is_integer_string(port)) + return ["", null]; + return [host, int(port)]; +} + +function parse_url(url) { + let scheme_pos = index(url, "://"); + if (scheme_pos <= 0) + return null; + + let scheme = lc(substr(url, 0, scheme_pos)); + let rest = substr(url, scheme_pos + 3); + let fragment = ""; + let hash_pos = index(rest, "#"); + if (hash_pos >= 0) { + fragment = urldecode_fragment(substr(rest, hash_pos + 1)); + rest = substr(rest, 0, hash_pos); + } + + let query = ""; + let question_pos = index(rest, "?"); + if (question_pos >= 0) { + query = substr(rest, question_pos + 1); + rest = substr(rest, 0, question_pos); + } + + let slash_pos = index(rest, "/"); + let authority = slash_pos >= 0 ? substr(rest, 0, slash_pos) : rest; + let userinfo = ""; + let hostport = authority; + let at_pos = rindex(authority, "@"); + if (at_pos >= 0) { + userinfo = urldecode(substr(authority, 0, at_pos)); + hostport = substr(authority, at_pos + 1); + } + + let host_port = parse_host_port(hostport); + return { + scheme: scheme, + userinfo: userinfo, + host: host_port[0] || "", + port: host_port[1], + query: parse_query(query), + fragment: fragment + }; +} + +function normalize_utls_fingerprint(value) { + if (value == null || value == "") + return ""; + + let allowed = { + "": true, + chrome: true, + firefox: true, + edge: true, + safari: true, + "360": true, + ios: true, + android: true, + randomized: true, + randomizedalpn: true, + randomizednoalpn: true + }; + + value = as_string(value); + return allowed[value] ? value : "chrome"; +} + +function add_tls(url, security, default_tls) { + let query = object_or_empty(url.query); + let sni = query.sni || query.peer || ""; + let insecure = query.allowInsecure || query.insecure || ""; + let alpn = query.alpn || ""; + let transport = query.type || ""; + if (transport == "xhttp" && alpn == "") + alpn = "h2,http/1.1"; + + let fingerprint = normalize_utls_fingerprint(query.fp || ""); + let public_key = query.pbk || ""; + let short_id = query.sid || ""; + + if (security == "reality" && public_key == "") + return [null, false]; + if ((security == "reality" || public_key != "") && fingerprint == "") + fingerprint = "chrome"; + + let tls_enabled = false; + if (security == "tls" || security == "xtls" || security == "reality") + tls_enabled = true; + else if (security == null || security == "") + tls_enabled = default_tls || sni != "" || alpn != "" || fingerprint != "" || public_key != ""; + + if (!tls_enabled) + return [null, true]; + + let tls = { enabled: true }; + if (sni != "") + tls.server_name = sni; + if (is_true(insecure)) + tls.insecure = true; + if (alpn != "") + tls.alpn = split_csv(alpn); + if (fingerprint != "") + tls.utls = { enabled: true, fingerprint: fingerprint }; + if (security == "reality" || public_key != "") { + tls.reality = { enabled: true }; + if (public_key != "") + tls.reality.public_key = public_key; + if (short_id != "") + tls.reality.short_id = short_id; + } + + return [tls, true]; +} + +function add_transport(url) { + let query = object_or_empty(url.query); + let transport = query.type || ""; + if (transport == "" || transport == "tcp") + return null; + + let path = query.path || ""; + let host = query.host || ""; + let early_data = query.ed || ""; + let grpc_service_name = query.serviceName || ""; + let xhttp_mode = query.mode || "auto"; + let sni = query.sni || ""; + + if (xhttp_mode != "auto" && xhttp_mode != "packet-up" && xhttp_mode != "stream-up" && xhttp_mode != "stream-one") + xhttp_mode = "auto"; + + if (transport == "ws") { + let result = { type: "ws", path: path != "" ? path : "/" }; + if (host != "") + result.headers = { Host: host }; + if (is_integer_string(early_data)) + result.max_early_data = int(early_data); + return result; + } + if (transport == "grpc") { + let result = { type: "grpc" }; + if (grpc_service_name != "") + result.service_name = grpc_service_name; + return result; + } + if (transport == "http" || transport == "h2") { + let result = { type: "http" }; + if (path != "") + result.path = path; + if (host != "") + result.host = split_csv(host); + return result; + } + if (transport == "httpupgrade") { + let result = { type: "httpupgrade" }; + if (path != "") + result.path = path; + if (host != "") + result.host = host; + return result; + } + if (transport == "xhttp") { + if (path == "") + path = "/"; + if (host == "") + host = sni; + let result = { + type: "xhttp", + mode: xhttp_mode, + path: path, + x_padding_bytes: "100-1000", + no_grpc_header: false, + sc_max_each_post_bytes: 1000000, + sc_min_posts_interval_ms: 30 + }; + if (host != "") + result.host = host; + return result; + } + + return null; +} + +function valid_port(port) { + let port_type = type(port); + return (port_type == "int" || port_type == "double") && port >= 1 && port <= 65535 && int(port) == port; +} + +function process_vless(raw, url) { + if (url.host == "" || !valid_port(url.port) || url.userinfo == "") + return null; + + let flow = url.query.flow || ""; + if (flow != "" && flow != "xtls-rprx-vision") + return null; + + let packet_encoding = url.query.packetEncoding || ""; + if (packet_encoding != "xudp" && packet_encoding != "packetaddr") + packet_encoding = ""; + + let outbound = { + type: "vless", + tag: url.fragment != "" ? url.fragment : (url.host + ":" + url.port), + share_link: raw, + server: url.host, + server_port: url.port, + uuid: url.userinfo + }; + if (flow != "") + outbound.flow = flow; + if (packet_encoding != "") + outbound.packet_encoding = packet_encoding; + + let tls_result = add_tls(url, url.query.security || "", false); + if (!tls_result[1]) + return null; + if (tls_result[0]) + outbound.tls = tls_result[0]; + + let transport = add_transport(url); + if (transport) + outbound.transport = transport; + return outbound; +} + +function process_trojan(raw, url) { + if (url.host == "" || !valid_port(url.port) || url.userinfo == "") + return null; + + let outbound = { + type: "trojan", + tag: url.fragment != "" ? url.fragment : (url.host + ":" + url.port), + share_link: raw, + server: url.host, + server_port: url.port, + password: url.userinfo + }; + + let tls_result = add_tls(url, url.query.security || "", true); + if (!tls_result[1]) + return null; + if (tls_result[0]) + outbound.tls = tls_result[0]; + + let transport = add_transport(url); + if (transport) + outbound.transport = transport; + return outbound; +} + +function process_socks(raw, url) { + if (url.host == "" || !valid_port(url.port)) + return null; + + let username = "", password = ""; + if (url.userinfo != "") { + let colon = index(url.userinfo, ":"); + if (colon >= 0) { + username = urldecode(substr(url.userinfo, 0, colon)); + password = urldecode(substr(url.userinfo, colon + 1)); + if (username == password) + password = ""; + } + else { + username = urldecode(url.userinfo); + } + } + + let outbound = { + type: "socks", + tag: url.fragment != "" ? url.fragment : (url.host + ":" + url.port), + share_link: raw, + server: url.host, + server_port: url.port + }; + let version = substr(url.scheme, 5); + if (version != "") + outbound.version = version; + if (username != "") + outbound.username = username; + if (password != "") + outbound.password = password; + return outbound; +} + +function is_shadowsocks_userinfo_format(value) { + if (type(value) != "string") + return false; + let first = index(value, ":"); + if (first <= 0 || first >= length(value) - 1) + return false; + let rest = substr(value, first + 1); + let second = index(rest, ":"); + return second < 0 || index(substr(rest, second + 1), ":") < 0; +} + +function process_shadowsocks(raw) { + if (!starts_with(raw, "ss://")) + return null; + + let body = substr(raw, 5); + let fragment = ""; + let hash_pos = index(body, "#"); + if (hash_pos >= 0) { + fragment = urldecode(substr(body, hash_pos + 1)); + body = substr(body, 0, hash_pos); + } + + let query = ""; + let question_pos = index(body, "?"); + if (question_pos >= 0) { + query = substr(body, question_pos + 1); + body = substr(body, 0, question_pos); + } + + let userinfo, hostport; + let at_pos = rindex(body, "@"); + if (at_pos >= 0) { + userinfo = substr(body, 0, at_pos); + hostport = substr(body, at_pos + 1); + } + else { + let decoded = base64_decode(body); + if (!decoded) + return null; + at_pos = rindex(decoded, "@"); + if (at_pos < 0) + return null; + userinfo = substr(decoded, 0, at_pos); + hostport = substr(decoded, at_pos + 1); + } + + userinfo = urldecode(userinfo); + if (!is_shadowsocks_userinfo_format(userinfo)) { + let decoded = base64_decode(userinfo); + if (!decoded) + return null; + userinfo = decoded; + } + + let cred_colon = index(userinfo, ":"); + let host_port = parse_host_port(hostport); + if (cred_colon <= 0) + return null; + let method = substr(userinfo, 0, cred_colon); + let password = substr(userinfo, cred_colon + 1); + if (method == "" || method == "ss" || password == "" || host_port[0] == "" || !valid_port(host_port[1])) + return null; + + let params = parse_query(query); + let plugin = params.plugin || ""; + let plugin_opts = params["plugin-opts"] || ""; + if (plugin != "" && plugin_opts == "") { + let parsed_plugin = match(plugin, /^([^;]+);(.*)$/); + if (parsed_plugin) { + plugin = parsed_plugin[1]; + plugin_opts = parsed_plugin[2]; + } + } + + let outbound = { + type: "shadowsocks", + tag: fragment != "" ? fragment : (host_port[0] + ":" + host_port[1]), + share_link: raw, + server: host_port[0], + server_port: host_port[1], + method: method, + password: password + }; + if (plugin != "") + outbound.plugin = plugin; + if (plugin_opts != "") + outbound.plugin_opts = plugin_opts; + return outbound; +} + +function process_hysteria2(raw, url) { + if (url.host == "" || !valid_port(url.port) || url.userinfo == "") + return null; + + let password = url.userinfo; + let colon = index(password, ":"); + if (colon >= 0) + password = substr(password, colon + 1); + if (password == "") + return null; + + let tls = { enabled: true }; + if ((url.query.sni || "") != "") + tls.server_name = url.query.sni; + if (is_true(url.query.insecure)) + tls.insecure = true; + if ((url.query.alpn || "") != "") + tls.alpn = split_csv(url.query.alpn); + + let outbound = { + type: "hysteria2", + tag: url.fragment != "" ? url.fragment : (url.host + ":" + url.port), + share_link: raw, + server: url.host, + server_port: url.port, + password: password, + tls: tls + }; + if ((url.query.network || "") != "") + outbound.network = url.query.network; + if (is_integer_string(url.query.upmbps || "")) + outbound.up_mbps = int(url.query.upmbps); + if (is_integer_string(url.query.downmbps || "")) + outbound.down_mbps = int(url.query.downmbps); + if ((url.query.obfs || "") != "" && url.query.obfs != "none") { + outbound.obfs = { type: url.query.obfs }; + if ((url.query["obfs-password"] || "") != "") + outbound.obfs.password = url.query["obfs-password"]; + } + return outbound; +} + +function string_value(value) { + return value == null ? "" : as_string(value); +} + +function process_vmess_json(raw, decoded) { + let vmess = json_decode_text(decoded); + if (type(vmess) != "object") + return null; + + let server = string_value(vmess.add); + let port = int(vmess.port || 0); + let uuid = string_value(vmess.id); + if (server == "" || !valid_port(port) || uuid == "") + return null; + + let outbound = { + type: "vmess", + tag: string_value(vmess.ps) != "" ? string_value(vmess.ps) : (server + ":" + port), + share_link: raw, + server: server, + server_port: port, + uuid: uuid, + security: string_value(vmess.scy) != "" ? string_value(vmess.scy) : "auto" + }; + + let alter_id = int(vmess.aid || 0); + if (as_string(vmess.aid) != "") + outbound.alter_id = alter_id; + + if (vmess.tls === true || vmess.tls == "tls" || vmess.tls == "true") { + let fingerprint = normalize_utls_fingerprint(string_value(vmess.fp)); + let tls = { enabled: true }; + if (string_value(vmess.sni) != "") + tls.server_name = string_value(vmess.sni); + if (string_value(vmess.alpn) != "") + tls.alpn = split_csv(string_value(vmess.alpn)); + if (fingerprint != "") + tls.utls = { enabled: true, fingerprint: fingerprint }; + outbound.tls = tls; + } + + let network = string_value(vmess.net); + if (network == "ws") { + outbound.transport = { + type: "ws", + path: string_value(vmess.path) != "" ? string_value(vmess.path) : "/" + }; + if (string_value(vmess.host) != "") + outbound.transport.headers = { Host: string_value(vmess.host) }; + } + else if (network == "grpc") { + outbound.transport = { type: "grpc" }; + if (string_value(vmess.path) != "") + outbound.transport.service_name = string_value(vmess.path); + } + else if (network == "http" || network == "h2") { + outbound.transport = { type: "http" }; + if (string_value(vmess.path) != "") + outbound.transport.path = string_value(vmess.path); + if (string_value(vmess.host) != "") + outbound.transport.host = split_csv(string_value(vmess.host)); + } + + return outbound; +} + +function process_vmess(raw) { + if (!starts_with(raw, "vmess://")) + return null; + + let encoded = substr(raw, 8); + let hash_pos = index(encoded, "#"); + if (hash_pos >= 0) + encoded = substr(encoded, 0, hash_pos); + let decoded = base64_decode(encoded); + if (!decoded) + return null; + if (index(decoded, "\r") >= 0 || index(decoded, "\n") >= 0) + decoded = replace(decoded, /[\r\n]/g, ""); + let trimmed = trim(decoded); + if (substr(trimmed, 0, 1) != "{" || substr(trimmed, length(trimmed) - 1) != "}") + return null; + return process_vmess_json(raw, trimmed); +} + +function parse_share_link(line) { + if (starts_with(line, "vmess://")) + return process_vmess(line); + if (starts_with(line, "ss://")) + return process_shadowsocks(line); + + let url = parse_url(line); + if (!url) + return null; + + if (url.scheme == "vless") + return process_vless(line, url); + if (url.scheme == "trojan") + return process_trojan(line, url); + if (url.scheme == "hysteria2" || url.scheme == "hy2") + return process_hysteria2(line, url); + if (match(url.scheme, /^socks/)) + return process_socks(line, url); + return null; +} + +function clean_scalar(value) { + value = trim(value); + let first = substr(value, 0, 1); + let last = substr(value, length(value) - 1); + if (length(value) >= 2 && ((first == "\"" && last == "\"") || (first == "'" && last == "'"))) + return substr(value, 1, length(value) - 2); + return value; +} + +function leading_indent(value) { + let m = match(as_string(value), /^[ \t]*/); + return m ? length(m[0]) : 0; +} + +function find_top_level_colon(value) { + let depth = 0, quote = "", escaped = false; + for (let i = 0; i < length(value); i++) { + let char = substr(value, i, 1); + if (quote != "") { + if (escaped) + escaped = false; + else if (char == "\\") + escaped = true; + else if (char == quote) + quote = ""; + } + else if (char == "\"" || char == "'") + quote = char; + else if (char == "{" || char == "[") + depth++; + else if (char == "}" || char == "]") + depth--; + else if (char == ":" && depth == 0) + return i; + } + return null; +} + +function split_top_level(value, separator) { + let result = [], depth = 0, quote = "", escaped = false, start = 0; + for (let i = 0; i < length(value); i++) { + let char = substr(value, i, 1); + if (quote != "") { + if (escaped) + escaped = false; + else if (char == "\\") + escaped = true; + else if (char == quote) + quote = ""; + } + else if (char == "\"" || char == "'") + quote = char; + else if (char == "{" || char == "[") + depth++; + else if (char == "}" || char == "]") + depth--; + else if (char == separator && depth == 0) { + push(result, substr(value, start, i - start)); + start = i + 1; + } + } + push(result, substr(value, start)); + return result; +} + +function set_record_field(record, key, value) { + key = clean_scalar(key); + value = clean_scalar(value); + if (key == "") + return; + if (record.fields[key] == null) + push(record.keys, key); + record.fields[key] = value; +} + +function parse_clash_pair(record, part, prefix) { + part = trim(part); + let colon = find_top_level_colon(part); + if (colon == null) + return; + let key = clean_scalar(substr(part, 0, colon)); + let value = trim(substr(part, colon + 1)); + if (substr(value, 0, 1) == "{" && substr(value, length(value) - 1) == "}") + parse_clash_map(record, value, prefix + key + "."); + else + set_record_field(record, prefix + key, value); +} + +function parse_clash_map(record, value, prefix) { + value = trim(value); + if (substr(value, 0, 1) == "{") + value = substr(value, 1); + if (substr(value, length(value) - 1) == "}") + value = substr(value, 0, length(value) - 1); + + for (let part in split_top_level(value, ",")) + parse_clash_pair(record, part, prefix); +} + +function empty_clash_record() { + return { + fields: {}, + keys: [], + context: "", + context_indent: -1 + }; +} + +function clash_record_has_fields(record) { + return length(record.keys) > 0; +} + +function emit_clash_record(records, record) { + if (!clash_record_has_fields(record)) + return empty_clash_record(); + + let item = {}; + for (let key in record.keys) { + if (record.fields[key] != null) + item[key] = record.fields[key]; + } + push(records, item); + return empty_clash_record(); +} + +let clash_nested_keys = { + "ws-opts": true, + "grpc-opts": true, + "reality-opts": true, + "obfs-opts": true, + headers: true +}; + +function parse_clash_block_line(record, line) { + let indent = leading_indent(line); + let text = trim(line); + if (text == "") + return; + + let colon = index(text, ":"); + if (colon < 0) + return; + + let key = clean_scalar(substr(text, 0, colon)); + let value = trim(substr(text, colon + 1)); + + if (record.context != "" && indent <= record.context_indent) { + record.context = ""; + record.context_indent = -1; + } + + if (value == "") { + if (clash_nested_keys[key]) { + if (record.context != "" && indent > record.context_indent) + record.context = record.context + "." + key; + else + record.context = key; + record.context_indent = indent; + } + return; + } + + let full_key = key; + if (record.context != "" && indent > record.context_indent) + full_key = record.context + "." + key; + set_record_field(record, full_key, value); +} + +function is_clash_proxies_header(line) { + let text = trim(line); + return text == "proxies:" || match(text, /^proxies:\s*#/); +} + +function clash_yaml_records(input_file) { + let data = read_file(input_file); + if (data == null) + return []; + + let records = []; + let record = empty_clash_record(); + let in_proxies = false; + + for (let line in split(data, "\n")) { + line = replace(line, /\r$/g, ""); + let proxies_header = is_clash_proxies_header(line); + + if (in_proxies && match(line, /^[^ \t]/) && !proxies_header && !match(line, /^-[ \t]/)) { + record = emit_clash_record(records, record); + in_proxies = false; + } + + if (proxies_header) { + record = emit_clash_record(records, record); + in_proxies = true; + } + else if (in_proxies) { + if (match(line, /^[ \t]*-[ \t]*\{/)) { + record = emit_clash_record(records, record); + record = empty_clash_record(); + let map_value = replace(line, /^[ \t]*-[ \t]*/g, ""); + parse_clash_map(record, map_value, ""); + record = emit_clash_record(records, record); + } + else if (match(line, /^[ \t]*-[ \t]*/)) { + record = emit_clash_record(records, record); + record = empty_clash_record(); + let rest = replace(line, /^[ \t]*-[ \t]*/g, ""); + if (trim(rest) != "") + parse_clash_block_line(record, rest); + } + else if (clash_record_has_fields(record)) { + parse_clash_block_line(record, line); + } + } + } + + emit_clash_record(records, record); + return records; +} + +function normalize_packet_encoding(value) { + value = as_string(value); + return value == "xudp" || value == "packetaddr" ? value : ""; +} + +function clash_vless_flow_supported(flow) { + return flow == null || flow == "" || flow == "xtls-rprx-vision"; +} + +function normalized_clash_alpn(value) { + return replace(replace(replace(as_string(value), /^\[/g, ""), /\]$/g, ""), /\s+/g, ""); +} + +function add_clash_tls(outbound, options) { + let enabled = options.always || is_true(options.tls) || options.sni != "" || + options.alpn != "" || options.fingerprint != "" || options.reality_public_key != ""; + if (!enabled) + return; + + let tls = { enabled: true }; + if (options.sni != "") + tls.server_name = options.sni; + if (is_true(options.skip_verify)) + tls.insecure = true; + if (options.alpn != "") + tls.alpn = split_csv(options.alpn); + + if (options.reality_public_key != "") { + tls.utls = { + enabled: true, + fingerprint: options.fingerprint != "" ? options.fingerprint : "chrome" + }; + tls.reality = { enabled: true, public_key: options.reality_public_key }; + if (options.reality_short_id != "") + tls.reality.short_id = options.reality_short_id; + } + else if (options.fingerprint != "") { + tls.utls = { enabled: true, fingerprint: options.fingerprint }; + } + + outbound.tls = tls; +} + +function add_clash_transport(outbound, options) { + if (options.network == "ws") { + outbound.transport = { + type: "ws", + path: options.ws_path != "" ? options.ws_path : "/" + }; + if (options.ws_host != "") + outbound.transport.headers = { Host: options.ws_host }; + } + else if (options.network == "grpc") { + outbound.transport = { type: "grpc" }; + if (options.grpc_service_name != "") + outbound.transport.service_name = options.grpc_service_name; + } +} + +function parse_clash_record(record) { + let proxy_type = lc(as_string(record.type)); + let name = as_string(record.name); + let server = as_string(record.server); + let port = int(record.port || 0); + if (name == "") + name = server + ":" + as_string(record.port); + if (proxy_type == "" || server == "" || !valid_port(port)) + return null; + + let options = { + tls: as_string(record.tls), + skip_verify: as_string(record["skip-cert-verify"]), + sni: as_string(record.sni || record.servername), + network: lc(as_string(record.network)), + ws_path: as_string(record["ws-opts.path"]), + ws_host: as_string(record["ws-opts.headers.Host"]), + grpc_service_name: as_string(record["grpc-opts.grpc-service-name"]), + reality_public_key: as_string(record["reality-opts.public-key"]), + reality_short_id: as_string(record["reality-opts.short-id"]), + alpn: normalized_clash_alpn(record.alpn || ""), + fingerprint: normalize_utls_fingerprint(as_string(record["client-fingerprint"] || record.fingerprint)) + }; + + if (proxy_type == "ss" || proxy_type == "shadowsocks") { + let method = as_string(record.cipher); + let password = as_string(record.password); + if (method == "" || method == "ss" || password == "") + return null; + return { type: "shadowsocks", tag: name, server: server, server_port: port, method: method, password: password }; + } + if (proxy_type == "vmess") { + let uuid = as_string(record.uuid); + if (uuid == "") + return null; + let outbound = { + type: "vmess", + tag: name, + server: server, + server_port: port, + uuid: uuid, + security: as_string(record.cipher) != "" ? as_string(record.cipher) : "auto" + }; + if (as_string(record.alterId || record["alter-id"]) != "") + outbound.alter_id = int(record.alterId || record["alter-id"]); + add_clash_tls(outbound, options); + add_clash_transport(outbound, options); + return outbound; + } + if (proxy_type == "vless") { + let uuid = as_string(record.uuid); + let flow = as_string(record.flow); + let packet_encoding = normalize_packet_encoding(record["packet-encoding"] || record.packetEncoding || ""); + if (uuid == "" || !clash_vless_flow_supported(flow)) + return null; + let outbound = { type: "vless", tag: name, server: server, server_port: port, uuid: uuid }; + if (flow != "") + outbound.flow = flow; + if (packet_encoding != "") + outbound.packet_encoding = packet_encoding; + add_clash_tls(outbound, options); + add_clash_transport(outbound, options); + return outbound; + } + if (proxy_type == "trojan") { + let password = as_string(record.password); + if (password == "") + return null; + let outbound = { type: "trojan", tag: name, server: server, server_port: port, password: password }; + options.always = true; + add_clash_tls(outbound, options); + add_clash_transport(outbound, options); + return outbound; + } + if (proxy_type == "hysteria2" || proxy_type == "hy2") { + let password = as_string(record.password); + if (password == "") + return null; + let outbound = { type: "hysteria2", tag: name, server: server, server_port: port, password: password, tls: { enabled: true } }; + if (options.sni != "") + outbound.tls.server_name = options.sni; + if (is_true(options.skip_verify)) + outbound.tls.insecure = true; + if (options.alpn != "") + outbound.tls.alpn = split_csv(options.alpn); + let obfs = as_string(record.obfs); + let obfs_password = as_string(record["obfs-password"] || record["obfs-opts.password"]); + if (obfs != "" && obfs != "none") { + outbound.obfs = { type: obfs }; + if (obfs_password != "") + outbound.obfs.password = obfs_password; + } + return outbound; + } + if (proxy_type == "socks5" || proxy_type == "socks") { + let outbound = { type: "socks", tag: name, server: server, server_port: port, version: "5" }; + if (as_string(record.username) != "") + outbound.username = as_string(record.username); + if (as_string(record.password) != "") + outbound.password = as_string(record.password); + return outbound; + } + + return null; +} + +function normalize_clash_yaml(input_file, output_file) { + let outbounds = []; + let skipped = 0; + + for (let record in clash_yaml_records(input_file)) { + let outbound = parse_clash_record(record); + if (outbound) + push(outbounds, outbound); + else + skipped++; + } + + if (length(outbounds) == 0) { + fs.unlink(output_file); + return false; + } + + return write_json_file(output_file, { + version: 1, + format: "clash-yaml", + skipped: skipped, + outbounds: outbounds + }); +} + +function normalize_uri_list_data(data, output_file) { + data = as_string(data); + if (index(data, "\r") >= 0) + data = replace(data, /\r/g, ""); + + let output = fs.open(output_file, "w"); + if (!output) + return false; + + let added = 0; + let skipped = 0; + output.write("{\"version\":1,\"format\":\"uri-list\",\"outbounds\":["); + + for (let line in split(data, "\n")) { + if (index(line, "\r") >= 0) + line = replace(line, /\r/g, ""); + line = trim(line); + if (line != "" && !starts_with(line, "#")) { + let outbound = parse_share_link(line); + if (outbound) { + if (added > 0) + output.write(","); + output.write(sprintf("%J", outbound)); + added++; + } + else { + skipped++; + } + } + } + + output.write("],\"skipped\":" + skipped + "}\n"); + output.close(); + + if (added == 0) { + fs.unlink(output_file); + return false; + } + return true; +} + +function normalize_uri_list(input_file, output_file) { + return normalize_uri_list_file(input_file, output_file, false); +} + +let metadata_allowed_keys = { + "profile-title": true, + "subscription-userinfo": true, + "profile-web-page-url": true, + "support-url": true, + "announce": true, + "announce-url": true, + "subscription-refill-date": true, + "content-disposition": true +}; + +function is_metadata_preamble_line(line) { + let trimmed = trim(line); + let m = match(trimmed, /^(#|\/\/)[ \t]*([A-Za-z0-9][A-Za-z0-9_-]*)[ \t]*:/); + return m && metadata_allowed_keys[lc(m[2])]; +} + +function normalize_uri_list_stream(input, output_file, strip_metadata) { + let output = fs.open(output_file, "w"); + if (!output) + return false; + + let added = 0; + let skipped = 0; + let line_no = 0; + output.write("{\"version\":1,\"format\":\"uri-list\",\"outbounds\":["); + + while (true) { + let line = input.read("line"); + if (line == null) + break; + line_no++; + line = trim(line); + if (strip_metadata && line_no <= 20 && is_metadata_preamble_line(line)) + continue; + if (line != "" && !starts_with(line, "#")) { + let outbound = parse_share_link(line); + if (outbound) { + if (added > 0) + output.write(","); + output.write(sprintf("%J", outbound)); + added++; + } + else { + skipped++; + } + } + } + + output.write("],\"skipped\":" + skipped + "}\n"); + output.close(); + + if (added == 0) { + fs.unlink(output_file); + return false; + } + return true; +} + +function file_looks_like_uri_list(input_file) { + let input = fs.open(input_file, "r"); + if (!input) + return false; + + let line_no = 0; + while (line_no < 200) { + let line = input.read("line"); + if (line == null) + break; + line_no++; + line = trim(line); + if (line == "" || starts_with(line, "#") || is_metadata_preamble_line(line)) + continue; + if (is_supported_share_link(line)) { + input.close(); + return true; + } + if (substr(line, 0, 1) == "{" || substr(line, 0, 1) == "[" || index(line, "proxies:") >= 0) { + input.close(); + return false; + } + } + + input.close(); + return false; +} + +function normalize_uri_list_file(input_file, output_file, strip_metadata) { + let input = fs.open(input_file, "r"); + if (!input) + return false; + let ok = normalize_uri_list_stream(input, output_file, strip_metadata); + input.close(); + return ok; +} + +function strip_metadata_preamble_data(data) { + data = as_string(data); + let offset = 0; + let changed = false; + + for (let line_no = 1; line_no <= 20 && offset <= length(data); line_no++) { + let rest = substr(data, offset); + let newline_pos = index(rest, "\n"); + let line = newline_pos >= 0 ? substr(rest, 0, newline_pos) : rest; + if (is_metadata_preamble_line(line)) { + changed = true; + break; + } + if (newline_pos < 0) + break; + offset += newline_pos + 1; + } + + if (!changed) + return data; + + let result = []; + let line_no = 0; + + for (let line in split(data, "\n")) { + line_no++; + let text = replace(line, /\r$/g, ""); + if (line_no <= 20) { + if (is_metadata_preamble_line(text)) + continue; + } + push(result, text); + } + + return join("\n", result); +} + +function content_has_share_links(data) { + for (let line in split(data, "\n")) { + if (match(line, /^[ \t]*(ss|vmess|vless|trojan|hysteria2|hy2|socks|socks4|socks4a|socks5):\/\//)) + return true; + } + return false; +} + +function content_is_clash_yaml(data) { + for (let line in split(data, "\n")) { + if (match(line, /^[ \t]*proxies:[ \t]*(#.*)?$/)) + return true; + } + return false; +} + +function normalize_sing_box_json_value(value, output_file) { + let candidates = []; + if (type(value) == "object" && type(value.outbounds) == "array") + candidates = value.outbounds; + else if (type(value) == "array") + candidates = value; + else if (type(value) == "object" && type(value.type) == "string") + candidates = [value]; + + let outbounds = []; + for (let outbound in candidates) { + if (type(outbound) == "object") + push(outbounds, outbound); + } + + if (length(outbounds) == 0) { + fs.unlink(output_file); + return false; + } + + return write_json_file(output_file, { + version: 1, + format: "sing-box-json", + outbounds: outbounds + }); +} + +function temp_path(prefix) { + let stamp = clock(); + return sprintf("/tmp/%s.%d.%d", prefix, stamp[0], stamp[1]); +} + +function normalize_content_data(data, output_file, depth) { + data = as_string(data); + if (index(data, "\r") >= 0) + data = replace(data, /\r/g, ""); + data = strip_metadata_preamble_data(data); + + let first = first_non_ws_char(data); + if (first == "{" || first == "[") { + let decoded_json = json_decode_text(data); + if (decoded_json != null) + return normalize_sing_box_json_value(decoded_json, output_file); + } + + if (index(data, "proxies:") >= 0 && content_is_clash_yaml(data)) { + let tmp = temp_path("podkop-subscription-clash"); + if (!fs.writefile(tmp, data)) + return false; + let ok = normalize_clash_yaml(tmp, output_file); + fs.unlink(tmp); + return ok; + } + + if (index(data, "://") >= 0) { + if (normalize_uri_list_data(data, output_file)) + return true; + } + + if (depth < 1) { + let compact = replace(data, /[\r\n\t ]/g, ""); + let decoded = base64_decode(compact); + if (decoded != null && decoded != "") + return normalize_content_data(decoded, output_file, depth + 1); + } + + return false; +} + +function normalize_content_file(input_file, output_file) { + if (file_looks_like_uri_list(input_file)) + return normalize_uri_list_file(input_file, output_file, true); + + let data = read_file(input_file); + return data == null ? false : normalize_content_data(data, output_file, 0); +} + +let mode = ARGV[0]; +let ok = false; + +if (mode == "normalize-uri-list") + ok = normalize_uri_list(ARGV[1], ARGV[2]); +else if (mode == "normalize-clash-yaml") + ok = normalize_clash_yaml(ARGV[1], ARGV[2]); +else if (mode == "normalize-content") + ok = normalize_content_file(ARGV[1], ARGV[2]); +else if (ARGV[0] && ARGV[1]) + ok = normalize_uri_list(ARGV[0], ARGV[1]); +else { + warn("Usage: subscription_parser.uc normalize-uri-list \n"); + warn(" subscription_parser.uc normalize-clash-yaml \n"); + warn(" subscription_parser.uc normalize-content \n"); + exit(2); +} + +if (!ok) + exit(1); diff --git a/podkop/files/usr/lib/updater.sh b/podkop/files/usr/lib/updater.sh index 01232252..577f3e9d 100644 --- a/podkop/files/usr/lib/updater.sh +++ b/podkop/files/usr/lib/updater.sh @@ -42,6 +42,21 @@ updates_log() { log "Updates: $message" "$level" } +updates_json_file_get_default() { + local json_file="$1" + local key="$2" + local fallback="$3" + + json_utils_ucode json-file-field "$json_file" "$key" "$fallback" 2>/dev/null +} + +updates_json_file_running_is() { + local json_file="$1" + local expected="$2" + + json_utils_ucode job-running-is "$json_file" "$expected" >/dev/null 2>&1 +} + updates_init_tmp_dir() { [ -n "$UPDATES_TMP_DIR" ] && return 0 @@ -103,25 +118,15 @@ updates_json_response() { local changed="${7:-0}" local status="${8:-}" - jq -cn \ - --argjson success "$success" \ - --arg component "$component" \ - --arg action "$action" \ - --arg message "$message" \ - --arg current_version "$current_version" \ - --arg latest_version "$latest_version" \ - --argjson changed "$changed" \ - --arg status "$status" \ - '{ - success: $success, - component: $component, - action: $action, - message: $message, - current_version: $current_version, - latest_version: $latest_version, - changed: $changed, - status: $status - }' + json_utils_ucode object-json \ + success b "$success" \ + component s "$component" \ + action s "$action" \ + message s "$message" \ + current_version s "$current_version" \ + latest_version s "$latest_version" \ + changed n "$changed" \ + status s "$status" } updates_success() { @@ -146,15 +151,10 @@ updates_job_json_response() { local job_id="$2" local message="${3:-}" - jq -cn \ - --argjson success "$success" \ - --arg job_id "$job_id" \ - --arg message "$message" \ - '{ - success: $success, - job_id: $job_id, - message: $message - }' + json_utils_ucode object-json \ + success b "$success" \ + job_id s "$job_id" \ + message s "$message" } updates_job_state_path() { @@ -194,7 +194,7 @@ updates_cleanup_component_jobs() { if [ -f "$state_file" ]; then updates_refresh_running_job_state "$state_file" - if jq -e '(.running // false) == true' "$state_file" >/dev/null 2>&1; then + if updates_json_file_running_is "$state_file" true; then continue fi fi @@ -207,7 +207,7 @@ updates_cleanup_component_jobs() { find "$UPDATES_JOB_DIR" -type f -name '*.json' -mmin "+$UPDATES_JOB_FINISHED_TTL_MINUTES" 2>/dev/null | while IFS= read -r state_file; do [ -f "$state_file" ] || continue - if jq -e '(.running // false) == false' "$state_file" >/dev/null 2>&1; then + if updates_json_file_running_is "$state_file" false; then rm -f "$state_file" 2>/dev/null || true fi done @@ -228,28 +228,35 @@ updates_write_running_job_state() { mkdir -p "$UPDATES_JOB_DIR" || return 1 tmp_file="$(updates_job_tmp_file "$state_file")" || return 1 - jq -cn \ - --argjson success true \ - --argjson running true \ - --arg component "$component" \ - --arg action "$action" \ - --arg message "Component action is running" \ - --arg pid "$pid" \ - --argjson started_at "$started_at" \ - '{ - success: $success, - running: $running, - component: $component, - action: $action, - message: $message, - pid: (if $pid == "" then null else $pid end), - started_at: $started_at, - current_version: "", - latest_version: "", - changed: 0, - status: "", - exit_code: null - }' >"$tmp_file" && mv "$tmp_file" "$state_file" + if [ -n "$pid" ]; then + json_utils_ucode object-json \ + success b true \ + running b true \ + component s "$component" \ + action s "$action" \ + message s "Component action is running" \ + pid s "$pid" \ + started_at n "$started_at" \ + current_version s "" \ + latest_version s "" \ + changed n 0 \ + status s "" \ + exit_code j null >"$tmp_file" && mv "$tmp_file" "$state_file" + else + json_utils_ucode object-json \ + success b true \ + running b true \ + component s "$component" \ + action s "$action" \ + message s "Component action is running" \ + pid j null \ + started_at n "$started_at" \ + current_version s "" \ + latest_version s "" \ + changed n 0 \ + status s "" \ + exit_code j null >"$tmp_file" && mv "$tmp_file" "$state_file" + fi local rc=$? rm -f "$tmp_file" 2>/dev/null @@ -266,10 +273,7 @@ updates_update_running_job_pid() { esac tmp_file="$(updates_job_tmp_file "$state_file")" || return 1 - jq -c \ - --arg pid "$pid" \ - 'if (.running // false) == true then . + {pid: $pid} else . end' \ - "$state_file" >"$tmp_file" && mv "$tmp_file" "$state_file" + json_utils_ucode updates-set-running-job-pid "$state_file" "$pid" >"$tmp_file" && mv "$tmp_file" "$state_file" local rc=$? rm -f "$tmp_file" 2>/dev/null @@ -281,23 +285,7 @@ updates_mark_stale_job_state() { local tmp_file tmp_file="$(updates_job_tmp_file "$state_file")" || return 1 - jq -c \ - --argjson success false \ - --argjson running false \ - --arg message "Component action job is stale or the worker process exited unexpectedly" \ - 'if (.running // false) == true then - . + { - success: $success, - running: $running, - message: $message, - changed: 0, - status: "", - exit_code: null - } - else - . - end' \ - "$state_file" >"$tmp_file" && mv "$tmp_file" "$state_file" + json_utils_ucode updates-mark-stale-job-state "$state_file" >"$tmp_file" && mv "$tmp_file" "$state_file" local rc=$? rm -f "$tmp_file" 2>/dev/null @@ -326,10 +314,10 @@ updates_refresh_running_job_state() { local state_file="$1" local pid started_at - jq -e '(.running // false) == true' "$state_file" >/dev/null 2>&1 || return 0 + updates_json_file_running_is "$state_file" true || return 0 - pid="$(jq -r '.pid // empty' "$state_file" 2>/dev/null)" - started_at="$(jq -r '.started_at // 0' "$state_file" 2>/dev/null)" + pid="$(updates_json_file_get_default "$state_file" pid "")" + started_at="$(updates_json_file_get_default "$state_file" started_at 0)" case "$pid" in "" | *[!0-9]*) updates_mark_stale_job_state "$state_file" @@ -342,7 +330,7 @@ updates_refresh_running_job_state() { fi updates_started_at_is_within_stale_grace "$started_at" && return 0 - jq -e '(.running // false) == true' "$state_file" >/dev/null 2>&1 || return 0 + updates_json_file_running_is "$state_file" true || return 0 updates_mark_stale_job_state "$state_file" } @@ -362,25 +350,15 @@ updates_write_finished_job_state() { "" | *[!0-9]*) updated_at=0 ;; esac - if jq -e . "$output_file" >/dev/null 2>&1; then - jq -c \ - --argjson running false \ - --argjson exit_code "$exit_code" \ - --argjson updated_at "$updated_at" \ - '. + {running: $running, exit_code: $exit_code, updated_at: $updated_at}' \ - "$output_file" >"$tmp_file" && mv "$tmp_file" "$state_file" + if json_utils_ucode file-json-valid "$output_file" >/dev/null 2>&1; then + json_utils_ucode updates-finish-job-state "$output_file" "$exit_code" "$updated_at" >"$tmp_file" && mv "$tmp_file" "$state_file" rm -f "$tmp_file" "$output_file" return 0 fi sed -n 's/^[^{]*\({.*\)$/\1/p' "$output_file" 2>/dev/null | tail -n 1 >"$json_file" - if [ -s "$json_file" ] && jq -e . "$json_file" >/dev/null 2>&1; then - jq -c \ - --argjson running false \ - --argjson exit_code "$exit_code" \ - --argjson updated_at "$updated_at" \ - '. + {running: $running, exit_code: $exit_code, updated_at: $updated_at}' \ - "$json_file" >"$tmp_file" && mv "$tmp_file" "$state_file" + if [ -s "$json_file" ] && json_utils_ucode file-json-valid "$json_file" >/dev/null 2>&1; then + json_utils_ucode updates-finish-job-state "$json_file" "$exit_code" "$updated_at" >"$tmp_file" && mv "$tmp_file" "$state_file" rm -f "$tmp_file" "$json_file" "$output_file" return 0 fi @@ -389,27 +367,7 @@ updates_write_finished_job_state() { raw_output="$(tr '\n' ' ' <"$output_file" 2>/dev/null | cut -c1-240)" [ -n "$raw_output" ] || raw_output="Failed to execute" - jq -cn \ - --argjson success false \ - --argjson running false \ - --arg component "$component" \ - --arg action "$action" \ - --arg message "$raw_output" \ - --argjson exit_code "$exit_code" \ - --argjson updated_at "$updated_at" \ - '{ - success: $success, - running: $running, - component: $component, - action: $action, - message: $message, - current_version: "", - latest_version: "", - changed: 0, - status: "", - exit_code: $exit_code, - updated_at: $updated_at - }' >"$tmp_file" && mv "$tmp_file" "$state_file" + json_utils_ucode updates-fallback-job-state "$component" "$action" "$raw_output" "$exit_code" "$updated_at" >"$tmp_file" && mv "$tmp_file" "$state_file" rm -f "$tmp_file" "$output_file" } @@ -930,18 +888,11 @@ updates_resolve_arch_candidates() { updates_fetch_github_release_json() { local owner="$1" local repo="$2" - local response message + local response response="$(updates_http_get "https://api.github.com/repos/${owner}/${repo}/releases/latest" 2>/dev/null || true)" [ -n "$response" ] || return 1 - printf '%s' "$response" | jq -e . >/dev/null 2>&1 || return 1 - - message="$(printf '%s' "$response" | jq -r 'if type == "object" then (.message // empty) else empty end' 2>/dev/null)" - case "$message" in - *"API rate limit"* | *"rate limit exceeded"* | "Not Found") - return 1 - ;; - esac + printf '%s' "$response" | json_utils_ucode github-response-ok >/dev/null 2>&1 || return 1 printf '%s' "$response" } @@ -950,18 +901,11 @@ updates_fetch_github_releases_json() { local owner="$1" local repo="$2" local per_page="${3:-30}" - local response message + local response response="$(updates_http_get "https://api.github.com/repos/${owner}/${repo}/releases?per_page=${per_page}" 2>/dev/null || true)" [ -n "$response" ] || return 1 - printf '%s' "$response" | jq -e . >/dev/null 2>&1 || return 1 - - message="$(printf '%s' "$response" | jq -r 'if type == "object" then (.message // empty) else empty end' 2>/dev/null)" - case "$message" in - *"API rate limit"* | *"rate limit exceeded"* | "Not Found") - return 1 - ;; - esac + printf '%s' "$response" | json_utils_ucode github-response-ok >/dev/null 2>&1 || return 1 printf '%s' "$response" } @@ -995,27 +939,14 @@ updates_select_release_asset_name() { local package_prefix="$2" local asset_ext="$3" - printf '%s' "$release_json" | jq -r \ - --arg prefix "$package_prefix" \ - --arg ext "$asset_ext" \ - '[ - .assets[] - | select( - ((.name | startswith($prefix + "_")) or (.name | startswith($prefix + "-"))) - and (.name | endswith("." + $ext)) - ) - | .name - ][0] // empty' + printf '%s' "$release_json" | json_utils_ucode release-asset-name "$package_prefix" "$asset_ext" } updates_select_release_asset_url() { local release_json="$1" local asset_name="$2" - printf '%s' "$release_json" | jq -r \ - --arg name "$asset_name" \ - '.assets[] | select(.name == $name) | .browser_download_url' | - sed -n '1p' + printf '%s' "$release_json" | json_utils_ucode release-asset-url "$asset_name" | sed -n '1p' } updates_resolve_podkop_plus_release() { @@ -1039,13 +970,7 @@ updates_resolve_podkop_plus_release() { updates_is_apk && asset_ext="apk" response="$(updates_fetch_github_releases_json "$owner" "$repo" 50)" || return 1 - release_json="$(printf '%s' "$response" | jq -c \ - --arg tag "$latest_version" \ - '.[] | select( - ((.draft // false) | not) - and ((.prerelease // false) | not) - and (.tag_name == $tag) - )' | sed -n '1p')" + release_json="$(printf '%s' "$response" | json_utils_ucode release-by-tag "$latest_version" | sed -n '1p')" [ -n "$release_json" ] || return 1 UPDATES_PODKOP_BACKEND_NAME="$(updates_select_release_asset_name "$release_json" "podkop-plus" "$asset_ext")" @@ -1106,7 +1031,7 @@ updates_resolve_zapret_release() { release_json="$(updates_fetch_github_release_json "remittor" "zapret-openwrt")" || return 1 for arch in $UPDATES_ARCH_CANDIDATES; do - candidate_name="$(printf '%s' "$release_json" | jq -r --arg arch "$arch" '[.assets[] | select(.name | endswith("_" + $arch + ".zip")) | .name][0] // empty')" + candidate_name="$(printf '%s' "$release_json" | json_utils_ucode release-asset-name-by-suffix "_${arch}.zip" 2>/dev/null)" if [ -n "$candidate_name" ]; then UPDATES_ZAPRET_ARCH="$arch" UPDATES_ZAPRET_BUNDLE_NAME="$candidate_name" @@ -1116,7 +1041,7 @@ updates_resolve_zapret_release() { [ -n "$UPDATES_ZAPRET_BUNDLE_NAME" ] || return 1 - url="$(printf '%s' "$release_json" | jq -r --arg name "$UPDATES_ZAPRET_BUNDLE_NAME" '[.assets[] | select(.name == $name) | .browser_download_url][0] // empty')" + url="$(printf '%s' "$release_json" | json_utils_ucode release-asset-url "$UPDATES_ZAPRET_BUNDLE_NAME" 2>/dev/null)" [ -n "$url" ] || return 1 UPDATES_ZAPRET_BUNDLE_URL="$url" UPDATES_ZAPRET_PACKAGE_VERSION="$(updates_extract_zapret_bundle_version "$UPDATES_ZAPRET_BUNDLE_NAME")" @@ -1166,20 +1091,7 @@ updates_resolve_byedpi_release() { response="$(updates_fetch_github_releases_json "DPITrickster" "ByeDPI-OpenWrt" 30)" || return 1 - if [ -n "$release_series" ]; then - resolved="$(printf '%s' "$response" | jq -c --arg series "$release_series" ' - .[] - | select(((.draft // false) | not) and ((.prerelease // false) | not)) - | select(((.tag_name // "") | contains($series)) or ((.name // "") | contains($series))) - ' | updates_select_byedpi_asset "$asset_ext")" - fi - - if [ -z "$resolved" ]; then - resolved="$(printf '%s' "$response" | jq -c ' - .[] - | select(((.draft // false) | not) and ((.prerelease // false) | not)) - ' | updates_select_byedpi_asset "$asset_ext")" - fi + resolved="$(printf '%s' "$response" | json_utils_ucode byedpi-select-asset "$release_series" "$asset_ext" "$UPDATES_ARCH_CANDIDATES" 2>/dev/null)" [ -n "$resolved" ] || return 1 @@ -1194,36 +1106,6 @@ updates_resolve_byedpi_release() { UPDATES_BYEDPI_PACKAGE_VERSION="$(updates_extract_arch_package_version "$UPDATES_BYEDPI_PACKAGE_NAME" "$UPDATES_BYEDPI_ARCH")" } -updates_select_byedpi_asset() { - local asset_ext="$1" - local release_json candidate_name arch url resolved - - while IFS= read -r release_json; do - [ -n "$release_json" ] || continue - [ -n "$resolved" ] && continue - - for arch in $UPDATES_ARCH_CANDIDATES; do - candidate_name="$(printf '%s' "$release_json" | jq -r --arg arch "$arch" --arg ext "$asset_ext" ' - [ - (.assets // [])[] - | select(((.name | startswith("byedpi_")) or (.name | startswith("byedpi-"))) and (.name | endswith("." + $ext))) - | select((.name | contains("_" + $arch + "." + $ext)) or (.name | contains("-" + $arch + "." + $ext))) - | .name - ][0] // empty - ')" - - if [ -n "$candidate_name" ]; then - url="$(printf '%s' "$release_json" | jq -r --arg name "$candidate_name" '[(.assets // [])[] | select(.name == $name) | .browser_download_url][0] // empty')" - [ -n "$url" ] || continue - resolved="$(printf '%s\t%s\t%s\n' "$arch" "$candidate_name" "$url")" - break - fi - done - done - - [ -n "$resolved" ] && printf '%s\n' "$resolved" -} - updates_download_byedpi_package() { UPDATES_BYEDPI_PACKAGE_FILE="$UPDATES_TMP_DIR/$UPDATES_BYEDPI_PACKAGE_NAME" updates_download_with_retry "$UPDATES_BYEDPI_PACKAGE_URL" "$UPDATES_BYEDPI_PACKAGE_FILE" "$UPDATES_BYEDPI_PACKAGE_NAME" || return 1 @@ -1404,22 +1286,13 @@ updates_resolve_sing_box_extended_release() { updates_resolve_sing_box_extended_arch_suffix || return 1 response="$(updates_fetch_github_releases_json "shtorm-7" "sing-box-extended" 30)" || return 1 - tag="$(printf '%s' "$response" | jq -r ' - [ - .[] - | select(((.prerelease // false) | not) and ((.draft // false) | not)) - | .tag_name // empty - | select(((ascii_downcase | contains("alpha")) or (ascii_downcase | contains("beta")) or (ascii_downcase | contains("rc"))) | not) - ][0] // empty - ')" + tag="$(printf '%s' "$response" | json_utils_ucode sing-box-extended-release-tag 2>/dev/null)" [ -n "$tag" ] || return 1 UPDATES_SING_BOX_EXTENDED_RELEASE_TAG="$tag" - release_json="$(printf '%s' "$response" | jq -c --arg tag "$tag" '[.[] | select(.tag_name == $tag)][0] // empty')" + release_json="$(printf '%s' "$response" | json_utils_ucode release-by-tag "$tag" 2>/dev/null)" asset_pattern="linux-${UPDATES_SING_BOX_EXTENDED_ARCH_SUFFIX}.tar.gz" - UPDATES_SING_BOX_EXTENDED_ASSET_URL="$(printf '%s' "$release_json" | jq -r --arg pattern "$asset_pattern" ' - [(.assets // [])[] | select(.name | endswith($pattern)) | .browser_download_url][0] // empty - ')" + UPDATES_SING_BOX_EXTENDED_ASSET_URL="$(printf '%s' "$release_json" | json_utils_ucode release-asset-url-by-suffix "$asset_pattern" 2>/dev/null)" [ -n "$UPDATES_SING_BOX_EXTENDED_ASSET_URL" ] || return 1 UPDATES_SING_BOX_EXTENDED_ASSET_NAME="$(basename "$UPDATES_SING_BOX_EXTENDED_ASSET_URL")" diff --git a/podkop/files/usr/lib/zapret.sh b/podkop/files/usr/lib/zapret.sh index 56d9c14e..6adedb10 100644 --- a/podkop/files/usr/lib/zapret.sh +++ b/podkop/files/usr/lib/zapret.sh @@ -820,12 +820,7 @@ zapret_rule_outbound_present() { outbound_tag="$(get_outbound_tag_by_section "$section")" - jq -e \ - --arg tag "$outbound_tag" \ - --argjson routing_mark "$routing_mark" \ - '.outbounds[]? | - select(.type == "direct" and .tag == $tag and (.routing_mark // empty) == $routing_mark)' \ - "$ZAPRET_SINGBOX_CONFIG_PATH" >/dev/null 2>&1 + provider_status_ucode has-direct-mark-outbound "$ZAPRET_SINGBOX_CONFIG_PATH" "$outbound_tag" "$routing_mark" >/dev/null 2>&1 } zapret_rule_route_rule_present() { @@ -836,12 +831,7 @@ zapret_rule_route_rule_present() { outbound_tag="$(get_outbound_tag_by_section "$section")" - jq -e \ - --arg inbound "$SB_TPROXY_INBOUND_TAG" \ - --arg outbound "$outbound_tag" \ - '.route.rules[]? | - select(.action == "route" and .inbound == $inbound and .outbound == $outbound)' \ - "$ZAPRET_SINGBOX_CONFIG_PATH" >/dev/null 2>&1 + provider_status_ucode has-route-rule "$ZAPRET_SINGBOX_CONFIG_PATH" "$SB_TPROXY_INBOUND_TAG" "$outbound_tag" >/dev/null 2>&1 } _collect_zapret_runtime_status_handler() { @@ -992,57 +982,33 @@ get_zapret_status_json() { status_message="zapret provider status is normal" fi - jq -cn \ - --arg version "$version" \ - --arg provider_path "$ZAPRET_PROVIDER_NFQWS_BIN" \ - --arg status_message "$status_message" \ - --argjson installed "$installed" \ - --argjson package_installed "$package_installed" \ - --argjson provider_available "$provider_available" \ - --argjson files_available "$files_available" \ - --argjson ipset_available "$ipset_available" \ - --argjson configured "$configured" \ - --argjson enabled_rule_count "${enabled_rule_count:-0}" \ - --argjson expected_process_count "${expected_process_count:-0}" \ - --argjson running_process_count "${running_process_count:-0}" \ - --argjson supervisor_process_count "${supervisor_process_count:-0}" \ - --argjson standalone_service_enabled "$standalone_service_enabled" \ - --argjson standalone_service_running "$standalone_service_running" \ - --argjson standalone_config_present "$standalone_config_present" \ - --argjson standalone_conflict "$standalone_conflict" \ - --argjson luci_app_installed "$luci_app_installed" \ - --argjson queue_base "$ZAPRET_QUEUE_BASE" \ - --argjson queue_range_end "$queue_range_end" \ - --argjson queue_overlap "$queue_overlap" \ - --argjson legacy_runtime_present "$legacy_runtime_present" \ - --argjson ready "$ready" \ - --argjson conflict "$conflict" \ - '{ - installed: $installed, - package_installed: $package_installed, - provider_available: $provider_available, - provider_path: $provider_path, - files_available: $files_available, - ipset_available: $ipset_available, - version: $version, - configured: $configured, - enabled_rule_count: $enabled_rule_count, - expected_process_count: $expected_process_count, - running_process_count: $running_process_count, - supervisor_process_count: $supervisor_process_count, - standalone_service_enabled: $standalone_service_enabled, - standalone_service_running: $standalone_service_running, - standalone_config_present: $standalone_config_present, - standalone_conflict: $standalone_conflict, - luci_app_installed: $luci_app_installed, - queue_base: $queue_base, - queue_range_end: $queue_range_end, - queue_overlap: $queue_overlap, - legacy_runtime_present: $legacy_runtime_present, - ready: $ready, - conflict: $conflict, - status_message: $status_message - }' + provider_status_ucode zapret-status \ + "$installed" \ + "$package_installed" \ + "$provider_available" \ + "$ZAPRET_PROVIDER_NFQWS_BIN" \ + "$files_available" \ + "$ipset_available" \ + "$version" \ + "$configured" \ + "${enabled_rule_count:-0}" \ + "${expected_process_count:-0}" \ + "${running_process_count:-0}" \ + "${supervisor_process_count:-0}" \ + "$standalone_service_enabled" \ + "$standalone_service_running" \ + "$standalone_config_present" \ + "$standalone_conflict" \ + "$luci_app_installed" \ + "$ZAPRET_QUEUE_BASE" \ + "$queue_range_end" \ + "$queue_overlap" \ + "$legacy_runtime_present" \ + "$ready" \ + "$conflict" \ + "$outbounds_configured" \ + "$routes_configured" \ + "$status_message" } check_zapret_runtime_json() { @@ -1056,15 +1022,7 @@ check_zapret_runtime_json() { package_installed=1 fi - jq -cn \ - --argjson zapret_installed "$installed" \ - --argjson zapret_package_installed "$package_installed" \ - --arg zapret_provider_path "$ZAPRET_PROVIDER_NFQWS_BIN" \ - '{ - zapret_installed: $zapret_installed, - zapret_package_installed: $zapret_package_installed, - zapret_provider_path: $zapret_provider_path - }' + provider_status_ucode zapret-check "$installed" "$package_installed" "$ZAPRET_PROVIDER_NFQWS_BIN" } _create_zapret_nft_rule_handler() { diff --git a/scripts/build-openwrt-packages-wsl.sh b/scripts/build-openwrt-packages-wsl.sh index 642cc269..fec693bb 100644 --- a/scripts/build-openwrt-packages-wsl.sh +++ b/scripts/build-openwrt-packages-wsl.sh @@ -41,8 +41,8 @@ APP_DESCRIPTION="Rule-based Podkop Plus LuCI app with hybrid sing-box + zapret o I18N_DESCRIPTION="Translation for luci-app-podkop-plus - Русский (Russian)" MAINTAINER="ushan0v " PROJECT_URL="https://github.com/ushan0v/podkop-plus" -BACKEND_DEPENDS_IPK="libc, sing-box, curl, jq, lua, luci-lib-jsonc, luci-lib-nixio, kmod-nft-tproxy, coreutils-base64, coreutils-sort, bind-dig, nftables, kmod-nft-nat, kmod-nft-offload" -BACKEND_DEPENDS_APK="bind-dig coreutils-base64 coreutils-sort curl jq lua luci-lib-jsonc luci-lib-nixio kmod-nft-nat kmod-nft-offload kmod-nft-tproxy libc nftables sing-box" +BACKEND_DEPENDS_IPK="libc, sing-box, curl, ucode, ucode-mod-fs, kmod-nft-tproxy, coreutils-base64, coreutils-sort, bind-dig, nftables, kmod-nft-nat, kmod-nft-offload" +BACKEND_DEPENDS_APK="bind-dig coreutils-base64 coreutils-sort curl ucode ucode-mod-fs kmod-nft-nat kmod-nft-offload kmod-nft-tproxy libc nftables sing-box" APP_DEPENDS_IPK="libc, luci-base, podkop-plus" APP_DEPENDS_APK="libc luci-base podkop-plus"