From e3ef9e70fa4478a12b9d0df7c9c13699dae0bacf Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 30 Jun 2026 20:09:28 +0300 Subject: [PATCH] Rewrite backend runtime from shell to ucode Replace the legacy shell-owned runtime with modular ucode implementations for configuration, service lifecycle, subscriptions, diagnostics, DNS, nftables, routing, providers, updates, and sing-box generation. Keep thin OpenWrt entrypoints and add regression and package-contract coverage for the new runtime ownership model. --- .github/workflows/backend-ci.yml | 63 + .github/workflows/build.yml | 2 +- String-example.md | 119 - install.sh | 1742 ++++------ .../root/etc/uci-defaults/50_luci-podkop-plus | 8 +- podkop/Makefile | 89 +- podkop/files/etc/init.d/podkop | 401 +-- podkop/files/usr/bin/podkop | 1447 ++------ podkop/files/usr/lib/byedpi.sh | 842 ----- podkop/files/usr/lib/components/action.uc | 1825 +++++++++++ .../files/usr/lib/{ => components}/updater.uc | 240 +- podkop/files/usr/lib/components/updates.uc | 2612 +++++++++++++++ podkop/files/usr/lib/config/migration.uc | 814 +++++ podkop/files/usr/lib/config/rule.uc | 352 ++ podkop/files/usr/lib/config/validator.uc | 1710 ++++++++++ podkop/files/usr/lib/config_validation.sh | 1375 -------- podkop/files/usr/lib/config_validation.uc | 247 -- podkop/files/usr/lib/constants.sh | 134 - podkop/files/usr/lib/core/common.uc | 148 + podkop/files/usr/lib/core/constants.uc | 166 + podkop/files/usr/lib/{ => core}/helpers.uc | 67 +- podkop/files/usr/lib/core/packages.uc | 141 + podkop/files/usr/lib/core/uci.uc | 500 +++ podkop/files/usr/lib/core/url.uc | 181 + podkop/files/usr/lib/diagnostics/runtime.uc | 1875 +++++++++++ .../status.uc} | 164 +- podkop/files/usr/lib/dns/apply.uc | 316 ++ .../files/usr/lib/dnsmasq_failsafe_restore.sh | 105 - podkop/files/usr/lib/dnsmasq_runtime.sh | 273 -- podkop/files/usr/lib/helpers.sh | 674 ---- podkop/files/usr/lib/logging.sh | 32 - podkop/files/usr/lib/nft.sh | 114 - podkop/files/usr/lib/nft/apply.uc | 1560 +++++++++ .../files/usr/lib/providers/byedpi/runtime.uc | 655 ++++ .../usr/lib/providers/byedpi/validator.uc | 254 ++ .../nfqueue/check.uc} | 54 +- .../usr/lib/providers/nfqueue/runtime.uc | 767 +++++ .../usr/lib/providers/nfqueue/validator.uc | 382 +++ podkop/files/usr/lib/providers/rules.uc | 170 + .../status.uc} | 2 +- .../files/usr/lib/providers/zapret/check.uc | 5 + .../files/usr/lib/providers/zapret/common.uc | 61 + .../files/usr/lib/providers/zapret/runtime.uc | 6 + .../usr/lib/providers/zapret/validator.uc | 23 + .../files/usr/lib/providers/zapret2/check.uc | 5 + .../files/usr/lib/providers/zapret2/common.uc | 68 + .../usr/lib/providers/zapret2/runtime.uc | 6 + .../usr/lib/providers/zapret2/validator.uc | 23 + .../files/usr/lib/{ => routing}/rulesets.uc | 30 +- podkop/files/usr/lib/rules_nft_runtime.sh | 700 ---- podkop/files/usr/lib/rules_nft_runtime.uc | 751 ----- podkop/files/usr/lib/rulesets.sh | 166 - podkop/files/usr/lib/runtime_state.sh | 1394 -------- podkop/files/usr/lib/runtime_state.uc | 142 - podkop/files/usr/lib/server/service.uc | 1104 +++++++ podkop/files/usr/lib/server_runtime.sh | 1200 ------- podkop/files/usr/lib/server_runtime.uc | 599 ---- podkop/files/usr/lib/service/initd.uc | 632 ++++ podkop/files/usr/lib/service/lifecycle.uc | 1045 ++++++ podkop/files/usr/lib/service/package.uc | 181 + podkop/files/usr/lib/service/reload.uc | 302 ++ podkop/files/usr/lib/service/state.uc | 1637 ++++++++++ podkop/files/usr/lib/service/ui.uc | 1344 ++++++++ .../files/usr/lib/sing_box_config_facade.sh | 1154 ------- .../files/usr/lib/sing_box_config_facade.uc | 1165 ------- .../files/usr/lib/sing_box_config_manager.sh | 329 -- .../files/usr/lib/sing_box_config_manager.uc | 1307 -------- podkop/files/usr/lib/sing_box_runtime.sh | 2503 -------------- podkop/files/usr/lib/sing_box_runtime.uc | 676 ---- podkop/files/usr/lib/singbox/constants.uc | 113 + podkop/files/usr/lib/singbox/dns.uc | 80 + podkop/files/usr/lib/singbox/generator.uc | 2248 +++++++++++++ podkop/files/usr/lib/singbox/route.uc | 84 + podkop/files/usr/lib/singbox/rulesets.uc | 141 + podkop/files/usr/lib/singbox/runtime.uc | 790 +++++ podkop/files/usr/lib/singbox/servers.uc | 388 +++ podkop/files/usr/lib/singbox/subscription.uc | 258 ++ podkop/files/usr/lib/singbox/urltest.uc | 158 + podkop/files/usr/lib/status_diagnostics.sh | 1726 ---------- podkop/files/usr/lib/subscription/cache.uc | 2901 +++++++++++++++++ .../parser.uc} | 235 +- podkop/files/usr/lib/subscription_cache.uc | 812 ----- podkop/files/usr/lib/subscription_parser.sh | 135 - podkop/files/usr/lib/subscription_runtime.sh | 1317 -------- podkop/files/usr/lib/ui_runtime.sh | 788 ----- podkop/files/usr/lib/ui_runtime.uc | 274 -- podkop/files/usr/lib/updater.sh | 2617 --------------- podkop/files/usr/lib/updates_runtime.sh | 973 ------ podkop/files/usr/lib/updates_runtime.uc | 191 -- podkop/files/usr/lib/zapret.sh | 958 ------ podkop/files/usr/lib/zapret2.sh | 918 ------ ...build-openwrt-packages-wsl.sh => build.sh} | 211 +- scripts/deploy-openwrt-ipk.ps1 | 133 - .../byedpi_runtime_owner_regression.sh | 57 + tests/backend/byedpi_validator_regression.sh | 78 + tests/backend/cli_entrypoint_regression.sh | 86 + .../components_updater_job_regression.sh | 269 ++ .../config_contract_matrix_regression.sh | 116 + tests/backend/config_migration_regression.sh | 215 ++ .../config_validation_owner_regression.sh | 49 + .../config_validator_detour_regression.sh | 66 + ...g_validator_download_section_regression.sh | 57 + .../config_validator_reference_regression.sh | 47 + .../config_validator_runtime_regression.sh | 228 ++ tests/backend/constants_owner_regression.sh | 54 + tests/backend/core_helpers_regression.sh | 47 + .../backend/diagnostics_status_regression.sh | 166 + tests/backend/dns_apply_regression.sh | 259 ++ tests/backend/helpers_owner_regression.sh | 139 + tests/backend/initd_state_regression.sh | 192 ++ tests/backend/installer_owner_regression.sh | 212 ++ tests/backend/mark_ranges_regression.sh | 49 +- tests/backend/nft_apply_regression.sh | 599 ++++ tests/backend/package_contract_regression.sh | 62 + tests/backend/package_lifecycle_regression.sh | 129 + tests/backend/provider_rules_regression.sh | 57 + .../backend/runtime_state_owner_regression.sh | 97 + .../runtime_state_predicates_regression.sh | 1251 +++++++ .../semantic_json_compare_regression.sh | 72 + tests/backend/server_runtime_regression.sh | 114 + .../backend/service_reload_plan_regression.sh | 221 ++ .../backend/service_start_trap_regression.sh | 62 + tests/backend/shell_inventory_regression.sh | 89 + tests/backend/sing_box_runtime_regression.sh | 729 +++++ tests/backend/singbox_rulesets_regression.sh | 50 + tests/backend/subscription_alpn_regression.sh | 58 +- .../subscription_cache_state_regression.sh | 360 ++ .../subscription_source_entry_regression.sh | 70 + .../subscription_update_job_regression.sh | 142 + .../subscription_user_agent_regression.sh | 56 + tests/backend/ui_runtime_job_regression.sh | 234 ++ tests/backend/updates_due_regression.sh | 272 ++ tests/backend/urltest_interrupt_regression.sh | 231 +- .../zapret_runtime_owner_regression.sh | 148 + tests/backend/zapret_validator_regression.sh | 112 + tests/differential/config_contract_matrix.js | 308 ++ tests/differential/semantic_json_compare.js | 77 + 137 files changed, 37458 insertions(+), 30157 deletions(-) create mode 100644 .github/workflows/backend-ci.yml delete mode 100644 String-example.md delete mode 100644 podkop/files/usr/lib/byedpi.sh create mode 100644 podkop/files/usr/lib/components/action.uc rename podkop/files/usr/lib/{ => components}/updater.uc (87%) create mode 100644 podkop/files/usr/lib/components/updates.uc create mode 100644 podkop/files/usr/lib/config/migration.uc create mode 100644 podkop/files/usr/lib/config/rule.uc create mode 100644 podkop/files/usr/lib/config/validator.uc delete mode 100644 podkop/files/usr/lib/config_validation.sh delete mode 100644 podkop/files/usr/lib/config_validation.uc delete mode 100644 podkop/files/usr/lib/constants.sh create mode 100644 podkop/files/usr/lib/core/common.uc create mode 100644 podkop/files/usr/lib/core/constants.uc rename podkop/files/usr/lib/{ => core}/helpers.uc (84%) create mode 100644 podkop/files/usr/lib/core/packages.uc create mode 100644 podkop/files/usr/lib/core/uci.uc create mode 100644 podkop/files/usr/lib/core/url.uc create mode 100644 podkop/files/usr/lib/diagnostics/runtime.uc rename podkop/files/usr/lib/{status_diagnostics.uc => diagnostics/status.uc} (92%) create mode 100644 podkop/files/usr/lib/dns/apply.uc delete mode 100644 podkop/files/usr/lib/dnsmasq_failsafe_restore.sh delete mode 100644 podkop/files/usr/lib/dnsmasq_runtime.sh delete mode 100644 podkop/files/usr/lib/helpers.sh delete mode 100644 podkop/files/usr/lib/logging.sh delete mode 100644 podkop/files/usr/lib/nft.sh create mode 100644 podkop/files/usr/lib/nft/apply.uc create mode 100644 podkop/files/usr/lib/providers/byedpi/runtime.uc create mode 100644 podkop/files/usr/lib/providers/byedpi/validator.uc rename podkop/files/usr/lib/{zapret_validation.uc => providers/nfqueue/check.uc} (90%) create mode 100644 podkop/files/usr/lib/providers/nfqueue/runtime.uc create mode 100644 podkop/files/usr/lib/providers/nfqueue/validator.uc create mode 100644 podkop/files/usr/lib/providers/rules.uc rename podkop/files/usr/lib/{provider_status.uc => providers/status.uc} (99%) create mode 100644 podkop/files/usr/lib/providers/zapret/check.uc create mode 100644 podkop/files/usr/lib/providers/zapret/common.uc create mode 100644 podkop/files/usr/lib/providers/zapret/runtime.uc create mode 100644 podkop/files/usr/lib/providers/zapret/validator.uc create mode 100644 podkop/files/usr/lib/providers/zapret2/check.uc create mode 100644 podkop/files/usr/lib/providers/zapret2/common.uc create mode 100644 podkop/files/usr/lib/providers/zapret2/runtime.uc create mode 100644 podkop/files/usr/lib/providers/zapret2/validator.uc rename podkop/files/usr/lib/{ => routing}/rulesets.uc (94%) delete mode 100644 podkop/files/usr/lib/rules_nft_runtime.sh delete mode 100644 podkop/files/usr/lib/rules_nft_runtime.uc delete mode 100644 podkop/files/usr/lib/rulesets.sh delete mode 100644 podkop/files/usr/lib/runtime_state.sh delete mode 100644 podkop/files/usr/lib/runtime_state.uc create mode 100644 podkop/files/usr/lib/server/service.uc delete mode 100644 podkop/files/usr/lib/server_runtime.sh delete mode 100644 podkop/files/usr/lib/server_runtime.uc create mode 100644 podkop/files/usr/lib/service/initd.uc create mode 100644 podkop/files/usr/lib/service/lifecycle.uc create mode 100644 podkop/files/usr/lib/service/package.uc create mode 100644 podkop/files/usr/lib/service/reload.uc create mode 100644 podkop/files/usr/lib/service/state.uc create mode 100644 podkop/files/usr/lib/service/ui.uc delete mode 100644 podkop/files/usr/lib/sing_box_config_facade.sh delete mode 100644 podkop/files/usr/lib/sing_box_config_facade.uc delete mode 100644 podkop/files/usr/lib/sing_box_config_manager.sh delete mode 100644 podkop/files/usr/lib/sing_box_config_manager.uc delete mode 100644 podkop/files/usr/lib/sing_box_runtime.sh delete mode 100644 podkop/files/usr/lib/sing_box_runtime.uc create mode 100644 podkop/files/usr/lib/singbox/constants.uc create mode 100644 podkop/files/usr/lib/singbox/dns.uc create mode 100644 podkop/files/usr/lib/singbox/generator.uc create mode 100644 podkop/files/usr/lib/singbox/route.uc create mode 100644 podkop/files/usr/lib/singbox/rulesets.uc create mode 100644 podkop/files/usr/lib/singbox/runtime.uc create mode 100644 podkop/files/usr/lib/singbox/servers.uc create mode 100644 podkop/files/usr/lib/singbox/subscription.uc create mode 100644 podkop/files/usr/lib/singbox/urltest.uc delete mode 100644 podkop/files/usr/lib/status_diagnostics.sh create mode 100644 podkop/files/usr/lib/subscription/cache.uc rename podkop/files/usr/lib/{subscription_parser.uc => subscription/parser.uc} (92%) delete mode 100644 podkop/files/usr/lib/subscription_cache.uc delete mode 100644 podkop/files/usr/lib/subscription_parser.sh delete mode 100644 podkop/files/usr/lib/subscription_runtime.sh delete mode 100644 podkop/files/usr/lib/ui_runtime.sh delete mode 100644 podkop/files/usr/lib/ui_runtime.uc delete mode 100644 podkop/files/usr/lib/updater.sh delete mode 100644 podkop/files/usr/lib/updates_runtime.sh delete mode 100644 podkop/files/usr/lib/updates_runtime.uc delete mode 100644 podkop/files/usr/lib/zapret.sh delete mode 100644 podkop/files/usr/lib/zapret2.sh rename scripts/{build-openwrt-packages-wsl.sh => build.sh} (81%) delete mode 100644 scripts/deploy-openwrt-ipk.ps1 create mode 100644 tests/backend/byedpi_runtime_owner_regression.sh create mode 100644 tests/backend/byedpi_validator_regression.sh create mode 100644 tests/backend/cli_entrypoint_regression.sh create mode 100644 tests/backend/components_updater_job_regression.sh create mode 100644 tests/backend/config_contract_matrix_regression.sh create mode 100644 tests/backend/config_migration_regression.sh create mode 100644 tests/backend/config_validation_owner_regression.sh create mode 100644 tests/backend/config_validator_detour_regression.sh create mode 100644 tests/backend/config_validator_download_section_regression.sh create mode 100644 tests/backend/config_validator_reference_regression.sh create mode 100644 tests/backend/config_validator_runtime_regression.sh create mode 100644 tests/backend/constants_owner_regression.sh create mode 100644 tests/backend/core_helpers_regression.sh create mode 100644 tests/backend/diagnostics_status_regression.sh create mode 100644 tests/backend/dns_apply_regression.sh create mode 100644 tests/backend/helpers_owner_regression.sh create mode 100755 tests/backend/initd_state_regression.sh create mode 100644 tests/backend/installer_owner_regression.sh create mode 100644 tests/backend/nft_apply_regression.sh create mode 100644 tests/backend/package_contract_regression.sh create mode 100644 tests/backend/package_lifecycle_regression.sh create mode 100644 tests/backend/provider_rules_regression.sh create mode 100644 tests/backend/runtime_state_owner_regression.sh create mode 100644 tests/backend/runtime_state_predicates_regression.sh create mode 100644 tests/backend/semantic_json_compare_regression.sh create mode 100644 tests/backend/server_runtime_regression.sh create mode 100644 tests/backend/service_reload_plan_regression.sh create mode 100644 tests/backend/service_start_trap_regression.sh create mode 100644 tests/backend/shell_inventory_regression.sh create mode 100644 tests/backend/sing_box_runtime_regression.sh create mode 100644 tests/backend/singbox_rulesets_regression.sh create mode 100644 tests/backend/subscription_cache_state_regression.sh create mode 100644 tests/backend/subscription_source_entry_regression.sh create mode 100644 tests/backend/subscription_update_job_regression.sh create mode 100644 tests/backend/subscription_user_agent_regression.sh create mode 100644 tests/backend/ui_runtime_job_regression.sh create mode 100644 tests/backend/updates_due_regression.sh create mode 100644 tests/backend/zapret_runtime_owner_regression.sh create mode 100644 tests/backend/zapret_validator_regression.sh create mode 100644 tests/differential/config_contract_matrix.js create mode 100644 tests/differential/semantic_json_compare.js diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..8b6ceafb --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,63 @@ +name: Backend CI + +on: + pull_request: + paths: + - 'podkop/files/**' + - 'podkop/Makefile' + - 'scripts/build.sh' + - 'tests/backend/**' + - 'tests/differential/**' + - '.github/workflows/backend-ci.yml' + push: + branches: + - main + paths: + - 'podkop/files/**' + - 'podkop/Makefile' + - 'scripts/build.sh' + - 'tests/backend/**' + - 'tests/differential/**' + - '.github/workflows/backend-ci.yml' + +jobs: + backend-checks: + name: Backend Runtime Checks + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v5.0.0 + with: + fetch-depth: 0 + + - name: Build ucode + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake git libjson-c-dev pkg-config + git clone --depth 1 --branch v0.0.20250529 https://github.com/jow-/ucode.git /tmp/ucode + cmake -S /tmp/ucode -B /tmp/ucode/build \ + -DUBUS_SUPPORT=OFF \ + -DUCI_SUPPORT=OFF \ + -DULOOP_SUPPORT=OFF + cmake --build /tmp/ucode/build --parallel + sudo cmake --install /tmp/ucode/build + sudo ldconfig + command -v ucode + ucode -e 'print("ucode ready\n")' + + - name: Check ucode syntax + shell: bash + run: | + find podkop/files/usr/lib -name '*.uc' -print0 | xargs -0 -n1 ucode -c -o /dev/null + find podkop/files/usr/lib -name '*.uc' -print0 | xargs -0 -n1 ucode -S -c -o /dev/null + + - name: Run backend tests + shell: bash + run: | + for test_script in tests/backend/*.sh; do + echo "::group::$test_script" + bash "$test_script" + echo "::endgroup::" + done diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67aa6410..744bc002 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,7 +57,7 @@ jobs: shell: bash run: | VERSION="${{ needs.preparation.outputs.version }}" - bash ./scripts/build-openwrt-packages-wsl.sh "$VERSION" "$PWD/filtered-bin/release" + bash ./scripts/build.sh "$VERSION" "$PWD/filtered-bin/release" ls -la "$PWD/filtered-bin/release" - name: Upload build artifacts diff --git a/String-example.md b/String-example.md deleted file mode 100644 index f9bbc856..00000000 --- a/String-example.md +++ /dev/null @@ -1,119 +0,0 @@ -## Socks -``` -socks4://127.0.0.1:1080 -socks4a://127.0.0.1:1080 -socks5://127.0.0.1:1080 -socks5://username:password@127.0.0.1:1080 -``` - -## Shadowsocks -``` -ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206ZG1DbHkvWmgxNVd3OStzK0dGWGlGVElrcHc3Yy9xQ0lTYUJyYWk3V2hoWT0@127.0.0.1:25144?type=tcp#shadowsocks-no-client -ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206S3FiWXZiNkhwb1RmTUt0N2VGcUZQSmJNNXBXaHlFU0ZKTXY2dEp1Ym1Fdz06dzRNMEx5RU9OTGQ5SWlkSGc0endTbzN2R3h4NS9aQ3hId0FpaWlxck5hcz0@127.0.0.1:26627?type=tcp#shadowsocks-client -ss://2022-blake3-aes-256-gcm:dmCly/Zh15Ww9+s+GFXiFTIkpw7c/qCISaBrai7WhhY=@127.0.0.1:27214?type=tcp#shadowsocks-plain-user -``` - -## VLESS -``` -# tcp -vless://94792286-7bbe-4f33-8b36-18d1bbf70723@127.0.0.1:34520?type=tcp&encryption=none&security=none#vless-tcp-none -vless://e95163dc-905e-480a-afe5-20b146288679@127.0.0.1:16399?type=tcp&encryption=none&security=reality&pbk=tqhSkeDR6jsqC-BYCnZWBrdL33g705ba8tV5-ZboWTM&fp=chrome&sni=google.com&sid=f6&spx=%2F#vless-tcp-reality -vless://2e9e8288-060e-4da2-8b9f-a1c81826feb7@127.0.0.1:19316?type=tcp&encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-tcp-tls -vless://0235c833-dc29-4202-8a7b-1bbba5b516a2@127.0.0.1:22993?type=tcp&encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#vless-tcp-tls-insecure -vless://17776137-e747-4268-a84d-99fd798accac@127.0.0.1:48076?type=tcp&encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com&ech=AFP%2BDQBPAAAgACDJXiKG5eoCHfd1MbMxgccxgrbGisBPPe3bz1KVIETUXQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAAAAA%3D%3D#vless-tcp-tls-ech - -# mKCP -vless://72e201d7-7841-4a32-b266-4aa3eb776d51@127.0.0.1:17270?type=kcp&encryption=none&headerType=none&seed=AirziWi4ng&security=none#vless-mKCP - -# WebSocket -vless://d86daef7-565b-4ecd-a9ee-bac847ad38e6@127.0.0.1:12928?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=none#vless-websocket-none -vless://fe0f0941-09a9-4e46-bc69-e00190d7bb9c@127.0.0.1:10156?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-websocket-tls -vless://599e8659-e2ef-47d9-bf72-2f9b4b673474@127.0.0.1:36567?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#vless-websocket-tls-insecure -vless://4d21ce62-8723-4c4d-93e3-d586b107aa40@127.0.0.1:51394?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com&ech=AF3%2BDQBZAAAgACD7fjrtDMlcigKXFBKoLn6UDB9%2BWR6HBZpY96DlBiD%2BIwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D#vless-websocket-tls-ech - -# gRPC -vless://974b39e3-f7bf-42b9-933c-16699c635e77@127.0.0.1:15633?type=grpc&encryption=none&serviceName=TunService&authority=&security=none#vless-gRPC-none -vless://651e7eca-5152-46f1-baf2-d502e0af7b27@127.0.0.1:28535?type=grpc&encryption=none&serviceName=TunService&authority=authority&security=reality&pbk=nhZ7NiKfcqESa5ZeBFfsq9o18W-OWOAHLln9UmuVXSk&fp=chrome&sni=google.com&sid=11cbaeaa&spx=%2F#vless-gRPC-reality -vless://221ff905-b783-41a0-a6a6-8089eaf3b34b@abc.def.xyz:443?security=reality&type=grpc&headerType=&authority=abc.def.xyz&serviceName=name&mode=gun&sni=abc.def.xyz&fp=chrome&pbk=C3nhDJw02ZU_rjx4GbC54Sp79-ysF5lWIQVWdY4FOnE&sid=#vless-gRPC-reality-mode -vless://af1f8b5f-26c9-4fe8-8ce7-6d6366c5c9ce@127.0.0.1:47904?type=grpc&encryption=none&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-gRPC-tls -vless://95f2c4bb-abcb-47ba-bfad-e181c03e4659@127.0.0.1:34530?type=grpc&encryption=none&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#vless-gRPC-tls-insecure -vless://bd39490f-9a4f-49b2-96b6-824190cf89e9@127.0.0.1:27779?type=grpc&encryption=none&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com&ech=AF3%2BDQBZAAAgACBc%2FiNdo4QkTt9eQCQgkOiJVSfA9G6UWAyipaBFtBD%2FVQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D#vless-gRPC-tls-ech - -# HTTPUpgrade -vless://2b98f144-847f-42f7-8798-e1a32d27bdc7@127.0.0.1:47154?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=none#vless-httpupgrade-none -vless://76dbd0ff-1a35-4f0c-a9ba-3c5890b7dea6@127.0.0.1:50639?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-httpupgrade-tls -vless://6d229881-50ed-4f3f-995d-bd3e725fdbff@127.0.0.1:57616?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#vless-httpupgrade-tls-insecure -vless://1897e9e4-6f5d-4a85-9512-9192e76c3f04@127.0.0.1:38658?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com&ech=AF3%2BDQBZAAAgACCmXTMzlrdcCk2FyINAWKZ4DBxq4%2BCgmJ69v%2BmH4EMlEQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D#vless-httpupgrade-tls-ech - -# XHTTP -vless://c2841505-ec32-4b8d-b6dd-3e19d648c321@127.0.0.1:45507?type=xhttp&encryption=none&path=%2Fxhttppath&host=xhttp&mode=auto&security=none#vless-xhttp -``` - -## Trojan -``` -# tcp -trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none -trojan://cME3ZlUrYF@127.0.0.1:43772?type=tcp&security=reality&pbk=DckTwU6p6pTX9QxFXOi6vH4Vzt_RCE1vMCnj2c6hvjw&fp=chrome&sni=google.com&sid=221a80cf94&spx=%2F#trojan-tcp-reality -trojan://EJjpAj02lg@127.0.0.1:11381?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-tcp-tls -trojan://ZP2Ik5sxN3@127.0.0.1:16247?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-tcp-tls-insecure -trojan://90caP481ay@127.0.0.1:59708?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACC2y%2BAe4dqthLNpfvmtE6g%2BnaJ%2FciK6P%2BREbRLkR%2Fg%2FEgAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-tcp-tls-ech - -# mKCP -trojan://N5v7iIOe9G@127.0.0.1:36319?type=kcp&headerType=none&seed=P91wFIfjzZ&security=none#trojan-mKCP - -# WebSocket -trojan://G3cE9phv1g@127.0.0.1:57370?type=ws&path=%2Fwspath&host=google.com&security=none#trojan-websocket-none -trojan://FBok41WczO@127.0.0.1:59919?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-websocket-tls -trojan://bhwvndUBPA@127.0.0.1:22969?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-websocket-tls-insecur -trojan://pwiduqFUWO@127.0.0.1:46765?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCFcQYEtwrFOidJJLYHvSiN%2BljRgaAIrNHoVnio3uXAOwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-websocket-tls-ech - -# gRPC -trojan://WMR7qkKhsV@127.0.0.1:27897?type=grpc&serviceName=TunService&authority=authority&security=none#trojan-gRPC-none -trojan://KVuRNsu6KG@127.0.0.1:46077?type=grpc&serviceName=TunService&authority=authority&security=reality&pbk=Xn59i4gum3ppCICS6-_NuywrhHIVVAH54b2mjd5CFkE&fp=chrome&sni=google.com&sid=e5be&spx=%2F#trojan-gRPC-reality -trojan://7BJtbywy8h@127.0.0.1:10627?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-gRPC-tls -trojan://TI3PakvtP4@127.0.0.1:10435?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-gRPC-tls-insecure -trojan://mbzoVKL27h@127.0.0.1:38681?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCq72Ru3VbFlDpKttl3LccmInu8R2oAsCr8wzyxB0vZZQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-gRPC-tls-ech - -# HTTPUpgrade -trojan://uc44gBwOKQ@127.0.0.1:29085?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=none#trojan-httpupgrade-none -trojan://MhNxbcVB14@127.0.0.1:32700?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-httpupgrade-tls -trojan://7SOQFUpLob@127.0.0.1:28474?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-httpupgrade-tls-insecure -trojan://ou8pLSyx9N@127.0.0.1:17737?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACB%2FlkIkit%2BblFzE7PtbYDVF3NXK8olXJ5a7YwY%2Biy9QQwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-httpupgrade-tls-ech - -# XHTTP -trojan://VEetltxLtw@127.0.0.1:59072?type=xhttp&path=%2Fxhttppath&host=google.com&mode=auto&security=none#trojan-xhttp -``` - -## Hysteria2 - -hysteria2:// -``` -# With password -hysteria2://password@example.com:443/#hysteria2-password -hysteria2://password@example.com:443/?insecure=1#hysteria2-password-insecure - -# With SNI -hysteria2://password@example.com:443/?sni=example.com#hysteria2-password-sni - -# With obfuscation -hysteria2://password@example.com:443/?obfs=salamander&obfs-password=obfspassword#hysteria2-obfs - -# All parameters combined -hysteria2://mypassword@example.com:8443/?sni=example.com&obfs=salamander&obfs-password=obfspass&insecure=1#hysteria2-all-params -``` - -hy2:// -``` -# With password -hy2://password@example.com:443/#hysteria2-password -hy2://password@example.com:443/?insecure=1#hysteria2-password-insecure - -# With SNI -hy2://password@example.com:443/?sni=example.com#hysteria2-password-sni - -# With obfuscation -hy2://password@example.com:443/?obfs=salamander&obfs-password=obfspassword#hysteria2-obfs - -# All parameters combined -hy2://mypassword@example.com:8443/?sni=example.com&obfs=salamander&obfs-password=obfspass&insecure=1#hysteria2-all-params -``` \ No newline at end of file diff --git a/install.sh b/install.sh index 70b0a4ab..dad3fe37 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,6 @@ REPO_OWNER="ushan0v" REPO_NAME="podkop-plus" REQUIRED_SPACE_KB=15360 -REQUIRED_SING_BOX_VERSION="1.12.4" PKG_IS_APK=0 FETCHER="" @@ -30,14 +29,6 @@ PODKOP_PLUS_I18N_FILE="" PODKOP_PLUS_PACKAGE_VERSION="" SING_BOX_VARIANT_STATE_FILE="/etc/podkop-plus/sing-box-variant" SING_BOX_VERSION_STATE_FILE="/etc/podkop-plus/sing-box-version" -SING_BOX_MANAGED_SERVICE_MARKER="Podkop Plus managed sing-box service for binary variants" -SING_BOX_EXTENDED_RELEASE_JSON="" -SING_BOX_EXTENDED_RELEASE_TAG="" -SING_BOX_EXTENDED_ARCH_SUFFIX="" -SING_BOX_EXTENDED_ASSET_URL="" -SING_BOX_EXTENDED_ASSET_NAME="" -SING_BOX_EXTENDED_ARCHIVE_FILE="" -SING_BOX_EXTENDED_PACKAGE_FILE="" command -v apk >/dev/null 2>&1 && PKG_IS_APK=1 @@ -101,18 +92,6 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } -podkop_dont_touch_dhcp_enabled() { - command_exists uci || return 1 - - case "$(uci -q get 'podkop-plus.settings.dont_touch_dhcp' 2>/dev/null)" in - 1|true|yes|on) - return 0 - ;; - esac - - return 1 -} - init_tmp_dir() { TMP_DIR="$(mktemp -d /tmp/podkop-plus.XXXXXX 2>/dev/null || true)" @@ -193,8 +172,616 @@ function ends_with(value, suffix) { return length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix; } -function contains(value, needle) { - return index(as_string(value), as_string(needle)) >= 0; +let uci_cursor_state = false; + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function truthy(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function path_parts(path) { + path = as_string(path); + let first = index(path, "."); + if (first < 0) + return null; + + let package_name = substr(path, 0, first); + let rest = substr(path, first + 1); + let second = index(rest, "."); + if (second < 0) + return { package: package_name, section: rest, option: "" }; + + return { + package: package_name, + section: substr(rest, 0, second), + option: substr(rest, second + 1) + }; +} + +function uci_cursor() { + if (uci_cursor_state !== false) + return uci_cursor_state; + + try { + uci_cursor_state = require("uci").cursor(); + } + catch (e) { + uci_cursor_state = null; + } + + return uci_cursor_state; +} + +function uci_available() { + return uci_cursor() != null; +} + +function uci_load(package_name) { + let c = uci_cursor(); + if (c == null) + return false; + + try { + c.load(as_string(package_name)); + return true; + } + catch (e) { + return false; + } +} + +function uci_value_to_string(value) { + if (value == null) + return ""; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function uci_value_to_list(value) { + if (value == null) + return []; + if (type(value) == "array") + return value; + return words(value); +} + +function uci_get(path) { + let parts = path_parts(path); + let c = uci_cursor(); + if (c == null || parts == null || parts.option == "") + return ""; + if (!uci_load(parts.package)) + return ""; + + return uci_value_to_string(c.get(parts.package, parts.section, parts.option)); +} + +function uci_exists(path) { + let parts = path_parts(path); + let c = uci_cursor(); + if (c == null || parts == null) + return false; + if (!uci_load(parts.package)) + return false; + + if (parts.option == "") + return c.get_all(parts.package, parts.section) != null; + return c.get(parts.package, parts.section, parts.option) != null; +} + +function uci_delete(path) { + let parts = path_parts(path); + let c = uci_cursor(); + if (c == null || parts == null) + return false; + + try { + if (parts.option == "") + c.delete(parts.package, parts.section); + else + c.delete(parts.package, parts.section, parts.option); + return true; + } + catch (e) { + return false; + } +} + +function uci_set(path, value) { + let parts = path_parts(path); + let c = uci_cursor(); + if (c == null || parts == null || parts.option == "") + return false; + + try { + c.set(parts.package, parts.section, parts.option, type(value) == "array" ? value : as_string(value)); + return true; + } + catch (e) { + return false; + } +} + +function uci_add_list(path, value) { + let parts = path_parts(path); + let c = uci_cursor(); + if (c == null || parts == null || parts.option == "") + return false; + + try { + let values = uci_value_to_list(c.get(parts.package, parts.section, parts.option)); + push(values, as_string(value)); + c.set(parts.package, parts.section, parts.option, values); + return true; + } + catch (e) { + return false; + } +} + +function uci_del_list(path, value) { + let parts = path_parts(path); + let c = uci_cursor(); + if (c == null || parts == null || parts.option == "") + return false; + + let values = []; + let removed = false; + for (let item in uci_value_to_list(c.get(parts.package, parts.section, parts.option))) { + if (item == value) { + removed = true; + continue; + } + push(values, item); + } + + if (!removed) + return false; + + try { + if (length(values) == 0) + c.delete(parts.package, parts.section, parts.option); + else + c.set(parts.package, parts.section, parts.option, values); + return true; + } + catch (e) { + return false; + } +} + +function uci_commit(package_name) { + let c = uci_cursor(); + if (c == null) + return false; + + try { + return c.commit(package_name) != false; + } + catch (e) { + return false; + } +} + +function run(command) { + return system(command) == 0; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function run_args(args) { + return normalize_status(system(command_from_args(args) + " >/dev/null 2>&1")) == 0; +} + +function command_output(args) { + let pipe = fs.popen(command_from_args(args) + " 2>/dev/null", "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + pipe.close(); + return data == null ? "" : data; +} + +function env(name, fallback) { + let value = getenv(name); + if (value == null || value == "") + return as_string(fallback); + return as_string(value); +} + +const INSTALLER_PODKOP_PLUS_INIT = env("PODKOP_INSTALLER_PODKOP_PLUS_INIT", "/etc/init.d/podkop-plus"); +const INSTALLER_ORIGINAL_PODKOP_INIT = env("PODKOP_INSTALLER_ORIGINAL_PODKOP_INIT", "/etc/init.d/podkop"); +const INSTALLER_PODKOP_PLUS_BIN = env("PODKOP_INSTALLER_PODKOP_PLUS_BIN", "/usr/bin/podkop-plus"); +const INSTALLER_PODKOP_PLUS_LIB = env("PODKOP_INSTALLER_PODKOP_PLUS_LIB", "/usr/lib/podkop-plus"); +const INSTALLER_PODKOP_PLUS_UCI_DEFAULTS = env("PODKOP_INSTALLER_PODKOP_PLUS_UCI_DEFAULTS", "/etc/uci-defaults/50_luci-podkop-plus"); +const INSTALLER_PODKOP_PLUS_LUCI_VIEW = env("PODKOP_INSTALLER_PODKOP_PLUS_LUCI_VIEW", "/www/luci-static/resources/view/podkop_plus"); +const INSTALLER_MENU_JSON = env("PODKOP_INSTALLER_MENU_JSON", "/usr/share/luci/menu.d/luci-app-podkop-plus.json"); +const INSTALLER_ACL_JSON = env("PODKOP_INSTALLER_ACL_JSON", "/usr/share/rpcd/acl.d/luci-app-podkop-plus.json"); +const INSTALLER_RU_LMO = env("PODKOP_INSTALLER_RU_LMO", "/usr/lib/lua/luci/i18n/podkop_plus.ru.lmo"); +const INSTALLER_EN_LMO = env("PODKOP_INSTALLER_EN_LMO", "/usr/lib/lua/luci/i18n/podkop_plus.en.lmo"); +const INSTALLER_RU_LUA = env("PODKOP_INSTALLER_RU_LUA", "/usr/lib/lua/luci/i18n/podkop_plus.ru.lua"); +const INSTALLER_EN_LUA = env("PODKOP_INSTALLER_EN_LUA", "/usr/lib/lua/luci/i18n/podkop_plus.en.lua"); +const INSTALLER_RPCD_INIT = env("PODKOP_INSTALLER_RPCD_INIT", "/etc/init.d/rpcd"); + +function path_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function path_executable(path) { + return run_args([ "test", "-x", path ]); +} + +function remove_path(path) { + if (as_string(path) == "" || !path_exists(path)) + return true; + return run_args([ "rm", "-rf", path ]); +} + +function remove_glob(pattern) { + pattern = as_string(pattern); + if (pattern == "") + return; + for (let path in fs.glob(pattern)) + remove_path(path); +} + +function remove_globs(patterns) { + for (let pattern in words(patterns)) + remove_glob(pattern); +} + +function restart_dnsmasq() { + return run("[ -x /etc/init.d/dnsmasq ] && /etc/init.d/dnsmasq restart"); +} + +function installer_package_manager() { + return run_args([ "apk", "--version" ]) ? "apk" : "opkg"; +} + +function installer_installed_package_names() { + let manager = installer_package_manager(); + let output = manager == "apk" ? + command_output([ "apk", "info" ]) : + command_output([ "opkg", "list-installed" ]); + let names = []; + + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + if (line == "") + continue; + if (manager == "opkg") { + let parts = split(line, /[ \t]+/); + line = parts[0] || ""; + } + if (line != "") + push(names, line); + } + + return names; +} + +function installer_package_installed(name) { + name = as_string(name); + if (name == "") + return false; + + if (installer_package_manager() == "apk") + return run_args([ "apk", "info", "-e", name ]); + + for (let installed in installer_installed_package_names()) + if (installed == name) + return true; + return false; +} + +function installer_remove_package(name) { + name = as_string(name); + if (name == "" || !installer_package_installed(name)) + return true; + + if (installer_package_manager() == "apk") + run_args([ "apk", "del", name ]); + else + run_args([ "opkg", "remove", "--force-depends", name ]); + return true; +} + +function installer_remove_package_prefix(prefix) { + prefix = as_string(prefix); + if (prefix == "") + return true; + + for (let name in installer_installed_package_names()) + if (starts_with(name, prefix)) + installer_remove_package(name); + return true; +} + +function installer_confirm_remove_https_dns_proxy() { + if (!installer_package_installed("https-dns-proxy")) + return true; + + warn("Detected conflicting package: https-dns-proxy\n"); + + if (run("[ ! -t 0 ]")) { + warn("Remove the conflicting https-dns-proxy package and continue?: 1 (yes, non-interactive)\n"); + return true; + } + + while (true) { + warn("\nRemove the conflicting https-dns-proxy package and continue?\n"); + warn(" 1) yes\n"); + warn(" 2) no\n"); + warn("Select [2]: "); + + let input = fs.open("/dev/stdin", "r"); + let answer = input ? trim(as_string(input.read("line"))) : ""; + if (input) + input.close(); + + if (answer == "1") + return true; + if (answer == "" || answer == "2") + return false; + warn("Invalid choice\n"); + } +} + +function installer_service_enabled(init_script) { + return path_executable(init_script) && run_args([ init_script, "enabled" ]); +} + +function installer_service_running(init_script) { + if (!path_executable(init_script)) + return false; + + if (trim(command_output([ init_script, "status" ])) == "running") + return true; + return run_args([ init_script, "running" ]); +} + +function installer_podkop_plus_status_running() { + if (!path_executable(INSTALLER_PODKOP_PLUS_BIN)) + return false; + return index(command_output([ INSTALLER_PODKOP_PLUS_BIN, "get_status" ]), "\"running\":1") >= 0; +} + +function installer_restore_dnsmasq() { + if (path_executable(INSTALLER_PODKOP_PLUS_BIN) && + run_args([ INSTALLER_PODKOP_PLUS_BIN, "restore_dnsmasq" ])) + return true; + + return dnsmasq_failsafe_restore(); +} + +function installer_deactivate_original_podkop() { + if (!path_executable(INSTALLER_ORIGINAL_PODKOP_INIT)) + return; + + if (installer_service_running(INSTALLER_ORIGINAL_PODKOP_INIT)) { + warn("Detected a running original Podkop service. Stopping it before installing Podkop Plus.\n"); + run_args([ INSTALLER_ORIGINAL_PODKOP_INIT, "stop" ]); + } + + if (installer_service_enabled(INSTALLER_ORIGINAL_PODKOP_INIT)) { + warn("Detected an enabled original Podkop autostart. Disabling it before installing Podkop Plus.\n"); + run_args([ INSTALLER_ORIGINAL_PODKOP_INIT, "disable" ]); + } +} + +function installer_cleanup_legacy() { + let backend_installed = installer_package_installed("podkop-plus"); + let was_enabled = installer_service_enabled(INSTALLER_PODKOP_PLUS_INIT); + let was_running = installer_service_running(INSTALLER_PODKOP_PLUS_INIT) || installer_podkop_plus_status_running(); + + if (!installer_confirm_remove_https_dns_proxy()) + return false; + + installer_deactivate_original_podkop(); + + if (path_executable(INSTALLER_PODKOP_PLUS_INIT)) { + run_args([ INSTALLER_PODKOP_PLUS_INIT, "stop" ]); + installer_restore_dnsmasq(); + run_args([ INSTALLER_PODKOP_PLUS_INIT, "disable" ]); + } + + installer_remove_package("luci-app-https-dns-proxy"); + installer_remove_package("https-dns-proxy"); + installer_remove_package_prefix("luci-i18n-https-dns-proxy"); + installer_remove_package_prefix("luci-i18n-podkop-plus"); + installer_remove_package("luci-app-podkop-plus"); + + if (!backend_installed) { + remove_path(INSTALLER_PODKOP_PLUS_LIB); + remove_path(INSTALLER_PODKOP_PLUS_INIT); + remove_path(INSTALLER_PODKOP_PLUS_BIN); + } + + for (let path in [ + INSTALLER_PODKOP_PLUS_LUCI_VIEW, + INSTALLER_MENU_JSON, + INSTALLER_ACL_JSON, + INSTALLER_PODKOP_PLUS_UCI_DEFAULTS, + INSTALLER_RU_LMO, + INSTALLER_EN_LMO, + INSTALLER_RU_LUA, + INSTALLER_EN_LUA + ]) + remove_path(path); + + print("PODKOP_WAS_ENABLED=", was_enabled ? "1" : "0", "\n"); + print("PODKOP_WAS_RUNNING=", was_running ? "1" : "0", "\n"); + return true; +} + +function installer_post_install() { + remove_globs(env("PODKOP_INSTALLER_LUCI_CACHE_GLOBS", "/var/luci-indexcache* /tmp/luci-indexcache*")); + for (let path in [ + env("PODKOP_INSTALLER_LATEST_VERSION_CACHE", "/tmp/podkop-plus.latest-version.cache"), + env("PODKOP_INSTALLER_SYSTEM_INFO_CACHE", "/var/run/podkop-plus/system-info.json"), + env("PODKOP_INSTALLER_SERVER_COUNTRY_CACHE", "/var/run/podkop-plus/server-country-cache.json"), + env("PODKOP_INSTALLER_SING_BOX_VERSION_CACHE", "/var/run/podkop-plus/ui-state/sing-box-version"), + env("PODKOP_INSTALLER_TMP_SYSTEM_INFO_CACHE", "/tmp/podkop-plus/system-info.json") + ]) + remove_path(path); + + if (path_executable(INSTALLER_RPCD_INIT)) + run_args([ INSTALLER_RPCD_INIT, "reload" ]); + + if (env("PODKOP_WAS_ENABLED", "0") == "1" && path_executable(INSTALLER_PODKOP_PLUS_INIT)) + run_args([ INSTALLER_PODKOP_PLUS_INIT, "enable" ]); + + if (env("PODKOP_WAS_RUNNING", "0") == "1" && path_executable(INSTALLER_PODKOP_PLUS_INIT)) { + if (!run_args([ INSTALLER_PODKOP_PLUS_INIT, "start" ]) && + !run_args([ INSTALLER_PODKOP_PLUS_INIT, "restart" ])) + warn("Failed to start Podkop Plus after upgrade.\n"); + } + + return true; +} + +function list_has(values, needle) { + for (let value in words(values)) + if (value == needle) + return true; + return false; +} + +function dnsmasq_legacy_instance_exists() { + return uci_exists("dhcp.podkop_plus"); +} + +function dnsmasq_default_servers() { + return uci_get("dhcp.@dnsmasq[0].server"); +} + +function dnsmasq_default_has_podkop_dns() { + return list_has(dnsmasq_default_servers(), "127.0.0.42"); +} + +function dnsmasq_has_podkop_dns() { + return dnsmasq_default_has_podkop_dns() || dnsmasq_legacy_instance_exists(); +} + +function dnsmasq_has_podkop_managed_state() { + return uci_get("dhcp.@dnsmasq[0].podkop_server") != "" || + uci_get("dhcp.@dnsmasq[0].podkop_noresolv") != "" || + uci_get("dhcp.@dnsmasq[0].podkop_cachesize") != "" || + uci_get("dhcp.@dnsmasq[0].podkop_notinterface") != "" || + dnsmasq_legacy_instance_exists(); +} + +function dnsmasq_management_disabled() { + return truthy(uci_get("podkop-plus.settings.dont_touch_dhcp")); +} + +function dnsmasq_legacy_interfaces() { + let legacy_interfaces = uci_get("dhcp.podkop_plus.interface"); + if (legacy_interfaces == "") + legacy_interfaces = uci_get("podkop-plus.settings.source_network_interfaces"); + if (legacy_interfaces == "") + legacy_interfaces = "br-lan"; + + return legacy_interfaces; +} + +function dnsmasq_cleanup_legacy_instance() { + let legacy_instance_present = dnsmasq_legacy_instance_exists(); + let legacy_interfaces = legacy_instance_present ? dnsmasq_legacy_interfaces() : ""; + + uci_delete("dhcp.podkop_plus"); + + let backup_notinterfaces = uci_get("dhcp.@dnsmasq[0].podkop_notinterface"); + if (backup_notinterfaces != "") { + uci_delete("dhcp.@dnsmasq[0].notinterface"); + for (let value in words(backup_notinterfaces)) + uci_add_list("dhcp.@dnsmasq[0].notinterface", value); + uci_delete("dhcp.@dnsmasq[0].podkop_notinterface"); + return; + } + + if (legacy_instance_present) { + for (let value in words(legacy_interfaces)) + uci_del_list("dhcp.@dnsmasq[0].notinterface", value); + } + + uci_delete("dhcp.@dnsmasq[0].podkop_notinterface"); +} + +function dnsmasq_restore_default_instance() { + let server_list = dnsmasq_default_servers(); + let backup_servers = uci_get("dhcp.@dnsmasq[0].podkop_server"); + let managed_global_dns = list_has(server_list, "127.0.0.42"); + + uci_delete("dhcp.@dnsmasq[0].server"); + if (backup_servers != "") { + for (let value in words(backup_servers)) + uci_add_list("dhcp.@dnsmasq[0].server", value); + uci_delete("dhcp.@dnsmasq[0].podkop_server"); + } + else { + for (let value in words(server_list)) { + if (value != "127.0.0.42") + uci_add_list("dhcp.@dnsmasq[0].server", value); + } + } + uci_delete("dhcp.@dnsmasq[0].podkop_server"); + + let noresolv = uci_get("dhcp.@dnsmasq[0].podkop_noresolv"); + if (noresolv != "") { + uci_set("dhcp.@dnsmasq[0].noresolv", noresolv); + uci_delete("dhcp.@dnsmasq[0].podkop_noresolv"); + } + else if (managed_global_dns) { + uci_set("dhcp.@dnsmasq[0].noresolv", "0"); + } + + let cachesize = uci_get("dhcp.@dnsmasq[0].podkop_cachesize"); + if (cachesize != "") { + uci_set("dhcp.@dnsmasq[0].cachesize", cachesize); + uci_delete("dhcp.@dnsmasq[0].podkop_cachesize"); + } + else if (managed_global_dns) { + uci_set("dhcp.@dnsmasq[0].cachesize", "150"); + } +} + +function dnsmasq_failsafe_restore() { + if (!uci_available()) + return true; + + if (dnsmasq_management_disabled() && !dnsmasq_has_podkop_managed_state()) + return true; + + if (!dnsmasq_has_podkop_dns() && !dnsmasq_has_podkop_managed_state()) + return true; + + dnsmasq_cleanup_legacy_instance(); + dnsmasq_restore_default_instance(); + uci_commit("dhcp"); + restart_dnsmasq(); + return true; } function asset_matches(name, kind, ext) { @@ -211,128 +798,6 @@ function asset_matches(name, kind, ext) { return false; } -function release_asset_url_by_suffix_from_release(release, suffix) { - suffix = as_string(suffix); - for (let asset in (type(release.assets) == "array" ? release.assets : [])) { - if (type(asset) != "object") - continue; - let name = as_string(asset.name || ""); - if (ends_with(name, suffix)) - return as_string(asset.browser_download_url || ""); - } - - return ""; -} - -function release_asset_url_by_suffix(suffix) { - let release = read_stdin_json(); - if (type(release) != "object") - return; - - let url = release_asset_url_by_suffix_from_release(release, suffix); - if (url != "") - print(url, "\n"); -} - -function sing_box_extended_arch_suffix(host_arch, distrib_arch) { - host_arch = as_string(host_arch); - distrib_arch = as_string(distrib_arch); - - if (contains(distrib_arch, "mipsel") || contains(distrib_arch, "mipsle")) - host_arch = "mipsel"; - else if (contains(distrib_arch, "mips64el") || contains(distrib_arch, "mips64le")) - host_arch = "mips64el"; - - if (host_arch == "aarch64") - print("arm64\n"); - else if (substr(host_arch, 0, 5) == "armv7") - print("armv7\n"); - else if (substr(host_arch, 0, 5) == "armv6") - print("armv6\n"); - else if (host_arch == "x86_64") - print("amd64\n"); - else if (host_arch == "i386" || host_arch == "i686") - print("386\n"); - else if (host_arch == "mips") - print("mips-softfloat\n"); - else if (host_arch == "mipsel" || host_arch == "mipsle") - print("mipsle-softfloat\n"); - else if (host_arch == "mips64") - print("mips64\n"); - else if (host_arch == "mips64el" || host_arch == "mips64le") - print("mips64le\n"); - else if (host_arch == "riscv64") - print("riscv64\n"); - else if (host_arch == "s390x") - print("s390x\n"); - else - exit(1); -} - -function sing_box_extended_asset_url(arch_suffix, _unused, compressed) { - let release = read_stdin_json(); - - if (type(release) != "object") - exit(1); - - if (as_string(compressed) != "1") - exit(1); - - arch_suffix = as_string(arch_suffix); - if (arch_suffix == "") - exit(1); - - let url = release_asset_url_by_suffix_from_release(release, "linux-" + arch_suffix + "-compressed.tar.gz"); - if (url != "") { - print(url, "\n"); - return; - } - - exit(1); -} - -function sing_box_extended_package_asset_url(distrib_arch, asset_ext) { - let release = read_stdin_json(); - let suffix; - - if (type(release) != "object") - exit(1); - - distrib_arch = as_string(distrib_arch); - asset_ext = as_string(asset_ext); - if (distrib_arch == "" || asset_ext == "") - exit(1); - - suffix = "_openwrt_" + distrib_arch + "." + asset_ext; - for (let asset in (type(release.assets) == "array" ? release.assets : [])) { - if (type(asset) != "object") - continue; - - let name = as_string(asset.name || ""); - let url = as_string(asset.browser_download_url || ""); - if (url != "" && starts_with(name, "sing-box-extended_") && ends_with(name, suffix)) { - print(url, "\n"); - return; - } - } - - exit(1); -} - -function archive_member_path(member_name) { - member_name = as_string(member_name); - for (let line in split(read_stdin(), "\n")) { - line = as_string(line); - if (line == "") - continue; - let parts = split(line, "/"); - if (length(parts) > 0 && as_string(parts[length(parts) - 1]) == member_name) { - print(line, "\n"); - return; - } - } -} - function github_message() { let value = read_stdin_json(); if (value == null) @@ -367,14 +832,17 @@ else if (mode == "release-tag") release_tag(); else if (mode == "release-asset-url") release_asset_url(ARGV[1], ARGV[2]); -else if (mode == "sing-box-extended-arch-suffix") - sing_box_extended_arch_suffix(ARGV[1], ARGV[2]); -else if (mode == "sing-box-extended-asset-url") - sing_box_extended_asset_url(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "sing-box-extended-package-asset-url") - sing_box_extended_package_asset_url(ARGV[1], ARGV[2]); -else if (mode == "archive-member-path") - archive_member_path(ARGV[1]); +else if (mode == "uci-get") { + let value = uci_get(ARGV[1]); + if (value != "") + print(value, "\n"); +} +else if (mode == "dnsmasq-failsafe-restore") + exit(dnsmasq_failsafe_restore() ? 0 : 1); +else if (mode == "installer-cleanup-legacy") + exit(installer_cleanup_legacy() ? 0 : 1); +else if (mode == "installer-post-install") + exit(installer_post_install() ? 0 : 1); else exit(1); EOF @@ -423,14 +891,6 @@ download_with_retry() { return 1 } -pkg_list_installed_names() { - if [ "$PKG_IS_APK" -eq 1 ]; then - apk info 2>/dev/null - else - opkg list-installed 2>/dev/null | awk '{print $1}' - fi -} - pkg_is_installed() { pkg_name="$1" @@ -459,57 +919,6 @@ pkg_install_name() { fi } -pkg_install_name_downgrade() { - pkg_name="$1" - - if [ "$PKG_IS_APK" -eq 1 ]; then - if pkg_is_installed "$pkg_name"; then - apk fix --reinstall --upgrade "$pkg_name" /dev/null 2>&1 /dev/null 2>&1 /dev/null); do - pkg_remove_if_installed "$pkg_name" - done -} - pkg_install_files() { if [ "$PKG_IS_APK" -eq 1 ]; then apk add --allow-untrusted "$@" /dev/null -} - -install_managed_sing_box_service_script() { - tmp_file="/etc/init.d/sing-box.podkop-plus.$$" - cat >"$tmp_file" <<'EOF' -#!/bin/sh /etc/rc.common -# Podkop Plus managed sing-box service for binary variants - -USE_PROCD=1 -START=99 -PROG="/usr/bin/sing-box" - -start_service() { - config_load "sing-box" - local enabled config_file working_directory - local log_stderr - - config_get_bool enabled "main" "enabled" "0" - [ "$enabled" -eq "1" ] || return 0 - - config_get config_file "main" "conffile" "/etc/sing-box/config.json" - config_get working_directory "main" "workdir" "/usr/share/sing-box" - config_get_bool log_stderr "main" "log_stderr" "1" - - procd_open_instance - procd_set_param command "$PROG" run -c "$config_file" -D "$working_directory" - procd_set_param file "$config_file" - procd_set_param stderr "$log_stderr" - procd_set_param limits core="unlimited" - procd_set_param limits nofile="1000000 1000000" - procd_set_param respawn - procd_close_instance -} - -service_triggers() { - procd_add_reload_trigger "sing-box" -} -EOF - - chmod 0755 "$tmp_file" && - mv -f "$tmp_file" /etc/init.d/sing-box -} - -remove_managed_sing_box_service_script() { - sing_box_managed_service_installed || return 0 - - /etc/init.d/sing-box stop >/dev/null 2>&1 || true - /etc/init.d/sing-box disable >/dev/null 2>&1 || true - rm -f /etc/init.d/sing-box -} - -disable_sing_box_service_config() { - command_exists uci || return 0 - - uci -q get sing-box.main >/dev/null 2>&1 || - uci -q set sing-box.main=sing-box >/dev/null 2>&1 || true - uci -q set sing-box.main.enabled='0' >/dev/null 2>&1 || true - uci -q commit sing-box >/dev/null 2>&1 || true -} - -prepare_sing_box_service_disabled() { - disable_sing_box_service_config - [ -x /etc/init.d/sing-box ] && /etc/init.d/sing-box stop >/dev/null 2>&1 || true - [ -x /etc/init.d/sing-box ] && /etc/init.d/sing-box disable >/dev/null 2>&1 || true -} - -prepare_sing_box_package_service_install() { - prepare_sing_box_service_disabled - remove_managed_sing_box_service_script -} - ensure_bootstrap_tool() { tool_name="$1" package_name="$2" @@ -604,6 +939,23 @@ ensure_bootstrap_tool() { pkg_install_name "$package_name" || fail "Failed to install $package_name" } +ensure_bootstrap_package() { + package_name="$1" + + if pkg_is_installed "$package_name"; then + return 0 + fi + + msg "Installing bootstrap dependency: $package_name" + pkg_install_name "$package_name" || fail "Failed to install $package_name" +} + +ensure_bootstrap_ucode_runtime() { + ensure_bootstrap_tool "ucode" "ucode" + ensure_bootstrap_package "ucode-mod-fs" + ensure_bootstrap_package "ucode-mod-uci" +} + sync_time() { current_year="" @@ -759,55 +1111,7 @@ confirm_prompt() { } get_luci_main_lang() { - command_exists uci || return 0 - uci -q get luci.main.lang 2>/dev/null || true -} - -sanitize_semver() { - printf '%s\n' "$1" | sed 's/^v//;s/-.*$//;s/[^0-9.].*$//' -} - -version_ge() { - lhs_major=0 - lhs_minor=0 - lhs_patch=0 - rhs_major=0 - rhs_minor=0 - rhs_patch=0 - - lhs_version="$(sanitize_semver "$1")" - rhs_version="$(sanitize_semver "$2")" - - old_ifs="$IFS" - IFS='.' - set -- $lhs_version - IFS="$old_ifs" - [ -n "$1" ] && lhs_major="$1" - [ -n "$2" ] && lhs_minor="$2" - [ -n "$3" ] && lhs_patch="$3" - - IFS='.' - set -- $rhs_version - IFS="$old_ifs" - [ -n "$1" ] && rhs_major="$1" - [ -n "$2" ] && rhs_minor="$2" - [ -n "$3" ] && rhs_patch="$3" - - if [ "$lhs_major" -gt "$rhs_major" ]; then - return 0 - fi - if [ "$lhs_major" -lt "$rhs_major" ]; then - return 1 - fi - - if [ "$lhs_minor" -gt "$rhs_minor" ]; then - return 0 - fi - if [ "$lhs_minor" -lt "$rhs_minor" ]; then - return 1 - fi - - [ "$lhs_patch" -ge "$rhs_patch" ] + install_json_ucode uci-get luci.main.lang 2>/dev/null || true } extract_package_version() { @@ -909,39 +1213,6 @@ resolve_podkop_plus_release() { fi } -remove_conflicting_dns_proxy() { - if ! pkg_is_installed "https-dns-proxy"; then - return 0 - fi - - warn "Detected conflicting package: https-dns-proxy" - confirm_prompt "Remove the conflicting https-dns-proxy package and continue?" || fail "Please remove https-dns-proxy manually and run the installer again" - - pkg_remove_if_installed "luci-app-https-dns-proxy" - pkg_remove_if_installed "https-dns-proxy" - pkg_remove_matching_prefix "luci-i18n-https-dns-proxy" -} - -remove_old_sing_box_if_needed() { - installed_version="" - - command_exists sing-box || return 0 - - installed_version="$(sing_box_version_value)" - [ -n "$installed_version" ] || return 0 - - if version_ge "$installed_version" "$REQUIRED_SING_BOX_VERSION"; then - return 0 - fi - - warn "sing-box $installed_version is older than the required version $REQUIRED_SING_BOX_VERSION. Removing the old package first." - [ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus stop >/dev/null 2>&1 || true - [ -x /etc/init.d/podkop ] && /etc/init.d/podkop stop >/dev/null 2>&1 || true - restore_podkop_dnsmasq_failsafe - pkg_remove_if_installed "sing-box-tiny" - pkg_remove_if_installed "sing-box" -} - sing_box_version_value() { command_exists sing-box || return 0 @@ -992,11 +1263,6 @@ read_sing_box_version_state() { sed -n '1p' "$SING_BOX_VERSION_STATE_FILE" 2>/dev/null } -read_sing_box_variant_marker() { - [ -r "$SING_BOX_VARIANT_STATE_FILE" ] || return 1 - sed -n '1p' "$SING_BOX_VARIANT_STATE_FILE" 2>/dev/null -} - read_sing_box_binary_version() { binary_path="$1" library_dir="${2:-}" @@ -1119,713 +1385,48 @@ select_sing_box_installation() { done } -pkg_install_sing_box_variant() { - target_pkg="$1" - conflict_pkg="$2" - - prepare_sing_box_package_service_install - - if { [ -z "$conflict_pkg" ] || ! pkg_is_installed "$conflict_pkg"; } && - { [ "$target_pkg" = "sing-box-extended" ] || ! pkg_is_installed "sing-box-extended"; }; then - pkg_install_name_downgrade "$target_pkg" && return 0 - fi - - if [ "$target_pkg" != "sing-box-extended" ]; then - pkg_remove_sing_box_conflict "sing-box-extended" || return 1 - fi - if [ -n "$conflict_pkg" ]; then - pkg_remove_sing_box_conflict "$conflict_pkg" || return 1 - fi - - pkg_install_name_downgrade "$target_pkg" -} - -write_sing_box_variant_marker() { - variant="$1" - marker_dir="$(dirname "$SING_BOX_VARIANT_STATE_FILE")" - - mkdir -p "$marker_dir" || return 1 - printf '%s\n' "$variant" >"$SING_BOX_VARIANT_STATE_FILE" -} - -write_sing_box_version_state() { - version="$1" - state_dir="$(dirname "$SING_BOX_VERSION_STATE_FILE")" - - [ -n "$version" ] || return 1 - mkdir -p "$state_dir" || return 1 - printf '%s\n' "$version" >"$SING_BOX_VERSION_STATE_FILE" -} - -restore_sing_box_variant_marker() { - previous_marker="$1" - - if [ -n "$previous_marker" ]; then - write_sing_box_variant_marker "$previous_marker" - else - rm -f "$SING_BOX_VARIANT_STATE_FILE" 2>/dev/null || true - fi -} - -restore_sing_box_version_state() { - previous_version="$1" - - if [ -n "$previous_version" ]; then - write_sing_box_version_state "$previous_version" - else - rm -f "$SING_BOX_VERSION_STATE_FILE" 2>/dev/null || true - fi -} - -resolve_sing_box_extended_release() { - compressed="$1" - host_arch="$(uname -m 2>/dev/null || true)" - distrib_arch="$(read_openwrt_release_value "DISTRIB_ARCH")" - asset_ext="ipk" - - SING_BOX_EXTENDED_RELEASE_JSON="$(fetch_github_latest_release_json "shtorm-7" "sing-box-extended")" - SING_BOX_EXTENDED_RELEASE_TAG="$(printf '%s' "$SING_BOX_EXTENDED_RELEASE_JSON" | install_json_ucode release-tag 2>/dev/null)" - [ -n "$SING_BOX_EXTENDED_RELEASE_TAG" ] || fail "Failed to detect the sing-box-extended release tag" - - if [ "$compressed" -eq 0 ]; then - [ "$PKG_IS_APK" -eq 1 ] && asset_ext="apk" - [ -n "$distrib_arch" ] || fail "Failed to detect OpenWrt package architecture for sing-box-extended" - - SING_BOX_EXTENDED_ASSET_URL="$(printf '%s' "$SING_BOX_EXTENDED_RELEASE_JSON" | install_json_ucode sing-box-extended-package-asset-url "$distrib_arch" "$asset_ext" 2>/dev/null)" - [ -n "$SING_BOX_EXTENDED_ASSET_URL" ] || fail "The sing-box-extended release does not contain an OpenWrt .$asset_ext package for $distrib_arch" - SING_BOX_EXTENDED_ASSET_NAME="$(basename "$SING_BOX_EXTENDED_ASSET_URL")" - return 0 - fi - - SING_BOX_EXTENDED_ARCH_SUFFIX="$(install_json_ucode sing-box-extended-arch-suffix "$host_arch" "$distrib_arch" 2>/dev/null)" || - fail "Failed to resolve sing-box-extended architecture for $host_arch/$distrib_arch" - [ -n "$SING_BOX_EXTENDED_ARCH_SUFFIX" ] || fail "Failed to resolve sing-box-extended architecture for $host_arch/$distrib_arch" - - SING_BOX_EXTENDED_ASSET_URL="$(printf '%s' "$SING_BOX_EXTENDED_RELEASE_JSON" | install_json_ucode sing-box-extended-asset-url "$SING_BOX_EXTENDED_ARCH_SUFFIX" 0 "$compressed" 2>/dev/null)" - [ -n "$SING_BOX_EXTENDED_ASSET_URL" ] || fail "The sing-box-extended release does not contain a matching asset for $SING_BOX_EXTENDED_ARCH_SUFFIX" - SING_BOX_EXTENDED_ASSET_NAME="$(basename "$SING_BOX_EXTENDED_ASSET_URL")" -} - -validate_extended_sing_box_binary() { - binary_path="$1" - library_dir="${2:-}" - version="" - - [ -x "$binary_path" ] || return 1 - - version="$(read_sing_box_binary_version "$binary_path" "$library_dir")" - - case "$version" in - *extended*) printf '%s\n' "$version"; return 0 ;; - esac - - return 1 -} - -move_file_to_backup() { - target_path="$1" - backup_path="$2" - - [ -e "$target_path" ] || return 0 - rm -f "$backup_path" - mv -f "$target_path" "$backup_path" -} - -restore_file_backup() { - target_path="$1" - backup_path="$2" - - if [ -n "$backup_path" ] && [ -s "$backup_path" ]; then - mv -f "$backup_path" "$target_path" - return $? - fi - - rm -f "$target_path" -} - -restore_sing_box_service_from_marker() { - previous_marker="$1" - - if [ "$previous_marker" = "extended-compressed" ]; then - install_managed_sing_box_service_script >/dev/null 2>&1 || return 1 - return 0 - fi - - remove_managed_sing_box_service_script >/dev/null 2>&1 || true -} - -restore_sing_box_extended_package_variant() { - package_file="" - new_version="" - - resolve_sing_box_extended_release 0 - package_file="$TMP_DIR/$SING_BOX_EXTENDED_ASSET_NAME" - download_with_retry "$SING_BOX_EXTENDED_ASSET_URL" "$package_file" "$SING_BOX_EXTENDED_ASSET_NAME" || return 1 - prepare_sing_box_package_service_install - pkg_remove_sing_box_conflict "sing-box-tiny" >/dev/null 2>&1 || true - pkg_remove_sing_box_conflict "sing-box" >/dev/null 2>&1 || true - pkg_install_files "$package_file" || { - rm -f "$package_file" - return 1 - } - rm -f "$package_file" - - new_version="$(validate_extended_sing_box_binary /usr/bin/sing-box /usr/lib)" || return 1 - write_sing_box_variant_marker "extended" >/dev/null 2>&1 || true - write_sing_box_version_state "$new_version" >/dev/null 2>&1 || true -} - -restore_sing_box_package_variant() { - previous_variant="$1" - - case "$previous_variant" in - tiny) - pkg_install_sing_box_variant "sing-box-tiny" "sing-box" - ;; - stable) - pkg_install_sing_box_variant "sing-box" "sing-box-tiny" - ;; - extended) - restore_sing_box_extended_package_variant - ;; - none) - pkg_remove_sing_box_conflict "sing-box-extended" - pkg_remove_if_installed "sing-box" - pkg_remove_if_installed "sing-box-tiny" - remove_managed_sing_box_service_script >/dev/null 2>&1 || true - rm -f /usr/bin/sing-box - ;; - *) - return 1 - ;; - esac -} - -sing_box_variant_is_package_managed() { - case "$1" in - stable | tiny | extended) - return 0 - ;; - esac - - return 1 -} - -restore_sing_box_install_backup() { - previous_variant="$1" - backup_binary="$2" - - if sing_box_variant_is_package_managed "$previous_variant"; then - if restore_sing_box_package_variant "$previous_variant"; then - return 0 - fi - - [ -n "$backup_binary" ] && restore_file_backup /usr/bin/sing-box "$backup_binary" >/dev/null 2>&1 || true - [ -x /usr/bin/sing-box ] && chmod 0755 /usr/bin/sing-box >/dev/null 2>&1 || true - return 1 - fi - - if [ -n "$backup_binary" ]; then - restore_file_backup /usr/bin/sing-box "$backup_binary" >/dev/null 2>&1 || return 1 - [ -x /usr/bin/sing-box ] && chmod 0755 /usr/bin/sing-box >/dev/null 2>&1 || true - return 0 - fi - - restore_sing_box_package_variant "$previous_variant" -} - -restore_sing_box_after_failed_extended_install() { - previous_variant="$1" - backup_binary="$2" - backup_cronet="$3" - previous_marker="$4" - previous_version_state="$5" - archive_file="${6:-}" - cronet_touched="${7:-0}" - restore_status=0 - - [ -n "$archive_file" ] && rm -f "$archive_file" - - if [ "$cronet_touched" -eq 1 ]; then - restore_file_backup /usr/lib/libcronet.so "$backup_cronet" >/dev/null 2>&1 || true - fi - - restore_sing_box_install_backup "$previous_variant" "$backup_binary" >/dev/null 2>&1 || restore_status=1 - - restore_sing_box_variant_marker "$previous_marker" >/dev/null 2>&1 || true - restore_sing_box_version_state "$previous_version_state" >/dev/null 2>&1 || true - restore_sing_box_service_from_marker "$previous_marker" >/dev/null 2>&1 || true - rm -f /var/run/podkop-plus/ui-state/sing-box-version 2>/dev/null || true - return "$restore_status" -} - -restore_sing_box_after_failed_extended_package_install() { - previous_variant="$1" - backup_binary="$2" - backup_cronet="$3" - previous_marker="$4" - previous_version_state="$5" - package_file="${6:-}" - cronet_touched="${7:-0}" - restore_status=0 - - [ -n "$package_file" ] && rm -f "$package_file" - pkg_remove_sing_box_conflict "sing-box-extended" >/dev/null 2>&1 || true - - restore_sing_box_install_backup "$previous_variant" "$backup_binary" >/dev/null 2>&1 || restore_status=1 - - if [ "$cronet_touched" -eq 1 ]; then - restore_file_backup /usr/lib/libcronet.so "$backup_cronet" >/dev/null 2>&1 || true - [ -s /usr/lib/libcronet.so ] && chmod 0644 /usr/lib/libcronet.so >/dev/null 2>&1 || true - fi - - restore_sing_box_variant_marker "$previous_marker" >/dev/null 2>&1 || true - restore_sing_box_version_state "$previous_version_state" >/dev/null 2>&1 || true - restore_sing_box_service_from_marker "$previous_marker" >/dev/null 2>&1 || true - rm -f /var/run/podkop-plus/ui-state/sing-box-version 2>/dev/null || true - return "$restore_status" -} - -install_sing_box_extended_package() { - label="sing-box-extended" - marker_variant="extended" - current_variant="$(sing_box_active_variant)" - previous_marker="$(read_sing_box_variant_marker 2>/dev/null || true)" - previous_version_state="$(read_sing_box_version_state 2>/dev/null || true)" - backup_binary="" - backup_cronet="" - cronet_touched=0 - - resolve_sing_box_extended_release 0 - SING_BOX_EXTENDED_PACKAGE_FILE="$TMP_DIR/$SING_BOX_EXTENDED_ASSET_NAME" - download_with_retry "$SING_BOX_EXTENDED_ASSET_URL" "$SING_BOX_EXTENDED_PACKAGE_FILE" "$SING_BOX_EXTENDED_ASSET_NAME" || - fail "Failed to download $SING_BOX_EXTENDED_ASSET_NAME" - - prepare_sing_box_package_service_install - - case "$current_variant" in - extended|extended-compressed) - if [ -e /usr/bin/sing-box ]; then - backup_binary="/usr/bin/sing-box.podkop-backup.$$" - move_file_to_backup /usr/bin/sing-box "$backup_binary" || { - rm -f "$backup_binary" - fail "Failed to backup current sing-box binary" - } - fi - if [ -e /usr/lib/libcronet.so ]; then - cronet_touched=1 - backup_cronet="/usr/lib/libcronet.so.podkop-backup.$$" - move_file_to_backup /usr/lib/libcronet.so "$backup_cronet" || { - rm -f "$backup_cronet" - backup_cronet="" - cronet_touched=0 - restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_PACKAGE_FILE" "$cronet_touched" - fail "Failed to backup current libcronet.so" - } - fi - ;; - esac - - pkg_remove_sing_box_conflict "sing-box-tiny" || { - restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_PACKAGE_FILE" "$cronet_touched" - fail "Failed to remove sing-box-tiny before $label package installation" - } - pkg_remove_sing_box_conflict "sing-box" || { - restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_PACKAGE_FILE" "$cronet_touched" - fail "Failed to remove sing-box before $label package installation" - } - - if [ -z "$backup_binary" ] && [ -e /usr/bin/sing-box ]; then - backup_binary="/usr/bin/sing-box.podkop-backup.$$" - move_file_to_backup /usr/bin/sing-box "$backup_binary" || { - rm -f "$backup_binary" - backup_binary="" - restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_PACKAGE_FILE" "$cronet_touched" - fail "Failed to backup existing sing-box binary" - } - fi - - if [ "$cronet_touched" -eq 0 ] && [ -e /usr/lib/libcronet.so ]; then - cronet_touched=1 - backup_cronet="/usr/lib/libcronet.so.podkop-backup.$$" - move_file_to_backup /usr/lib/libcronet.so "$backup_cronet" || { - rm -f "$backup_cronet" - backup_cronet="" - cronet_touched=0 - restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_PACKAGE_FILE" "$cronet_touched" - fail "Failed to backup current libcronet.so" - } - fi - - pkg_install_files "$SING_BOX_EXTENDED_PACKAGE_FILE" || { - restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_PACKAGE_FILE" "$cronet_touched" - fail "Failed to install $SING_BOX_EXTENDED_ASSET_NAME" - } - - new_version="$(validate_extended_sing_box_binary /usr/bin/sing-box /usr/lib)" || { - restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_PACKAGE_FILE" "$cronet_touched" - fail "Installed $label package failed validation" - } - - rm -f "$SING_BOX_EXTENDED_PACKAGE_FILE" "$backup_binary" "$backup_cronet" - write_sing_box_variant_marker "$marker_variant" || warn "Failed to write sing-box variant marker" - write_sing_box_version_state "$new_version" || warn "Failed to write sing-box version state" - rm -f /var/run/podkop-plus/ui-state/sing-box-version 2>/dev/null || true - msg "Installed $label $new_version" -} - -install_sing_box_extended_binary() { - compressed="$1" - if [ "$compressed" -ne 1 ]; then - install_sing_box_extended_package - return 0 - fi - - label="sing-box-extended compressed" - marker_variant="extended-compressed" - current_variant="$(sing_box_active_variant)" - previous_marker="$(read_sing_box_variant_marker 2>/dev/null || true)" - previous_version_state="$(read_sing_box_version_state 2>/dev/null || true)" - binary_path="" - cronet_path="" - backup_binary="" - backup_cronet="" - cronet_touched=0 - tmp_binary="$TMP_DIR/sing-box.compressed.$$" - tmp_cronet="" - extract_error="$TMP_DIR/sing-box-extract.err" - - resolve_sing_box_extended_release "$compressed" - SING_BOX_EXTENDED_ARCHIVE_FILE="$TMP_DIR/$SING_BOX_EXTENDED_ASSET_NAME" - download_with_retry "$SING_BOX_EXTENDED_ASSET_URL" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$SING_BOX_EXTENDED_ASSET_NAME" || - fail "Failed to download $SING_BOX_EXTENDED_ASSET_NAME" - - binary_path="$(tar -tzf "$SING_BOX_EXTENDED_ARCHIVE_FILE" 2>/dev/null | install_json_ucode archive-member-path sing-box 2>/dev/null)" - [ -n "$binary_path" ] || fail "sing-box binary was not found in $SING_BOX_EXTENDED_ASSET_NAME" - cronet_path="$(tar -tzf "$SING_BOX_EXTENDED_ARCHIVE_FILE" 2>/dev/null | install_json_ucode archive-member-path libcronet.so 2>/dev/null)" - - if ! tar -xzf "$SING_BOX_EXTENDED_ARCHIVE_FILE" -O "$binary_path" >"$tmp_binary" 2>"$extract_error"; then - [ -s "$extract_error" ] && cat "$extract_error" >&2 - rm -f "$tmp_binary" - fail "Failed to extract $label" - fi - if [ ! -s "$tmp_binary" ]; then - rm -f "$tmp_binary" - fail "Extracted $label binary is empty" - fi - if ! chmod 0755 "$tmp_binary"; then - rm -f "$tmp_binary" - fail "Failed to prepare $label binary" - fi - - if [ -n "$cronet_path" ]; then - tmp_cronet="$TMP_DIR/libcronet.so" - if ! tar -xzf "$SING_BOX_EXTENDED_ARCHIVE_FILE" -O "$cronet_path" >"$tmp_cronet" 2>"$extract_error"; then - [ -s "$extract_error" ] && cat "$extract_error" >&2 - rm -f "$tmp_binary" "$tmp_cronet" - fail "Failed to extract libcronet.so from $label" - fi - if [ ! -s "$tmp_cronet" ]; then - rm -f "$tmp_binary" "$tmp_cronet" - fail "Extracted libcronet.so is empty" - fi - if ! chmod 0644 "$tmp_cronet"; then - rm -f "$tmp_binary" "$tmp_cronet" - fail "Failed to prepare libcronet.so" - fi - fi - - new_version="$(validate_extended_sing_box_binary "$tmp_binary" "$TMP_DIR")" || { - rm -f "$tmp_binary" "$tmp_cronet" - fail "Downloaded $label failed validation" - } - - if [ -e /usr/bin/sing-box ]; then - backup_binary="/usr/bin/sing-box.podkop-backup.$$" - move_file_to_backup /usr/bin/sing-box "$backup_binary" || { - rm -f "$backup_binary" "$tmp_binary" "$tmp_cronet" - fail "Failed to backup current sing-box binary" - } - fi - - if [ -n "$cronet_path" ]; then - cronet_touched=1 - if [ -e /usr/lib/libcronet.so ]; then - backup_cronet="/usr/lib/libcronet.so.podkop-backup.$$" - move_file_to_backup /usr/lib/libcronet.so "$backup_cronet" || { - rm -f "$backup_cronet" "$tmp_binary" "$tmp_cronet" - backup_cronet="" - cronet_touched=0 - restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$cronet_touched" - fail "Failed to backup current libcronet.so" - } - fi - fi - - pkg_remove_sing_box_conflict "sing-box-extended" || { - restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$cronet_touched" - rm -f "$tmp_binary" "$tmp_cronet" - fail "Failed to remove sing-box-extended before $label installation" - } - pkg_remove_sing_box_conflict "sing-box-tiny" || { - restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$cronet_touched" - rm -f "$tmp_binary" "$tmp_cronet" - fail "Failed to remove sing-box-tiny before $label installation" - } - pkg_remove_sing_box_conflict "sing-box" || { - restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$cronet_touched" - rm -f "$tmp_binary" "$tmp_cronet" - fail "Failed to remove sing-box before $label installation" - } - - remove_managed_sing_box_service_script >/dev/null 2>&1 || true - install_managed_sing_box_service_script || { - restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$cronet_touched" - rm -f "$tmp_binary" "$tmp_cronet" - fail "Failed to install managed sing-box service for $label" - } - - rm -f /usr/bin/sing-box - if ! mv -f "$tmp_binary" /usr/bin/sing-box || ! chmod 0755 /usr/bin/sing-box; then - rm -f /usr/bin/sing-box "$tmp_binary" - restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$cronet_touched" - fail "Failed to install $label binary" - fi - - if [ -n "$tmp_cronet" ]; then - rm -f /usr/lib/libcronet.so - if ! mv -f "$tmp_cronet" /usr/lib/libcronet.so || ! chmod 0644 /usr/lib/libcronet.so; then - rm -f /usr/lib/libcronet.so "$tmp_cronet" - restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$cronet_touched" - fail "Failed to install libcronet.so for $label" - fi - fi - - rm -f "$SING_BOX_EXTENDED_ARCHIVE_FILE" - - new_version="$(validate_extended_sing_box_binary /usr/bin/sing-box /usr/lib)" || { - restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$SING_BOX_EXTENDED_ARCHIVE_FILE" "$cronet_touched" - fail "Installed $label failed validation" - } - - rm -f "$backup_binary" "$backup_cronet" - write_sing_box_variant_marker "$marker_variant" || warn "Failed to write sing-box variant marker" - write_sing_box_version_state "$new_version" || warn "Failed to write sing-box version state" - rm -f /var/run/podkop-plus/ui-state/sing-box-version 2>/dev/null || true - msg "Installed $label $new_version" -} - -stop_podkop_for_sing_box_install() { - remember_service_state - [ -x /etc/init.d/podkop-plus ] && [ "$PODKOP_WAS_RUNNING" -eq 1 ] && - /etc/init.d/podkop-plus stop >/dev/null 2>&1 || true - [ "$PODKOP_WAS_RUNNING" -eq 1 ] && restore_podkop_dnsmasq_failsafe - prepare_sing_box_service_disabled -} - install_selected_sing_box() { + action="" + output_file="$TMP_DIR/sing-box-component-action.json" + case "$SING_BOX_INSTALL_VARIANT" in "") msg "$(installer_text sing_box_skip_msg)" + return 0 ;; stable) - stop_podkop_for_sing_box_install - pkg_install_sing_box_variant "sing-box" "sing-box-tiny" || fail "Failed to install stable sing-box" - new_version="$(read_sing_box_binary_version /usr/bin/sing-box)" - [ -n "$new_version" ] || fail "Stable sing-box package was installed, but sing-box binary is not available" - sing_box_is_extended "$new_version" && fail "Stable sing-box package was installed, but the active binary is still sing-box-extended" - write_sing_box_variant_marker "stable" || warn "Failed to write sing-box variant marker" - write_sing_box_version_state "$new_version" || warn "Failed to write sing-box version state" - rm -f /var/run/podkop-plus/ui-state/sing-box-version 2>/dev/null || true + action="install_stable" ;; tiny) - stop_podkop_for_sing_box_install - pkg_install_sing_box_variant "sing-box-tiny" "sing-box" || fail "Failed to install sing-box-tiny" - new_version="$(read_sing_box_binary_version /usr/bin/sing-box)" - [ -n "$new_version" ] || fail "sing-box-tiny package was installed, but sing-box binary is not available" - sing_box_is_extended "$new_version" && fail "sing-box-tiny package was installed, but the active binary is still sing-box-extended" - write_sing_box_variant_marker "tiny" || warn "Failed to write sing-box variant marker" - write_sing_box_version_state "$new_version" || warn "Failed to write sing-box version state" - rm -f /var/run/podkop-plus/ui-state/sing-box-version 2>/dev/null || true + action="install_tiny" ;; extended) - stop_podkop_for_sing_box_install - install_sing_box_extended_package + action="install_extended" ;; extended-compressed) - stop_podkop_for_sing_box_install - install_sing_box_extended_binary 1 + action="install_extended_compressed" ;; *) fail "Unknown sing-box installation variant: $SING_BOX_INSTALL_VARIANT" ;; esac -} -restore_podkop_dnsmasq_failsafe_raw() { - podkop_legacy_dnsmasq_section="podkop_plus" - podkop_dns_address="127.0.0.42" - podkop_config_name="podkop-plus" - podkop_dnsmasq_changed=0 - podkop_default_has_dns=0 - podkop_legacy_instance_present=0 - - command_exists uci || return 0 - podkop_dont_touch_dhcp_enabled && return 0 - - podkop_legacy_interfaces="$(uci -q get "dhcp.$podkop_legacy_dnsmasq_section.interface" 2>/dev/null)" - [ -n "$podkop_legacy_interfaces" ] || - podkop_legacy_interfaces="$(uci -q get "$podkop_config_name.settings.source_network_interfaces" 2>/dev/null)" - [ -n "$podkop_legacy_interfaces" ] || podkop_legacy_interfaces="br-lan" - - podkop_default_servers="$(uci -q get 'dhcp.@dnsmasq[0].server' 2>/dev/null)" - for podkop_value in $podkop_default_servers; do - [ "$podkop_value" = "$podkop_dns_address" ] && podkop_default_has_dns=1 - done - - if uci -q show "dhcp.$podkop_legacy_dnsmasq_section" >/dev/null 2>&1; then - podkop_legacy_instance_present=1 - podkop_dnsmasq_changed=1 - fi - uci -q delete "dhcp.$podkop_legacy_dnsmasq_section" >/dev/null 2>&1 || true - - podkop_backup_notinterfaces="$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" - if [ -n "$podkop_backup_notinterfaces" ]; then - uci -q delete 'dhcp.@dnsmasq[0].notinterface' >/dev/null 2>&1 || true - for podkop_value in $podkop_backup_notinterfaces; do - uci -q add_list "dhcp.@dnsmasq[0].notinterface=$podkop_value" >/dev/null 2>&1 || true - done - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - podkop_dnsmasq_changed=1 - else - if [ "$podkop_legacy_instance_present" -eq 1 ]; then - for podkop_value in $podkop_legacy_interfaces; do - uci -q del_list "dhcp.@dnsmasq[0].notinterface=$podkop_value" >/dev/null 2>&1 && podkop_dnsmasq_changed=1 - done - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - fi - - podkop_backup_servers="$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" - if [ -n "$podkop_backup_servers" ]; then - uci -q delete 'dhcp.@dnsmasq[0].server' >/dev/null 2>&1 || true - for podkop_value in $podkop_backup_servers; do - uci -q add_list "dhcp.@dnsmasq[0].server=$podkop_value" >/dev/null 2>&1 || true - done - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - podkop_dnsmasq_changed=1 - else - uci -q del_list "dhcp.@dnsmasq[0].server=$podkop_dns_address" >/dev/null 2>&1 && podkop_dnsmasq_changed=1 - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - fi - - podkop_noresolv="$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" - if [ -n "$podkop_noresolv" ]; then - uci -q set "dhcp.@dnsmasq[0].noresolv=$podkop_noresolv" >/dev/null 2>&1 || true - uci -q delete 'dhcp.@dnsmasq[0].podkop_noresolv' >/dev/null 2>&1 || true - podkop_dnsmasq_changed=1 - elif [ "$podkop_default_has_dns" -eq 1 ]; then - uci -q set 'dhcp.@dnsmasq[0].noresolv=0' >/dev/null 2>&1 || true - podkop_dnsmasq_changed=1 - fi - - podkop_cachesize="$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" - if [ -n "$podkop_cachesize" ]; then - uci -q set "dhcp.@dnsmasq[0].cachesize=$podkop_cachesize" >/dev/null 2>&1 || true - uci -q delete 'dhcp.@dnsmasq[0].podkop_cachesize' >/dev/null 2>&1 || true - podkop_dnsmasq_changed=1 - elif [ "$podkop_default_has_dns" -eq 1 ]; then - uci -q set 'dhcp.@dnsmasq[0].cachesize=150' >/dev/null 2>&1 || true - podkop_dnsmasq_changed=1 - fi - - [ "$podkop_dnsmasq_changed" -eq 1 ] || return 0 - - uci -q commit dhcp >/dev/null 2>&1 || true - [ -x /etc/init.d/dnsmasq ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true -} - -restore_podkop_dnsmasq_failsafe() { - podkop_dont_touch_dhcp_enabled && return 0 - - [ -x /usr/bin/podkop-plus ] && /usr/bin/podkop-plus restore_dnsmasq >/dev/null 2>&1 || true - - if [ -r /usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh ]; then - sh /usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh >/dev/null 2>&1 || true - else - restore_podkop_dnsmasq_failsafe_raw - fi -} - -remember_service_state() { - service_status="" - - if [ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus enabled >/dev/null 2>&1; then - PODKOP_WAS_ENABLED=1 - fi - - if [ -x /etc/init.d/podkop-plus ]; then - service_status="$(/etc/init.d/podkop-plus status 2>/dev/null || true)" - if [ "$service_status" = "running" ]; then - PODKOP_WAS_RUNNING=1 - return 0 - fi - fi - - if [ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus running >/dev/null 2>&1; then - PODKOP_WAS_RUNNING=1 - return 0 - fi - - if [ -x /usr/bin/podkop-plus ] && /usr/bin/podkop-plus get_status 2>/dev/null | grep -q '"running":1'; then - PODKOP_WAS_RUNNING=1 - fi -} - -stop_conflicting_services() { - [ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus stop >/dev/null 2>&1 || true - restore_podkop_dnsmasq_failsafe - [ -x /etc/init.d/podkop-plus ] && /etc/init.d/podkop-plus disable >/dev/null 2>&1 || true -} - -deactivate_original_podkop_if_present() { - [ -x /etc/init.d/podkop ] || return 0 - - if /etc/init.d/podkop running >/dev/null 2>&1; then - warn "Detected a running original Podkop service. Stopping it before installing Podkop Plus." - /etc/init.d/podkop stop >/dev/null 2>&1 || warn "Failed to stop the original Podkop service." - fi - - if /etc/init.d/podkop enabled >/dev/null 2>&1; then - warn "Detected an enabled original Podkop autostart. Disabling it before installing Podkop Plus." - /etc/init.d/podkop disable >/dev/null 2>&1 || warn "Failed to disable original Podkop autostart." + [ -x /usr/bin/podkop-plus ] || fail "podkop-plus backend must be installed before sing-box component action" + msg "Installing selected sing-box variant through Podkop Plus ucode backend" + if ! /usr/bin/podkop-plus component_action sing_box "$action" >"$output_file" 2>&1; then + cat "$output_file" >&2 2>/dev/null || true + fail "Failed to install selected sing-box variant" fi } cleanup_legacy_installation() { - backend_package_installed=0 + state_file="$TMP_DIR/install-state.env" - pkg_is_installed "podkop-plus" && backend_package_installed=1 - remember_service_state - stop_conflicting_services + install_json_ucode installer-cleanup-legacy >"$state_file" || + fail "Failed to prepare the system before Podkop Plus package installation" - pkg_remove_matching_prefix "luci-i18n-podkop-plus" - pkg_remove_if_installed "luci-app-podkop-plus" - - if [ "$backend_package_installed" -eq 0 ]; then - rm -rf /usr/lib/podkop-plus - rm -f /etc/init.d/podkop-plus - rm -f /usr/bin/podkop-plus - fi - - rm -rf /www/luci-static/resources/view/podkop_plus - rm -f /usr/share/luci/menu.d/luci-app-podkop-plus.json - rm -f /usr/share/rpcd/acl.d/luci-app-podkop-plus.json - rm -f /etc/uci-defaults/50_luci-podkop-plus - rm -f /usr/lib/lua/luci/i18n/podkop_plus.ru.lmo - rm -f /usr/lib/lua/luci/i18n/podkop_plus.en.lmo - rm -f /usr/lib/lua/luci/i18n/podkop_plus.ru.lua - rm -f /usr/lib/lua/luci/i18n/podkop_plus.en.lua + # shellcheck disable=SC1090 + . "$state_file" } decide_i18n_installation() { @@ -1878,21 +1479,9 @@ install_packages() { } post_install() { - rm -f /var/luci-indexcache* /tmp/luci-indexcache* - rm -f /tmp/podkop-plus.latest-version.cache - rm -f /var/run/podkop-plus/system-info.json - rm -f /var/run/podkop-plus/server-country-cache.json - rm -f /var/run/podkop-plus/ui-state/sing-box-version - rm -f /tmp/podkop-plus/system-info.json - [ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload >/dev/null 2>&1 || true - - if [ "$PODKOP_WAS_ENABLED" -eq 1 ] && [ -x /etc/init.d/podkop-plus ]; then - /etc/init.d/podkop-plus enable >/dev/null 2>&1 || true - fi - - if [ "$PODKOP_WAS_RUNNING" -eq 1 ] && [ -x /etc/init.d/podkop-plus ]; then - /etc/init.d/podkop-plus start >/dev/null 2>&1 || /etc/init.d/podkop-plus restart >/dev/null 2>&1 || warn "Failed to start Podkop Plus after upgrade." - fi + PODKOP_WAS_ENABLED="$PODKOP_WAS_ENABLED" PODKOP_WAS_RUNNING="$PODKOP_WAS_RUNNING" \ + install_json_ucode installer-post-install || + fail "Failed to complete Podkop Plus post-install actions" } main() { @@ -1905,21 +1494,18 @@ main() { sync_time check_system + pkg_list_update || fail "Failed to update package lists" + ensure_bootstrap_ucode_runtime + decide_i18n_installation select_sing_box_installation - deactivate_original_podkop_if_present - - pkg_list_update || fail "Failed to update package lists" - ensure_bootstrap_tool "ucode" "ucode" resolve_podkop_plus_release download_podkop_plus_packages - remove_conflicting_dns_proxy - remove_old_sing_box_if_needed - install_selected_sing_box cleanup_legacy_installation install_packages + install_selected_sing_box post_install msg "Podkop Plus $PODKOP_PLUS_PACKAGE_VERSION has been installed successfully" diff --git a/luci-app-podkop-plus/root/etc/uci-defaults/50_luci-podkop-plus b/luci-app-podkop-plus/root/etc/uci-defaults/50_luci-podkop-plus index 16e7c151..4bc0757f 100644 --- a/luci-app-podkop-plus/root/etc/uci-defaults/50_luci-podkop-plus +++ b/luci-app-podkop-plus/root/etc/uci-defaults/50_luci-podkop-plus @@ -1,10 +1,4 @@ #!/bin/sh -rm -f /var/luci-indexcache* -rm -f /tmp/luci-indexcache* - -[ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload - -logger -t "podkop-plus" "uci-defaults script executed" - +/usr/bin/podkop-plus luci_postinst >/dev/null 2>&1 || true exit 0 diff --git a/podkop/Makefile b/podkop/Makefile index 96d9392f..6f25d460 100644 --- a/podkop/Makefile +++ b/podkop/Makefile @@ -24,7 +24,7 @@ include $(INCLUDE_DIR)/package.mk define Package/podkop-plus SECTION:=net CATEGORY:=Network - DEPENDS:=+ca-bundle +kmod-inet-diag +kmod-netlink-diag +kmod-tun +curl +ucode +ucode-mod-fs +kmod-nft-tproxy +coreutils-base64 +coreutils-sort +bind-dig +nftables +kmod-nft-nat + DEPENDS:=+ca-bundle +kmod-inet-diag +kmod-netlink-diag +kmod-tun +curl +ucode +ucode-mod-fs +ucode-mod-uci +kmod-nft-tproxy +coreutils-base64 +coreutils-sort +bind-dig +nftables +kmod-nft-nat CONFLICTS:=https-dns-proxy nextdns luci-app-passwall luci-app-passwall2 TITLE:=Podkop Plus backend service URL:=https://github.com/ushan0v/podkop-plus @@ -42,81 +42,30 @@ define Build/Compile endef define Package/podkop-plus/preinst -#!/bin/sh +#!/usr/bin/ucode -[ -n "$${IPKG_INSTROOT}" ] && exit 0 +let fs = require("fs"); -if [ ! -f /etc/config/podkop-plus ] && [ -f /etc/config/podkop_plus ]; then - cp /etc/config/podkop_plus /etc/config/podkop-plus || exit 1 - chmod 0644 /etc/config/podkop-plus 2>/dev/null || true -fi +if (getenv("IPKG_INSTROOT") != null && getenv("IPKG_INSTROOT") != "") + exit(0); -exit 0 +if (fs.stat("/etc/config/podkop-plus") == null && fs.stat("/etc/config/podkop_plus") != null) { + let data = fs.readfile("/etc/config/podkop_plus"); + if (data == null || fs.writefile("/etc/config/podkop-plus", data) == null) + exit(1); + system("chmod 0644 /etc/config/podkop-plus >/dev/null 2>&1"); +} + +exit(0); endef define Package/podkop-plus/prerm -#!/bin/sh +#!/usr/bin/ucode -grep -q "105 podkopplus" /etc/iproute2/rt_tables && sed -i "/105 podkopplus/d" /etc/iproute2/rt_tables +if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "") + system("/usr/bin/podkop-plus package_prerm >/dev/null 2>&1"); -/etc/init.d/podkop-plus stop >/dev/null 2>&1 || true -podkop_dont_touch_dhcp="$$(uci -q get 'podkop-plus.settings.dont_touch_dhcp' 2>/dev/null)" -case "$$podkop_dont_touch_dhcp" in - 1|true|yes|on) - ;; - *) - /usr/bin/podkop-plus restore_dnsmasq >/dev/null 2>&1 || true - if [ -r /usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh ]; then - sh /usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh >/dev/null 2>&1 || true - else - podkop_backup_notinterfaces="$$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" - uci -q delete dhcp.podkop_plus >/dev/null 2>&1 || true - if [ -n "$$podkop_backup_notinterfaces" ]; then - uci -q delete 'dhcp.@dnsmasq[0].notinterface' >/dev/null 2>&1 || true - for podkop_value in $$podkop_backup_notinterfaces; do - uci -q add_list "dhcp.@dnsmasq[0].notinterface=$$podkop_value" >/dev/null 2>&1 || true - done - else - uci -q del_list 'dhcp.@dnsmasq[0].notinterface=br-lan' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - podkop_backup_servers="$$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" - if [ -n "$$podkop_backup_servers" ]; then - uci -q delete 'dhcp.@dnsmasq[0].server' >/dev/null 2>&1 || true - for podkop_value in $$podkop_backup_servers; do - uci -q add_list "dhcp.@dnsmasq[0].server=$$podkop_value" >/dev/null 2>&1 || true - done - else - uci -q del_list 'dhcp.@dnsmasq[0].server=127.0.0.42' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - podkop_noresolv="$$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" - if [ -n "$$podkop_noresolv" ]; then - uci -q set "dhcp.@dnsmasq[0].noresolv=$$podkop_noresolv" >/dev/null 2>&1 || true - else - uci -q set 'dhcp.@dnsmasq[0].noresolv=0' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_noresolv' >/dev/null 2>&1 || true - podkop_cachesize="$$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" - if [ -n "$$podkop_cachesize" ]; then - uci -q set "dhcp.@dnsmasq[0].cachesize=$$podkop_cachesize" >/dev/null 2>&1 || true - else - uci -q set 'dhcp.@dnsmasq[0].cachesize=150' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_cachesize' >/dev/null 2>&1 || true - uci -q commit dhcp >/dev/null 2>&1 || true - [ -x /etc/init.d/dnsmasq ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true - fi - ;; -esac - -if [ -r /etc/init.d/sing-box ] && grep -q "Podkop Plus managed sing-box service for binary variants" /etc/init.d/sing-box 2>/dev/null; then - /etc/init.d/sing-box stop >/dev/null 2>&1 || true - /etc/init.d/sing-box disable >/dev/null 2>&1 || true - rm -f /etc/init.d/sing-box /usr/bin/sing-box /usr/lib/libcronet.so -fi - -exit 0 +exit(0); endef define Package/podkop-plus/conffiles @@ -134,9 +83,9 @@ define Package/podkop-plus/install $(INSTALL_BIN) ./files/usr/bin/podkop $(1)/usr/bin/podkop-plus $(INSTALL_DIR) $(1)/usr/lib/podkop-plus - $(CP) ./files/usr/lib/* $(1)/usr/lib/podkop-plus/ + $(CP) ./files/usr/lib/. $(1)/usr/lib/podkop-plus/ - sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PODKOP_COMPILED_VERSION)/g' $(1)/usr/lib/podkop-plus/constants.sh + sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PODKOP_COMPILED_VERSION)/g' $(1)/usr/lib/podkop-plus/core/constants.uc endef $(eval $(call BuildPackage,podkop-plus)) diff --git a/podkop/files/etc/init.d/podkop b/podkop/files/etc/init.d/podkop index d57551e7..a3fb00c4 100755 --- a/podkop/files/etc/init.d/podkop +++ b/podkop/files/etc/init.d/podkop @@ -1,5 +1,5 @@ #!/bin/sh /etc/rc.common -# shellcheck disable=SC2034,SC2154 +# shellcheck disable=SC2034 START=99 USE_PROCD=1 @@ -8,403 +8,80 @@ EXTRA_HELP=" retry_start_on_wan_up Retry failed cold start after WAN come script=$(readlink "$initscript") NAME="$(basename ${script:-$initscript})" -CONFIG_NAME="podkop-plus" PODKOP_LIB="/usr/lib/podkop-plus" -PODKOP_BIN="/usr/bin/podkop-plus" -PODKOP_SERVICE_INIT="/etc/init.d/podkop-plus" -PODKOP_CONFIG_FILE="/etc/config/$CONFIG_NAME" -PODKOP_RELOAD_LOCK_DIR="/var/run/podkop-plus.reload.lock" -PODKOP_RUNTIME_STATE_DIR="/var/run/podkop-plus" -PODKOP_PENDING_RELOAD_FILE="$PODKOP_RUNTIME_STATE_DIR/reload.pending" -PODKOP_SERVICE_TRIGGER_SYNC_FILE="$PODKOP_RUNTIME_STATE_DIR/service-triggers.sync" -PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD="/var/run/podkop-plus.internal-config-change" -PODKOP_CONFIG_CHANGE_REASON="on_config_change" -PODKOP_DNSMASQ_FAILSAFE="/usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh" -PODKOP_STATUS_DIAGNOSTICS_UC="/usr/lib/podkop-plus/status_diagnostics.uc" -PODKOP_CONSTANTS_LIB="$PODKOP_LIB/constants.sh" -PODKOP_UI_RUNTIME_LIB="$PODKOP_LIB/ui_runtime.sh" -config_load "$CONFIG_NAME" +PODKOP_INITD_UC="$PODKOP_LIB/service/initd.uc" -dnsmasq_management_disabled() { - local dont_touch_dhcp - - config_load "$CONFIG_NAME" - config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0 - [ "$dont_touch_dhcp" -eq 1 ] -} - -restore_dnsmasq_failsafe() { - if [ -r "$PODKOP_DNSMASQ_FAILSAFE" ]; then - sh "$PODKOP_DNSMASQ_FAILSAFE" >/dev/null 2>&1 || true - return 0 - fi - - dnsmasq_management_disabled && return 0 - - podkop_backup_notinterfaces="$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" - uci -q delete dhcp.podkop_plus >/dev/null 2>&1 || true - if [ -n "$podkop_backup_notinterfaces" ]; then - uci -q delete 'dhcp.@dnsmasq[0].notinterface' >/dev/null 2>&1 || true - for podkop_value in $podkop_backup_notinterfaces; do - uci -q add_list "dhcp.@dnsmasq[0].notinterface=$podkop_value" >/dev/null 2>&1 || true - done - else - uci -q del_list 'dhcp.@dnsmasq[0].notinterface=br-lan' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - podkop_backup_servers="$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" - if [ -n "$podkop_backup_servers" ]; then - uci -q delete 'dhcp.@dnsmasq[0].server' >/dev/null 2>&1 || true - for podkop_value in $podkop_backup_servers; do - uci -q add_list "dhcp.@dnsmasq[0].server=$podkop_value" >/dev/null 2>&1 || true - done - else - uci -q del_list 'dhcp.@dnsmasq[0].server=127.0.0.42' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - podkop_noresolv="$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" - if [ -n "$podkop_noresolv" ]; then - uci -q set "dhcp.@dnsmasq[0].noresolv=$podkop_noresolv" >/dev/null 2>&1 || true - else - uci -q set 'dhcp.@dnsmasq[0].noresolv=0' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_noresolv' >/dev/null 2>&1 || true - podkop_cachesize="$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" - if [ -n "$podkop_cachesize" ]; then - uci -q set "dhcp.@dnsmasq[0].cachesize=$podkop_cachesize" >/dev/null 2>&1 || true - else - uci -q set 'dhcp.@dnsmasq[0].cachesize=150' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_cachesize' >/dev/null 2>&1 || true - uci -q commit dhcp >/dev/null 2>&1 || true - [ -x /etc/init.d/dnsmasq ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true -} - -acquire_reload_lock() { - local owner_pid - - if mkdir "$PODKOP_RELOAD_LOCK_DIR" 2> /dev/null; then - printf '%s\n' "$$" > "$PODKOP_RELOAD_LOCK_DIR/pid" - return 0 - fi - - owner_pid="$(sed -n '1p' "$PODKOP_RELOAD_LOCK_DIR/pid" 2> /dev/null)" - if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2> /dev/null; then - return 1 - fi - - rm -f "$PODKOP_RELOAD_LOCK_DIR/pid" 2> /dev/null - rmdir "$PODKOP_RELOAD_LOCK_DIR" 2> /dev/null || return 1 - mkdir "$PODKOP_RELOAD_LOCK_DIR" 2> /dev/null || return 1 - printf '%s\n' "$$" > "$PODKOP_RELOAD_LOCK_DIR/pid" -} - -release_reload_lock() { - rm -f "$PODKOP_RELOAD_LOCK_DIR/pid" 2> /dev/null - rmdir "$PODKOP_RELOAD_LOCK_DIR" 2> /dev/null -} - -mark_pending_reload() { - local reason="${1:-pending}" - local now - - mkdir -p "$PODKOP_RUNTIME_STATE_DIR" - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) - now=0 - ;; - esac - - { - printf 'reason=%s\n' "$reason" - printf 'updated_at=%s\n' "$now" - } > "$PODKOP_PENDING_RELOAD_FILE" -} - -consume_pending_reload() { - [ -f "$PODKOP_PENDING_RELOAD_FILE" ] || return 1 - rm -f "$PODKOP_PENDING_RELOAD_FILE" 2>/dev/null -} - -run_pending_reload_if_requested() { - consume_pending_reload || return 0 - "$PODKOP_SERVICE_INIT" reload pending >/dev/null 2>&1 & +initd_ucode() { + [ -r "$PODKOP_INITD_UC" ] || return 1 + PODKOP_LIB="$PODKOP_LIB" \ + PODKOP_SERVICE_NAME="$NAME" \ + ucode -L "$PODKOP_LIB" "$PODKOP_INITD_UC" "$@" } sync_service_triggers() { - config_load "$CONFIG_NAME" _procd_call sync_service_triggers_payload } -service_trigger_sync_requested() { - local value - - [ -f "$PODKOP_SERVICE_TRIGGER_SYNC_FILE" ] || return 0 - value="$(sed -n '1p' "$PODKOP_SERVICE_TRIGGER_SYNC_FILE" 2>/dev/null)" - rm -f "$PODKOP_SERVICE_TRIGGER_SYNC_FILE" 2>/dev/null || true - [ "$value" = "1" ] -} - sync_service_triggers_payload() { - # rc.common registers triggers when the service is started. Config reloads can - # change Bad WAN interface triggers, so refresh the trigger set after a - # successful config-change reload without restarting the runtime. json_init json_add_string name "$NAME" service_triggers - _procd_ubus_call set > /dev/null 2>&1 || true -} - -is_config_change_reload() { - [ "$1" = "$PODKOP_CONFIG_CHANGE_REASON" ] -} - -runtime_is_running() { - "$PODKOP_BIN" get_status 2> /dev/null | - ucode "$PODKOP_STATUS_DIAGNOSTICS_UC" service-status-running >/dev/null 2>&1 -} - -service_is_enabled() { - [ -x "/etc/rc.d/S99$NAME" ] -} - -load_ui_runtime() { - [ "${PODKOP_INITD_UI_RUNTIME_LOADED:-0}" = "1" ] && return 0 - [ -r "$PODKOP_CONSTANTS_LIB" ] || return 1 - [ -r "$PODKOP_UI_RUNTIME_LIB" ] || return 1 - - . "$PODKOP_CONSTANTS_LIB" || return 1 - . "$PODKOP_UI_RUNTIME_LIB" || return 1 - PODKOP_INITD_UI_RUNTIME_LOADED=1 -} - -begin_external_service_action() { - local action="$1" - local job_id state_file - - [ "${PODKOP_UI_ACTION_TRACKED:-0}" = "1" ] && return 1 - load_ui_runtime || return 1 - - job_id="$(ui_runtime_service_action_begin_if_idle "$action" "initd" 2>/dev/null)" || return 1 - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_SERVICE_ACTION_DIR" "$job_id" 2>/dev/null)" || return 1 - ui_runtime_update_running_pid "$state_file" "$$" >/dev/null 2>&1 || true - - printf '%s\n' "$job_id" -} - -finish_external_service_action() { - local action="$1" - local job_id="$2" - local status="$3" - local state_file job_pid - - [ -n "$job_id" ] || return 0 - load_ui_runtime || return 0 - - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_SERVICE_ACTION_DIR" "$job_id" 2>/dev/null)" || return 0 - [ -f "$state_file" ] || return 0 - - if [ "$status" -ne 0 ]; then - ui_runtime_service_action_finish_job "$job_id" false "Service $action failed" "$status" >/dev/null 2>&1 || true - return 0 - fi - - ( - trap '' HUP - if ui_runtime_service_action_wait_for_expected_state "$action"; then - ui_runtime_service_action_finish_job "$job_id" true "Service $action completed" 0 - else - ui_runtime_service_action_finish_job "$job_id" false "Service $action did not reach expected state" 1 - fi - ) >/dev/null 2>&1 & - job_pid="$!" - ui_runtime_update_running_pid "$state_file" "$job_pid" >/dev/null 2>&1 || true -} - -consume_internal_config_guard() { - rm -f "$PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD" 2> /dev/null -} - -current_config_hash() { - local hash - - [ -r "$PODKOP_CONFIG_FILE" ] || return 1 - - hash="$(md5sum "$PODKOP_CONFIG_FILE" 2>/dev/null)" - hash="${hash%% *}" - [ -n "$hash" ] || return 1 - - printf "%s\n" "$hash" -} - -internal_config_guard_matches_current_config() { - local now guard_timestamp guard_hash current_hash - - guard_timestamp="$(sed -n '1p' "$PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD" 2>/dev/null)" - case "$guard_timestamp" in - '' | *[!0-9]*) - return 1 - ;; - esac - - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) - return 0 - ;; - esac - - [ $((now - guard_timestamp)) -le 30 ] || return 1 - - guard_hash="$(sed -n '2p' "$PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD" 2>/dev/null)" - [ -n "$guard_hash" ] || return 1 - - current_hash="$(current_config_hash)" || return 1 - [ "$guard_hash" = "$current_hash" ] -} - -should_skip_internal_config_reload() { - local reason="$1" - - [ "$reason" = "on_config_change" ] || return 1 - [ -f "$PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD" ] || return 1 - - if internal_config_guard_matches_current_config; then - consume_internal_config_guard - return 0 - fi - - consume_internal_config_guard - - return 1 -} - -should_restore_dnsmasq_on_start() { - local reason="$1" - local shutdown_correctly - - [ "$reason" = "triggered" ] && return 1 - - config_get shutdown_correctly "settings" "shutdown_correctly" "1" - [ "$shutdown_correctly" = "0" ] + _procd_ubus_call set >/dev/null 2>&1 || true } start_service() { - local ui_job_id status - - echo "Start Podkop Plus" - ui_job_id="$(begin_external_service_action start 2>/dev/null || true)" - - if [ ! -x "$PODKOP_BIN" ]; then - restore_dnsmasq_failsafe - finish_external_service_action start "$ui_job_id" 1 - return 1 - fi - - if should_restore_dnsmasq_on_start "$1"; then - restore_dnsmasq_failsafe - fi - - config_get enable_badwan_interface_monitoring "settings" "enable_badwan_interface_monitoring" - config_get badwan_monitored_interfaces "settings" "badwan_monitored_interfaces" - - procd_open_instance - procd_set_param command "$PODKOP_BIN" start - [ "$enable_badwan_interface_monitoring" = "1" ] && [ -n "$badwan_monitored_interfaces" ] && - procd_set_param netdev "$badwan_monitored_interfaces" - procd_set_param stdout 1 - procd_set_param stderr 1 - procd_close_instance - status="$?" - - finish_external_service_action start "$ui_job_id" "$status" - return "$status" + initd_ucode start-service "$1" "$$" } stop_service() { - local status ui_job_id - - ui_job_id="$(begin_external_service_action stop 2>/dev/null || true)" - - if [ ! -x "$PODKOP_BIN" ]; then - restore_dnsmasq_failsafe - finish_external_service_action stop "$ui_job_id" 1 - return 1 - fi - - "$PODKOP_BIN" stop - status="$?" - [ "$status" -eq 0 ] || restore_dnsmasq_failsafe - - finish_external_service_action stop "$ui_job_id" "$status" - return "$status" + initd_ucode stop-service "$$" } reload_service() { local reason="$1" + local result status - if should_skip_internal_config_reload "$reason"; then - return 0 - fi - - if is_config_change_reload "$reason" && ! runtime_is_running && ! service_is_enabled; then - return 0 - fi - - if ! acquire_reload_lock; then - mark_pending_reload "${reason:-reload_busy}" - return 0 - fi - - rm -f "$PODKOP_SERVICE_TRIGGER_SYNC_FILE" 2>/dev/null || true - trap 'release_reload_lock' EXIT INT TERM - PODKOP_UI_ACTION_TRACKED=1 "$PODKOP_BIN" reload "$reason" > /dev/null 2>&1 - local status="$?" - if [ "$status" -eq 0 ] && is_config_change_reload "$reason" && service_trigger_sync_requested; then - sync_service_triggers - fi - trap - EXIT INT TERM - release_reload_lock - run_pending_reload_if_requested + trap 'initd_ucode reload-release >/dev/null 2>&1 || true' INT TERM + result="$(initd_ucode reload-service "$reason" "$$")" + status="$?" + trap - INT TERM + [ "$result" = "sync" ] && sync_service_triggers return "$status" } status_service() { - if runtime_is_running; then - echo "running" - return 0 - fi - - echo "not running" - return 1 + initd_ucode status-service } retry_start_on_wan_up() { - if runtime_is_running; then - return 0 - fi - - restart triggered + initd_ucode retry-start-on-wan-up "$$" } service_triggers() { - config_get enable_badwan_interface_monitoring "settings" "enable_badwan_interface_monitoring" - config_get badwan_monitored_interfaces "settings" "badwan_monitored_interfaces" - config_get badwan_reload_delay "settings" "badwan_reload_delay" "2000" + local plan kind event target init action reason - PROCD_RELOAD_DELAY=$badwan_reload_delay + plan="$(initd_ucode trigger-plan)" || return 0 procd_open_trigger - procd_add_config_trigger "config.change" "$CONFIG_NAME" "$PODKOP_SERVICE_INIT" reload "$PODKOP_CONFIG_CHANGE_REASON" - procd_add_interface_trigger "interface.*.up" "wan" "$PODKOP_SERVICE_INIT" retry_start_on_wan_up - - if [ "$enable_badwan_interface_monitoring" = "1" ]; then - for iface in $badwan_monitored_interfaces; do - [ "$iface" = "wan" ] && continue - procd_add_interface_trigger "interface.*.up" "$iface" "$PODKOP_SERVICE_INIT" reload - done - fi + while IFS="$(printf '\t')" read -r kind event target init action reason; do + case "$kind" in + delay) + PROCD_RELOAD_DELAY="$event" + ;; + config) + procd_add_config_trigger "$event" "$target" "$init" "$action" "$reason" + ;; + interface) + if [ -n "$reason" ]; then + procd_add_interface_trigger "$event" "$target" "$init" "$action" "$reason" + else + procd_add_interface_trigger "$event" "$target" "$init" "$action" + fi + ;; + esac + done << EOF +$plan +EOF procd_close_trigger } diff --git a/podkop/files/usr/bin/podkop b/podkop/files/usr/bin/podkop index 754aca2a..d8c5268e 100755 --- a/podkop/files/usr/bin/podkop +++ b/podkop/files/usr/bin/podkop @@ -1,1277 +1,230 @@ -#!/bin/ash +#!/usr/bin/ucode -PODKOP_COMMAND="${1:-}" -PODKOP_CONFIG_NAME="${PODKOP_CONFIG_NAME:-podkop-plus}" -SB_DNS_INBOUND_ADDRESS="${SB_DNS_INBOUND_ADDRESS:-127.0.0.42}" +let fs = require("fs"); -podkop_dnsmasq_failsafe_restore_raw() { - local legacy_dnsmasq_section legacy_interfaces default_servers backup_servers backup_notinterfaces value - local default_has_podkop_dns noresolv cachesize changed - local legacy_instance_present dont_touch_dhcp podkop_managed_state - - command -v uci >/dev/null 2>&1 || return 0 - dont_touch_dhcp=0 - case "$(uci -q get "$PODKOP_CONFIG_NAME.settings.dont_touch_dhcp" 2>/dev/null)" in - 1|true|yes|on) - dont_touch_dhcp=1 - ;; - esac - - legacy_dnsmasq_section="podkop_plus" - changed=0 - default_has_podkop_dns=0 - legacy_instance_present=0 - legacy_interfaces="$(uci -q get "dhcp.$legacy_dnsmasq_section.interface" 2>/dev/null)" - [ -n "$legacy_interfaces" ] || - legacy_interfaces="$(uci -q get "$PODKOP_CONFIG_NAME.settings.source_network_interfaces" 2>/dev/null)" - [ -n "$legacy_interfaces" ] || legacy_interfaces="br-lan" - - default_servers="$(uci -q get 'dhcp.@dnsmasq[0].server' 2>/dev/null)" - for value in $default_servers; do - [ "$value" = "$SB_DNS_INBOUND_ADDRESS" ] && default_has_podkop_dns=1 - done - - podkop_managed_state=0 - [ -n "$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" ] && podkop_managed_state=1 - [ -n "$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" ] && podkop_managed_state=1 - [ -n "$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" ] && podkop_managed_state=1 - [ -n "$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" ] && podkop_managed_state=1 - - if uci -q show "dhcp.$legacy_dnsmasq_section" >/dev/null 2>&1; then - legacy_instance_present=1 - podkop_managed_state=1 - changed=1 - fi - [ "$dont_touch_dhcp" -eq 0 ] || [ "$podkop_managed_state" -eq 1 ] || return 0 - - uci -q delete "dhcp.$legacy_dnsmasq_section" >/dev/null 2>&1 || true - - backup_notinterfaces="$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" - if [ -n "$backup_notinterfaces" ]; then - uci -q delete 'dhcp.@dnsmasq[0].notinterface' >/dev/null 2>&1 || true - for value in $backup_notinterfaces; do - uci -q add_list "dhcp.@dnsmasq[0].notinterface=$value" >/dev/null 2>&1 || true - done - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - changed=1 - else - if [ "$legacy_instance_present" -eq 1 ]; then - for value in $legacy_interfaces; do - uci -q del_list "dhcp.@dnsmasq[0].notinterface=$value" >/dev/null 2>&1 && changed=1 - done - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - fi - - backup_servers="$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" - if [ -n "$backup_servers" ]; then - uci -q delete 'dhcp.@dnsmasq[0].server' >/dev/null 2>&1 || true - for value in $backup_servers; do - uci -q add_list "dhcp.@dnsmasq[0].server=$value" >/dev/null 2>&1 || true - done - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - changed=1 - else - uci -q del_list "dhcp.@dnsmasq[0].server=$SB_DNS_INBOUND_ADDRESS" >/dev/null 2>&1 && changed=1 - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - fi - - noresolv="$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" - if [ -n "$noresolv" ]; then - uci -q set "dhcp.@dnsmasq[0].noresolv=$noresolv" >/dev/null 2>&1 || true - uci -q delete 'dhcp.@dnsmasq[0].podkop_noresolv' >/dev/null 2>&1 || true - changed=1 - elif [ "$default_has_podkop_dns" -eq 1 ]; then - uci -q set 'dhcp.@dnsmasq[0].noresolv=0' >/dev/null 2>&1 || true - changed=1 - fi - - cachesize="$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" - if [ -n "$cachesize" ]; then - uci -q set "dhcp.@dnsmasq[0].cachesize=$cachesize" >/dev/null 2>&1 || true - uci -q delete 'dhcp.@dnsmasq[0].podkop_cachesize' >/dev/null 2>&1 || true - changed=1 - elif [ "$default_has_podkop_dns" -eq 1 ]; then - uci -q set 'dhcp.@dnsmasq[0].cachesize=150' >/dev/null 2>&1 || true - changed=1 - fi - - [ "$changed" -eq 1 ] || return 0 - - uci -q commit dhcp >/dev/null 2>&1 || true - [ -x /etc/init.d/dnsmasq ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true +function as_string(value) { + return value == null ? "" : "" + value; } -podkop_dnsmasq_failsafe_restore_on_loader_failure() { - case "$PODKOP_COMMAND" in - start | stop | restart | reload | uninstall | dnsmasq_restore | restore_dnsmasq) - podkop_dnsmasq_failsafe_restore_raw - ;; - esac +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const BIN_PATH = getenv("PODKOP_BIN") || "/usr/bin/podkop-plus"; +const SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || "/etc/init.d/podkop-plus"; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; } -check_required_file() { - local file="$1" - - if [ ! -r "$file" ]; then - echo "Error: required file '$file' is missing or not readable" >&2 - podkop_dnsmasq_failsafe_restore_on_loader_failure - exit 1 - fi +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); } -PODKOP_LIB="/usr/lib/podkop-plus" -check_required_file /lib/functions.sh -check_required_file /lib/config/uci.sh -check_required_file "$PODKOP_LIB/constants.sh" -check_required_file "$PODKOP_LIB/status_diagnostics.uc" -check_required_file "$PODKOP_LIB/ui_runtime.sh" -check_required_file "$PODKOP_LIB/ui_runtime.uc" - -. /lib/functions.sh -. /lib/config/uci.sh -. "$PODKOP_LIB/constants.sh" -. "$PODKOP_LIB/ui_runtime.sh" - -podkop_fast_status_diagnostics_ucode() { - ucode "$PODKOP_LIB/status_diagnostics.uc" "$@" +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; } -podkop_fast_ui_capabilities_server_handler() { - local section="$1" enabled - - config_get enabled "$section" "enabled" "1" - [ "$enabled" = "0" ] && return 0 - - PODKOP_FAST_SERVER_INBOUNDS_ENABLED_COUNT=$((PODKOP_FAST_SERVER_INBOUNDS_ENABLED_COUNT + 1)) +function module_path(path) { + return LIB_DIR + "/" + as_string(path); } -podkop_fast_collect_ui_capabilities() { - local sing_box_version sing_box_output sing_box_tags - - PODKOP_FAST_SING_BOX_EXTENDED=0 - PODKOP_FAST_SING_BOX_TINY=0 - PODKOP_FAST_SING_BOX_COMPRESSED=0 - PODKOP_FAST_SING_BOX_TAILSCALE=0 - PODKOP_FAST_ZAPRET_INSTALLED=0 - PODKOP_FAST_ZAPRET2_INSTALLED=0 - PODKOP_FAST_BYEDPI_INSTALLED=0 - PODKOP_FAST_SERVER_INBOUNDS_ENABLED_COUNT=0 - - if command -v sing-box >/dev/null 2>&1; then - if ui_runtime_sing_box_compressed_marker_set; then - PODKOP_FAST_SING_BOX_EXTENDED=1 - PODKOP_FAST_SING_BOX_COMPRESSED=1 - PODKOP_FAST_SING_BOX_TAILSCALE=1 - elif ui_runtime_sing_box_marker_is extended; then - PODKOP_FAST_SING_BOX_EXTENDED=1 - PODKOP_FAST_SING_BOX_TAILSCALE=1 - elif ui_runtime_sing_box_tiny_package_installed || ui_runtime_sing_box_marker_is tiny; then - PODKOP_FAST_SING_BOX_TINY=1 - elif ui_runtime_component_action_running_for sing_box; then - PODKOP_FAST_SING_BOX_TAILSCALE=1 - else - sing_box_output="$(sing-box version 2>/dev/null || true)" - sing_box_version="$(printf '%s\n' "$sing_box_output" | sed -n '1{s/.*[[:space:]]//;p;q;}')" - case "$sing_box_version" in - *extended*) - PODKOP_FAST_SING_BOX_EXTENDED=1 - PODKOP_FAST_SING_BOX_TAILSCALE=1 - ;; - *) - sing_box_tags="$(printf '%s\n' "$sing_box_output" | sed -n 's/^Tags:[[:space:]]*//p' | head -n 1)" - printf '%s\n' "$sing_box_tags" | grep -Eq '(^|[,:[:space:]])with_tailscale([,[:space:]]|$)' && - PODKOP_FAST_SING_BOX_TAILSCALE=1 - [ "$PODKOP_FAST_SING_BOX_TAILSCALE" -eq 0 ] && PODKOP_FAST_SING_BOX_TINY=1 - ;; - esac - fi - fi - - [ -x "$ZAPRET_PROVIDER_NFQWS_BIN" ] && PODKOP_FAST_ZAPRET_INSTALLED=1 - [ -x "$ZAPRET2_PROVIDER_NFQWS2_BIN" ] && PODKOP_FAST_ZAPRET2_INSTALLED=1 - [ -x "$BYEDPI_BIN" ] && PODKOP_FAST_BYEDPI_INSTALLED=1 - - config_load "$PODKOP_CONFIG_NAME" - config_foreach podkop_fast_ui_capabilities_server_handler "server" +function file_readable(path) { + return fs.stat(as_string(path)) != null; } -podkop_fast_get_ui_capabilities() { - podkop_fast_collect_ui_capabilities - - podkop_fast_status_diagnostics_ucode ui-capabilities-json \ - "$PODKOP_FAST_SING_BOX_EXTENDED" \ - "$PODKOP_FAST_SING_BOX_TINY" \ - "$PODKOP_FAST_SING_BOX_COMPRESSED" \ - "$PODKOP_FAST_SING_BOX_TAILSCALE" \ - "$PODKOP_FAST_ZAPRET_INSTALLED" \ - "$PODKOP_FAST_ZAPRET2_INSTALLED" \ - "$PODKOP_FAST_BYEDPI_INSTALLED" \ - "${PODKOP_FAST_SERVER_INBOUNDS_ENABLED_COUNT:-0}" +function restore_dnsmasq_failsafe() { + let module = module_path("dns/apply.uc"); + if (!file_readable(module)) + return; + system(command_from_args([ "ucode", "-L", LIB_DIR, module, "failsafe-restore" ]) + " >/dev/null 2>&1"); } -podkop_fast_get_ui_state() { - local podkop_running=0 podkop_enabled=0 podkop_status dns_configured=0 - local sing_box_running=0 sing_box_enabled=0 sing_box_status active_action - - podkop_fast_collect_ui_capabilities - ui_runtime_ensure_dirs - ui_runtime_refresh_action_dirs - - ui_runtime_sing_box_running && sing_box_running=1 - ui_runtime_sing_box_enabled && sing_box_enabled=1 - - ui_runtime_podkop_running && podkop_running=1 - ui_runtime_service_enabled && podkop_enabled=1 - ui_runtime_dns_configured && dns_configured=1 - - podkop_status="$(ui_runtime_status_text "$podkop_running" "$podkop_enabled")" - sing_box_status="$(ui_runtime_status_text "$sing_box_running" "$sing_box_enabled")" - - active_action="$(ui_runtime_active_service_action 2>/dev/null || true)" - case "$active_action" in - start) podkop_status="starting" ;; - stop) podkop_status="stopping" ;; - restart) podkop_status="restarting" ;; - reload) podkop_status="reloading" ;; - esac - - ui_runtime_write_ui_state \ - "$podkop_running" \ - "$podkop_enabled" \ - "$podkop_status" \ - "$dns_configured" \ - "$sing_box_running" \ - "$sing_box_enabled" \ - "$sing_box_status" \ - "$PODKOP_FAST_SING_BOX_EXTENDED" \ - "$PODKOP_FAST_SING_BOX_TINY" \ - "$PODKOP_FAST_SING_BOX_COMPRESSED" \ - "$PODKOP_FAST_SING_BOX_TAILSCALE" \ - "$PODKOP_FAST_ZAPRET_INSTALLED" \ - "$PODKOP_FAST_ZAPRET2_INSTALLED" \ - "$PODKOP_FAST_BYEDPI_INSTALLED" \ - "${PODKOP_FAST_SERVER_INBOUNDS_ENABLED_COUNT:-0}" +function restore_dnsmasq_failsafe_on_loader_failure(command) { + command = as_string(command); + if (command == "start" || command == "stop" || command == "restart" || + command == "reload" || command == "uninstall" || + command == "dnsmasq_restore" || command == "restore_dnsmasq") + restore_dnsmasq_failsafe(); } -case "$PODKOP_COMMAND" in -get_ui_capabilities) - podkop_fast_get_ui_capabilities - exit $? - ;; -get_ui_state) - podkop_fast_get_ui_state - exit $? - ;; -service_action_async) - ui_runtime_service_action_async "$2" - exit $? - ;; -service_action_status) - ui_runtime_service_action_status "$2" - exit $? - ;; -latency_test_async) - ui_runtime_latency_test_async "$2" "$3" "$4" "$5" - exit $? - ;; -latency_test_status) - ui_runtime_latency_test_status "$2" - exit $? - ;; -ui_action_ack) - ui_runtime_action_ack "$2" "$3" - exit $? - ;; -esac - -check_required_file /lib/functions/network.sh -check_required_file "$PODKOP_LIB/nft.sh" -check_required_file "$PODKOP_LIB/helpers.sh" -check_required_file "$PODKOP_LIB/helpers.uc" -check_required_file "$PODKOP_LIB/config_validation.sh" -check_required_file "$PODKOP_LIB/config_validation.uc" -check_required_file "$PODKOP_LIB/dnsmasq_runtime.sh" -check_required_file "$PODKOP_LIB/provider_status.uc" -check_required_file "$PODKOP_LIB/zapret.sh" -check_required_file "$PODKOP_LIB/zapret2.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/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/subscription_runtime.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/runtime_state.sh" -check_required_file "$PODKOP_LIB/server_runtime.sh" -check_required_file "$PODKOP_LIB/server_runtime.uc" -check_required_file "$PODKOP_LIB/rules_nft_runtime.sh" -check_required_file "$PODKOP_LIB/rules_nft_runtime.uc" -check_required_file "$PODKOP_LIB/updates_runtime.sh" -check_required_file "$PODKOP_LIB/updates_runtime.uc" -check_required_file "$PODKOP_LIB/sing_box_runtime.sh" -check_required_file "$PODKOP_LIB/sing_box_runtime.uc" -check_required_file "$PODKOP_LIB/status_diagnostics.sh" -check_required_file "$PODKOP_LIB/status_diagnostics.uc" -check_required_file "$PODKOP_LIB/updater.sh" -check_required_file "$PODKOP_LIB/updater.uc" -. /lib/functions/network.sh -. "$PODKOP_LIB/nft.sh" -. "$PODKOP_LIB/helpers.sh" -. "$PODKOP_LIB/zapret.sh" -. "$PODKOP_LIB/zapret2.sh" -. "$PODKOP_LIB/byedpi.sh" -. "$PODKOP_LIB/sing_box_config_manager.sh" -. "$PODKOP_LIB/sing_box_config_facade.sh" -. "$PODKOP_LIB/subscription_parser.sh" -. "$PODKOP_LIB/logging.sh" -. "$PODKOP_LIB/rulesets.sh" -. "$PODKOP_LIB/updater.sh" - -PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD="/var/run/podkop-plus.internal-config-change" -PODKOP_RUNTIME_STATE_DIR="/var/run/podkop-plus" -PODKOP_SYSTEM_INFO_CACHE_FILE="$PODKOP_RUNTIME_STATE_DIR/system-info.json" -PODKOP_RELOAD_STATE_FILE="$PODKOP_RUNTIME_STATE_DIR/reload-state" -PODKOP_PENDING_RELOAD_FILE="$PODKOP_RUNTIME_STATE_DIR/reload.pending" -PODKOP_SERVICE_TRIGGER_SYNC_FILE="$PODKOP_RUNTIME_STATE_DIR/service-triggers.sync" -PODKOP_LIST_UPDATE_STATE_FILE="$PODKOP_RUNTIME_STATE_DIR/list-update.timestamp" -PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR="$PODKOP_RUNTIME_STATE_DIR/subscription-update" -PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR="$PODKOP_RUNTIME_STATE_DIR/subscription-update-jobs" -PODKOP_SUBSCRIPTION_LINKS_DIR="$PODKOP_RUNTIME_STATE_DIR/subscription-links" -PODKOP_SUBSCRIPTION_METADATA_DIR="$PODKOP_RUNTIME_STATE_DIR/subscription-metadata" -PODKOP_OUTBOUND_METADATA_DIR="$PODKOP_RUNTIME_STATE_DIR/outbound-metadata" -PODKOP_SECTION_CACHE_DIR="$PODKOP_RUNTIME_STATE_DIR/section-cache" -PODKOP_RUNTIME_CACHE_FORMAT_FILE="$PODKOP_RUNTIME_STATE_DIR/cache-format" -PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="/etc/podkop-plus/subscription-cache" -PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE="$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR/cache-format" -PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE="$PODKOP_RUNTIME_STATE_DIR/subscription-bootstrap-retry.pid" -PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR="$PODKOP_RUNTIME_STATE_DIR/subscription-update.lock" -PODKOP_RELOAD_LOCK_DIR="/var/run/podkop-plus.reload.lock" -PODKOP_LIST_UPDATE_CRON_MARKER="# podkop-plus-list-update" -PODKOP_SUBSCRIPTION_UPDATE_CRON_MARKER="# podkop-plus-subscription-update" -# sing-box falls back to 1d when update_interval is empty, so use a large -# duration to emulate disabled automatic refreshes for remote rule sets. -SING_BOX_DISABLED_UPDATE_INTERVAL="876000h" -SING_BOX_URLTEST_DEFAULT_IDLE_TIMEOUT="30m" -SERVER_COUNTRY_METHOD_FLAG_EMOJI="flag_emoji" -SERVER_COUNTRY_METHOD_COUNTRY_IS="country_is" -PODKOP_RELOAD_STATE_FORMAT=1 -PODKOP_RUNTIME_CACHE_FORMAT=6 -PODKOP_RUNTIME_STABLE_MIN_AGE=2 -PODKOP_SYSTEM_INFO_CACHE_TTL=3600 -PODKOP_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES=60 -PODKOP_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES=60 -PODKOP_NFT_POPULATE_ENABLED=1 -PODKOP_ZAPRET_MISSING_WARNING_EMITTED=0 -PODKOP_ZAPRET2_MISSING_WARNING_EMITTED=0 -PODKOP_BYEDPI_MISSING_WARNING_EMITTED=0 -PODKOP_URLTEST_NEW_ENABLED_SECTIONS="" -PODKOP_URLTEST_SELECTOR_SWITCHES="" -SUBSCRIPTION_DEFERRED_SECTIONS="" -SUBSCRIPTION_STARTUP_BLOCKED_SECTIONS="" - -config_load "$PODKOP_CONFIG_NAME" - -. "$PODKOP_LIB/subscription_runtime.sh" - -. "$PODKOP_LIB/runtime_state.sh" - -get_rule_action() { - local section="$1" - local action - - config_get action "$section" "action" - echo "$action" +function show_help() { + print("Usage: ", BIN_PATH, " COMMAND\n\n"); + print("Available commands:\n"); + print(" start Start Podkop Plus service\n"); + print(" stop Stop Podkop Plus service\n"); + print(" reload Reload Podkop Plus configuration\n"); + print(" restart Restart Podkop Plus service\n"); + print(" enable Enable Podkop Plus autostart\n"); + print(" disable Disable Podkop Plus autostart\n"); + print(" uninstall Remove Podkop Plus files installed outside opkg/apk\n"); + print(" main Run main Podkop Plus process\n"); + print(" list_update Update remote lists\n"); + print(" list_update_if_due Update remote lists only when the configured interval has elapsed\n"); + print(" subscription_update [rule] [source_index]\n"); + print(" Update all subscription proxies, one rule, or one source in a rule\n"); + print(" subscription_update_async [rule] [source_index]\n"); + print(" Start forced subscription update in the background\n"); + print(" subscription_update_status [job_id]\n"); + print(" Get background subscription update status\n"); + print(" subscription_update_if_due\n"); + print(" Update subscription proxies only when per-rule intervals have elapsed\n"); + print(" check_proxy Check proxy connectivity\n"); + print(" check_nft Check NFT rules\n"); + print(" check_nft_rules Check NFT rules status\n"); + print(" check_sing_box Check sing-box installation and status\n"); + print(" check_inbounds_config Check whether enabled server inbounds are configured\n"); + print(" check_inbounds Check server inbounds from the Servers tab\n"); + print(" check_logs Show Podkop Plus logs from system journal\n"); + print(" check_sing_box_logs Show sing-box logs\n"); + print(" check_fakeip Test sing-box FakeIP DNS\n"); + print(" check_zapret_runtime Check external zapret provider state\n"); + print(" check_zapret2_runtime Check external zapret2 provider state\n"); + print(" check_byedpi_runtime Check external ByeDPI provider state\n"); + print(" neutralize_zapret_defaults\n"); + print(" Compatibility no-op; Podkop Plus does not manage standalone zapret\n"); + print(" clash_api Clash API interface for managing proxies and groups\n"); + print(" show_config Display current Podkop Plus configuration\n"); + print(" show_version Show Podkop Plus version\n"); + print(" show_sing_box_config Show sing-box configuration\n"); + print(" show_sing_box_version Show sing-box version\n"); + print(" get_status Get Podkop Plus service status\n"); + print(" get_outbound_link Get source proxy link for a generated outbound\n"); + print(" get_outbound_metadata Get generated outbound display names and countries\n"); + print(" get_subscription_metadata\n"); + print(" Get UI-only metadata for a subscription rule\n"); + print(" get_sing_box_status Get sing-box service status\n"); + print(" get_zapret_status Get zapret provider and Podkop Plus-managed nfqws status\n"); + print(" get_zapret2_status Get zapret2 provider and Podkop Plus-managed nfqws2 status\n"); + print(" get_byedpi_status Get ByeDPI provider and Podkop Plus-managed ciadpi status\n"); + print(" get_system_info Get system information in JSON format\n"); + print(" get_server_capabilities Get server UI capabilities in JSON format\n"); + print(" get_ui_capabilities Get lightweight UI capabilities in JSON format\n"); + print(" get_ui_state Get lightweight UI service/action state in JSON format\n"); + print(" service_action_async Start a service action in the background\n"); + print(" service_action_status Get background service action status\n"); + print(" latency_test_async Start a dashboard latency test in the background\n"); + print(" latency_test_status Get background latency test status\n"); + print(" ui_action_ack Acknowledge a finished UI action state\n"); + print(" generate_reality_keypair\n"); + print(" Generate a sing-box Reality key pair in JSON format\n"); + print(" get_tls_certificate_sha256 [server]\n"); + print(" Get a server TLS certificate SHA-256 fingerprint in JSON format\n"); + print(" component_action Manage Podkop Plus, sing-box, zapret, zapret2, and ByeDPI components\n"); + print(" component_action_async Start a component action in the background\n"); + print(" component_action_status Get background component action status\n"); + print(" check_dns_available Check DNS server availability\n"); + print(" global_check Run global system check\n"); + print(" validate_nfqws_strategy_json\n"); + print(" Validate an NFQWS strategy and print JSON result\n"); + print(" validate_nfqws2_strategy_json\n"); + print(" Validate an NFQWS2 strategy and print JSON result\n"); + print(" validate_byedpi_strategy_json\n"); + print(" Validate a ByeDPI ciadpi strategy and print JSON result\n"); } -rule_is_enabled() { - local section="$1" - local enabled +function command_spec(command) { + let commands = { + start: [ "service/lifecycle.uc", "start", 0 ], + stop: [ "service/lifecycle.uc", "stop", 0 ], + reload: [ "service/lifecycle.uc", "reload", 1 ], + restart: [ "service/lifecycle.uc", "restart", 0 ], + enable: [ "service/lifecycle.uc", "enable", 0 ], + disable: [ "service/lifecycle.uc", "disable", 0 ], + uninstall: [ "service/lifecycle.uc", "uninstall", 0 ], + dnsmasq_restore: [ "service/lifecycle.uc", "dnsmasq-restore", 0 ], + restore_dnsmasq: [ "service/lifecycle.uc", "dnsmasq-restore", 0 ], + main: [ "service/lifecycle.uc", "main", 0 ], + list_update: [ "components/updates.uc", "list-update", 0 ], + list_update_if_due: [ "components/updates.uc", "list-update-if-due", 0 ], + subscription_update: [ "components/updates.uc", "subscription-update", 2 ], + subscription_update_async: [ "components/updates.uc", "subscription-update-async", 2 ], + subscription_update_status: [ "components/updates.uc", "subscription-update-status", 1 ], + subscription_update_if_due: [ "components/updates.uc", "subscription-update-if-due", 0 ], + check_proxy: [ "diagnostics/runtime.uc", "check-proxy", 0 ], + check_nft: [ "diagnostics/runtime.uc", "check-nft", 0 ], + check_nft_rules: [ "diagnostics/runtime.uc", "check-nft-rules", 0 ], + check_sing_box: [ "diagnostics/runtime.uc", "check-sing-box", 0 ], + check_inbounds_config: [ "diagnostics/runtime.uc", "check-inbounds-config", 0 ], + check_inbounds: [ "diagnostics/runtime.uc", "check-inbounds", 0 ], + check_logs: [ "diagnostics/runtime.uc", "check-logs", 0 ], + check_sing_box_logs: [ "diagnostics/runtime.uc", "check-sing-box-logs", 0 ], + check_fakeip: [ "diagnostics/runtime.uc", "check-fakeip", 0 ], + check_zapret_runtime: [ "diagnostics/runtime.uc", "check-zapret-runtime", 0 ], + check_zapret2_runtime: [ "diagnostics/runtime.uc", "check-zapret2-runtime", 0 ], + check_byedpi_runtime: [ "diagnostics/runtime.uc", "check-byedpi-runtime", 0 ], + neutralize_zapret_defaults: [ "diagnostics/runtime.uc", "neutralize-zapret-defaults", 0 ], + clash_api: [ "diagnostics/runtime.uc", "clash-api", 3 ], + show_config: [ "diagnostics/runtime.uc", "show-config", 1 ], + show_version: [ "diagnostics/runtime.uc", "show-version", 0 ], + show_sing_box_config: [ "diagnostics/runtime.uc", "show-sing-box-config", 1 ], + show_sing_box_version: [ "diagnostics/runtime.uc", "show-sing-box-version", 0 ], + get_status: [ "diagnostics/runtime.uc", "get-status", 0 ], + get_outbound_link: [ "diagnostics/runtime.uc", "get-outbound-link", 2 ], + get_outbound_link_states: [ "diagnostics/runtime.uc", "get-outbound-link-states", 1 ], + get_outbound_metadata: [ "diagnostics/runtime.uc", "get-outbound-metadata", 1 ], + get_subscription_metadata: [ "diagnostics/runtime.uc", "get-subscription-metadata", 1 ], + get_sing_box_status: [ "diagnostics/runtime.uc", "get-sing-box-status", 0 ], + get_zapret_status: [ "diagnostics/runtime.uc", "get-zapret-status", 0 ], + get_zapret2_status: [ "diagnostics/runtime.uc", "get-zapret2-status", 0 ], + get_byedpi_status: [ "diagnostics/runtime.uc", "get-byedpi-status", 0 ], + get_system_info: [ "diagnostics/runtime.uc", "get-system-info", 0 ], + get_server_capabilities: [ "diagnostics/runtime.uc", "get-server-capabilities", 0 ], + get_ui_capabilities: [ "service/ui.uc", "get-ui-capabilities", 0 ], + get_ui_state: [ "service/ui.uc", "get-ui-state", 0 ], + service_action_async: [ "service/ui.uc", "service-action-async", 1 ], + service_action_status: [ "service/ui.uc", "service-action-status", 1 ], + latency_test_async: [ "service/ui.uc", "latency-test-async", 4 ], + latency_test_status: [ "service/ui.uc", "latency-test-status", 1 ], + ui_action_ack: [ "service/ui.uc", "action-ack", 2 ], + generate_reality_keypair: [ "server/service.uc", "generate-reality-keypair", 0 ], + get_tls_certificate_sha256: [ "server/service.uc", "tls-certificate-sha256", 1 ], + component_action: [ "components/action.uc", "component-action", 2 ], + component_action_async: [ "components/updates.uc", "component-action-async", 2 ], + component_action_status: [ "components/updates.uc", "component-action-status", 1 ], + check_dns_available: [ "diagnostics/runtime.uc", "check-dns-available", 0 ], + global_check: [ "diagnostics/runtime.uc", "global-check", 2 ], + validate_nfqws_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws-strategy-json", 1 ], + validate_nfqws2_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws2-strategy-json", 1 ], + validate_byedpi_strategy_json: [ "providers/byedpi/validator.uc", "validate-json", 1 ], + package_prerm: [ "service/package.uc", "prerm", 0 ], + luci_postinst: [ "service/package.uc", "luci-postinst", 0 ] + }; - config_get_bool enabled "$section" "enabled" 1 - [ "$enabled" -eq 1 ] + return commands[as_string(command)]; } -server_is_enabled() { - local section="$1" - local enabled +function run_spec(command, spec, user_args) { + let module = module_path(spec[0]); + if (!file_readable(module)) { + warn("Error: required file '", module, "' is missing or not readable\n"); + restore_dnsmasq_failsafe_on_loader_failure(command); + return 1; + } - config_get_bool enabled "$section" "enabled" 0 - [ "$enabled" -eq 1 ] + let args = [ "ucode", "-L", LIB_DIR, module, spec[1] ]; + let count = int(spec[2] || 0); + for (let i = 0; i < count; i++) + push(args, user_args[i] || ""); + + return normalize_status(system(command_from_args(args))); } -. "$PODKOP_LIB/config_validation.sh" +function main() { + let command = ARGV[0] || ""; + let spec = command_spec(command); + if (spec == null) { + show_help(); + return 1; + } -. "$PODKOP_LIB/server_runtime.sh" + let user_args = []; + for (let i = 1; i < length(ARGV); i++) + push(user_args, ARGV[i]); -start_main() { - log "Starting Podkop Plus" - - migration - - check_requirements - - prepare_all_server_defaults - config_foreach process_validate_rule "section" - validate_runtime_settings - - br_netfilter_disable - - # Sync time only when the system clock is clearly invalid. - sync_time_if_needed - - ensure_runtime_dirs - acquire_start_subscription_update_lock - if ! prepare_subscription_caches_for_startup; then - log "Subscription caches are not ready. Aborted." "fatal" - exit 1 - fi - - # base - route_table_rule_mark - create_nft_rules - sing_box_configure_service - - # sing-box - sing_box_init_config - refresh_list_update_cron - start_byedpi_runtime - if ! /etc/init.d/sing-box start; then - log "Failed to start sing-box. Aborted." "fatal" - exit 1 - fi - apply_pending_urltest_selector_switches - run_deferred_subscription_bootstrap - release_start_subscription_update_lock - start_zapret_runtime - start_zapret2_runtime - - (close_inherited_service_lock_fd; list_update) & + return run_spec(command, spec, user_args); } -main() { - start_main -} - -stop_main() { - local status=0 - - log "Stopping Podkop Plus" - - stop_deferred_subscription_bootstrap_retry_worker - - if [ -f /var/run/podkop_list_update.pid ]; then - pid=$(cat /var/run/podkop_list_update.pid) - if kill -0 "$pid" 2> /dev/null; then - kill "$pid" 2> /dev/null - log "Stopped list_update" - fi - rm -f /var/run/podkop_list_update.pid - fi - - remove_cron_job - - rm -f "$TMP_RULESET_FOLDER"/* - - stop_zapret_runtime - stop_zapret2_runtime - stop_byedpi_runtime - - log "Flush nft" - if nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then - nft delete table inet "$NFT_TABLE_NAME" - fi - - log "Flush ip rule" - if has_tproxy_marking_rule; then - ip rule del fwmark "$NFT_FAKEIP_MARK"/"$NFT_FAKEIP_MARK" table "$RT_TABLE_NAME" priority 105 - fi - - log "Flush ip route" - if has_tproxy_route; then - ip route flush table "$RT_TABLE_NAME" - fi - - log "Stop sing-box" - /etc/init.d/sing-box stop || status="$?" - - return "$status" -} - -start_impl() { - start_main - - config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0 - if [ "$dont_touch_dhcp" -eq 0 ]; then - dnsmasq_configure || return 1 - elif dnsmasq_has_podkop_managed_state; then - log "dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes" - dnsmasq_restore force || return 1 - fi - - uci_set "$PODKOP_CONFIG_NAME" "settings" "shutdown_correctly" 0 - commit_podkop_config || return 1 - capture_reload_state - write_reload_state || return 1 - (refresh_system_info_cache) >/dev/null 2>&1 & -} - -start_failure_failsafe_dnsmasq_restore() { - local status="$1" - - trap - EXIT INT TERM - [ "$status" -eq 0 ] && exit 0 - - dnsmasq_restore_fail_safe - exit "$status" -} - -acquire_start_subscription_update_lock() { - if acquire_runtime_dir_lock_wait "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" 300; then - PODKOP_START_SUBSCRIPTION_UPDATE_LOCK_HELD=1 - return 0 - fi - - log "Subscription update is already running during startup. Aborted." "fatal" - exit 1 -} - -release_start_subscription_update_lock() { - [ "${PODKOP_START_SUBSCRIPTION_UPDATE_LOCK_HELD:-0}" = "1" ] || return 0 - - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - PODKOP_START_SUBSCRIPTION_UPDATE_LOCK_HELD=0 -} - -start_exit_cleanup() { - local status="$1" - - release_start_subscription_update_lock - start_failure_failsafe_dnsmasq_restore "$status" -} - -start() { - local status - - trap 'start_exit_cleanup "$?"' EXIT INT TERM - start_impl - status="$?" - trap - EXIT INT TERM - release_start_subscription_update_lock - - if [ "$status" -ne 0 ]; then - dnsmasq_restore_fail_safe - return "$status" - fi - - wait_for_podkop_stable_start - status="$?" - if [ "$status" -ne 0 ]; then - log "Podkop Plus did not reach a stable running state after start. Restoring dnsmasq." "fatal" - stop_main || true - dnsmasq_restore_fail_safe - return "$status" - fi - - run_pending_reload_if_requested - return 0 -} - -stop_impl() { - local dont_touch_dhcp status - - status=0 - config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0 - if [ "$dont_touch_dhcp" -eq 0 ]; then - dnsmasq_restore || status="$?" - elif dnsmasq_has_podkop_managed_state; then - log "dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes" - dnsmasq_restore force || status="$?" - fi - - stop_main || status="$?" - - uci_set "$PODKOP_CONFIG_NAME" "settings" "shutdown_correctly" 1 - commit_podkop_config || status="$?" - clear_reload_state - - if [ "$status" -ne 0 ]; then - dnsmasq_restore_fail_safe - fi - - return "$status" -} - -stop() { - local status - - stop_impl - status="$?" - - return "$status" -} - -restart_runtime_for_reload() { - local dont_touch_dhcp - - log "Reload fallback: restart Podkop Plus runtime in-process" - - stop_main - start_main - - config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0 - if [ "$dont_touch_dhcp" -eq 0 ]; then - dnsmasq_configure force - elif dnsmasq_has_podkop_managed_state; then - log "dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes" - dnsmasq_restore force - fi - - uci_set "$PODKOP_CONFIG_NAME" "settings" "shutdown_correctly" 0 - commit_podkop_config - capture_reload_state - write_reload_state -} - -write_service_trigger_sync_state() { - local changed="$1" - - mkdir -p "$PODKOP_RUNTIME_STATE_DIR" - printf '%s\n' "$changed" > "$PODKOP_SERVICE_TRIGGER_SYNC_FILE" 2>/dev/null || true -} - -reload() { - local reload_reason="${1:-}" - local status - local force_runtime_reload=0 - local previous_service_trigger_signature previous_dnsmasq_signature previous_sing_box_signature previous_nft_signature - local previous_zapret_queue_signature previous_zapret_runtime_signature previous_zapret2_queue_signature previous_zapret2_runtime_signature previous_byedpi_runtime_signature previous_list_signature previous_cron_signature - local previous_urltest_enabled_sections previous_urltest_enabled_sections_known needs_sing_box_reload needs_nft_rebuild needs_zapret_restart needs_zapret2_restart needs_byedpi_restart - local needs_dnsmasq_configure needs_dnsmasq_restore needs_cron_refresh needs_list_update - local changed_service_triggers changed_dnsmasq changed_sing_box changed_nft changed_zapret_queue changed_zapret_runtime changed_zapret2_queue changed_zapret2_runtime changed_byedpi_runtime changed_cron changed_list - - log "Podkop Plus reload" - - [ "$reload_reason" = "on_config_change" ] || force_runtime_reload=1 - PODKOP_RULE_CONDITION_CACHE_ENABLED="$force_runtime_reload" - - migration - config_load "$PODKOP_CONFIG_NAME" - check_requirements - prepare_all_server_defaults - config_foreach process_validate_rule "section" - validate_runtime_settings - ensure_runtime_dirs - - if ! podkop_is_running; then - log "Podkop Plus runtime is incomplete, falling back to in-process restart" - restart_runtime_for_reload - status="$?" - return "$status" - fi - - if ! load_previous_reload_state; then - log "Reload state is missing or incompatible, falling back to in-process restart" - restart_runtime_for_reload - status="$?" - return "$status" - fi - - previous_service_trigger_signature="$PREVIOUS_RELOAD_STATE_SERVICE_TRIGGER_SIGNATURE" - [ -n "$previous_service_trigger_signature" ] || - previous_service_trigger_signature="$PREVIOUS_RELOAD_STATE_RESTART_SIGNATURE" - previous_dnsmasq_signature="$PREVIOUS_RELOAD_STATE_DNSMASQ_SIGNATURE" - previous_sing_box_signature="$PREVIOUS_RELOAD_STATE_SING_BOX_SIGNATURE" - previous_nft_signature="$PREVIOUS_RELOAD_STATE_NFT_SIGNATURE" - previous_zapret_queue_signature="$PREVIOUS_RELOAD_STATE_ZAPRET_QUEUE_SIGNATURE" - previous_zapret_runtime_signature="$PREVIOUS_RELOAD_STATE_ZAPRET_RUNTIME_SIGNATURE" - previous_zapret2_queue_signature="$PREVIOUS_RELOAD_STATE_ZAPRET2_QUEUE_SIGNATURE" - previous_zapret2_runtime_signature="$PREVIOUS_RELOAD_STATE_ZAPRET2_RUNTIME_SIGNATURE" - previous_byedpi_runtime_signature="$PREVIOUS_RELOAD_STATE_BYEDPI_RUNTIME_SIGNATURE" - previous_list_signature="$PREVIOUS_RELOAD_STATE_LIST_SIGNATURE" - previous_cron_signature="$PREVIOUS_RELOAD_STATE_CRON_SIGNATURE" - previous_urltest_enabled_sections_known="$PREVIOUS_RELOAD_STATE_URLTEST_ENABLED_SECTIONS_KNOWN" - previous_urltest_enabled_sections="$PREVIOUS_RELOAD_STATE_URLTEST_ENABLED_SECTIONS" - capture_reload_state - if [ "$previous_urltest_enabled_sections_known" -eq 1 ]; then - PODKOP_URLTEST_NEW_ENABLED_SECTIONS="$( - build_new_urltest_enabled_sections "$previous_urltest_enabled_sections" "$RELOAD_STATE_URLTEST_ENABLED_SECTIONS" - )" - else - PODKOP_URLTEST_NEW_ENABLED_SECTIONS="" - fi - - needs_sing_box_reload=0 - needs_nft_rebuild=0 - needs_zapret_restart=0 - needs_zapret2_restart=0 - needs_byedpi_restart=0 - needs_dnsmasq_configure=0 - needs_dnsmasq_restore=0 - needs_cron_refresh=0 - needs_list_update=0 - changed_service_triggers=0 - changed_dnsmasq=0 - changed_sing_box=0 - changed_nft=0 - changed_zapret_queue=0 - changed_zapret_runtime=0 - changed_zapret2_queue=0 - changed_zapret2_runtime=0 - changed_byedpi_runtime=0 - changed_cron=0 - changed_list=0 - - [ "$RELOAD_STATE_SERVICE_TRIGGER_SIGNATURE" != "$previous_service_trigger_signature" ] && changed_service_triggers=1 - write_service_trigger_sync_state "$changed_service_triggers" - - if [ "$RELOAD_STATE_SING_BOX_SIGNATURE" != "$previous_sing_box_signature" ]; then - changed_sing_box=1 - needs_sing_box_reload=1 - fi - - if [ "$RELOAD_STATE_NFT_SIGNATURE" != "$previous_nft_signature" ]; then - changed_nft=1 - needs_nft_rebuild=1 - fi - - if [ "$RELOAD_STATE_ZAPRET_QUEUE_SIGNATURE" != "$previous_zapret_queue_signature" ]; then - changed_zapret_queue=1 - needs_zapret_restart=1 - needs_nft_rebuild=1 - needs_sing_box_reload=1 - fi - - if [ "$RELOAD_STATE_ZAPRET_RUNTIME_SIGNATURE" != "$previous_zapret_runtime_signature" ]; then - changed_zapret_runtime=1 - needs_zapret_restart=1 - fi - - if [ "$RELOAD_STATE_ZAPRET2_QUEUE_SIGNATURE" != "$previous_zapret2_queue_signature" ]; then - changed_zapret2_queue=1 - needs_zapret2_restart=1 - needs_nft_rebuild=1 - needs_sing_box_reload=1 - fi - - if [ "$RELOAD_STATE_ZAPRET2_RUNTIME_SIGNATURE" != "$previous_zapret2_runtime_signature" ]; then - changed_zapret2_runtime=1 - needs_zapret2_restart=1 - fi - - if [ "$RELOAD_STATE_BYEDPI_RUNTIME_SIGNATURE" != "$previous_byedpi_runtime_signature" ]; then - changed_byedpi_runtime=1 - needs_byedpi_restart=1 - fi - - if [ "$RELOAD_STATE_DNSMASQ_SIGNATURE" != "$previous_dnsmasq_signature" ]; then - changed_dnsmasq=1 - if [ "$RELOAD_STATE_DONT_TOUCH_DHCP" -eq 0 ]; then - needs_dnsmasq_configure=1 - elif dnsmasq_has_podkop_managed_state; then - needs_dnsmasq_restore=1 - fi - fi - - if [ "$RELOAD_STATE_CRON_SIGNATURE" != "$previous_cron_signature" ]; then - changed_cron=1 - needs_cron_refresh=1 - fi - - if [ "$RELOAD_STATE_LIST_SIGNATURE" != "$previous_list_signature" ]; then - changed_list=1 - has_list_update_sources && needs_list_update=1 - fi - - if [ "$needs_nft_rebuild" -eq 1 ] && has_nft_list_update_sources; then - needs_list_update=1 - fi - - if [ "$force_runtime_reload" -eq 1 ] && - [ "$needs_sing_box_reload" -eq 0 ] && - [ "$needs_nft_rebuild" -eq 0 ] && - [ "$needs_zapret_restart" -eq 0 ] && - [ "$needs_zapret2_restart" -eq 0 ] && - [ "$needs_byedpi_restart" -eq 0 ] && - [ "$needs_dnsmasq_configure" -eq 0 ] && - [ "$needs_dnsmasq_restore" -eq 0 ]; then - needs_sing_box_reload=1 - fi - - log "Reload signature changes: service_triggers=$changed_service_triggers sing-box=$changed_sing_box nft=$changed_nft zapret_queue=$changed_zapret_queue zapret_runtime=$changed_zapret_runtime zapret2_queue=$changed_zapret2_queue zapret2_runtime=$changed_zapret2_runtime byedpi_runtime=$changed_byedpi_runtime dnsmasq=$changed_dnsmasq cron=$changed_cron list=$changed_list forced=$force_runtime_reload" "debug" - log "Reload plan: sing-box=$needs_sing_box_reload nft=$needs_nft_rebuild zapret=$needs_zapret_restart zapret2=$needs_zapret2_restart byedpi=$needs_byedpi_restart dnsmasq_configure=$needs_dnsmasq_configure dnsmasq_restore=$needs_dnsmasq_restore cron=$needs_cron_refresh list_update=$needs_list_update" "debug" - - if [ "$needs_sing_box_reload" -eq 0 ] && - [ "$needs_nft_rebuild" -eq 0 ] && - [ "$needs_zapret_restart" -eq 0 ] && - [ "$needs_zapret2_restart" -eq 0 ] && - [ "$needs_byedpi_restart" -eq 0 ] && - [ "$needs_dnsmasq_configure" -eq 0 ] && - [ "$needs_dnsmasq_restore" -eq 0 ] && - [ "$needs_cron_refresh" -eq 0 ] && - [ "$needs_list_update" -eq 0 ]; then - write_reload_state - log "Reload skipped: runtime-relevant configuration is unchanged" - return 0 - fi - - if [ "$needs_zapret_restart" -eq 1 ]; then - stop_zapret_runtime - fi - - if [ "$needs_zapret2_restart" -eq 1 ]; then - stop_zapret2_runtime - fi - - if [ "$needs_byedpi_restart" -eq 1 ]; then - stop_byedpi_runtime - fi - - if [ "$needs_nft_rebuild" -eq 1 ]; then - rebuild_nft_runtime - fi - - if [ "$needs_sing_box_reload" -eq 1 ]; then - sing_box_configure_service - if [ "$needs_nft_rebuild" -eq 1 ]; then - PODKOP_NFT_POPULATE_ENABLED=1 - else - PODKOP_NFT_POPULATE_ENABLED=0 - fi - sing_box_init_config - PODKOP_NFT_POPULATE_ENABLED=1 - reload_sing_box_runtime - apply_pending_urltest_selector_switches - elif [ "$needs_nft_rebuild" -eq 1 ]; then - populate_nft_runtime_sets - fi - - if [ "$needs_zapret_restart" -eq 1 ]; then - start_zapret_runtime - fi - - if [ "$needs_zapret2_restart" -eq 1 ]; then - start_zapret2_runtime - fi - - if [ "$needs_byedpi_restart" -eq 1 ]; then - start_byedpi_runtime - fi - - if [ "$needs_dnsmasq_configure" -eq 1 ]; then - dnsmasq_configure force - capture_reload_state - elif [ "$needs_dnsmasq_restore" -eq 1 ]; then - log "dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes" - dnsmasq_restore force - capture_reload_state - fi - - if [ "$needs_cron_refresh" -eq 1 ]; then - refresh_list_update_cron - fi - - if [ "$needs_list_update" -eq 1 ]; then - (close_inherited_service_lock_fd; list_update) & - fi - - write_reload_state - return 0 -} - -reload_tracked() { - local reload_reason="${1:-}" - local job_id="" state_file="" status job_pid - - if [ "${PODKOP_UI_ACTION_TRACKED:-0}" = "1" ]; then - reload "$reload_reason" - return "$?" - fi - - job_id="$(ui_runtime_service_action_begin_if_idle reload "runtime_reload" 2>/dev/null || true)" - if [ -n "$job_id" ]; then - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_SERVICE_ACTION_DIR" "$job_id" 2>/dev/null || true)" - [ -n "$state_file" ] && ui_runtime_update_running_pid "$state_file" "$$" >/dev/null 2>&1 || true - fi - - reload "$reload_reason" - status="$?" - - if [ -z "$job_id" ] || [ -z "$state_file" ]; then - return "$status" - fi - - if [ "$status" -ne 0 ]; then - ui_runtime_service_action_finish_job "$job_id" false "Service reload failed" "$status" >/dev/null 2>&1 || true - return "$status" - fi - - if ui_runtime_service_enabled || ui_runtime_podkop_running; then - ( - trap '' HUP - if ui_runtime_service_action_wait_for_expected_state reload; then - ui_runtime_service_action_finish_job "$job_id" true "Service reload completed" 0 - else - ui_runtime_service_action_finish_job "$job_id" false "Service reload did not reach expected state" 1 - fi - ) >/dev/null 2>&1 & - job_pid="$!" - ui_runtime_update_running_pid "$state_file" "$job_pid" >/dev/null 2>&1 || true - else - ui_runtime_service_action_finish_job "$job_id" true "Service reload completed" 0 >/dev/null 2>&1 || true - fi - - return "$status" -} - -restart() { - local status - - log "Podkop Plus restart" - - stop_impl - status="$?" - if [ "$status" -ne 0 ]; then - return "$status" - fi - - start_impl - status="$?" - - [ "$status" -eq 0 ] && podkop_is_stably_running && run_pending_reload_if_requested - return "$status" -} - -package_manager_remove_if_installed() { - local package_name="$1" - - if command -v apk >/dev/null 2>&1; then - apk info -e "$package_name" >/dev/null 2>&1 && apk del "$package_name" >/dev/null 2>&1 || true - return 0 - fi - - if opkg_package_is_installed "$package_name"; then - opkg remove --force-depends "$package_name" >/dev/null 2>&1 || true - fi -} - -uninstall() { - log "Uninstalling Podkop Plus" - - if [ -x "$PODKOP_SERVICE_INIT" ]; then - stop >/dev/null 2>&1 || true - "$PODKOP_SERVICE_INIT" disable >/dev/null 2>&1 || true - fi - - dnsmasq_restore_fail_safe >/dev/null 2>&1 || true - - if [ -x /etc/init.d/podkop ]; then - /etc/init.d/podkop stop >/dev/null 2>&1 || true - /etc/init.d/podkop disable >/dev/null 2>&1 || true - fi - - package_manager_remove_if_installed "luci-i18n-podkop-plus-ru" - package_manager_remove_if_installed "luci-app-podkop-plus" - - if command -v sing_box_managed_service_installed >/dev/null 2>&1 && sing_box_managed_service_installed; then - sing_box_remove_managed_service_script >/dev/null 2>&1 || true - rm -f /usr/bin/sing-box /usr/lib/libcronet.so - fi - - rm -rf /usr/lib/podkop-plus - rm -rf "$PODKOP_LUCI_VIEW_DIR" - rm -f "$PODKOP_SERVICE_INIT" - rm -f "$PODKOP_BIN" - rm -f /usr/share/luci/menu.d/luci-app-podkop-plus.json - rm -f /usr/share/rpcd/acl.d/luci-app-podkop-plus.json - rm -f /etc/uci-defaults/50_luci-podkop-plus - rm -f "/usr/lib/lua/luci/i18n/${PODKOP_LUCI_I18N_DOMAIN}."*.lmo - rm -f "/usr/lib/lua/luci/i18n/${PODKOP_LUCI_I18N_DOMAIN}.ru.lua" - rm -f "/usr/lib/lua/luci/i18n/${PODKOP_LUCI_I18N_DOMAIN}.en.lua" - rm -f /var/luci-indexcache* - rm -f /tmp/luci-indexcache* - [ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload >/dev/null 2>&1 || true - - printf '%s\n' '{"removed":true}' -} - -. "$PODKOP_LIB/rules_nft_runtime.sh" - -. "$PODKOP_LIB/updates_runtime.sh" - -. "$PODKOP_LIB/sing_box_runtime.sh" - -. "$PODKOP_LIB/status_diagnostics.sh" - -show_help() { - cat << EOF -Usage: $0 COMMAND - -Available commands: - start Start Podkop Plus service - stop Stop Podkop Plus service - reload Reload Podkop Plus configuration - restart Restart Podkop Plus service - enable Enable Podkop Plus autostart - disable Disable Podkop Plus autostart - uninstall Remove Podkop Plus files installed outside opkg/apk - main Run main Podkop Plus process - list_update Update remote lists - list_update_if_due Update remote lists only when the configured interval has elapsed - subscription_update [rule] [source_index] - Update all subscription proxies, one rule, or one source in a rule - subscription_update_async [rule] [source_index] - Start forced subscription update in the background - subscription_update_status [job_id] - Get background subscription update status - subscription_update_if_due - Update subscription proxies only when per-rule intervals have elapsed - check_proxy Check proxy connectivity - check_nft Check NFT rules - check_nft_rules Check NFT rules status - check_sing_box Check sing-box installation and status - check_inbounds_config Check whether enabled server inbounds are configured - check_inbounds Check server inbounds from the Servers tab - check_logs Show Podkop Plus logs from system journal - check_sing_box_logs Show sing-box logs - check_fakeip Test sing-box FakeIP DNS - check_zapret_runtime Check external zapret provider state - check_zapret2_runtime Check external zapret2 provider state - check_byedpi_runtime Check external ByeDPI provider state - neutralize_zapret_defaults - Compatibility no-op; Podkop Plus does not manage standalone zapret - clash_api Clash API interface for managing proxies and groups - show_config Display current Podkop Plus configuration - show_version Show Podkop Plus version - show_sing_box_config Show sing-box configuration - show_sing_box_version Show sing-box version - get_status Get Podkop Plus service status - get_outbound_link Get source proxy link for a generated outbound - get_outbound_metadata Get generated outbound display names and countries - get_subscription_metadata - Get UI-only metadata for a subscription rule - get_sing_box_status Get sing-box service status - get_zapret_status Get zapret provider and Podkop Plus-managed nfqws status - get_zapret2_status Get zapret2 provider and Podkop Plus-managed nfqws2 status - get_byedpi_status Get ByeDPI provider and Podkop Plus-managed ciadpi status - get_system_info Get system information in JSON format - get_server_capabilities Get server UI capabilities in JSON format - get_ui_capabilities Get lightweight UI capabilities in JSON format - get_ui_state Get lightweight UI service/action state in JSON format - service_action_async Start a service action in the background - service_action_status Get background service action status - latency_test_async Start a dashboard latency test in the background - latency_test_status Get background latency test status - ui_action_ack Acknowledge a finished UI action state - generate_reality_keypair - Generate a sing-box Reality key pair in JSON format - get_tls_certificate_sha256 [server] - Get a server TLS certificate SHA-256 fingerprint in JSON format - component_action Manage Podkop Plus, sing-box, zapret, zapret2, and ByeDPI components - component_action_async Start a component action in the background - component_action_status Get background component action status - check_dns_available Check DNS server availability - global_check Run global system check - validate_nfqws_strategy_json - Validate an NFQWS strategy and print JSON result - validate_nfqws2_strategy_json - Validate an NFQWS2 strategy and print JSON result - validate_byedpi_strategy_json - Validate a ByeDPI ciadpi strategy and print JSON result -EOF -} - -case "$1" in -start) - start - ;; -stop) - stop - ;; -reload) - reload_tracked "$2" - ;; -restart) - restart - ;; -enable) - "$PODKOP_SERVICE_INIT" enable - ;; -disable) - "$PODKOP_SERVICE_INIT" disable - ;; -uninstall) - uninstall - ;; -dnsmasq_restore | restore_dnsmasq) - dnsmasq_restore_fail_safe - ;; -main) - main - ;; -list_update) - list_update - ;; -list_update_if_due) - list_update_if_due - ;; -subscription_update) - subscription_update "$2" "$3" - ;; -subscription_update_async) - subscription_update_async "$2" "$3" - ;; -subscription_update_status) - subscription_update_status "$2" - ;; -subscription_update_if_due) - subscription_update_if_due - ;; -check_proxy) - check_proxy - ;; -check_nft) - check_nft - ;; -check_nft_rules) - check_nft_rules - ;; -check_sing_box) - check_sing_box - ;; -check_inbounds_config) - check_inbounds_config - ;; -check_inbounds) - check_inbounds - ;; -check_logs) - check_logs - ;; -check_sing_box_logs) - check_sing_box_logs - ;; -check_fakeip) - check_fakeip - ;; -check_zapret_runtime) - check_zapret_runtime - ;; -check_zapret2_runtime) - check_zapret2_runtime - ;; -check_byedpi_runtime) - check_byedpi_runtime - ;; -neutralize_zapret_defaults) - neutralize_zapret_defaults - ;; -clash_api) - clash_api "$2" "$3" "$4" - ;; -show_config) - show_config "${2:-}" - ;; -show_version) - show_version - ;; -show_sing_box_config) - show_sing_box_config "${2:-}" - ;; -show_sing_box_version) - show_sing_box_version - ;; -get_status) - get_status - ;; -get_outbound_link) - get_outbound_link "$2" "$3" - ;; -get_outbound_link_states) - get_outbound_link_states "$2" - ;; -get_outbound_metadata) - get_outbound_metadata "$2" - ;; -get_subscription_metadata) - get_subscription_metadata "$2" - ;; -get_sing_box_status) - get_sing_box_status - ;; -get_zapret_status) - get_zapret_status - ;; -get_zapret2_status) - get_zapret2_status - ;; -get_byedpi_status) - get_byedpi_status - ;; -get_system_info) - get_system_info - ;; -get_server_capabilities) - get_server_capabilities - ;; -get_ui_capabilities) - get_ui_capabilities - ;; -get_ui_state) - podkop_fast_get_ui_state - ;; -service_action_async) - ui_runtime_service_action_async "$2" - ;; -service_action_status) - ui_runtime_service_action_status "$2" - ;; -latency_test_async) - ui_runtime_latency_test_async "$2" "$3" "$4" "$5" - ;; -latency_test_status) - ui_runtime_latency_test_status "$2" - ;; -ui_action_ack) - ui_runtime_action_ack "$2" "$3" - ;; -generate_reality_keypair) - generate_reality_keypair - ;; -get_tls_certificate_sha256) - get_tls_certificate_sha256 "$2" - ;; -component_action) - component_action "$2" "$3" - ;; -component_action_async) - component_action_async "$2" "$3" - ;; -component_action_status) - component_action_status "$2" - ;; -check_dns_available) - check_dns_available - ;; -global_check) - global_check "${2:-}" "${3:-}" - ;; -validate_nfqws_strategy_json) - validate_nfqws_strategy_json "${2:-}" - ;; -validate_nfqws2_strategy_json) - validate_nfqws2_strategy_json "${2:-}" - ;; -validate_byedpi_strategy_json) - validate_byedpi_strategy_json "${2:-}" - ;; -*) - show_help - exit 1 - ;; -esac +exit(main()); diff --git a/podkop/files/usr/lib/byedpi.sh b/podkop/files/usr/lib/byedpi.sh deleted file mode 100644 index 2dc7cac7..00000000 --- a/podkop/files/usr/lib/byedpi.sh +++ /dev/null @@ -1,842 +0,0 @@ -# shellcheck shell=ash - -has_enabled_byedpi_rules() { - local count - - count="$(get_byedpi_rule_count)" - [ "${count:-0}" -gt 0 ] -} - -_count_byedpi_rule_handler() { - local section="$1" - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "byedpi" ] || return 0 - - BYEDPI_RULE_COUNT=$((BYEDPI_RULE_COUNT + 1)) -} - -get_byedpi_rule_count() { - BYEDPI_RULE_COUNT=0 - config_foreach _count_byedpi_rule_handler "section" - echo "$BYEDPI_RULE_COUNT" -} - -_find_byedpi_rule_index_handler() { - local section="$1" - local target_section="$2" - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "byedpi" ] || return 0 - - BYEDPI_RULE_INDEX_WALK=$((BYEDPI_RULE_INDEX_WALK + 1)) - if [ "$section" = "$target_section" ]; then - BYEDPI_RULE_INDEX_RESULT="$BYEDPI_RULE_INDEX_WALK" - fi -} - -get_byedpi_rule_index() { - local section="$1" - - BYEDPI_RULE_INDEX_WALK=0 - BYEDPI_RULE_INDEX_RESULT=0 - config_foreach _find_byedpi_rule_index_handler "section" "$section" - - echo "$BYEDPI_RULE_INDEX_RESULT" -} - -get_byedpi_rule_port() { - local index="$1" - - echo $((BYEDPI_PORT_BASE + index - 1)) -} - -normalize_byedpi_strategy_whitespace() { - normalize_strategy_whitespace "$1" -} - -get_rule_byedpi_cmd_opts() { - local section="$1" - local cmd_opts - - config_get cmd_opts "$section" "byedpi_cmd_opts" - - if [ -n "$cmd_opts" ]; then - normalize_byedpi_strategy_whitespace "$cmd_opts" - else - normalize_byedpi_strategy_whitespace "$BYEDPI_DEFAULT_CMD_OPTS" - fi -} - -byedpi_package_installed() { - if command -v apk >/dev/null 2>&1 && apk info -e byedpi >/dev/null 2>&1; then - return 0 - fi - - if opkg_package_is_installed "byedpi"; then - return 0 - fi - - return 1 -} - -is_byedpi_provider_available() { - [ -x "$BYEDPI_BIN" ] -} - -is_byedpi_installed() { - is_byedpi_provider_available -} - -get_byedpi_package_version() { - local version="" - - if command -v apk >/dev/null 2>&1 && apk info -e byedpi >/dev/null 2>&1; then - version="$(get_apk_installed_package_version "byedpi")" - elif command -v opkg >/dev/null 2>&1; then - version="$(get_opkg_installed_package_version "byedpi")" - fi - - if [ -z "$version" ] && [ -x "$BYEDPI_BIN" ]; then - version="$("$BYEDPI_BIN" --version 2>/dev/null | provider_status_ucode stdin-first-nonempty-field)" - fi - - echo "$version" -} - -is_byedpi_standalone_service_enabled() { - [ -x "$BYEDPI_SERVICE_INIT" ] || return 1 - "$BYEDPI_SERVICE_INIT" enabled >/dev/null 2>&1 -} - -is_byedpi_standalone_service_running() { - [ -x "$BYEDPI_SERVICE_INIT" ] || return 1 - "$BYEDPI_SERVICE_INIT" status >/dev/null 2>&1 -} - -prepare_byedpi_runtime() { - mkdir -p "$BYEDPI_STATE_DIR" "$BYEDPI_PID_DIR" "$BYEDPI_CHILD_PID_DIR" "$BYEDPI_LOG_DIR" -} - -check_byedpi_requirements() { - has_enabled_byedpi_rules || return 0 - - if ! is_byedpi_provider_available; then - log "ByeDPI provider is not available at $BYEDPI_BIN. Rules with action 'byedpi' will be skipped until the byedpi package is installed." "error" - return 0 - fi - - if ! prepare_byedpi_runtime; then - log "Failed to prepare the Podkop Plus ByeDPI state directory in $BYEDPI_STATE_DIR. Aborted." "fatal" - exit 1 - fi -} - -clear_byedpi_validation_state() { - BYEDPI_VALIDATE_ERROR="" - BYEDPI_VALIDATE_NEEDLE="" - BYEDPI_VALIDATE_NEEDLES="" -} - -append_byedpi_validation_needle() { - local needle="$1" - - [ -n "$needle" ] || return 0 - - case " -$BYEDPI_VALIDATE_NEEDLES -" in - *" -$needle -"*) - return 0 - ;; - esac - - if [ -n "$BYEDPI_VALIDATE_NEEDLES" ]; then - BYEDPI_VALIDATE_NEEDLES="${BYEDPI_VALIDATE_NEEDLES} -$needle" - else - BYEDPI_VALIDATE_NEEDLES="$needle" - fi - - [ -n "$BYEDPI_VALIDATE_NEEDLE" ] || BYEDPI_VALIDATE_NEEDLE="$needle" -} - -set_byedpi_validation_failure() { - BYEDPI_VALIDATE_ERROR="$1" - BYEDPI_VALIDATE_NEEDLE="" - BYEDPI_VALIDATE_NEEDLES="" - shift - - while [ "$#" -gt 0 ]; do - append_byedpi_validation_needle "$1" - shift - done - - return 1 -} - -byedpi_is_allowed_long_value_option() { - case "$1" in - --max-conn | --conn-ip | --buf-size | --debug | --def-ttl | --auto | --auto-mode | --cache-ttl | --cache-dump | --timeout | --proto | --hosts | --ipset | --pf | --round | --split | --disorder | --oob | --disoob | --fake | --fake-sni | --ttl | --fake-offset | --fake-data | --fake-tls-mod | --oob-data | --mod-http | --tlsrec | --tlsminor | --udp-fake) - return 0 - ;; - esac - - return 1 -} - -byedpi_is_allowed_long_flag_option() { - case "$1" in - --md5sig | --tfo | --drop-sack | --no-domain | --no-udp) - return 0 - ;; - esac - - return 1 -} - -byedpi_is_allowed_short_value_option() { - case "$1" in - -c | -I | -b | -x | -g | -A | -L | -u | -y | -T | -K | -H | -j | -V | -R | -s | -d | -o | -q | -f | -n | -t | -O | -l | -Q | -e | -M | -r | -m | -a) - return 0 - ;; - esac - - return 1 -} - -byedpi_is_allowed_short_flag_option() { - case "$1" in - -N | -U | -F | -S | -Y) - return 0 - ;; - esac - - return 1 -} - -byedpi_get_short_option_name() { - local token="$1" - local short_char - - short_char="${token#-}" - short_char="${short_char%"${short_char#?}"}" - printf -- '-%s' "$short_char" -} - -byedpi_token_looks_like_option() { - case "$1" in - --?*) - return 0 - ;; - -[A-Za-z]*) - return 0 - ;; - esac - - return 1 -} - -byedpi_reject_controlled_option() { - local token="$1" - local next_token="$2" - local base_token display - - base_token="${token%%=*}" - display="$token" - - case "$token" in - --ip | --ip=* | -i | -i?* | --port | --port=* | -p | -p?*) - case "$token" in - --ip | -i | --port | -p) - if [ -n "$next_token" ] && [ "${next_token#-}" = "$next_token" ]; then - display="$display $next_token" - fi - ;; - esac - set_byedpi_validation_failure "ByeDPI listen address and port are assigned by Podkop Plus and must not be set in the strategy: $display" "$base_token" - return 0 - ;; - --transparent | -E | -E?*) - set_byedpi_validation_failure "Transparent proxy mode is incompatible with action=byedpi because Podkop Plus connects to ciadpi through SOCKS." "$base_token" - return 0 - ;; - --daemon | -D | -D?*) - set_byedpi_validation_failure "Podkop Plus manages the ciadpi process lifecycle itself, so daemon mode is not allowed here." "$base_token" - return 0 - ;; - --pidfile | --pidfile=* | -w | -w?*) - set_byedpi_validation_failure "Podkop Plus manages ciadpi pid files itself, so pidfile options are not allowed here." "$base_token" - return 0 - ;; - --help | -h | -h?* | --version | -v | -v?*) - set_byedpi_validation_failure "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here." "$base_token" - return 0 - ;; - esac - - return 1 -} - -byedpi_validate_strategy_token() { - local token="$1" - local next_token="$2" - local base value short - - BYEDPI_TOKEN_CONSUME_NEXT=0 - - if byedpi_reject_controlled_option "$token" "$next_token"; then - return 1 - fi - - case "$token" in - --*=*) - base="${token%%=*}" - value="${token#*=}" - - if byedpi_is_allowed_long_value_option "$base"; then - if [ -z "$value" ]; then - set_byedpi_validation_failure "ByeDPI option requires a value: $base" "$base" - return 1 - fi - return 0 - fi - - if byedpi_is_allowed_long_flag_option "$base"; then - set_byedpi_validation_failure "ByeDPI option does not accept a value: $base" "$base" - return 1 - fi - - set_byedpi_validation_failure "Unknown ByeDPI option: $base" "$base" - return 1 - ;; - --*) - base="$token" - - if byedpi_is_allowed_long_value_option "$base"; then - if [ -z "$next_token" ] || byedpi_token_looks_like_option "$next_token"; then - set_byedpi_validation_failure "ByeDPI option requires a value: $base" "$base" - return 1 - fi - BYEDPI_TOKEN_CONSUME_NEXT=1 - return 0 - fi - - if byedpi_is_allowed_long_flag_option "$base"; then - return 0 - fi - - set_byedpi_validation_failure "Unknown ByeDPI option: $base" "$base" - return 1 - ;; - -*) - [ "$token" = "-" ] && { - set_byedpi_validation_failure "Unexpected ByeDPI strategy argument: $token" "$token" - return 1 - } - - short="$(byedpi_get_short_option_name "$token")" - value="${token#"$short"}" - - if byedpi_is_allowed_short_value_option "$short"; then - if [ "$token" = "$short" ]; then - if [ -z "$next_token" ] || byedpi_token_looks_like_option "$next_token"; then - set_byedpi_validation_failure "ByeDPI option requires a value: $short" "$short" - return 1 - fi - BYEDPI_TOKEN_CONSUME_NEXT=1 - elif [ -z "$value" ]; then - set_byedpi_validation_failure "ByeDPI option requires a value: $short" "$short" - return 1 - fi - return 0 - fi - - if byedpi_is_allowed_short_flag_option "$short"; then - if [ "$token" != "$short" ]; then - set_byedpi_validation_failure "ByeDPI option does not accept a compact value: $short" "$short" - return 1 - fi - return 0 - fi - - set_byedpi_validation_failure "Unknown ByeDPI option: $short" "$short" - return 1 - ;; - *) - set_byedpi_validation_failure "Unexpected ByeDPI strategy argument: $token" "$token" - return 1 - ;; - esac -} - -check_byedpi_strategy() { - local raw_opt="$1" - local old_ifs token next_token - - clear_byedpi_validation_state - raw_opt="$(normalize_byedpi_strategy_whitespace "$raw_opt")" - - if [ -z "$raw_opt" ]; then - set_byedpi_validation_failure "ByeDPI strategy cannot be empty." - return 1 - fi - - set -f - old_ifs="$IFS" - IFS=' ' - set -- $raw_opt - IFS="$old_ifs" - - while [ "$#" -gt 0 ]; do - token="$1" - next_token="$2" - - if ! byedpi_validate_strategy_token "$token" "$next_token"; then - set +f - return 1 - fi - - if [ "${BYEDPI_TOKEN_CONSUME_NEXT:-0}" -eq 1 ]; then - shift - fi - - shift - done - - set +f - return 0 -} - -validate_byedpi_strategy() { - local raw_opt="$1" - local context="$2" - - if ! check_byedpi_strategy "$raw_opt"; then - if [ -n "$context" ]; then - log "$context: $BYEDPI_VALIDATE_ERROR" "fatal" - else - log "$BYEDPI_VALIDATE_ERROR" "fatal" - fi - exit 1 - fi -} - -validate_byedpi_strategy_json() { - local raw_opt="$1" - - if check_byedpi_strategy "$raw_opt"; then - provider_status_ucode byedpi-validation 1 "" "" - return 0 - fi - - printf '%s\n' "$BYEDPI_VALIDATE_NEEDLES" | - provider_status_ucode byedpi-validation 0 "$BYEDPI_VALIDATE_ERROR" "$BYEDPI_VALIDATE_NEEDLE" -} - -stop_byedpi_pidfile_process() { - local pidfile="$1" - local pid - - [ -f "$pidfile" ] || return 0 - pid="$(cat "$pidfile" 2>/dev/null)" - [ -n "$pid" ] || return 0 - kill "$pid" 2>/dev/null || true -} - -kill_byedpi_pidfile_process() { - local pidfile="$1" - local pid - - [ -f "$pidfile" ] || return 0 - pid="$(cat "$pidfile" 2>/dev/null)" - [ -n "$pid" ] || return 0 - kill -0 "$pid" 2>/dev/null || return 0 - kill -9 "$pid" 2>/dev/null || true -} - -stop_byedpi_runtime() { - local pidfile - - if [ -d "$BYEDPI_PID_DIR" ]; then - for pidfile in "$BYEDPI_PID_DIR"/*.pid; do - stop_byedpi_pidfile_process "$pidfile" - done - fi - - if [ -d "$BYEDPI_CHILD_PID_DIR" ]; then - for pidfile in "$BYEDPI_CHILD_PID_DIR"/*.pid; do - stop_byedpi_pidfile_process "$pidfile" - done - fi - - sleep 1 - - if [ -d "$BYEDPI_PID_DIR" ]; then - for pidfile in "$BYEDPI_PID_DIR"/*.pid; do - kill_byedpi_pidfile_process "$pidfile" - done - fi - - if [ -d "$BYEDPI_CHILD_PID_DIR" ]; then - for pidfile in "$BYEDPI_CHILD_PID_DIR"/*.pid; do - kill_byedpi_pidfile_process "$pidfile" - done - fi - - rm -rf "$BYEDPI_PID_DIR" "$BYEDPI_CHILD_PID_DIR" "$BYEDPI_LOG_DIR" -} - -run_byedpi_supervisor() { - local section="$1" - local port="$2" - local raw_opt="$3" - local child_pidfile="$4" - local child_pid="" old_ifs rc - - trap 'rm -f "$child_pidfile"; [ -n "$child_pid" ] && kill "$child_pid" 2>/dev/null; [ -n "$child_pid" ] && wait "$child_pid" 2>/dev/null; exit 0' TERM INT - - while :; do - if [ ! -x "$BYEDPI_BIN" ]; then - printf '%s Provider %s is not executable; retrying in %s seconds\n' \ - "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null)" "$BYEDPI_BIN" "$BYEDPI_RESPAWN_DELAY" - sleep "$BYEDPI_RESPAWN_DELAY" & - child_pid="$!" - wait "$child_pid" - child_pid="" - continue - fi - - ulimit -n "$BYEDPI_OPEN_FILES_LIMIT" >/dev/null 2>&1 || true - - set -f - old_ifs="$IFS" - IFS=' ' - set -- $raw_opt - IFS="$old_ifs" - - "$BYEDPI_BIN" --ip "$BYEDPI_LISTEN_ADDRESS" --port "$port" "$@" & - child_pid="$!" - echo "$child_pid" > "$child_pidfile" - wait "$child_pid" - rc="$?" - rm -f "$child_pidfile" - child_pid="" - set +f - - printf '%s ciadpi for rule %s exited with code %s; respawning in %s seconds\n' \ - "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null)" "$section" "$rc" "$BYEDPI_RESPAWN_DELAY" - sleep "$BYEDPI_RESPAWN_DELAY" & - child_pid="$!" - wait "$child_pid" - child_pid="" - done -} - -_start_byedpi_runtime_handler() { - local section="$1" - local index port raw_opt pidfile child_pidfile logfile pid child_pid - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "byedpi" ] || return 0 - - index="$(get_byedpi_rule_index "$section")" - if [ "${index:-0}" -le 0 ]; then - log "Unable to resolve ByeDPI index for rule '$section'. Aborted." "fatal" - exit 1 - fi - - port="$(get_byedpi_rule_port "$index")" - raw_opt="$(get_rule_byedpi_cmd_opts "$section")" - validate_byedpi_strategy "$raw_opt" "Invalid ByeDPI strategy for rule '$section'" - - pidfile="$BYEDPI_PID_DIR/$section.pid" - child_pidfile="$BYEDPI_CHILD_PID_DIR/$section.pid" - logfile="$BYEDPI_LOG_DIR/$section.log" - - log "Starting ciadpi for rule '$section' on $BYEDPI_LISTEN_ADDRESS:$port" - (close_inherited_service_lock_fd; run_byedpi_supervisor "$section" "$port" "$raw_opt" "$child_pidfile") >>"$logfile" 2>&1 & - pid="$!" - echo "$pid" > "$pidfile" - sleep 1 - - if ! kill -0 "$pid" 2>/dev/null; then - log "ciadpi failed to start for rule '$section'. Check $logfile. Aborted." "fatal" - exit 1 - fi - - child_pid="$(cat "$child_pidfile" 2>/dev/null)" - if [ -z "$child_pid" ] || ! kill -0 "$child_pid" 2>/dev/null; then - log "ciadpi supervisor started for rule '$section', but ciadpi is not running yet. Check $logfile." "warn" - fi -} - -start_byedpi_runtime() { - stop_byedpi_runtime - - has_enabled_byedpi_rules || return 0 - is_byedpi_provider_available || return 0 - - if is_byedpi_standalone_service_enabled; then - log "Standalone byedpi service is enabled. Podkop Plus manages ciadpi itself for action 'byedpi'; disable standalone byedpi autostart to avoid boot-time port conflicts." "warn" - fi - - if is_byedpi_standalone_service_running; then - log "Stopping standalone byedpi service before starting Podkop Plus-managed ciadpi runtime" - "$BYEDPI_SERVICE_INIT" stop >/dev/null 2>&1 || true - sleep 1 - if is_byedpi_standalone_service_running; then - log "Standalone byedpi service is still running and may conflict with Podkop Plus-managed ciadpi runtime. Aborted." "fatal" - exit 1 - fi - fi - - check_byedpi_requirements - mkdir -p "$BYEDPI_PID_DIR" "$BYEDPI_CHILD_PID_DIR" "$BYEDPI_LOG_DIR" - config_foreach _start_byedpi_runtime_handler "section" -} - -get_byedpi_runtime_process_count() { - local count=0 pidfile pid - - [ -d "$BYEDPI_CHILD_PID_DIR" ] || { - echo 0 - return 0 - } - - for pidfile in "$BYEDPI_CHILD_PID_DIR"/*.pid; do - [ -f "$pidfile" ] || continue - pid="$(cat "$pidfile" 2>/dev/null)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - count=$((count + 1)) - else - rm -f "$pidfile" - fi - done - - echo "$count" -} - -get_byedpi_supervisor_process_count() { - local count=0 pidfile pid - - [ -d "$BYEDPI_PID_DIR" ] || { - echo 0 - return 0 - } - - for pidfile in "$BYEDPI_PID_DIR"/*.pid; do - [ -f "$pidfile" ] || continue - pid="$(cat "$pidfile" 2>/dev/null)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - count=$((count + 1)) - else - rm -f "$pidfile" - fi - done - - echo "$count" -} - -get_byedpi_restart_count() { - local count=0 logfile lines - - [ -d "$BYEDPI_LOG_DIR" ] || { - echo 0 - return 0 - } - - for logfile in "$BYEDPI_LOG_DIR"/*.log; do - [ -f "$logfile" ] || continue - lines="$(provider_status_ucode byedpi-restart-count-file "$logfile" 2>/dev/null || true)" - count=$((count + ${lines:-0})) - done - - echo "$count" -} - -byedpi_rule_outbound_present() { - local section="$1" - local port="$2" - local outbound_tag - - [ -f "$BYEDPI_SINGBOX_CONFIG_PATH" ] || return 1 - - outbound_tag="$(get_outbound_tag_by_section "$section")" - - 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() { - local section="$1" - local outbound_tag - - [ -f "$BYEDPI_SINGBOX_CONFIG_PATH" ] || return 1 - - outbound_tag="$(get_outbound_tag_by_section "$section")" - - 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() { - local section="$1" - local index port - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "byedpi" ] || return 0 - - BYEDPI_RUNTIME_RULES_CONFIGURED=1 - index="$(get_byedpi_rule_index "$section")" - port="$(get_byedpi_rule_port "$index")" - - if ! byedpi_rule_outbound_present "$section" "$port"; then - BYEDPI_RUNTIME_OUTBOUNDS_CONFIGURED=0 - fi - - if ! byedpi_rule_route_rule_present "$section"; then - BYEDPI_RUNTIME_ROUTES_CONFIGURED=0 - fi -} - -collect_byedpi_runtime_status() { - local sing_box_config_path - - BYEDPI_RUNTIME_RULES_CONFIGURED=0 - BYEDPI_RUNTIME_OUTBOUNDS_CONFIGURED=1 - BYEDPI_RUNTIME_ROUTES_CONFIGURED=1 - - config_get sing_box_config_path "settings" "config_path" - BYEDPI_SINGBOX_CONFIG_PATH="$sing_box_config_path" - config_foreach _collect_byedpi_runtime_status_handler "section" - - if [ "$BYEDPI_RUNTIME_RULES_CONFIGURED" -eq 0 ]; then - BYEDPI_RUNTIME_OUTBOUNDS_CONFIGURED=0 - BYEDPI_RUNTIME_ROUTES_CONFIGURED=0 - fi -} - -get_byedpi_status_json() { - local installed=0 package_installed=0 provider_available=0 configured=0 ready=0 conflict=0 - local standalone_service_enabled=0 standalone_service_running=0 enabled_rule_count expected_process_count running_process_count - local supervisor_process_count restart_count runtime_unstable=0 version status_message outbounds_configured=0 routes_configured=0 - - enabled_rule_count="$(get_byedpi_rule_count)" - expected_process_count="${enabled_rule_count:-0}" - running_process_count="$(get_byedpi_runtime_process_count)" - supervisor_process_count="$(get_byedpi_supervisor_process_count)" - restart_count="$(get_byedpi_restart_count)" - version="not installed" - status_message="" - - if [ "${enabled_rule_count:-0}" -gt 0 ]; then - configured=1 - fi - - if byedpi_package_installed; then - package_installed=1 - version="$(get_byedpi_package_version)" - [ -n "$version" ] || version="unknown" - fi - - if is_byedpi_provider_available; then - provider_available=1 - installed=1 - if [ "$package_installed" -eq 0 ]; then - version="$(get_byedpi_package_version)" - [ -n "$version" ] || version="unknown" - fi - fi - - if is_byedpi_standalone_service_enabled; then - standalone_service_enabled=1 - fi - - if is_byedpi_standalone_service_running; then - standalone_service_running=1 - fi - - collect_byedpi_runtime_status - outbounds_configured="${BYEDPI_RUNTIME_OUTBOUNDS_CONFIGURED:-0}" - routes_configured="${BYEDPI_RUNTIME_ROUTES_CONFIGURED:-0}" - - if [ "${running_process_count:-0}" -gt "${expected_process_count:-0}" ]; then - conflict=1 - fi - - if [ "$configured" -eq 1 ] && [ "${restart_count:-0}" -gt 0 ]; then - runtime_unstable=1 - fi - - if [ "$configured" -eq 1 ] && - [ "$provider_available" -eq 1 ] && - [ "$standalone_service_running" -eq 0 ] && - [ "$conflict" -eq 0 ] && - [ "$runtime_unstable" -eq 0 ] && - [ "$outbounds_configured" -eq 1 ] && - [ "$routes_configured" -eq 1 ] && - [ "${expected_process_count:-0}" -gt 0 ] && - [ "${running_process_count:-0}" -eq "${expected_process_count:-0}" ]; then - ready=1 - fi - - if [ "$configured" -eq 1 ] && [ "$provider_available" -eq 0 ]; then - status_message="action=byedpi is configured, but ciadpi is not available at $BYEDPI_BIN" - elif [ "$configured" -eq 1 ] && [ "$standalone_service_running" -eq 1 ]; then - status_message="standalone byedpi service is active together with Podkop Plus action=byedpi; port conflicts are possible" - conflict=1 - elif [ "$configured" -eq 1 ] && [ "$standalone_service_enabled" -eq 1 ]; then - status_message="standalone byedpi service autostart is enabled; disable it to avoid boot-time port conflicts with Podkop Plus action=byedpi" - elif [ "${running_process_count:-0}" -gt "${expected_process_count:-0}" ] || - [ "${supervisor_process_count:-0}" -gt "${expected_process_count:-0}" ]; then - status_message="unexpected Podkop Plus-managed ciadpi processes are running without matching action=byedpi rules" - elif [ "$configured" -eq 1 ] && [ "$runtime_unstable" -eq 1 ]; then - status_message="Podkop Plus-managed ciadpi has restarted after exiting; the ByeDPI strategy or traffic load may be unstable" - elif [ "$configured" -eq 1 ] && [ "$ready" -eq 0 ]; then - status_message="action=byedpi is configured, but the Podkop Plus-managed ciadpi runtime is not ready" - elif [ "$configured" -eq 0 ] && [ "$provider_available" -eq 0 ] && [ "$package_installed" -eq 1 ]; then - status_message="byedpi package is installed, but ciadpi is not available at $BYEDPI_BIN" - elif [ "$configured" -eq 0 ] && [ "$provider_available" -eq 0 ]; then - status_message="byedpi package is not installed; action=byedpi is unavailable" - else - status_message="byedpi provider status is normal" - fi - - 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() { - local installed=0 package_installed=0 - - if is_byedpi_provider_available; then - installed=1 - fi - - if byedpi_package_installed; then - package_installed=1 - fi - - provider_status_ucode byedpi-check "$installed" "$package_installed" "$BYEDPI_BIN" -} diff --git a/podkop/files/usr/lib/components/action.uc b/podkop/files/usr/lib/components/action.uc new file mode 100644 index 00000000..6dabcf72 --- /dev/null +++ b/podkop/files/usr/lib/components/action.uc @@ -0,0 +1,1825 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || constants.PODKOP_CONFIG_NAME || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const BIN_PATH = getenv("PODKOP_BIN") || constants.PODKOP_BIN || "/usr/bin/podkop-plus"; +const SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || constants.PODKOP_SERVICE_INIT || "/etc/init.d/podkop-plus"; +const PODKOP_VERSION = getenv("PODKOP_VERSION") || constants.PODKOP_VERSION || ""; +const PODKOP_RELEASE_REPO = getenv("PODKOP_RELEASE_REPO") || constants.PODKOP_RELEASE_REPO || "ushan0v/podkop-plus"; +const RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const SYSTEM_INFO_CACHE_FILE = getenv("PODKOP_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json"; +const COMPONENT_JOB_DIR = getenv("UPDATES_JOB_DIR") || getenv("PODKOP_UI_COMPONENT_ACTION_DIR") || RUNTIME_STATE_DIR + "/component-actions"; +const COMPONENT_LOCK_DIR = getenv("UPDATES_LOCK_DIR") || RUNTIME_STATE_DIR + "/component-action.lock"; +const TMP_STALE_TTL_MINUTES = getenv("UPDATES_TMP_STALE_TTL_MINUTES") || "30"; +const TMP_FILE_STALE_TTL_MINUTES = getenv("UPDATES_TMP_FILE_STALE_TTL_MINUTES") || "10"; +const SB_MANAGED_SERVICE_MARKER = getenv("SB_MANAGED_SERVICE_MARKER") || constants.SB_MANAGED_SERVICE_MARKER || "Podkop Plus managed sing-box service for binary variants"; + +let tmp_dir = ""; +let lock_held = false; +let podkop_was_running = false; +let podkop_stopped_for_sing_box_change = false; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + return join(" ", parts); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return command_success(command_from_args(args)); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", name ]); +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function read_file(path) { + let data = fs.readfile(as_string(path)); + return data == null ? "" : as_string(data); +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", as_string(path) ]); +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && int(stat.size || 0) > 0; +} + +function path_basename(path) { + let parts = split(as_string(path), "/"); + return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : ""; +} + +function parent_dir(path) { + path = as_string(path); + let slash = rindex(path, "/"); + return slash >= 0 ? substr(path, 0, slash) : ""; +} + +function ensure_parent_dir(path) { + let dir = parent_dir(path); + return dir == "" || dir == "." || ensure_dir(dir); +} + +function now_seconds() { + return int(clock()[0]); +} + +function owner_pid() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "podkop-plus", "[" + level + "] " + as_string(message) ]); +} + +function updates_log(message, level) { + log_message("Updates: " + as_string(message), level || "info"); +} + +function module_command(args) { + let command_args = [ "ucode", "-L", LIB_DIR ]; + for (let arg in args) + push(command_args, arg); + return command_from_args(command_args); +} + +function module_output(args) { + return command_output(module_command(args)); +} + +function module_success(args) { + return command_success(module_command(args)); +} + +function helper_output(mode, args) { + let command_args = [ LIB_DIR + "/components/updater.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + return module_output(command_args); +} + +function helper_success(mode, args) { + let command_args = [ LIB_DIR + "/components/updater.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + return module_success(command_args); +} + +function cleanup_stale_tmp_files() { + command_success_from_args([ "find", "/tmp", "-maxdepth", "1", "-type", "d", "-name", "podkop-plus-updates.*", "-mmin", "+" + as_string(TMP_STALE_TTL_MINUTES), "-exec", "rm", "-rf", "{}", "+" ]); + command_success_from_args([ "find", "/tmp", "-maxdepth", "1", "-type", "f", "(", "-name", "podkop-plus-updates-command.*", "-o", "-name", "podkop-plus-updates-http.*", ")", "-mmin", "+" + as_string(TMP_FILE_STALE_TTL_MINUTES), "-delete" ]); +} + +function init_tmp_dir() { + if (tmp_dir != "") + return true; + + cleanup_stale_tmp_files(); + tmp_dir = trim(command_output_from_args([ "mktemp", "-d", "/tmp/podkop-plus-updates.XXXXXX" ])); + if (tmp_dir == "") { + tmp_dir = "/tmp/podkop-plus-updates." + owner_pid(); + if (!ensure_dir(tmp_dir)) { + tmp_dir = ""; + return false; + } + } + return true; +} + +function make_tmp_file(prefix) { + init_tmp_dir(); + let base = tmp_dir != "" ? tmp_dir + "/" + as_string(prefix) + ".XXXXXX" : "/tmp/podkop-plus-updates-" + as_string(prefix) + ".XXXXXX"; + let path = trim(command_output_from_args([ "mktemp", base ])); + if (path == "") { + path = (tmp_dir != "" ? tmp_dir : "/tmp") + "/" + as_string(prefix) + "." + owner_pid() + "." + now_seconds(); + if (!write_file(path, "")) + return ""; + } + return path; +} + +function helper_output_input(input, mode, args) { + let input_path = make_tmp_file("helper-input"); + if (input_path == "") + return ""; + write_file(input_path, as_string(input)); + + let command_args = [ LIB_DIR + "/components/updater.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + let output = command_output(command_from_args([ "cat", input_path ]) + " | " + module_command(command_args)); + remove_file(input_path); + return output; +} + +function helper_success_input(input, mode, args) { + let input_path = make_tmp_file("helper-input"); + if (input_path == "") + return false; + write_file(input_path, as_string(input)); + + let command_args = [ LIB_DIR + "/components/updater.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + let ok = command_success(command_from_args([ "cat", input_path ]) + " | " + module_command(command_args)); + remove_file(input_path); + return ok; +} + +function cleanup_tmp_dir() { + if (tmp_dir != "") { + command_success_from_args([ "rm", "-rf", tmp_dir ]); + tmp_dir = ""; + } + cleanup_stale_tmp_files(); +} + +function acquire_component_lock() { + ensure_dir(RUNTIME_STATE_DIR); + if (command_success_from_args([ "mkdir", COMPONENT_LOCK_DIR ])) { + write_file(COMPONENT_LOCK_DIR + "/pid", owner_pid() + "\n"); + lock_held = true; + return true; + } + + let current_owner = trim(read_file(COMPONENT_LOCK_DIR + "/pid")); + if (current_owner != "" && pid_running(current_owner)) + return false; + + remove_file(COMPONENT_LOCK_DIR + "/pid"); + command_success_from_args([ "rmdir", COMPONENT_LOCK_DIR ]); + if (!command_success_from_args([ "mkdir", COMPONENT_LOCK_DIR ])) + return false; + + write_file(COMPONENT_LOCK_DIR + "/pid", owner_pid() + "\n"); + lock_held = true; + return true; +} + +function release_component_lock() { + if (!lock_held) + return; + remove_file(COMPONENT_LOCK_DIR + "/pid"); + command_success_from_args([ "rmdir", COMPONENT_LOCK_DIR ]); + lock_held = false; +} + +function cleanup_action() { + cleanup_tmp_dir(); + release_component_lock(); +} + +function updates_response(success, component, action, message, current_version, latest_version, changed, status, release_url) { + write_json({ + success: !!success, + kind: "component", + component: as_string(component), + action: as_string(action), + message: as_string(message), + current_version: as_string(current_version), + latest_version: as_string(latest_version), + changed: int(changed || 0), + status: as_string(status), + release_url: as_string(release_url) + }); +} + +function restart_podkop_after_failed_sing_box_change() { + if (!podkop_stopped_for_sing_box_change || !podkop_was_running || !file_exists(SERVICE_INIT)) + return; + updates_log("Restarting Podkop Plus after failed sing-box component change"); + if (!command_success_from_args([ SERVICE_INIT, "start" ])) + command_success_from_args([ SERVICE_INIT, "restart" ]); +} + +function action_success(component, action, message, current_version, latest_version, changed, status, release_url) { + updates_response(true, component, action, message, current_version, latest_version, changed || 0, status || "", release_url || ""); + cleanup_action(); + exit(0); +} + +function action_fail(component, action, message, current_version, latest_version, status, release_url) { + updates_log(message, "error"); + restart_podkop_after_failed_sing_box_change(); + updates_response(false, component, action, message, current_version || "", latest_version || "", 0, status || "", release_url || ""); + cleanup_action(); + exit(1); +} + +function run_logged(description, command) { + init_tmp_dir(); + let output_file = make_tmp_file("command"); + if (output_file == "") + output_file = "/tmp/podkop-plus-updates-command." + owner_pid(); + + updates_log(description); + let status = command_status(as_string(command) + " >" + shell_quote(output_file) + " 2>&1"); + for (let line in split(read_file(output_file), "\n")) + if (trim(as_string(line)) != "") + updates_log(line); + remove_file(output_file); + if (status != 0) + updates_log(description + " failed with exit code " + status, "warn"); + return status == 0; +} + +function is_apk() { + return command_exists("apk"); +} + +function pkg_is_installed(package_name) { + package_name = as_string(package_name); + if (is_apk()) + return command_success_from_args([ "apk", "info", "-e", package_name ]); + return module_success([ LIB_DIR + "/core/packages.uc", "opkg-installed", package_name ]); +} + +function installed_package_version(package_name) { + package_name = as_string(package_name); + if (is_apk()) { + if (!pkg_is_installed(package_name)) + return ""; + return trim(module_output([ LIB_DIR + "/core/packages.uc", "apk-version", package_name ])); + } + return trim(module_output([ LIB_DIR + "/core/packages.uc", "opkg-version", package_name ])); +} + +function opkg_package_version_from_list(package_name, output) { + return trim(helper_output_input(output, "updates-opkg-package-version", [ package_name ])); +} + +function available_package_version(package_name) { + package_name = as_string(package_name); + if (is_apk()) + return trim(helper_output_input(command_output_from_args([ "apk", "policy", package_name ]), "updates-apk-policy-version", [])); + return opkg_package_version_from_list(package_name, command_output_from_args([ "opkg", "list", package_name ])); +} + +function pkg_list_update_command() { + return is_apk() ? "apk update ") + return 1; + if (apk_result == "<") + return -1; + if (apk_result == "=") + return 0; + } + + if (command_exists("opkg")) { + if (command_success_from_args([ "opkg", "compare-versions", lhs, ">", rhs ])) + return 1; + if (command_success_from_args([ "opkg", "compare-versions", lhs, "<", rhs ])) + return -1; + if (command_success_from_args([ "opkg", "compare-versions", lhs, "=", rhs ])) + return 0; + } + + return module_success([ LIB_DIR + "/core/helpers.uc", "version-at-least", lhs, rhs ]) ? 1 : -1; +} + +function status_from_compare(compare_result) { + if (compare_result == -1) + return "outdated"; + if (compare_result == 0) + return "latest"; + if (compare_result == 1) + return "dev"; + return ""; +} + +function check_success_compared(component, current_version, latest_version, compare_current_version, compare_latest_version, release_url) { + let compare_result = compare_versions(compare_current_version, compare_latest_version); + if (compare_result == null) + action_fail(component, "check_update", "Failed to compare versions", current_version, latest_version); + + let status = status_from_compare(compare_result); + if (status == "") + action_fail(component, "check_update", "Failed to compare versions", current_version, latest_version); + + let result_row = trim(helper_output("updates-check-result-row", [ component, current_version, latest_version, status ])); + if (result_row == "") + action_fail(component, "check_update", "Failed to compare versions", current_version, latest_version); + + let fields = split(result_row, "\t"); + let message = as_string(fields[0] || ""); + let log_line = length(fields) > 1 ? as_string(fields[1]) : message; + updates_log(log_line); + action_success(component, "check_update", message, current_version, latest_version, 0, status, release_url || ""); +} + +function check_success(component, current_version, latest_version, release_url) { + check_success_compared(component, current_version, latest_version, current_version, latest_version, release_url || ""); +} + +function read_openwrt_release_value(key) { + return trim(helper_output("openwrt-release-value", [ "/etc/openwrt_release", key ])); +} + +function service_proxy_address() { + if (!file_exists(LIB_DIR + "/singbox/runtime.uc")) + return ""; + if (file_exists(LIB_DIR + "/service/state.uc") && + !module_success([ LIB_DIR + "/service/state.uc", "sing-box-service-running" ])) + return ""; + return trim(module_output([ LIB_DIR + "/singbox/runtime.uc", "service-proxy-address", "components" ])); +} + +function http_get_once(url, output_path, proxy_address, timeout) { + url = as_string(url); + output_path = as_string(output_path); + proxy_address = as_string(proxy_address); + timeout = as_string(timeout || "30"); + + if (command_exists("curl")) { + let args = [ "curl", "--connect-timeout", "5", "-m", timeout, "-fsSL" ]; + if (proxy_address != "") { + push(args, "-x"); + push(args, "http://" + proxy_address); + } + push(args, url); + push(args, "-o"); + push(args, output_path); + return command_success_from_args(args); + } + + if (command_exists("wget")) { + let command = command_from_args([ "wget", "-T", timeout, "-q", "-O", output_path, url ]); + if (proxy_address != "") + command = command_env({ http_proxy: "http://" + proxy_address, https_proxy: "http://" + proxy_address }) + " " + command; + return command_success(command); + } + + return false; +} + +function http_get(url) { + init_tmp_dir(); + let output_path = make_tmp_file("http"); + if (output_path == "") + return ""; + + let proxy_address = service_proxy_address(); + if (proxy_address != "") { + if (http_get_once(url, output_path, proxy_address, "30")) { + let data = read_file(output_path); + remove_file(output_path); + return data; + } + remove_file(output_path); + updates_log("HTTP request via service proxy failed for " + as_string(url) + "; retrying directly", "warn"); + } + + if (http_get_once(url, output_path, "", "30")) { + let data = read_file(output_path); + remove_file(output_path); + return data; + } + + remove_file(output_path); + return ""; +} + +function download_file_once(url, output_path) { + let proxy_address = service_proxy_address(); + if (proxy_address != "") { + if (http_get_once(url, output_path, proxy_address, "120")) + return true; + remove_file(output_path); + updates_log("Download via service proxy failed for " + as_string(url) + "; retrying directly", "warn"); + } + return http_get_once(url, output_path, "", "120"); +} + +function download_with_retry(url, output_path, label) { + for (let attempt = 1; attempt <= 3; attempt++) { + updates_log("Downloading " + as_string(label) + " (" + attempt + "/3)"); + if (download_file_once(url, output_path) && file_nonempty(output_path)) + return true; + remove_file(output_path); + updates_log("Retrying " + as_string(label), "warn"); + } + return false; +} + +function fetch_github_release_json(owner, repo) { + let response = http_get("https://api.github.com/repos/" + as_string(owner) + "/" + as_string(repo) + "/releases/latest"); + if (response == "" || !helper_success_input(response, "github-response-ok", [])) + return ""; + return response; +} + +function fetch_github_releases_json(owner, repo, per_page) { + let response = http_get("https://api.github.com/repos/" + as_string(owner) + "/" + as_string(repo) + "/releases?per_page=" + as_string(per_page || "30")); + if (response == "" || !helper_success_input(response, "github-response-ok", [])) + return ""; + return response; +} + +function latest_podkop_release_json() { + let parts = split(PODKOP_RELEASE_REPO, "/"); + if (length(parts) != 2 || as_string(parts[0]) == "" || as_string(parts[1]) == "") + return ""; + return fetch_github_release_json(parts[0], parts[1]); +} + +function latest_podkop_version() { + let response = latest_podkop_release_json(); + if (response == "") + return ""; + return trim(helper_output_input(response, "object-get-default", [ "tag_name", "" ])); +} + +function fetch_podkop_latest_release_metadata() { + let response = latest_podkop_release_json(); + if (response == "") + return ""; + return trim(helper_output_input(response, "release-metadata-tsv", [])); +} + +function write_podkop_latest_version_cache(value, timestamp) { + if (as_string(value) == "") + return; + write_file("/tmp/podkop-plus.latest-version.cache", as_string(value) + "\n" + as_string(timestamp) + "\n"); +} + +function retry_resolve(description, fn) { + for (let attempt = 1; attempt <= 3; attempt++) { + if (fn()) + return true; + updates_log(as_string(description) + " failed (" + attempt + "/3)", "warn"); + command_success_from_args([ "sleep", "2" ]); + } + return false; +} + +function ensure_package_tool(tool_name, package_name, component, action) { + if (command_exists(tool_name)) + return true; + if (!run_logged("Updating package lists before installing " + as_string(package_name), pkg_list_update_command())) + return false; + return run_logged("Installing bootstrap package " + as_string(package_name), pkg_install_name_command(package_name)); +} + +function clear_version_caches() { + remove_file("/tmp/podkop-plus.latest-version.cache"); + remove_file(SYSTEM_INFO_CACHE_FILE); + remove_file("/tmp/podkop-plus/system-info.json"); +} + +function managed_sing_box_service_installed() { + return file_exists("/etc/init.d/sing-box") && index(read_file("/etc/init.d/sing-box"), SB_MANAGED_SERVICE_MARKER) >= 0; +} + +function managed_sing_box_service_text() { + return "#!/bin/sh /etc/rc.common\n" + + "# " + SB_MANAGED_SERVICE_MARKER + "\n\n" + + "USE_PROCD=1\n" + + "START=99\n" + + "PROG=\"/usr/bin/sing-box\"\n\n" + + "start_service() {\n" + + " config_load \"sing-box\"\n" + + " local enabled config_file working_directory\n" + + " local log_stderr\n\n" + + " config_get_bool enabled \"main\" \"enabled\" \"0\"\n" + + " [ \"$enabled\" -eq \"1\" ] || return 0\n\n" + + " config_get config_file \"main\" \"conffile\" \"/etc/sing-box/config.json\"\n" + + " config_get working_directory \"main\" \"workdir\" \"/usr/share/sing-box\"\n" + + " config_get_bool log_stderr \"main\" \"log_stderr\" \"1\"\n\n" + + " procd_open_instance\n" + + " procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" + + " procd_set_param file \"$config_file\"\n" + + " procd_set_param stderr \"$log_stderr\"\n" + + " procd_set_param limits core=\"unlimited\"\n" + + " procd_set_param limits nofile=\"1000000 1000000\"\n" + + " procd_set_param respawn\n" + + " procd_close_instance\n" + + "}\n\n" + + "service_triggers() {\n" + + " procd_add_reload_trigger \"sing-box\"\n" + + "}\n"; +} + +function install_managed_sing_box_service_script() { + let tmp = "/etc/init.d/sing-box.podkop-plus." + owner_pid(); + if (!write_file(tmp, managed_sing_box_service_text())) + return false; + if (!command_success_from_args([ "chmod", "0755", tmp ])) { + remove_file(tmp); + return false; + } + return fs.rename(tmp, "/etc/init.d/sing-box"); +} + +function remove_managed_sing_box_service_script() { + if (!managed_sing_box_service_installed()) + return true; + command_success_from_args([ "/etc/init.d/sing-box", "stop" ]); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + remove_file("/etc/init.d/sing-box"); + return true; +} + +function disable_sing_box_service_config() { + if (!uci_core.available()) + return true; + if (!uci_core.exists("sing-box.main") && !uci_core.set_section("sing-box.main", "sing-box")) + return false; + if (!uci_core.set("sing-box.main.enabled", "0")) + return false; + return uci_core.commit("sing-box"); +} + +function prepare_sing_box_service_disabled() { + disable_sing_box_service_config(); + if (file_exists("/etc/init.d/sing-box")) { + command_success_from_args([ "/etc/init.d/sing-box", "stop" ]); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + } +} + +function prepare_sing_box_package_service_install() { + prepare_sing_box_service_disabled(); + remove_managed_sing_box_service_script(); +} + +function capture_podkop_running_state() { + podkop_was_running = file_exists(SERVICE_INIT) && command_success_from_args([ SERVICE_INIT, "status" ]); +} + +function restart_podkop_after_successful_change() { + if (!file_exists(SERVICE_INIT)) + return; + if (!podkop_was_running) { + updates_log("Podkop Plus was not running before component change; restart skipped"); + prepare_sing_box_service_disabled(); + return; + } + run_logged("Restarting Podkop Plus after successful component change", command_from_args([ SERVICE_INIT, "restart" ])); +} + +function stop_podkop_before_sing_box_change() { + if (podkop_stopped_for_sing_box_change) + return; + podkop_stopped_for_sing_box_change = true; + + if (podkop_was_running && file_exists(SERVICE_INIT)) + run_logged("Stopping Podkop Plus before sing-box package change", command_from_args([ SERVICE_INIT, "stop" ])); + + if (podkop_was_running && file_exists(BIN_PATH)) + command_success_from_args([ BIN_PATH, "restore_dnsmasq" ]); + + prepare_sing_box_service_disabled(); +} + +function podkop_status_running_with_timeout() { + init_tmp_dir(); + let output_file = make_tmp_file("podkop-status"); + if (output_file == "") + return false; + + let command = command_from_args([ BIN_PATH, "get_status" ]) + " >" + shell_quote(output_file) + " 2>/dev/null & pid=$!; " + + "( sleep 6; kill $pid 2>/dev/null || true ) & watcher=$!; " + + "wait $pid 2>/dev/null; rc=$?; kill $watcher 2>/dev/null || true; wait $watcher 2>/dev/null || true; exit $rc"; + let ok = command_status("sh -c " + shell_quote(command)) == 0 && + match(read_file(output_file), /"running"[ \t]*:[ \t]*1/) != null; + remove_file(output_file); + return ok; +} + +function wait_podkop_running_after_sing_box_change() { + if (!podkop_was_running) + return true; + if (!file_exists(BIN_PATH)) + return false; + + let waited = 0; + while (waited < 60) { + if (podkop_status_running_with_timeout()) { + command_success_from_args([ "sleep", "8" ]); + if (podkop_status_running_with_timeout()) + return true; + } + command_success_from_args([ "sleep", "4" ]); + waited += 4; + } + return false; +} + +function opkg_arch_list() { + return trim(helper_output_input(command_output_from_args([ "opkg", "print-architecture" ]), "updates-opkg-arch-list", [])); +} + +function resolve_arch_candidates() { + let arch_list = ""; + if (is_apk()) { + if (file_exists("/etc/apk/arch")) + arch_list += " " + trim(helper_output("file-whitespace-list", [ "/etc/apk/arch" ])); + arch_list += " " + trim(command_output_from_args([ "apk", "--print-arch" ])); + } + else { + arch_list = opkg_arch_list(); + } + + let release_arch = read_openwrt_release_value("DISTRIB_ARCH"); + if (release_arch != "") + arch_list += " " + release_arch; + if (!helper_success("string-has-whitespace-field", [ arch_list ])) + arch_list = trim(command_output_from_args([ "uname", "-m" ])); + + let resolved = trim(helper_output("updates-arch-candidates", [ arch_list ])); + let fields = split(resolved, "\t"); + if (length(fields) < 2 || as_string(fields[0]) == "" || as_string(fields[1]) == "") + return null; + + updates_log("Detected package architecture candidates: " + fields[1]); + return { + target: as_string(fields[0]), + candidates: as_string(fields[1]) + }; +} + +function select_inner_package_path(bundle_file, component, arch, ext) { + return trim(helper_output_input(command_output_from_args([ "unzip", "-l", bundle_file ]), "updates-zip-inner-package-path", [ component, arch, ext ])); +} + +function select_archive_member_path(archive_file, member_name) { + return trim(helper_output_input(command_output_from_args([ "tar", "-tzf", archive_file ]), "updates-archive-member-path", [ member_name ])); +} + +function extract_arch_package_version(package_name, package_arch) { + return trim(helper_output("updates-arch-package-version", [ package_name, package_arch ])); +} + +function extract_zapret_bundle_version(bundle_name) { + return trim(helper_output("updates-zapret-bundle-version", [ bundle_name ])); +} + +function extract_zapret2_bundle_version(bundle_name) { + return trim(helper_output("updates-zapret2-bundle-version", [ bundle_name ])); +} + +function normalize_zapret_version(value) { + return trim(helper_output("updates-normalize-zapret-version", [ value ])); +} + +function normalize_sing_box_version(value) { + return trim(helper_output("updates-normalize-sing-box-version", [ value ])); +} + +function resolve_zapret_release(arch) { + let release_json = fetch_github_release_json("remittor", "zapret-openwrt"); + if (release_json == "") + return null; + let resolved = trim(helper_output_input(release_json, "release-select-arch-suffix-asset", [ "zip", arch.candidates ])); + let fields = split(resolved, "\t"); + if (length(fields) < 4) + return null; + let version = extract_zapret_bundle_version(fields[1]); + if (version == "") + version = trim(helper_output("string-remove-suffix", [ fields[1], ".zip" ])); + return { + arch: fields[0], + bundle_name: fields[1], + bundle_url: fields[2], + release_url: fields[3], + version + }; +} + +function resolve_zapret2_release(arch) { + let releases_json = fetch_github_releases_json("remittor", "zapret-openwrt", "30"); + if (releases_json == "") + return null; + let resolved = trim(helper_output_input(releases_json, "named-release-select-asset", [ "zapret2 ", "zapret2", "zip", arch.candidates ])); + let fields = split(resolved, "\t"); + if (length(fields) < 4) + return null; + let version = extract_zapret2_bundle_version(fields[1]); + if (version == "") + version = trim(helper_output("string-remove-suffix", [ fields[1], ".zip" ])); + return { + arch: fields[0], + bundle_name: fields[1], + bundle_url: fields[2], + release_url: fields[3], + version + }; +} + +function download_and_extract_zip_package(release, component) { + let bundle_file = tmp_dir + "/" + release.bundle_name; + if (!download_with_retry(release.bundle_url, bundle_file, release.bundle_name)) + return null; + + let inner_package_path = is_apk() ? + select_inner_package_path(bundle_file, component, "", "apk") : + select_inner_package_path(bundle_file, component, release.arch, "ipk"); + if (inner_package_path == "") + return null; + + let package_name = path_basename(inner_package_path); + let package_file = tmp_dir + "/" + package_name; + if (!command_success(command_from_args([ "unzip", "-p", bundle_file, inner_package_path ]) + " >" + shell_quote(package_file)) || + !file_nonempty(package_file)) + return null; + + let version = as_string(release.version || ""); + if (version == "") + version = component == "zapret2" ? extract_zapret2_bundle_version(release.bundle_name) : extract_zapret_bundle_version(release.bundle_name); + if (version == "") + version = extract_arch_package_version(package_name, release.arch); + + return { + name: package_name, + file: package_file, + version + }; +} + +function resolve_byedpi_release(arch) { + let asset_ext = is_apk() ? "apk" : "ipk"; + let release_series = trim(helper_output("openwrt-release-series", [ "/etc/openwrt_release" ])); + let releases_json = fetch_github_releases_json("DPITrickster", "ByeDPI-OpenWrt", "30"); + if (releases_json == "") + return null; + let resolved = trim(helper_output_input(releases_json, "byedpi-select-asset", [ release_series, asset_ext, arch.candidates ])); + let fields = split(resolved, "\t"); + if (length(fields) < 4) + return null; + return { + arch: fields[0], + package_name: fields[1], + package_url: fields[2], + release_url: fields[3], + version: extract_arch_package_version(fields[1], fields[0]) + }; +} + +function download_byedpi_package(release) { + let package_file = tmp_dir + "/" + release.package_name; + if (!download_with_retry(release.package_url, package_file, release.package_name) || !file_nonempty(package_file)) + return null; + let version = as_string(release.version || ""); + if (version == "") + version = extract_arch_package_version(release.package_name, release.arch); + return { + name: release.package_name, + file: package_file, + version + }; +} + +function disable_standalone_service(name) { + let init = "/etc/init.d/" + as_string(name); + if (!file_exists(init)) + return; + run_logged("Stopping standalone " + as_string(name) + " service", command_from_args([ init, "stop" ])); + run_logged("Disabling standalone " + as_string(name) + " autostart", command_from_args([ init, "disable" ])); +} + +function provider_installed(runtime_module) { + return module_success([ runtime_module, "installed" ]); +} + +function provider_package_version(runtime_module) { + return trim(module_output([ runtime_module, "package-version" ])); +} + +function install_zapret_like(component, action, runtime_module, resolve_fn, label) { + init_tmp_dir() || action_fail(component, action, "Failed to create temporary directory"); + let arch = resolve_arch_candidates(); + if (arch == null) + action_fail(component, action, "Failed to detect package architecture"); + let release = null; + retry_resolve("Resolving " + label + " package", function() { + release = resolve_fn(arch); + return release != null; + }); + if (release == null) + action_fail(component, action, "Failed to resolve " + label + " package for this router architecture"); + + let installed = provider_installed(runtime_module); + let current_version = provider_package_version(runtime_module); + if (action == "check_update") { + if (!installed) + action_fail(component, action, label + " is not installed", current_version, release.version, "", release.release_url || ""); + check_success_compared(component, current_version, release.version, normalize_zapret_version(current_version), normalize_zapret_version(release.version), release.release_url || ""); + } + + if (!ensure_package_tool("unzip", "unzip", component, action)) + action_fail(component, action, "Failed to install unzip"); + let pkg = download_and_extract_zip_package(release, component); + if (pkg == null) + action_fail(component, action, "Failed to download " + label + " package", current_version, release.version, "", release.release_url || ""); + + if (!run_logged("Installing " + label + " package " + pkg.name, pkg_install_files_command([ pkg.file ]))) + action_fail(component, action, "Failed to install " + label + " package", current_version, pkg.version, "", release.release_url || ""); + + disable_standalone_service(component); + restart_podkop_after_successful_change(); + clear_version_caches(); + current_version = provider_package_version(runtime_module); + if (current_version == "") + current_version = "unknown"; + action_success(component, action, label + " package has been installed", current_version, pkg.version, 1, "latest", release.release_url || ""); +} + +function install_zapret(action) { + install_zapret_like("zapret", action, LIB_DIR + "/providers/zapret/runtime.uc", resolve_zapret_release, "zapret"); +} + +function install_zapret2(action) { + install_zapret_like("zapret2", action, LIB_DIR + "/providers/zapret2/runtime.uc", resolve_zapret2_release, "zapret2"); +} + +function install_byedpi(action) { + init_tmp_dir() || action_fail("byedpi", action, "Failed to create temporary directory"); + let arch = resolve_arch_candidates(); + if (arch == null) + action_fail("byedpi", action, "Failed to detect package architecture"); + let release = null; + retry_resolve("Resolving ByeDPI package", function() { + release = resolve_byedpi_release(arch); + return release != null; + }); + if (release == null) + action_fail("byedpi", action, "Failed to resolve ByeDPI package for this router architecture"); + + let runtime_module = LIB_DIR + "/providers/byedpi/runtime.uc"; + let installed = provider_installed(runtime_module); + let current_version = provider_package_version(runtime_module); + if (action == "check_update") { + if (!installed) + action_fail("byedpi", action, "ByeDPI is not installed", current_version, release.version); + check_success("byedpi", current_version, release.version, release.release_url || ""); + } + + let pkg = download_byedpi_package(release); + if (pkg == null) + action_fail("byedpi", action, "Failed to download ByeDPI package"); + if (!run_logged("Installing ByeDPI package " + pkg.name, pkg_install_files_command([ pkg.file ]))) + action_fail("byedpi", action, "Failed to install ByeDPI package", current_version, pkg.version); + + disable_standalone_service("byedpi"); + restart_podkop_after_successful_change(); + clear_version_caches(); + current_version = provider_package_version(runtime_module); + if (current_version == "") + current_version = "unknown"; + action_success("byedpi", action, "ByeDPI package has been installed", current_version, pkg.version, 1, "latest", release.release_url || ""); +} + +function remove_optional_component(component, package_name, label, runtime_module) { + if (!pkg_is_installed(package_name)) { + if (provider_installed(runtime_module)) + action_fail(component, "remove", label + " exists outside the package manager and was not removed automatically"); + action_success(component, "remove", label + " is already removed", "", "", 0); + } + + let current_version = provider_package_version(runtime_module); + let command = is_apk() ? + command_from_args([ "apk", "del", package_name ]) + " = 0 ? version : ""; +} + +function move_file_to_backup(target_path, backup_path) { + if (!file_exists(target_path)) + return true; + remove_file(backup_path); + return fs.rename(target_path, backup_path); +} + +function restore_sing_box_backup(backup_binary) { + if (as_string(backup_binary) != "" && file_nonempty(backup_binary)) { + if (!fs.rename(backup_binary, "/usr/bin/sing-box")) + return false; + return command_success_from_args([ "chmod", "0755", "/usr/bin/sing-box" ]); + } + remove_file("/usr/bin/sing-box"); + return true; +} + +function restore_file_backup(target_path, backup_path) { + if (as_string(backup_path) != "" && file_nonempty(backup_path)) + return fs.rename(backup_path, target_path); + remove_file(target_path); + return true; +} + +function restore_sing_box_service_from_marker(marker) { + if (as_string(marker) == "extended-compressed") + return install_managed_sing_box_service_script(); + remove_managed_sing_box_service_script(); + return true; +} + +function resolve_sing_box_extended_arch_suffix() { + let host_arch = trim(command_output_from_args([ "uname", "-m" ])); + let distrib_arch = read_openwrt_release_value("DISTRIB_ARCH"); + return trim(helper_output("sing-box-extended-arch-suffix", [ host_arch, distrib_arch ])); +} + +function sing_box_extended_tag_is_stable(tag) { + tag = lc(as_string(tag)); + return tag != "" && index(tag, "alpha") < 0 && index(tag, "beta") < 0 && index(tag, "rc") < 0; +} + +function set_sing_box_extended_release_from_json(release_json, compressed) { + if (as_string(release_json) == "") + return null; + let tag = trim(helper_output_input(release_json, "object-get-default", [ "tag_name", "" ])); + if (!sing_box_extended_tag_is_stable(tag)) + return null; + + let asset_url = ""; + if (compressed) { + let arch_suffix = resolve_sing_box_extended_arch_suffix(); + if (arch_suffix == "") + return null; + asset_url = trim(helper_output_input(release_json, "sing-box-extended-asset-url", [ arch_suffix, "0", "1" ])); + } + else { + let distrib_arch = read_openwrt_release_value("DISTRIB_ARCH"); + if (distrib_arch == "") + return null; + let asset_ext = is_apk() ? "apk" : "ipk"; + asset_url = trim(helper_output_input(release_json, "sing-box-extended-package-asset-url", [ distrib_arch, asset_ext ])); + } + + if (asset_url == "") + return null; + + return { + tag, + release_url: trim(helper_output_input(release_json, "object-get-default", [ "html_url", "" ])), + asset_url, + asset_name: path_basename(asset_url) + }; +} + +function resolve_sing_box_extended_release(compressed) { + let release_json = fetch_github_release_json("shtorm-7", "sing-box-extended"); + let resolved = set_sing_box_extended_release_from_json(release_json, compressed); + if (resolved != null) + return resolved; + + let releases_json = fetch_github_releases_json("shtorm-7", "sing-box-extended", "30"); + if (releases_json == "") + return null; + let tag = trim(helper_output_input(releases_json, "sing-box-extended-release-tag", [])); + if (tag == "") + return null; + release_json = helper_output_input(releases_json, "release-by-tag", [ tag ]); + return set_sing_box_extended_release_from_json(release_json, compressed); +} + +function sing_box_runtime_output(mode, args) { + let command_args = [ LIB_DIR + "/singbox/runtime.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + return trim(module_output(command_args)); +} + +function sing_box_runtime_success(mode, args) { + let command_args = [ LIB_DIR + "/singbox/runtime.uc", mode ]; + for (let arg in (type(args) == "array" ? args : [])) + push(command_args, arg); + return module_success(command_args); +} + +function write_sing_box_variant_state(marker, version) { + if (!sing_box_runtime_success("write-variant-marker", [ marker ])) + updates_log("Failed to write sing-box variant marker", "warn"); + if (!sing_box_runtime_success("write-version-state", [ version ])) + updates_log("Failed to write sing-box version state", "warn"); +} + +function restore_sing_box_variant_state(previous_marker, previous_version_state) { + sing_box_runtime_success("restore-variant-marker", [ previous_marker ]); + sing_box_runtime_success("restore-version-state", [ previous_version_state ]); +} + +function restore_sing_box_extended_package_variant() { + init_tmp_dir(); + let release = resolve_sing_box_extended_release(false); + if (release == null) + return false; + let package_file = tmp_dir + "/" + release.asset_name; + if (!download_with_retry(release.asset_url, package_file, release.asset_name)) + return false; + prepare_sing_box_package_service_install(); + pkg_remove_sing_box_conflict("sing-box-tiny"); + pkg_remove_sing_box_conflict("sing-box"); + if (!pkg_install_files([ package_file ])) { + remove_file(package_file); + return false; + } + remove_file(package_file); + let new_version = validate_sing_box_extended_binary("/usr/bin/sing-box", "/usr/lib"); + if (new_version == "") + return false; + write_sing_box_variant_state("extended", new_version); + return true; +} + +function replace_sing_box_package_variant(target_package, conflict_package) { + prepare_sing_box_package_service_install(); + if ((as_string(conflict_package) == "" || !pkg_is_installed(conflict_package)) && + (target_package == "sing-box-extended" || !pkg_is_installed("sing-box-extended"))) + return pkg_install_name_downgrade(target_package); + + if (target_package != "sing-box-extended" && !pkg_remove_sing_box_conflict("sing-box-extended")) + return false; + if (as_string(conflict_package) != "" && !pkg_remove_sing_box_conflict(conflict_package)) + return false; + return pkg_install_name_downgrade(target_package); +} + +function restore_sing_box_package_variant(previous_variant) { + if (previous_variant == "tiny") + return replace_sing_box_package_variant("sing-box-tiny", "sing-box"); + if (previous_variant == "stable") + return replace_sing_box_package_variant("sing-box", "sing-box-tiny"); + if (previous_variant == "extended") + return restore_sing_box_extended_package_variant(); + if (previous_variant == "not-installed") { + pkg_remove_sing_box_conflict("sing-box-extended"); + pkg_remove_sing_box_conflict("sing-box-tiny"); + pkg_remove_sing_box_conflict("sing-box"); + remove_managed_sing_box_service_script(); + remove_file("/usr/bin/sing-box"); + return true; + } + return false; +} + +function sing_box_variant_is_package_managed(variant) { + return variant == "stable" || variant == "tiny" || variant == "extended"; +} + +function restore_sing_box_install_backup(previous_variant, backup_binary) { + if (sing_box_variant_is_package_managed(previous_variant)) { + if (restore_sing_box_package_variant(previous_variant)) + return true; + if (as_string(backup_binary) != "") + restore_sing_box_backup(backup_binary); + return false; + } + + if (as_string(backup_binary) != "") + return restore_sing_box_backup(backup_binary); + return restore_sing_box_package_variant(previous_variant); +} + +function restore_sing_box_after_failed_extended_install(previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched) { + if (as_string(archive_file) != "") + remove_file(archive_file); + let restore_status = true; + if (cronet_touched) + restore_file_backup("/usr/lib/libcronet.so", backup_cronet); + if (!restore_sing_box_install_backup(previous_variant, backup_binary)) + restore_status = false; + restore_sing_box_variant_state(previous_marker, previous_version_state); + restore_sing_box_service_from_marker(previous_marker); + clear_version_caches(); + return restore_status; +} + +function restore_sing_box_after_failed_extended_package_install(previous_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched) { + if (as_string(package_file) != "") + remove_file(package_file); + pkg_remove_sing_box_conflict("sing-box-extended"); + let restore_status = restore_sing_box_install_backup(previous_variant, backup_binary); + if (cronet_touched) { + restore_file_backup("/usr/lib/libcronet.so", backup_cronet); + if (file_nonempty("/usr/lib/libcronet.so")) + command_success_from_args([ "chmod", "0644", "/usr/lib/libcronet.so" ]); + } + restore_sing_box_variant_state(previous_marker, previous_version_state); + restore_sing_box_service_from_marker(previous_marker); + clear_version_caches(); + return restore_status; +} + +function install_sing_box_extended_package(action) { + init_tmp_dir() || action_fail("sing_box", action, "Failed to create temporary directory"); + let current_version = sing_box_runtime_output("version", []); + let current_variant = sing_box_runtime_output("variant", []); + let previous_marker = sing_box_runtime_output("read-variant-marker", []); + let previous_version_state = sing_box_runtime_output("read-version-state", []); + let release = resolve_sing_box_extended_release(false); + if (release == null) + action_fail("sing_box", action, "Failed to resolve sing-box-extended package release", current_version); + let latest_version = normalize_sing_box_version(release.tag); + + if (action == "check_update") { + if (!sing_box_runtime_success("is-extended", [ current_version ])) + action_fail("sing_box", action, "sing-box-extended is not installed", current_version, latest_version); + check_success("sing_box", normalize_sing_box_version(current_version), normalize_sing_box_version(latest_version), release.release_url); + } + + let package_file = tmp_dir + "/" + release.asset_name; + if (!download_with_retry(release.asset_url, package_file, release.asset_name)) + action_fail("sing_box", action, "Failed to download sing-box-extended package", current_version, latest_version); + + if (!run_logged("Updating package lists before sing-box-extended package installation", pkg_list_update_command())) + action_fail("sing_box", action, "Failed to update package lists", current_version, latest_version); + + stop_podkop_before_sing_box_change(); + prepare_sing_box_package_service_install(); + + let backup_binary = ""; + let backup_cronet = ""; + let cronet_touched = false; + if (current_variant == "extended" || current_variant == "extended-compressed") { + if (file_exists("/usr/bin/sing-box")) { + backup_binary = "/usr/bin/sing-box.podkop-backup." + owner_pid(); + if (!move_file_to_backup("/usr/bin/sing-box", backup_binary)) + action_fail("sing_box", action, "Failed to backup current sing-box binary", current_version, latest_version); + } + if (file_exists("/usr/lib/libcronet.so")) { + cronet_touched = true; + backup_cronet = "/usr/lib/libcronet.so.podkop-backup." + owner_pid(); + if (!move_file_to_backup("/usr/lib/libcronet.so", backup_cronet)) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to backup current libcronet.so", current_version, latest_version); + } + } + } + + if (!run_logged_pkg_remove_sing_box_conflict("sing-box-tiny", "Removing sing-box-tiny before sing-box-extended package installation")) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to remove sing-box-tiny before sing-box-extended package installation", current_version, latest_version); + } + if (!run_logged_pkg_remove_sing_box_conflict("sing-box", "Removing sing-box before sing-box-extended package installation")) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to remove sing-box before sing-box-extended package installation", current_version, latest_version); + } + + if (backup_binary == "" && file_exists("/usr/bin/sing-box")) { + backup_binary = "/usr/bin/sing-box.podkop-backup." + owner_pid(); + if (!move_file_to_backup("/usr/bin/sing-box", backup_binary)) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to backup existing sing-box binary", current_version, latest_version); + } + } + if (!cronet_touched && file_exists("/usr/lib/libcronet.so")) { + cronet_touched = true; + backup_cronet = "/usr/lib/libcronet.so.podkop-backup." + owner_pid(); + if (!move_file_to_backup("/usr/lib/libcronet.so", backup_cronet)) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to backup current libcronet.so", current_version, latest_version); + } + } + + if (!run_logged("Installing sing-box-extended package " + release.asset_name, pkg_install_files_command([ package_file ]))) { + restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched); + action_fail("sing_box", action, "Failed to install sing-box-extended package", current_version, latest_version); + } + remove_file(package_file); + + let new_version = validate_sing_box_extended_binary("/usr/bin/sing-box", "/usr/lib"); + if (new_version == "") { + if (restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched)) + action_fail("sing_box", action, "Installed sing-box-extended package failed validation; previous sing-box variant was restored", current_version, latest_version); + action_fail("sing_box", action, "Installed sing-box-extended package failed validation and previous sing-box variant could not be restored", current_version, latest_version); + } + + write_sing_box_variant_state("extended", new_version); + restart_podkop_after_successful_change(); + if (!wait_podkop_running_after_sing_box_change()) { + updates_log("sing-box-extended package did not start cleanly; restoring previous sing-box variant", "error"); + if (file_exists(SERVICE_INIT)) + command_success_from_args([ SERVICE_INIT, "stop" ]); + if (restore_sing_box_after_failed_extended_package_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, package_file, cronet_touched)) { + remove_file(backup_binary); + remove_file(backup_cronet); + action_fail("sing_box", action, "sing-box-extended package was installed but Podkop Plus did not start cleanly; previous sing-box variant was restored", current_version, latest_version); + } + action_fail("sing_box", action, "sing-box-extended package was installed but Podkop Plus did not start cleanly and previous sing-box variant could not be restored", current_version, latest_version); + } + + remove_file(backup_binary); + remove_file(backup_cronet); + clear_version_caches(); + updates_log("Installed sing-box-extended " + (new_version != "" ? new_version : "unknown") + " from package"); + action_success("sing_box", action, "sing-box-extended has been installed", new_version, latest_version, new_version == current_version ? 0 : 1, "latest", release.release_url); +} + +function install_sing_box_extended(action, compressed) { + if (!compressed) { + install_sing_box_extended_package(action); + return; + } + + init_tmp_dir() || action_fail("sing_box", action, "Failed to create temporary directory"); + let label = "sing-box-extended compressed"; + let current_version = sing_box_runtime_output("version", []); + let current_variant = sing_box_runtime_output("variant", []); + let previous_marker = sing_box_runtime_output("read-variant-marker", []); + let previous_version_state = sing_box_runtime_output("read-version-state", []); + let release = resolve_sing_box_extended_release(true); + if (release == null) + action_fail("sing_box", action, "Failed to resolve " + label + " release", current_version); + let latest_version = normalize_sing_box_version(release.tag); + + if (action == "check_update") { + if (!sing_box_runtime_success("is-extended", [ current_version ])) + action_fail("sing_box", action, "sing-box-extended is not installed", current_version, latest_version); + if (!sing_box_runtime_success("marker-is", [ "extended-compressed" ])) + action_fail("sing_box", action, "sing-box-extended compressed is not installed", current_version, latest_version); + check_success("sing_box", normalize_sing_box_version(current_version), normalize_sing_box_version(latest_version), release.release_url); + } + + let archive_file = tmp_dir + "/" + release.asset_name; + if (!download_with_retry(release.asset_url, archive_file, release.asset_name)) + action_fail("sing_box", action, "Failed to download " + label, current_version, latest_version); + + let binary_path = select_archive_member_path(archive_file, "sing-box"); + if (binary_path == "") { + remove_file(archive_file); + action_fail("sing_box", action, "sing-box binary was not found in the downloaded archive", current_version, latest_version); + } + let cronet_path = select_archive_member_path(archive_file, "libcronet.so"); + let extract_error = tmp_dir + "/sing-box-extract.err"; + let tmp_binary = tmp_dir + "/sing-box.compressed." + owner_pid(); + let tmp_cronet = ""; + if (!command_success(command_from_args([ "tar", "-xzf", archive_file, "-O", binary_path ]) + " >" + shell_quote(tmp_binary) + " 2>" + shell_quote(extract_error)) || + !file_nonempty(tmp_binary) || + !command_success_from_args([ "chmod", "0755", tmp_binary ])) { + for (let line in split(read_file(extract_error), "\n")) + if (trim(as_string(line)) != "") + updates_log(line); + remove_file(tmp_binary); + remove_file(archive_file); + action_fail("sing_box", action, "Failed to extract " + label, current_version, latest_version); + } + + if (cronet_path != "") { + tmp_cronet = tmp_dir + "/libcronet.so"; + if (!command_success(command_from_args([ "tar", "-xzf", archive_file, "-O", cronet_path ]) + " >" + shell_quote(tmp_cronet) + " 2>" + shell_quote(extract_error)) || + !file_nonempty(tmp_cronet) || + !command_success_from_args([ "chmod", "0644", tmp_cronet ])) { + for (let line in split(read_file(extract_error), "\n")) + if (trim(as_string(line)) != "") + updates_log(line); + remove_file(tmp_binary); + remove_file(tmp_cronet); + remove_file(archive_file); + action_fail("sing_box", action, "Failed to extract libcronet.so from sing-box-extended archive", current_version, latest_version); + } + } + + let new_version = validate_sing_box_extended_binary(tmp_binary, tmp_dir); + if (new_version == "") { + remove_file(tmp_binary); + remove_file(tmp_cronet); + remove_file(archive_file); + action_fail("sing_box", action, "Downloaded " + label + " failed validation", current_version, latest_version); + } + + stop_podkop_before_sing_box_change(); + let backup_binary = ""; + let backup_cronet = ""; + let cronet_touched = false; + if (file_exists("/usr/bin/sing-box")) { + backup_binary = "/usr/bin/sing-box.podkop-backup." + owner_pid(); + if (!move_file_to_backup("/usr/bin/sing-box", backup_binary)) { + remove_file(backup_binary); + remove_file(tmp_binary); + remove_file(tmp_cronet); + remove_file(archive_file); + action_fail("sing_box", action, "Failed to backup current sing-box binary", current_version, latest_version); + } + } + if (cronet_path != "") { + cronet_touched = true; + if (file_exists("/usr/lib/libcronet.so")) { + backup_cronet = "/usr/lib/libcronet.so.podkop-backup." + owner_pid(); + if (!move_file_to_backup("/usr/lib/libcronet.so", backup_cronet)) { + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + remove_file(tmp_binary); + remove_file(tmp_cronet); + action_fail("sing_box", action, "Failed to backup current libcronet.so", current_version, latest_version); + } + } + } + + for (let item in [ + [ "sing-box-extended", "Removing sing-box-extended package before " + label + " installation" ], + [ "sing-box-tiny", "Removing sing-box-tiny package before " + label + " installation" ], + [ "sing-box", "Removing sing-box package before " + label + " installation" ] + ]) { + if (!run_logged_pkg_remove_sing_box_conflict(item[0], item[1])) { + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + remove_file(tmp_binary); + remove_file(tmp_cronet); + action_fail("sing_box", action, "Failed to remove " + item[0] + " before " + label + " installation", current_version, latest_version); + } + } + + remove_managed_sing_box_service_script(); + if (!install_managed_sing_box_service_script()) { + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + remove_file(tmp_binary); + remove_file(tmp_cronet); + action_fail("sing_box", action, "Failed to install managed sing-box service for " + label, current_version, latest_version); + } + + remove_file("/usr/bin/sing-box"); + if (!fs.rename(tmp_binary, "/usr/bin/sing-box") || !command_success_from_args([ "chmod", "0755", "/usr/bin/sing-box" ])) { + remove_file("/usr/bin/sing-box"); + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + action_fail("sing_box", action, "Failed to install " + label + " binary", current_version, latest_version); + } + if (tmp_cronet != "") { + remove_file("/usr/lib/libcronet.so"); + if (!fs.rename(tmp_cronet, "/usr/lib/libcronet.so") || !command_success_from_args([ "chmod", "0644", "/usr/lib/libcronet.so" ])) { + remove_file("/usr/lib/libcronet.so"); + restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched); + action_fail("sing_box", action, "Failed to install libcronet.so for " + label, current_version, latest_version); + } + } + remove_file(archive_file); + + new_version = validate_sing_box_extended_binary("/usr/bin/sing-box", "/usr/lib"); + if (new_version == "") { + if (restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched)) + action_fail("sing_box", action, "Installed " + label + " failed validation; previous sing-box variant was restored", current_version, latest_version); + action_fail("sing_box", action, "Installed " + label + " failed validation and previous sing-box variant could not be restored", current_version, latest_version); + } + + write_sing_box_variant_state("extended-compressed", new_version); + restart_podkop_after_successful_change(); + if (!wait_podkop_running_after_sing_box_change()) { + updates_log(label + " did not start cleanly; restoring previous sing-box binary", "error"); + if (file_exists(SERVICE_INIT)) + command_success_from_args([ SERVICE_INIT, "stop" ]); + if (restore_sing_box_after_failed_extended_install(current_variant, backup_binary, backup_cronet, previous_marker, previous_version_state, archive_file, cronet_touched)) { + remove_file(backup_binary); + remove_file(backup_cronet); + action_fail("sing_box", action, label + " was installed but Podkop Plus did not start cleanly; previous sing-box variant was restored", current_version, latest_version); + } + action_fail("sing_box", action, label + " was installed but Podkop Plus did not start cleanly and previous sing-box variant could not be restored", current_version, latest_version); + } + + remove_file(backup_binary); + remove_file(backup_cronet); + clear_version_caches(); + updates_log("Installed " + label + " " + (new_version != "" ? new_version : "unknown")); + action_success("sing_box", action, label + " has been installed", new_version, latest_version, 1, "latest", release.release_url); +} + +function install_package_sing_box(action, tiny) { + let package_name = tiny ? "sing-box-tiny" : "sing-box"; + let conflict = tiny ? "sing-box" : "sing-box-tiny"; + let label = tiny ? "tiny sing-box" : "stable sing-box"; + let package_version = installed_package_version(package_name); + let binary_version = sing_box_runtime_output("version", []); + let current_version = package_version; + if (sing_box_runtime_success("is-extended", [ binary_version ])) + current_version = binary_version; + if (current_version == "") + current_version = binary_version; + let latest_version = available_package_version(package_name); + if (latest_version == "") + latest_version = installed_package_version(package_name); + if (latest_version == "") + action_fail("sing_box", action, "Failed to resolve " + (tiny ? "tiny" : "stable") + " sing-box package version", current_version); + + if (action == "check_update") { + if (tiny && !sing_box_runtime_success("is-tiny", [ binary_version ])) + action_fail("sing_box", action, "sing-box-tiny is not installed", current_version, latest_version); + check_success("sing_box", current_version, latest_version, ""); + } + + if (!run_logged("Updating package lists before " + package_name + " installation", pkg_list_update_command())) + action_fail("sing_box", action, "Failed to update package lists", current_version, latest_version); + latest_version = available_package_version(package_name); + if (latest_version == "") + latest_version = installed_package_version(package_name); + if (latest_version == "") + action_fail("sing_box", action, "Failed to resolve " + (tiny ? "tiny" : "stable") + " sing-box package version", current_version); + + stop_podkop_before_sing_box_change(); + if (!run_logged("Installing " + label + " package", "sh -c " + shell_quote("exit 0")) || + !replace_sing_box_package_variant(package_name, conflict)) + action_fail("sing_box", action, "Failed to install " + (tiny ? "sing-box-tiny" : "stable sing-box"), current_version, latest_version); + + let new_version = read_sing_box_binary_version("/usr/bin/sing-box", ""); + if (new_version == "") { + clear_version_caches(); + action_fail("sing_box", action, (tiny ? "sing-box-tiny" : "Stable sing-box") + " package was installed, but sing-box binary is not available", current_version, latest_version); + } + if (sing_box_runtime_success("is-extended", [ new_version ])) { + clear_version_caches(); + action_fail("sing_box", action, (tiny ? "sing-box-tiny" : "Stable sing-box") + " package was installed, but the active binary is still sing-box-extended", new_version, latest_version); + } + write_sing_box_variant_state(tiny ? "tiny" : "stable", new_version); + restart_podkop_after_successful_change(); + if (!wait_podkop_running_after_sing_box_change()) { + clear_version_caches(); + action_fail("sing_box", action, (tiny ? "Tiny" : "Stable") + " sing-box was installed, but Podkop Plus did not start cleanly", new_version, latest_version); + } + clear_version_caches(); + action_success("sing_box", action, label + " has been installed", new_version, latest_version, new_version == current_version ? 0 : 1, "latest"); +} + +function check_podkop_plus() { + let metadata = fetch_podkop_latest_release_metadata(); + let fields = split(metadata, "\t"); + let latest_version = length(fields) > 0 && as_string(fields[0]) != "" ? as_string(fields[0]) : "unknown"; + let release_url = length(fields) > 1 ? as_string(fields[1]) : ""; + if (latest_version == "unknown") + action_fail("podkop", "check_update", "Failed to check Podkop Plus updates", PODKOP_VERSION, latest_version); + + write_podkop_latest_version_cache(latest_version, now_seconds()); + if (!helper_success("podkop-release-version-valid", [ PODKOP_VERSION ])) { + updates_log("Podkop Plus current version is not a release version (" + PODKOP_VERSION + ")"); + action_success("podkop", "check_update", "Installed version is newer than release", PODKOP_VERSION, latest_version, 0, "dev", release_url); + } + + let compare = trim(helper_output("podkop-release-version-compare", [ PODKOP_VERSION, latest_version ])); + if (compare == "") + action_fail("podkop", "check_update", "Failed to compare Podkop Plus versions", PODKOP_VERSION, latest_version); + let status = status_from_compare(int(compare)); + if (status == "") + action_fail("podkop", "check_update", "Failed to compare Podkop Plus versions", PODKOP_VERSION, latest_version); + if (status == "latest") { + updates_log("Podkop Plus is already up to date (" + PODKOP_VERSION + ")"); + action_success("podkop", "check_update", "Latest version is installed", PODKOP_VERSION, latest_version, 0, status, release_url); + } + if (status == "outdated") { + updates_log("Podkop Plus update found: " + PODKOP_VERSION + " -> " + latest_version); + action_success("podkop", "check_update", "Update is available", PODKOP_VERSION, latest_version, 0, status, release_url); + } + updates_log("Podkop Plus installed version is newer than upstream release: " + PODKOP_VERSION + " -> " + latest_version); + action_success("podkop", "check_update", "Installed version is newer than release", PODKOP_VERSION, latest_version, 0, status, release_url); +} + +function resolve_podkop_plus_release(latest_version) { + let release_json = latest_podkop_release_json(); + if (release_json == "") + return null; + let asset_ext = is_apk() ? "apk" : "ipk"; + let i18n_required = pkg_is_installed("luci-i18n-podkop-plus-ru") ? "1" : "0"; + let plan = trim(helper_output_input(release_json, "podkop-plus-release-plan", [ latest_version, asset_ext, i18n_required ])); + let fields = split(plan, "\t"); + if (length(fields) < 7 || as_string(fields[1]) == "" || as_string(fields[2]) == "" || as_string(fields[3]) == "" || as_string(fields[4]) == "") + return null; + return { + release_url: fields[0], + backend_name: fields[1], + backend_url: fields[2], + app_name: fields[3], + app_url: fields[4], + i18n_name: fields[5], + i18n_url: fields[6] + }; +} + +function install_podkop_plus() { + let latest_version = latest_podkop_version(); + if (latest_version == "") + latest_version = "unknown"; + if (latest_version == "unknown") + action_fail("podkop", "install", "Failed to resolve Podkop Plus release", PODKOP_VERSION, latest_version); + + write_podkop_latest_version_cache(latest_version, now_seconds()); + init_tmp_dir() || action_fail("podkop", "install", "Failed to create temporary directory", PODKOP_VERSION, latest_version); + updates_log("Resolving Podkop Plus release " + latest_version + " packages"); + let release = resolve_podkop_plus_release(latest_version); + if (release == null) + action_fail("podkop", "install", "Failed to resolve Podkop Plus release packages", PODKOP_VERSION, latest_version); + + let backend_file = tmp_dir + "/" + release.backend_name; + let app_file = tmp_dir + "/" + release.app_name; + let i18n_file = release.i18n_url != "" ? tmp_dir + "/" + release.i18n_name : ""; + if (!download_with_retry(release.backend_url, backend_file, release.backend_name) || + !download_with_retry(release.app_url, app_file, release.app_name) || + (release.i18n_url != "" && !download_with_retry(release.i18n_url, i18n_file, release.i18n_name))) + action_fail("podkop", "install", "Failed to download Podkop Plus release packages", PODKOP_VERSION, latest_version); + + if (!run_logged("Installing LuCI app package " + release.app_name, pkg_install_files_command([ app_file ]))) + action_fail("podkop", "install", "Failed to install LuCI app package", PODKOP_VERSION, latest_version); + if (i18n_file != "" && !run_logged("Installing LuCI Russian i18n package " + release.i18n_name, pkg_install_files_command([ i18n_file ]))) + action_fail("podkop", "install", "Failed to install LuCI Russian i18n package", PODKOP_VERSION, latest_version); + if (!run_logged("Installing Podkop Plus package " + release.backend_name, pkg_install_files_command([ backend_file ]))) + action_fail("podkop", "install", "Failed to install Podkop Plus package", PODKOP_VERSION, latest_version); + + remove_file("/var/luci-indexcache"); + command_success("rm -f /var/luci-indexcache* /tmp/luci-indexcache* 2>/dev/null"); + command_success("rm -rf /tmp/luci-modulecache/ 2>/dev/null"); + if (file_exists("/etc/init.d/rpcd") && !command_success_from_args([ "/etc/init.d/rpcd", "reload" ])) + command_success_from_args([ "/etc/init.d/rpcd", "restart" ]); + command_success_from_args([ "killall", "-HUP", "rpcd" ]); + + restart_podkop_after_successful_change(); + clear_version_caches(); + let new_version = installed_package_version("podkop-plus"); + if (new_version == "") + new_version = latest_version; + updates_log("Podkop Plus updated to " + new_version); + action_success("podkop", "install", "Podkop Plus has been installed", new_version, latest_version, 1, "latest", release.release_url); +} + +function dispatch_sing_box(action) { + if (action == "install_extended") { + install_sing_box_extended(action, false); + return; + } + if (action == "install_extended_compressed") { + install_sing_box_extended(action, true); + return; + } + if (action == "install_tiny") { + install_package_sing_box(action, true); + return; + } + if (action == "install_stable") { + install_package_sing_box(action, false); + return; + } + + let variant = sing_box_runtime_output("variant", []); + if (variant == "extended-compressed") + install_sing_box_extended(action, true); + else if (variant == "extended") + install_sing_box_extended(action, false); + else if (variant == "tiny") + install_package_sing_box(action, true); + else + install_package_sing_box(action, false); +} + +function normalize_component_name(component) { + component = as_string(component); + if (component == "sing-box" || component == "singbox") + return "sing_box"; + if (component == "podkop-plus" || component == "podkop_plus") + return "podkop"; + return component; +} + +function component_action(component, action) { + component = normalize_component_name(component); + action = as_string(action); + if (!acquire_component_lock()) + action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Another component action is already running"); + if (!init_tmp_dir()) + action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Failed to create temporary directory"); + capture_podkop_running_state(); + + if (component == "podkop" && action == "check_update") + check_podkop_plus(); + else if (component == "podkop" && action == "install") + install_podkop_plus(); + else if (component == "sing_box" && (action == "check_update" || action == "install" || + action == "install_extended" || action == "install_extended_compressed" || + action == "install_tiny" || action == "install_stable")) + dispatch_sing_box(action); + else if (component == "zapret" && (action == "check_update" || action == "install")) + install_zapret(action); + else if (component == "zapret" && action == "remove") + remove_optional_component("zapret", "zapret", "zapret", LIB_DIR + "/providers/zapret/runtime.uc"); + else if (component == "zapret2" && (action == "check_update" || action == "install")) + install_zapret2(action); + else if (component == "zapret2" && action == "remove") + remove_optional_component("zapret2", "zapret2", "zapret2", LIB_DIR + "/providers/zapret2/runtime.uc"); + else if (component == "byedpi" && (action == "check_update" || action == "install")) + install_byedpi(action); + else if (component == "byedpi" && action == "remove") + remove_optional_component("byedpi", "byedpi", "ByeDPI", LIB_DIR + "/providers/byedpi/runtime.uc"); + else + action_fail(component != "" ? component : "unknown", action != "" ? action : "unknown", "Unknown component action"); +} + +let mode = ARGV[0] || ""; + +if (mode == "component-action") + component_action(ARGV[1], ARGV[2]); +else if (mode == "latest-podkop-release-json") + print(latest_podkop_release_json()); +else if (mode == "latest-podkop-version") + print(latest_podkop_version(), "\n"); +else if (mode == "podkop-release-metadata") + print(fetch_podkop_latest_release_metadata(), "\n"); +else { + warn("Usage: components/action.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/updater.uc b/podkop/files/usr/lib/components/updater.uc similarity index 87% rename from podkop/files/usr/lib/updater.uc rename to podkop/files/usr/lib/components/updater.uc index a03d6086..ab421a6b 100644 --- a/podkop/files/usr/lib/updater.uc +++ b/podkop/files/usr/lib/components/updater.uc @@ -223,6 +223,45 @@ function release_asset_url_by_suffix(suffix) { print(url, "\n"); } +function release_asset_pair_by_prefix(release, prefix, ext) { + 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 ((str_startswith(name, prefix + "_") || str_startswith(name, prefix + "-")) && + str_endswith(name, "." + ext) && url != "") + return { name, url }; + } + + return null; +} + +function podkop_plus_release_plan(latest_version, asset_ext, i18n_required) { + let release = object_or_empty(read_stdin_json()); + + if (as_string(release.tag_name || "") != as_string(latest_version)) + exit(1); + + let backend = release_asset_pair_by_prefix(release, "podkop-plus", asset_ext); + let app = release_asset_pair_by_prefix(release, "luci-app-podkop-plus", asset_ext); + if (backend == null || app == null) + exit(1); + + let i18n = { name: "", url: "" }; + if (arg_bool(i18n_required)) { + i18n = release_asset_pair_by_prefix(release, "luci-i18n-podkop-plus-ru", asset_ext); + if (i18n == null) + exit(1); + } + + print(as_string(release.html_url || ""), "\t", + backend.name, "\t", backend.url, "\t", + app.name, "\t", app.url, "\t", + i18n.name, "\t", i18n.url, "\n"); +} + function release_metadata_tsv() { let release = object_or_empty(read_stdin_json()); let tag = as_string(release.tag_name || ""); @@ -839,6 +878,29 @@ function named_release_select_asset(release_prefix, asset_prefix, asset_ext, arc } } +function release_select_arch_suffix_asset(asset_ext, arch_candidates) { + let release = object_or_empty(read_stdin_json()); + + for (let arch in split(as_string(arch_candidates), " ")) { + if (arch == "") + continue; + + let suffix = "_" + arch + "." + as_string(asset_ext); + 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 != "" && str_endswith(name, suffix)) { + print(arch, "\t", name, "\t", url, "\t", + as_string(release.html_url || ""), "\t", as_string(release.tag_name || ""), "\n"); + return; + } + } + } +} + function select_byedpi_asset_from_release(release, asset_ext, arch_candidates) { for (let arch in split(as_string(arch_candidates), " ")) { if (arch == "") @@ -897,86 +959,6 @@ function sing_box_extended_release_tag() { } } -function duration_to_seconds_value(value) { - let rest = as_string(value); - if (rest == "") - return null; - - let total = 0.0; - let multipliers = { - ns: 0.000000001, - us: 0.000001, - ms: 0.001, - s: 1, - m: 60, - h: 3600, - d: 86400 - }; - - while (rest != "") { - let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); - if (!matched) - return null; - - let token = as_string(matched[0]); - let amount = matched[1] * 1; - let unit = matched[3]; - total = total + amount * multipliers[unit]; - rest = substr(rest, length(token)); - } - - if (total <= 0) - return null; - - return int(total + 0.5); -} - -function duration_to_seconds(value) { - let seconds = duration_to_seconds_value(value); - if (seconds == null) - exit(1); - - print(seconds, "\n"); -} - -function due_check_cron_schedule(value) { - let seconds = arg_number(value); - - if (seconds <= 60) { - print("* * * * *\n"); - return; - } - - if (seconds % 86400 == 0) { - print("0 0 * * *\n"); - return; - } - - if (seconds % 3600 == 0) { - let hours = seconds / 3600; - if (hours >= 1 && hours <= 23) { - if (hours == 1) - print("0 * * * *\n"); - else - print("0 */", hours, " * * *\n"); - return; - } - } - - if (seconds % 60 == 0) { - let minutes = seconds / 60; - if (minutes >= 1 && minutes <= 59) { - if (minutes == 1) - print("* * * * *\n"); - else - print("*/", minutes, " * * * *\n"); - return; - } - } - - print("* * * * *\n"); -} - function text_first_chars(value, max_chars) { value = as_string(value); max_chars = int(max_chars || "0", 10) || 0; @@ -1044,6 +1026,43 @@ function updates_json_response(success, component, action, message, current_vers }); } +function updates_status_from_compare(compare_result) { + compare_result = as_string(compare_result); + + if (compare_result == "-1") + print("outdated\n"); + else if (compare_result == "0") + print("latest\n"); + else if (compare_result == "1") + print("dev\n"); + else + exit(1); +} + +function updates_check_result_row(component, current_version, latest_version, status) { + component = as_string(component); + current_version = as_string(current_version); + latest_version = as_string(latest_version); + status = as_string(status); + + if (status == "latest") { + print("Latest version is installed\t", component, " is up to date (", current_version, ")\n"); + return; + } + + if (status == "outdated") { + print("Update is available\t", component, " update is available: ", current_version, " -> ", latest_version, "\n"); + return; + } + + if (status == "dev") { + print("Installed version is newer than release\t", component, " installed version is newer than upstream release: ", current_version, " -> ", latest_version, "\n"); + return; + } + + exit(1); +} + function updates_job_json_response(success, job_id, message) { write_json({ success: arg_bool(success), @@ -1052,6 +1071,16 @@ function updates_job_json_response(success, job_id, message) { }); } +function updates_job_state_path(job_dir, job_id) { + job_dir = as_string(job_dir); + job_id = as_string(job_id); + + if (job_id == "" || job_id == "." || job_id == ".." || match(job_id, /[^A-Za-z0-9._-]/) != null) + exit(1); + + print(job_dir, "/", job_id, ".json\n"); +} + function updates_running_job_state(component, action, pid, started_at) { pid = as_string(pid); write_json({ @@ -1072,6 +1101,39 @@ function updates_running_job_state(component, action, pid, started_at) { }); } +function job_started_at_within_grace(value, now, grace_seconds) { + let started_at = arg_number(value); + now = arg_number(now); + grace_seconds = arg_number(grace_seconds); + + if (started_at <= 0 || now <= 0) + return false; + + return now - started_at < grace_seconds; +} + +function job_pid_valid(pid) { + pid = as_string(pid); + return pid != "" && match(pid, /^[0-9]+$/) != null; +} + +function updates_job_refresh_plan(path, now, grace_seconds) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) { + print("skip\n"); + return; + } + + let within_grace = job_started_at_within_grace(value.started_at, now, grace_seconds); + let pid = as_string(value.pid || ""); + if (!job_pid_valid(pid)) { + print(within_grace ? "skip\n" : "stale\n"); + return; + } + + print("pid\t", pid, "\t", within_grace ? "0" : "1", "\n"); +} + function updates_set_running_job_pid(path, pid) { let value = object_or_empty(read_json_file(path)); if (value.running === true) @@ -1145,6 +1207,8 @@ 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 == "podkop-plus-release-plan") + podkop_plus_release_plan(ARGV[1], ARGV[2], ARGV[3]); else if (mode == "release-metadata-tsv") release_metadata_tsv(); else if (mode == "openwrt-release-value") @@ -1197,14 +1261,12 @@ else if (mode == "updates-apk-policy-version") updates_apk_policy_version(); else if (mode == "named-release-select-asset") named_release_select_asset(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "release-select-arch-suffix-asset") + release_select_arch_suffix_asset(ARGV[1], ARGV[2]); 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 == "duration-to-seconds") - duration_to_seconds(ARGV[1]); -else if (mode == "due-check-cron-schedule") - due_check_cron_schedule(ARGV[1]); else if (mode == "file-last-nonblank-line") file_last_nonblank_line(ARGV[1], ARGV[2], ARGV[3]); else if (mode == "file-flat-snippet") @@ -1215,10 +1277,18 @@ else if (mode == "job-running-is") exit(job_running_is(ARGV[1], ARGV[2]) ? 0 : 1); else if (mode == "updates-json-response") updates_json_response(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9]); +else if (mode == "updates-status-from-compare") + updates_status_from_compare(ARGV[1]); +else if (mode == "updates-check-result-row") + updates_check_result_row(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); else if (mode == "updates-job-json-response") updates_job_json_response(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "updates-job-state-path") + updates_job_state_path(ARGV[1], ARGV[2]); else if (mode == "updates-running-job-state") updates_running_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "updates-job-refresh-plan") + updates_job_refresh_plan(ARGV[1], ARGV[2], ARGV[3]); 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") @@ -1228,6 +1298,6 @@ else if (mode == "updates-finish-job-state") else if (mode == "updates-fallback-job-state") updates_fallback_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); else { - warn("Usage: updater.uc ...\n"); + warn("Usage: components/updater.uc ...\n"); exit(1); } diff --git a/podkop/files/usr/lib/components/updates.uc b/podkop/files/usr/lib/components/updates.uc new file mode 100644 index 00000000..7c448a8c --- /dev/null +++ b/podkop/files/usr/lib/components/updates.uc @@ -0,0 +1,2612 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const BIN_PATH = getenv("PODKOP_BIN") || "/usr/bin/podkop-plus"; +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || "/tmp/sing-box"; +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || TMP_SING_BOX_FOLDER + "/rulesets"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || TMP_SING_BOX_FOLDER + "/subscriptions"; +const RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const LIST_UPDATE_STATE_FILE = getenv("PODKOP_LIST_UPDATE_STATE_FILE") || RUNTIME_STATE_DIR + "/list-update.timestamp"; +const LIST_UPDATE_PID_FILE = getenv("PODKOP_LIST_UPDATE_PID_FILE") || "/var/run/podkop_list_update.pid"; +const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update"; +const SUBSCRIPTION_JOB_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR") || "/var/run/podkop-plus/subscription-update-jobs"; +const SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR") || RUNTIME_STATE_DIR + "/subscription-update.lock"; +const SUBSCRIPTION_LINKS_DIR = getenv("PODKOP_SUBSCRIPTION_LINKS_DIR") || RUNTIME_STATE_DIR + "/subscription-links"; +const SUBSCRIPTION_METADATA_DIR = getenv("PODKOP_SUBSCRIPTION_METADATA_DIR") || RUNTIME_STATE_DIR + "/subscription-metadata"; +const OUTBOUND_METADATA_DIR = getenv("PODKOP_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata"; +const SECTION_CACHE_DIR = getenv("PODKOP_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const RUNTIME_CACHE_FORMAT_FILE = getenv("PODKOP_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format"; +const RUNTIME_CACHE_FORMAT = getenv("PODKOP_RUNTIME_CACHE_FORMAT") || "7"; +const PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/podkop-plus/subscription-cache"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const PENDING_RELOAD_FILE = getenv("PODKOP_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending"; +const RELOAD_STATE_FILE = getenv("PODKOP_RELOAD_STATE_FILE") || RUNTIME_STATE_DIR + "/reload-state"; +const RELOAD_STATE_FORMAT = getenv("PODKOP_RELOAD_STATE_FORMAT") || "1"; +const RULE_CONDITION_CACHE_DIR = getenv("PODKOP_RULE_CONDITION_CACHE_DIR") || RUNTIME_STATE_DIR + "/rule-condition-cache"; +const RELOAD_LOCK_DIR = getenv("PODKOP_RELOAD_LOCK_DIR") || "/var/run/podkop-plus.reload.lock"; +const SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || "/etc/init.d/podkop-plus"; +const COMPONENT_JOB_DIR = getenv("UPDATES_JOB_DIR") || getenv("PODKOP_UI_COMPONENT_ACTION_DIR") || "/var/run/podkop-plus/component-actions"; +const COMPONENT_JOB_FINISHED_TTL_MINUTES = getenv("UPDATES_JOB_FINISHED_TTL_MINUTES") || "60"; +const COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES = getenv("UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES") || "60"; +const COMPONENT_JOB_STALE_GRACE_SECONDS = getenv("UPDATES_JOB_STALE_GRACE_SECONDS") || getenv("PODKOP_UI_ACTION_STALE_GRACE_SECONDS") || "15"; +const SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES = getenv("PODKOP_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES") || "60"; +const SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES = getenv("PODKOP_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES") || "60"; +const SUBSCRIPTION_JOB_STALE_GRACE_SECONDS = getenv("PODKOP_UI_ACTION_STALE_GRACE_SECONDS") || "15"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "PodkopPlusTable"; +const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || "podkop_plus_subnets"; +const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || "podkop_plus_ip_ports"; +const NFT_DISCORD_SET_NAME = getenv("NFT_DISCORD_SET_NAME") || "podkop_plus_discord_subnets"; +const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || "podkop_plus_interfaces"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x00100000"; +const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1"; +const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; +const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/podkop-plus/sing-box-variant"; +const GITHUB_RAW_URL = getenv("GITHUB_RAW_URL") || "https://raw.githubusercontent.com/itdoginfo/allow-domains/main"; +const BUILTIN_SUBNET_URLS = { + twitter: getenv("SUBNETS_TWITTER") || GITHUB_RAW_URL + "/Subnets/IPv4/twitter.lst", + meta: getenv("SUBNETS_META") || GITHUB_RAW_URL + "/Subnets/IPv4/meta.lst", + discord: getenv("SUBNETS_DISCORD") || GITHUB_RAW_URL + "/Subnets/IPv4/discord.lst", + roblox: getenv("SUBNETS_ROBLOX") || GITHUB_RAW_URL + "/Subnets/IPv4/roblox.lst", + telegram: getenv("SUBNETS_TELERAM") || GITHUB_RAW_URL + "/Subnets/IPv4/telegram.lst", + cloudflare: getenv("SUBNETS_CLOUDFLARE") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudflare.lst", + hetzner: getenv("SUBNETS_HETZNER") || GITHUB_RAW_URL + "/Subnets/IPv4/hetzner.lst", + ovh: getenv("SUBNETS_OVH") || GITHUB_RAW_URL + "/Subnets/IPv4/ovh.lst", + digitalocean: getenv("SUBNETS_DIGITALOCEAN") || GITHUB_RAW_URL + "/Subnets/IPv4/digitalocean.lst", + cloudfront: getenv("SUBNETS_CLOUDFRONT") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudfront.lst" +}; +let rule_config = null; +let routing_rulesets_module_value = null; +let singbox_rulesets_module_value = null; + +function routing_rulesets_module() { + if (routing_rulesets_module_value == null) + routing_rulesets_module_value = require("routing.rulesets"); + return routing_rulesets_module_value; +} + +function singbox_rulesets_module() { + if (singbox_rulesets_module_value == null) + singbox_rulesets_module_value = require("singbox.rulesets"); + return singbox_rulesets_module_value; +} + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let data = fs.readfile("/dev/stdin"); + return data == null ? "" : data; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function now_seconds() { + return int(clock()[0]); +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", path ]); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "podkop-plus", "[" + level + "] " + as_string(message) ]); +} + +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 json_text(value) { + return sprintf("%J", value) + "\n"; +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function write_state_file(path, value) { + path = as_string(path); + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + + if (!write_file(tmp_path, json_text(value))) { + fs.unlink(tmp_path); + return false; + } + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + return false; + } + return true; +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let newline = index(data, "\n"); + print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); +} + +function file_first_line_value(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "true" || value == "1" || value == "yes" || value == "on"; +} + +function arg_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9-]/)) + return 0; + return int(value); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function text_first_chars(value, max_chars) { + value = as_string(value); + max_chars = int(max_chars || "0", 10) || 0; + return max_chars > 0 && length(value) > max_chars ? substr(value, 0, max_chars) : value; +} + +function file_last_nonblank_line_value(path, fallback, max_chars) { + let data = fs.readfile(path); + let result = ""; + + if (data != null) { + for (let line in split(as_string(data), "\n")) + if (match(line, /^[[:space:]]*$/) == null) + result = line; + } + + if (result == "") + result = as_string(fallback); + + return text_first_chars(result, max_chars); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + return value == null ? !!fallback : arg_bool(value); +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function file_exists_value(path) { + return fs.stat(as_string(path)) != null; +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && int(stat.size || 0) > 0; +} + +function copy_file(source, target) { + let data = fs.readfile(as_string(source)); + if (data == null) + return false; + return write_file(target, data); +} + +function parent_dir(path) { + path = as_string(path); + let slash = rindex(path, "/"); + return slash >= 0 ? substr(path, 0, slash) : ""; +} + +function ensure_parent_dir(path) { + let dir = parent_dir(path); + return dir == "" || dir == "." || ensure_dir(dir); +} + +function temp_path() { + return trim(command_output_from_args([ "mktemp" ])); +} + +function remove_files(paths) { + for (let path in paths) + if (as_string(path) != "") + remove_file(path); +} + +function owner_pid() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function runtime_pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function whitespace_items(value) { + let result = []; + if (type(value) == "array") { + for (let item in value) { + item = as_string(item); + if (item != "") + push(result, item); + } + return result; + } + + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function list_option_values(section, key) { + return whitespace_items(object_or_empty(section)[key]); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, as_string(type_name)); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function module_command(args) { + let command_args = [ "ucode", "-L", LIB_DIR ]; + for (let arg in args) + push(command_args, arg); + return command_from_args(command_args); +} + +function module_status(args) { + return command_status(module_command(args)); +} + +function module_success(args) { + return module_status(args) == 0; +} + +function module_output(args) { + return command_output(module_command(args)); +} + +function module_env_status(env, args) { + return command_status(command_env(env) + " " + module_command(args)); +} + +function nft_module_success(args) { + let command_args = [ LIB_DIR + "/nft/apply.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_success(command_args); +} + +function service_state_success(args) { + let command_args = [ LIB_DIR + "/service/state.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_success(command_args); +} + +function acquire_runtime_lock(lock_dir, wait) { + return service_state_success([ + wait ? "acquire-runtime-dir-lock-wait" : "acquire-runtime-dir-lock", + lock_dir, + owner_pid(), + "300" + ]); +} + +function release_runtime_lock(lock_dir) { + service_state_success([ "release-runtime-dir-lock", lock_dir ]); +} + +function unsigned_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9]/) != null) + return null; + return int(value); +} + +function update_due_status(now_value, last_run_value, interval_value) { + let now = unsigned_number(now_value); + let interval = unsigned_number(interval_value); + + if (now == null || interval == null || interval <= 0) + return 2; + + let last_run = unsigned_number(last_run_value); + if (last_run == null) + last_run = 0; + + if (last_run > 0 && now - last_run < interval) + return 1; + + return 0; +} + +function update_is_due(now_value, last_run_value, interval_value) { + exit(update_due_status(now_value, last_run_value, interval_value)); +} + +function duration_to_seconds_value(value) { + let rest = as_string(value); + if (rest == "") + return null; + + let total = 0.0; + let multipliers = { + ns: 0.000000001, + us: 0.000001, + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return null; + + let token = as_string(matched[0]); + let amount = matched[1] * 1; + let unit = matched[3]; + total = total + amount * multipliers[unit]; + rest = substr(rest, length(token)); + } + + if (total <= 0) + return null; + + return int(total + 0.5); +} + +function duration_to_seconds(value) { + let seconds = duration_to_seconds_value(value); + if (seconds == null) + exit(1); + + print(seconds, "\n"); +} + +function due_check_cron_schedule_text(value) { + let seconds = arg_number(value); + + if (seconds <= 60) + return "* * * * *"; + + if (seconds % 86400 == 0) + return "0 0 * * *"; + + if (seconds % 3600 == 0) { + let hours = seconds / 3600; + if (hours >= 1 && hours <= 23) + return hours == 1 ? "0 * * * *" : "0 */" + hours + " * * *"; + } + + if (seconds % 60 == 0) { + let minutes = seconds / 60; + if (minutes >= 1 && minutes <= 59) + return minutes == 1 ? "* * * * *" : "*/" + minutes + " * * * *"; + } + + return "* * * * *"; +} + +function due_check_cron_schedule(value) { + print(due_check_cron_schedule_text(value), "\n"); +} + +function update_cron_job(interval, command, bin, marker) { + let seconds = duration_to_seconds_value(interval); + if (seconds == null) + exit(1); + + print(due_check_cron_schedule_text(seconds), " ", as_string(bin), " ", as_string(command), " ", as_string(marker), "\n"); +} + +function subscription_update_cron_job(min_interval_seconds, bin, marker) { + let seconds = arg_number(min_interval_seconds); + if (seconds <= 0) + exit(1); + + print(due_check_cron_schedule_text(seconds), " ", as_string(bin), " subscription_update_if_due ", as_string(marker), "\n"); +} + +function subscription_update_interval_plan() { + let min_interval = 0; + + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (line == "") + continue; + + let separator = index(line, "\t"); + let section = separator >= 0 ? substr(line, 0, separator) : line; + let interval = separator >= 0 ? substr(line, separator + 1) : ""; + if (interval == "") + continue; + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + print("error\t", section, "\t", interval, "\n"); + continue; + } + + if (min_interval == 0 || seconds < min_interval) + min_interval = seconds; + } + + print("min\t", min_interval, "\n"); +} + +function list_has_remote_references(value) { + for (let item in split(as_string(value), /[ \t\r\n]+/)) { + if (match(item, /^https?:\/\//) != null) + return true; + } + + return false; +} + +function rule_has_list_update_source(section) { + section = object_or_empty(section); + if (rule_config == null) + rule_config = require("config.rule"); + + return bool_option(section, "enabled", true) && ( + rule_config.has_community_subnet_list(option(section, "community_lists", "")) || + option(section, "remote_domain_lists", "") != "" || + option(section, "remote_subnet_lists", "") != "" || + option(section, "rule_set_with_subnets", "") != "" || + list_has_remote_references(option(section, "domain_ip_lists", "")) + ); +} + +function has_list_update_sources(sections) { + for (let section in sections) + if (rule_has_list_update_source(section)) + return true; + return false; +} + +function settings_update_interval(settings) { + settings = object_or_empty(settings); + + if (!bool_option(settings, "list_update_enabled", true)) + return ""; + + let value = option(settings, "update_interval", "1d"); + return value != "" ? value : "1d"; +} + +function section_subscription_update_interval(section) { + if (option(section, "subscription_urls", "") == "") + return ""; + + if (!bool_option(section, "subscription_update_enabled", true)) + return ""; + + let value = option(section, "subscription_update_interval", ""); + return value != "" ? value : "1h"; +} + +function section_is_subscription_proxy(section) { + return bool_option(section, "enabled", true) && + option(section, "action", "") == "proxy" && + option(section, "subscription_urls", "") != ""; +} + +function line_contains_any_marker(line, markers) { + line = as_string(line); + for (let marker in markers) { + marker = as_string(marker); + if (marker != "" && index(line, marker) >= 0) + return true; + } + + return false; +} + +function filter_cron_markers_text(data, markers) { + data = as_string(data); + if (data == "") + return ""; + + let lines = split(data, "\n"); + let has_trailing_newline = substr(data, length(data) - 1) == "\n"; + let result = ""; + + for (let i = 0; i < length(lines); i++) { + let line = as_string(lines[i]); + if (i == length(lines) - 1 && has_trailing_newline && line == "") + continue; + if (line_contains_any_marker(line, markers)) + continue; + result += line + "\n"; + } + + return result; +} + +function filter_cron_markers(markers) { + print(filter_cron_markers_text(read_stdin(), markers)); +} + +function cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker) { + let status = 0; + let rows = []; + + if (has_list_update_sources(sections)) { + let interval = settings_update_interval(settings); + if (interval == "") { + push(rows, "list-disabled"); + } + else { + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + push(rows, "list-error\t" + as_string(interval)); + status = 1; + } + else { + push(rows, "list\t" + due_check_cron_schedule_text(seconds) + " " + as_string(bin) + " list_update_if_due " + as_string(list_marker)); + } + } + } + + let min_interval = 0; + let subscription_source_count = 0; + for (let section in sections) { + section = object_or_empty(section); + if (!section_is_subscription_proxy(section)) + continue; + + let interval = section_subscription_update_interval(section); + if (interval == "") + continue; + + subscription_source_count++; + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + push(rows, "subscription-error\t" + section_name(section) + "\t" + as_string(interval)); + continue; + } + + if (min_interval == 0 || seconds < min_interval) + min_interval = seconds; + } + + if (subscription_source_count > 0) { + if (min_interval <= 0) + status = 1; + else + push(rows, "subscription\t" + due_check_cron_schedule_text(min_interval) + " " + as_string(bin) + " subscription_update_if_due " + as_string(subscription_marker)); + } + + return { + status, + rows + }; +} + +function print_cron_refresh_plan(result) { + result = object_or_empty(result); + for (let row in result.rows) + print(row, "\n"); + exit(int(result.status || 0)); +} + +function cron_refresh_plan(settings, sections, bin, list_marker, subscription_marker) { + print_cron_refresh_plan(cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker)); +} + +function cron_refresh_apply_result(settings, sections, existing_crontab, bin, list_marker, subscription_marker) { + let plan = cron_refresh_plan_rows(settings, sections, bin, list_marker, subscription_marker); + let filtered_crontab = filter_cron_markers_text(existing_crontab, [ list_marker, subscription_marker ]); + let cron_jobs = ""; + let logs = [ { level: "info", message: "The cron job removed" } ]; + let tab = "\t"; + + for (let row in plan.rows) { + let line = as_string(row); + let separator = index(line, tab); + let type = separator >= 0 ? substr(line, 0, separator) : line; + let rest = separator >= 0 ? substr(line, separator + 1) : ""; + + if (type == "list") { + cron_jobs += rest + "\n"; + push(logs, { level: "info", message: "The cron job has been created: " + rest }); + } + else if (type == "list-disabled") { + push(logs, { level: "info", message: "Remote list auto-update is disabled" }); + } + else if (type == "list-error") { + push(logs, { level: "error", message: "Invalid update_interval value: " + rest }); + } + else if (type == "subscription") { + cron_jobs += rest + "\n"; + push(logs, { level: "info", message: "The subscription cron job has been created: " + rest }); + } + else if (type == "subscription-error") { + let section_separator = index(rest, tab); + let section = section_separator >= 0 ? substr(rest, 0, section_separator) : rest; + let interval = section_separator >= 0 ? substr(rest, section_separator + 1) : ""; + push(logs, { level: "error", message: "Invalid subscription_update_interval value for rule '" + section + "': " + interval }); + } + } + + return { + status: int(plan.status || 0), + crontab: filtered_crontab + (int(plan.status || 0) == 0 ? cron_jobs : ""), + logs + }; +} + +function write_crontab_text(text) { + let tmp = trim(command_output_from_args([ "mktemp" ])); + if (tmp == "") + return false; + + if (!fs.writefile(tmp, as_string(text))) { + fs.unlink(tmp); + return false; + } + + let ok = command_success_from_args([ "crontab", tmp ]); + fs.unlink(tmp); + return ok; +} + +function log_cron_apply_result(result) { + for (let item in array_or_empty(object_or_empty(result).logs)) + log_message(item.message, item.level); +} + +function remove_cron_jobs(list_marker, subscription_marker) { + let crontab = command_output_from_args([ "crontab", "-l" ]); + let result = { + crontab: filter_cron_markers_text(crontab, [ list_marker, subscription_marker ]), + logs: [ { level: "info", message: "The cron job removed" } ] + }; + + if (!write_crontab_text(result.crontab)) + exit(1); + + log_cron_apply_result(result); +} + +function refresh_cron_from_sources(settings, sections, bin, list_marker, subscription_marker) { + let result = cron_refresh_apply_result( + settings, + sections, + command_output_from_args([ "crontab", "-l" ]), + bin, + list_marker, + subscription_marker + ); + + if (!write_crontab_text(result.crontab)) + exit(1); + + log_cron_apply_result(result); + exit(result.status); +} + +function list_update_due_status(settings, timestamp_path, now) { + let interval = settings_update_interval(settings); + if (interval == "") + exit(1); + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + print("error\t", interval, "\n"); + exit(2); + } + + exit(update_due_status(now, file_first_line_value(timestamp_path), seconds)); +} + +function subscription_update_section_due_status(section, timestamp_path, now) { + section = object_or_empty(section); + let interval = section_subscription_update_interval(section); + if (interval == "") + exit(1); + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + print("error\t", interval, "\n"); + exit(2); + } + + exit(update_due_status(now, file_first_line_value(timestamp_path), seconds)); +} + +function stdin_first_ipv4_line() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^[0-9]+\./) != null) { + print(line, "\n"); + return; + } + } +} + +function json_length(path) { + let value = read_json_file(path); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function job_pid(path) { + let value = read_json_file(path); + if (type(value) == "object" && value.pid != null) + print(as_string(value.pid), "\n"); +} + +function subscription_job_state_path(job_dir, job_id) { + job_dir = as_string(job_dir); + job_id = as_string(job_id); + + if (job_id == "" || job_id == "." || job_id == ".." || match(job_id, /[^A-Za-z0-9._-]/) != null) + exit(1); + + print(job_dir, "/", job_id, ".json\n"); +} + +function subscription_job_json_response(success, job_id, message) { + write_json({ + success: arg_bool(success), + job_id: as_string(job_id), + message: as_string(message) + }); +} + +function subscription_running_job_state_value(section, source_index, started_at) { + return { + success: true, + running: true, + kind: "subscription", + message: "Subscription update is running", + section: as_string(section), + source_index: as_string(source_index), + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }; +} + +function subscription_running_job_state(section, source_index, started_at) { + write_json(subscription_running_job_state_value(section, source_index, started_at)); +} + +function job_started_at_within_grace(value, now, grace_seconds) { + let started_at = arg_number(value); + now = arg_number(now); + grace_seconds = arg_number(grace_seconds); + + if (started_at <= 0 || now <= 0) + return false; + + return now - started_at < grace_seconds; +} + +function job_pid_valid(pid) { + pid = as_string(pid); + return pid != "" && match(pid, /^[0-9]+$/) != null; +} + +function set_subscription_running_job_pid(path, pid) { + pid = as_string(pid); + if (!job_pid_valid(pid)) + return false; + + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.pid = pid; + return write_state_file(path, value); + } + + return false; +} + +function subscription_job_refresh_plan(path, now, grace_seconds) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) { + print("skip\n"); + return; + } + + let within_grace = job_started_at_within_grace(value.started_at, now, grace_seconds); + let pid = as_string(value.pid || ""); + if (!job_pid_valid(pid)) { + print(within_grace ? "skip\n" : "stale\n"); + return; + } + + print("pid\t", pid, "\t", within_grace ? "0" : "1", "\n"); +} + +function subscription_finished_job_state_value(success, message, exit_code, updated_at, section, source_index, started_at) { + return { + success: arg_bool(success), + running: false, + kind: "subscription", + message: as_string(message), + section: as_string(section), + source_index: as_string(source_index), + pid: null, + started_at: arg_number(started_at), + exit_code: arg_number(exit_code), + updated_at: arg_number(updated_at) + }; +} + +function subscription_finished_job_state(success, message, exit_code, updated_at, section, source_index, started_at) { + write_json(subscription_finished_job_state_value(success, message, exit_code, updated_at, section, source_index, started_at)); +} + +function subscription_stale_job_state_value(updated_at, section, source_index, started_at) { + return { + success: false, + running: false, + kind: "subscription", + message: "Subscription update worker exited unexpectedly", + section: as_string(section), + source_index: as_string(source_index), + pid: null, + started_at: arg_number(started_at), + exit_code: null, + updated_at: arg_number(updated_at) + }; +} + +function subscription_stale_job_state(updated_at, section, source_index, started_at) { + write_json(subscription_stale_job_state_value(updated_at, section, source_index, started_at)); +} + +function subscription_status_error(message) { + write_json({ + success: false, + running: false, + message: as_string(message), + exit_code: null + }); +} + +function subscription_status_error_exit(message) { + subscription_status_error(message); + exit(1); +} + +function valid_subscription_job_id(job_id) { + job_id = as_string(job_id); + return job_id != "" && job_id != "." && job_id != ".." && match(job_id, /[^A-Za-z0-9._-]/) == null; +} + +function subscription_job_state_path_value(job_dir, job_id) { + if (!valid_subscription_job_id(job_id)) + return ""; + return as_string(job_dir) + "/" + as_string(job_id) + ".json"; +} + +function subscription_job_id() { + let stamp = clock(); + return sprintf("%d-%d", stamp[0], stamp[1]); +} + +function ensure_subscription_runtime_dirs() { + let command = command_env({ + PODKOP_CONFIG_NAME: CONFIG_NAME, + PODKOP_LIB: LIB_DIR, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + PODKOP_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + PODKOP_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + PODKOP_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + PODKOP_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + PODKOP_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + PODKOP_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + PODKOP_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE + }) + " " + command_from_args([ + "ucode", + "-L", LIB_DIR, + LIB_DIR + "/subscription/cache.uc", + "ensure-runtime-dirs" + ]); + + return command_success(command) && ensure_dir(SUBSCRIPTION_JOB_DIR); +} + +function subscription_job_output_path(job_id) { + return SUBSCRIPTION_JOB_DIR + "/" + as_string(job_id) + ".out"; +} + +function subscription_job_output_path_from_state(path) { + path = as_string(path); + if (length(path) >= 5 && substr(path, length(path) - 5) == ".json") + return substr(path, 0, length(path) - 5) + ".out"; + return path + ".out"; +} + +function remove_subscription_job_state(path) { + remove_file(path); + remove_file(subscription_job_output_path_from_state(path)); +} + +function subscription_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 subscription_cleanup_jobs() { + ensure_dir(SUBSCRIPTION_JOB_DIR); + + command_success_from_args([ + "find", + SUBSCRIPTION_JOB_DIR, + "-type", "f", + "-name", "*.out", + "-mmin", "+" + as_string(SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES), + "-delete" + ]); + + let old = command_output_from_args([ + "find", + SUBSCRIPTION_JOB_DIR, + "-type", "f", + "-name", "*.json", + "-mmin", "+" + as_string(SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES) + ]); + + for (let path in split(old, "\n")) { + path = trim(as_string(path)); + if (path != "" && subscription_job_running_is(path, false)) + remove_subscription_job_state(path); + } +} + +function pid_running(pid) { + pid = as_string(pid); + return job_pid_valid(pid) && command_success_from_args([ "kill", "-0", pid ]); +} + +function write_subscription_stale_job_state(path) { + let value = object_or_empty(read_json_file(path)); + return write_state_file(path, subscription_stale_job_state_value( + now_seconds(), + value.section || "", + value.source_index || "", + value.started_at || 0 + )); +} + +function refresh_subscription_running_job_state(path) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + + let now = now_seconds(); + let within_grace = job_started_at_within_grace(value.started_at, now, SUBSCRIPTION_JOB_STALE_GRACE_SECONDS); + let pid = as_string(value.pid || ""); + + if (!job_pid_valid(pid)) { + if (!within_grace) + write_subscription_stale_job_state(path); + return; + } + + if (pid_running(pid)) + return; + if (within_grace) + return; + + command_success_from_args([ "sleep", "1" ]); + value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + if (pid_running(pid)) + return; + + write_subscription_stale_job_state(path); +} + +function finish_subscription_job(path, exit_code, output_file) { + let value = object_or_empty(read_json_file(path)); + let success = arg_number(exit_code) == 0; + let message = file_last_nonblank_line_value( + output_file, + success ? "Subscription update completed" : "Subscription update failed", + 240 + ); + + let ok = write_state_file(path, subscription_finished_job_state_value( + success, + message, + exit_code, + now_seconds(), + value.section || "", + value.source_index || "", + value.started_at || 0 + )); + remove_file(output_file); + return ok; +} + +function subscription_worker_env() { + return { + PODKOP_CONFIG_NAME: CONFIG_NAME, + PODKOP_LIB: LIB_DIR, + PODKOP_BIN: BIN_PATH, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + PODKOP_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR: SUBSCRIPTION_JOB_DIR, + PODKOP_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + PODKOP_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + PODKOP_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + PODKOP_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + PODKOP_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + PODKOP_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + PODKOP_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES: SUBSCRIPTION_JOB_FINISHED_TTL_MINUTES, + PODKOP_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES: SUBSCRIPTION_JOB_ORPHAN_OUTPUT_TTL_MINUTES, + PODKOP_UI_ACTION_STALE_GRACE_SECONDS: SUBSCRIPTION_JOB_STALE_GRACE_SECONDS + }; +} + +function launch_subscription_worker(args) { + let command_args = [ "ucode", "-L", LIB_DIR, LIB_DIR + "/components/updates.uc" ]; + for (let arg in args) + push(command_args, arg); + + let command = command_env(subscription_worker_env()) + " " + + command_from_args(command_args) + + " >/dev/null 2>&1 & echo $!"; + return trim(command_output("sh -c " + shell_quote(command))); +} + +function subscription_update_worker(state_file, output_file, section, source_index) { + let status = command_status(command_from_args([ + BIN_PATH, + "subscription_update", + as_string(section), + as_string(source_index) + ]) + " >" + shell_quote(output_file) + " 2>&1"); + + finish_subscription_job(state_file, status, output_file); +} + +function subscription_update_async(section, source_index) { + if (!ensure_subscription_runtime_dirs()) { + subscription_job_json_response(false, "", "Failed to create subscription update state directory"); + exit(1); + } + + subscription_cleanup_jobs(); + + let job_id = subscription_job_id(); + let state_file = subscription_job_state_path_value(SUBSCRIPTION_JOB_DIR, job_id); + if (state_file == "") { + subscription_job_json_response(false, "", "Failed to prepare subscription update job"); + exit(1); + } + + if (!write_state_file(state_file, subscription_running_job_state_value(section, source_index, now_seconds()))) { + subscription_job_json_response(false, "", "Failed to write subscription update state"); + exit(1); + } + + let output_file = subscription_job_output_path(job_id); + let pid = launch_subscription_worker([ + "subscription-update-worker", + state_file, + output_file, + as_string(section), + as_string(source_index) + ]); + + if (pid == "" || !set_subscription_running_job_pid(state_file, pid)) { + if (pid != "") + command_success_from_args([ "kill", pid ]); + subscription_job_json_response(false, "", "Failed to write subscription update worker pid"); + exit(1); + } + + subscription_job_json_response(true, job_id, "Subscription update started"); +} + +function subscription_update_status(job_id) { + ensure_dir(SUBSCRIPTION_JOB_DIR); + subscription_cleanup_jobs(); + + let state_file = subscription_job_state_path_value(SUBSCRIPTION_JOB_DIR, job_id); + if (state_file == "") + subscription_status_error_exit("Invalid subscription update job id"); + + if (fs.stat(state_file) == null) + subscription_status_error_exit("Subscription update job was not found"); + + refresh_subscription_running_job_state(state_file); + print(as_string(fs.readfile(state_file))); +} + +function component_job_json_response(success, job_id, message) { + write_json({ + success: arg_bool(success), + job_id: as_string(job_id), + message: as_string(message) + }); +} + +function component_action_status_error(message) { + write_json({ + success: false, + running: false, + kind: "component", + component: "unknown", + action: "status", + message: as_string(message), + current_version: "", + latest_version: "", + changed: 0, + status: "", + exit_code: null + }); +} + +function component_action_status_error_exit(message) { + component_action_status_error(message); + exit(1); +} + +function component_running_job_state_value(component, action, started_at) { + return { + success: true, + running: true, + kind: "component", + component: as_string(component), + action: as_string(action), + message: "Component action is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + current_version: "", + latest_version: "", + changed: 0, + status: "", + exit_code: null + }; +} + +function normalize_component_name(component) { + component = as_string(component); + if (component == "sing-box" || component == "singbox") + return "sing_box"; + if (component == "podkop-plus" || component == "podkop_plus") + return "podkop"; + return component; +} + +function valid_component_job_id(job_id) { + job_id = as_string(job_id); + return job_id != "" && job_id != "." && job_id != ".." && match(job_id, /[^A-Za-z0-9._-]/) == null; +} + +function component_job_state_path_value(job_id) { + if (!valid_component_job_id(job_id)) + return ""; + return COMPONENT_JOB_DIR + "/" + as_string(job_id) + ".json"; +} + +function component_job_id() { + let stamp = clock(); + return sprintf("%d-%d", stamp[0], stamp[1]); +} + +function component_job_output_path(job_id) { + return COMPONENT_JOB_DIR + "/" + as_string(job_id) + ".out"; +} + +function component_job_output_path_from_state(path) { + path = as_string(path); + if (length(path) >= 5 && substr(path, length(path) - 5) == ".json") + return substr(path, 0, length(path) - 5) + ".out"; + return path + ".out"; +} + +function remove_component_job_state(path) { + let output_path = component_job_output_path_from_state(path); + remove_file(path); + remove_file(output_path); + remove_file(output_path + ".json"); +} + +function component_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 ensure_component_runtime_dirs() { + return ensure_dir(COMPONENT_JOB_DIR); +} + +function write_component_stale_job_state(path) { + let value = object_or_empty(read_json_file(path)); + value.success = false; + value.running = false; + value.kind = "component"; + value.message = "Component action job is stale or the worker process exited unexpectedly"; + value.changed = 0; + value.status = ""; + value.exit_code = null; + value.updated_at = now_seconds(); + return write_state_file(path, value); +} + +function refresh_component_running_job_state(path) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + + let now = now_seconds(); + let within_grace = job_started_at_within_grace(value.started_at, now, COMPONENT_JOB_STALE_GRACE_SECONDS); + let pid = as_string(value.pid || ""); + + if (!job_pid_valid(pid)) { + if (!within_grace) + write_component_stale_job_state(path); + return; + } + + if (pid_running(pid) || within_grace) + return; + + command_success_from_args([ "sleep", "1" ]); + value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + if (pid_running(pid)) + return; + + write_component_stale_job_state(path); +} + +function path_basename_without_suffix(path, suffix) { + let parts = split(as_string(path), "/"); + let name = length(parts) > 0 ? as_string(parts[length(parts) - 1]) : ""; + suffix = as_string(suffix); + if (length(name) >= length(suffix) && substr(name, length(name) - length(suffix)) == suffix) + return substr(name, 0, length(name) - length(suffix)); + return name; +} + +function component_cleanup_jobs() { + ensure_dir(COMPONENT_JOB_DIR); + + for (let path in fs.glob(COMPONENT_JOB_DIR + "/*.json")) + refresh_component_running_job_state(path); + + for (let output_path in fs.glob(COMPONENT_JOB_DIR + "/*.out")) { + let state_file = component_job_state_path_value(path_basename_without_suffix(output_path, ".out")); + if (state_file == "" || fs.stat(state_file) == null || !component_job_running_is(state_file, true)) { + remove_file(output_path); + remove_file(output_path + ".json"); + } + } + + command_success_from_args([ + "find", + COMPONENT_JOB_DIR, + "-type", "f", + "-name", "*.out", + "-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES), + "-delete" + ]); + command_success_from_args([ + "find", + COMPONENT_JOB_DIR, + "-type", "f", + "-name", "*.out.json", + "-mmin", "+" + as_string(COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES), + "-delete" + ]); + command_success_from_args([ + "find", + COMPONENT_JOB_DIR, + "-type", "f", + "-name", "*.json.*", + "-mmin", "+10", + "-delete" + ]); + + let old = command_output_from_args([ + "find", + COMPONENT_JOB_DIR, + "-type", "f", + "-name", "*.json", + "-mmin", "+" + as_string(COMPONENT_JOB_FINISHED_TTL_MINUTES) + ]); + + for (let path in split(old, "\n")) { + path = trim(as_string(path)); + if (path != "" && component_job_running_is(path, false)) + remove_component_job_state(path); + } +} + +function set_component_running_job_pid(path, pid) { + pid = as_string(pid); + if (!job_pid_valid(pid)) + return false; + + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.pid = pid; + return write_state_file(path, value); + } + + return false; +} + +function output_json_object(path) { + let data = as_string(fs.readfile(path) || ""); + + try { + let value = json(data); + if (type(value) == "object") + return value; + } + catch (e) { + } + + let result = null; + for (let line in split(data, "\n")) { + line = trim(as_string(line)); + let start = index(line, "{"); + if (start < 0) + continue; + try { + let value = json(substr(line, start)); + if (type(value) == "object") + result = value; + } + catch (e) { + } + } + + return result; +} + +function component_fallback_job_state(component, action, message, exit_code, updated_at) { + return { + success: false, + running: false, + kind: "component", + 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 finish_component_job(path, component, action, exit_code, output_file) { + let updated_at = now_seconds(); + let value = output_json_object(output_file); + let ok; + + if (type(value) == "object") { + value.running = false; + value.kind = "component"; + value.exit_code = arg_number(exit_code); + value.updated_at = updated_at; + ok = write_state_file(path, value); + } + else { + let raw_output = file_last_nonblank_line_value(output_file, "Failed to execute", 240); + ok = write_state_file(path, component_fallback_job_state(component, action, raw_output, exit_code, updated_at)); + } + + remove_file(output_file); + remove_file(output_file + ".json"); + return ok; +} + +function component_worker_env() { + return { + PODKOP_CONFIG_NAME: CONFIG_NAME, + PODKOP_LIB: LIB_DIR, + PODKOP_BIN: BIN_PATH, + PODKOP_SERVICE_INIT: SERVICE_INIT, + UPDATES_JOB_DIR: COMPONENT_JOB_DIR, + UPDATES_JOB_FINISHED_TTL_MINUTES: COMPONENT_JOB_FINISHED_TTL_MINUTES, + UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES: COMPONENT_JOB_ORPHAN_OUTPUT_TTL_MINUTES, + UPDATES_JOB_STALE_GRACE_SECONDS: COMPONENT_JOB_STALE_GRACE_SECONDS, + PODKOP_UI_COMPONENT_ACTION_TRACKED: "1" + }; +} + +function launch_component_worker(args) { + let command_args = [ "ucode", "-L", LIB_DIR, LIB_DIR + "/components/updates.uc" ]; + for (let arg in args) + push(command_args, arg); + + let command = command_env(component_worker_env()) + " " + + command_from_args(command_args) + + " >/dev/null 2>&1 & echo $!"; + return trim(command_output("sh -c " + shell_quote(command))); +} + +function component_action_worker(state_file, output_file, component, action) { + component = normalize_component_name(component); + let command = command_env(component_worker_env()) + " " + + command_from_args([ + "ucode", + "-L", LIB_DIR, + LIB_DIR + "/components/action.uc", + "component-action", + as_string(component), + as_string(action) + ]) + " >" + shell_quote(output_file) + " 2>&1"; + let status = command_status(command); + + finish_component_job(state_file, component, action, status, output_file); +} + +function component_action_async(component, action) { + component = normalize_component_name(component); + if (!ensure_component_runtime_dirs()) { + component_job_json_response(false, "", "Failed to create component action state directory"); + exit(1); + } + + component_cleanup_jobs(); + + let job_id = component_job_id(); + let state_file = component_job_state_path_value(job_id); + if (state_file == "") { + component_job_json_response(false, "", "Failed to prepare component action job"); + exit(1); + } + + if (!write_state_file(state_file, component_running_job_state_value(component, action, now_seconds()))) { + component_job_json_response(false, "", "Failed to write component action state"); + exit(1); + } + + let output_file = component_job_output_path(job_id); + let pid = launch_component_worker([ + "component-action-worker", + state_file, + output_file, + as_string(component), + as_string(action) + ]); + + if (pid == "" || !set_component_running_job_pid(state_file, pid)) { + if (pid != "") + command_success_from_args([ "kill", pid ]); + component_job_json_response(false, "", "Failed to write component action worker pid"); + exit(1); + } + + component_job_json_response(true, job_id, "Component action started"); +} + +function component_action_status(job_id) { + ensure_dir(COMPONENT_JOB_DIR); + component_cleanup_jobs(); + + let state_file = component_job_state_path_value(job_id); + if (state_file == "") + component_action_status_error_exit("Invalid component action job id"); + + if (fs.stat(state_file) == null) + component_action_status_error_exit("Component action job was not found"); + + refresh_component_running_job_state(state_file); + print(as_string(fs.readfile(state_file))); +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy"; + if (purpose == "subscriptions") + return "download_subscriptions_via_proxy"; + if (purpose == "components") + return "download_components_via_proxy"; + return ""; +} + +function service_proxy_address(settings, purpose) { + let option_name = download_via_proxy_option_for_purpose(purpose); + return option_name != "" && bool_option(settings, option_name, false) ? + SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + SB_SERVICE_MIXED_INBOUND_PORT : ""; +} + +function download_to_file(url, filepath, proxy_address) { + let attempt = 1; + while (attempt <= 3) { + let command = command_from_args([ "wget", "-O", filepath, url ]); + if (as_string(proxy_address) != "") + command = "http_proxy=" + shell_quote("http://" + as_string(proxy_address)) + + " https_proxy=" + shell_quote("http://" + as_string(proxy_address)) + " " + command; + + if (command_success(command)) + return true; + + log_message("Attempt " + attempt + "/3 to download " + as_string(url) + " failed", "warn"); + command_success_from_args([ "sleep", "2" ]); + attempt++; + } + + return false; +} + +function convert_crlf_to_lf(path) { + let data = fs.readfile(as_string(path)); + if (data == null || index(data, "\r") < 0) + return; + + log_message("File '" + as_string(path) + "' contains CRLF line endings. Converting to LF...", "debug"); + write_file(path, replace(data, /\r/g, "")); +} + +function ruleset_module_success(args) { + let command_args = [ LIB_DIR + "/routing/rulesets.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_success(command_args); +} + +function domain_ip_list_ruleset_path(section) { + return TMP_RULESET_FOLDER + "/" + routing_rulesets_module().ruleset_tag(section_name(section), "lists", "") + ".json"; +} + +function remote_ruleset_path(section, kind) { + return TMP_RULESET_FOLDER + "/" + routing_rulesets_module().ruleset_tag(section_name(section), "remote", kind) + ".json"; +} + +function reset_domain_ip_list_ruleset(section) { + let path = domain_ip_list_ruleset_path(section); + ensure_dir(TMP_RULESET_FOLDER); + remove_file(path); + return ruleset_module_success([ "create-source", path ]); +} + +function ensure_ruleset_source(path) { + ensure_dir(TMP_RULESET_FOLDER); + if (file_exists_value(path)) + return true; + return ruleset_module_success([ "create-source", path ]); +} + +function cleanup_empty_ruleset(path) { + if (routing_rulesets_module().has_rules(path)) + return true; + remove_file(path); + return false; +} + +function add_plain_subnet_file_to_nft_for_section(section, filepath) { + if (!file_nonempty(filepath)) + return true; + + return nft_module_success([ + "nft-add-subnet-file-for-uci-section", + section_name(section), + filepath, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_IP_PORT_SET_NAME, + "5000" + ]); +} + +function add_json_ruleset_subnets_to_nft_for_section(section, json_file, label) { + let unscoped_tmpfile = temp_path(); + let scoped_tmpfile = temp_path(); + if (unscoped_tmpfile == "" || scoped_tmpfile == "") { + remove_files([ unscoped_tmpfile, scoped_tmpfile ]); + return false; + } + + let ok = nft_module_success([ + "nft-add-json-ruleset-subnets-for-uci-section", + section_name(section), + json_file, + label, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_IP_PORT_SET_NAME, + unscoped_tmpfile, + scoped_tmpfile, + "5000" + ]); + remove_files([ unscoped_tmpfile, scoped_tmpfile ]); + return ok; +} + +function import_domain_ip_list_file_into_rulesets(filepath, section) { + if (!file_exists_value(filepath)) + return true; + + let domains_tmpfile = temp_path(); + let subnets_tmpfile = temp_path(); + if (domains_tmpfile == "" || subnets_tmpfile == "") { + remove_files([ domains_tmpfile, subnets_tmpfile ]); + return false; + } + + let ruleset_filepath = domain_ip_list_ruleset_path(section); + let ok = nft_module_success([ "split-domain-subnet-file", filepath, domains_tmpfile, subnets_tmpfile ]); + if (ok) + ok = ruleset_module_success([ "import-plain-list", domains_tmpfile, ruleset_filepath, "domain_suffix", "domains", "5000" ]); + if (ok) + ok = ruleset_module_success([ "import-plain-list", subnets_tmpfile, ruleset_filepath, "ip_cidr", "subnets", "5000" ]); + if (ok) + ok = add_plain_subnet_file_to_nft_for_section(section, subnets_tmpfile); + + remove_files([ domains_tmpfile, subnets_tmpfile ]); + return ok; +} + +function import_domain_ip_list_reference_into_rulesets(reference, section, settings) { + reference = as_string(reference); + if (match(reference, /^https?:\/\//) == null) + return import_domain_ip_list_file_into_rulesets(reference, section); + + let tmpfile = temp_path(); + if (tmpfile == "") + return false; + + let ok = true; + if (download_to_file(reference, tmpfile, service_proxy_address(settings, "lists")) && file_nonempty(tmpfile)) { + convert_crlf_to_lf(tmpfile); + ok = import_domain_ip_list_file_into_rulesets(tmpfile, section); + } + else { + log_message("Failed to download remote domain/IP list " + reference + "; skipping it until the next successful update", "warn"); + } + + remove_file(tmpfile); + return ok; +} + +function rebuild_domain_ip_lists_from_rule(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + + let references = list_option_values(section, "domain_ip_lists"); + if (length(references) == 0) + return true; + + if (!reset_domain_ip_list_ruleset(section)) + return false; + + let ok = true; + for (let reference in references) + if (!import_domain_ip_list_reference_into_rulesets(reference, section, settings)) + ok = false; + + cleanup_empty_ruleset(domain_ip_list_ruleset_path(section)); + return ok; +} + +function import_builtin_subnets_from_rule(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + + let ok = true; + for (let service in list_option_values(section, "community_lists")) { + if (!singbox_rulesets_module().is_community(service)) + continue; + + let url = BUILTIN_SUBNET_URLS[as_string(service)]; + if (url == null) + continue; + + let tmpfile = temp_path(); + if (tmpfile == "") { + ok = false; + continue; + } + + if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) { + log_message("Failed to download built-in " + as_string(service) + " subnet list; skipping it until the next successful update", "warn"); + remove_file(tmpfile); + continue; + } + + if (!nft_module_success([ + "nft-add-community-subnet-file-for-uci-section", + section_name(section), + service, + tmpfile, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_DISCORD_SET_NAME, + NFT_FAKEIP_MARK, + "5000" + ])) + ok = false; + + remove_file(tmpfile); + } + + return ok; +} + +function import_custom_ruleset_subnets_from_local(path, format, section, label) { + if (!file_exists_value(path)) { + log_message("Local rule set file " + as_string(path) + " not found", "error"); + return false; + } + + let json_tmpfile = temp_path(); + if (json_tmpfile == "") + return false; + + let ok = true; + if (as_string(format) == "binary") { + if (!command_success_from_args([ "sing-box", "rule-set", "decompile", path, "-o", json_tmpfile ])) { + log_message("Failed to decompile rule set " + as_string(path), "error"); + ok = false; + } + } + else if (!copy_file(path, json_tmpfile)) { + log_message("Failed to copy source rule set file " + as_string(path), "error"); + ok = false; + } + + if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, label)) + ok = false; + + remove_file(json_tmpfile); + return ok; +} + +function import_custom_ruleset_subnets_from_remote(url, format, section, label, settings) { + let remote_tmpfile = temp_path(); + let json_tmpfile = temp_path(); + if (remote_tmpfile == "" || json_tmpfile == "") { + remove_files([ remote_tmpfile, json_tmpfile ]); + return false; + } + + if (!download_to_file(url, remote_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(remote_tmpfile)) { + log_message("Failed to download remote rule set " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_files([ remote_tmpfile, json_tmpfile ]); + return true; + } + + let ok = true; + if (as_string(format) == "binary") { + if (!command_success_from_args([ "sing-box", "rule-set", "decompile", remote_tmpfile, "-o", json_tmpfile ])) { + log_message("Failed to decompile rule set " + as_string(url), "error"); + ok = false; + } + } + else if (!copy_file(remote_tmpfile, json_tmpfile)) { + log_message("Failed to copy downloaded source rule set " + as_string(url), "error"); + ok = false; + } + + if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, label)) + ok = false; + + remove_files([ remote_tmpfile, json_tmpfile ]); + return ok; +} + +function import_rule_sets_with_subnets_from_rule(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + + let references = list_option_values(section, "rule_set_with_subnets"); + if (length(references) == 0) + return true; + + log_message("Importing subnets from rule sets with subnets for '" + section_name(section) + "' section", "info"); + let ok = true; + + for (let reference in references) { + reference = as_string(reference); + log_message("Importing subnets from rule set reference for '" + section_name(section) + "': " + reference, "info"); + + let extension = singbox_rulesets_module().file_extension(reference); + if (match(reference, /^\/.*\.srs$/) != null) { + if (!import_custom_ruleset_subnets_from_local(reference, "binary", section, "Rule set " + reference)) + ok = false; + } + else if (match(reference, /^\/.*\.json$/) != null) { + if (!import_custom_ruleset_subnets_from_local(reference, "source", section, "Rule set " + reference)) + ok = false; + } + else if (match(reference, /^https?:\/\//) != null) { + let format = extension == "json" ? "source" : (extension == "srs" ? "binary" : singbox_rulesets_module().remote_format(reference)); + if (!import_custom_ruleset_subnets_from_remote(reference, format, section, "Rule set " + reference, settings)) + ok = false; + } + else { + log_message("Unsupported rule set reference for subnet import: " + reference, "error"); + ok = false; + } + } + + return ok; +} + +function import_domains_from_remote_plain_file(url, section, settings) { + let tmpfile = temp_path(); + if (tmpfile == "") + return false; + + if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) { + log_message("Failed to download remote domain list " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_file(tmpfile); + return true; + } + + convert_crlf_to_lf(tmpfile); + let ruleset_path = remote_ruleset_path(section, "domains"); + let ok = ensure_ruleset_source(ruleset_path) && + ruleset_module_success([ "import-plain-list", tmpfile, ruleset_path, "domain_suffix", "domains", "5000" ]); + remove_file(tmpfile); + return ok; +} + +function import_domains_from_remote_domain_lists(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + + let references = list_option_values(section, "remote_domain_lists"); + if (length(references) == 0) + return true; + + log_message("Importing domains from remote domain lists for '" + section_name(section) + "' section", "info"); + let ok = true; + for (let url in references) { + log_message("Importing domains from URL: " + as_string(url), "info"); + let extension = singbox_rulesets_module().file_extension(url); + log_message("Detected file extension: '" + extension + "'", "debug"); + if (extension == "json" || extension == "srs") { + log_message("No update needed - sing-box manages updates automatically.", "info"); + continue; + } + log_message("Import domains from a remote plain-text list", "info"); + if (!import_domains_from_remote_plain_file(url, section, settings)) + ok = false; + } + return ok; +} + +function import_subnets_from_remote_json_file(url, section, settings) { + let json_tmpfile = temp_path(); + if (json_tmpfile == "") + return false; + + if (!download_to_file(url, json_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(json_tmpfile)) { + log_message("Failed to download remote JSON subnet list " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_file(json_tmpfile); + return true; + } + + let ok = add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, "Remote JSON rule set " + as_string(url)); + if (!ok) + log_message("Failed to add subnets from remote JSON list " + as_string(url) + " to nftables", "error"); + remove_file(json_tmpfile); + return ok; +} + +function import_subnets_from_remote_srs_file(url, section, settings) { + let binary_tmpfile = temp_path(); + let json_tmpfile = temp_path(); + if (binary_tmpfile == "" || json_tmpfile == "") { + remove_files([ binary_tmpfile, json_tmpfile ]); + return false; + } + + if (!download_to_file(url, binary_tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(binary_tmpfile)) { + log_message("Failed to download remote SRS subnet list " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_files([ binary_tmpfile, json_tmpfile ]); + return true; + } + + let ok = command_success_from_args([ "sing-box", "rule-set", "decompile", binary_tmpfile, "-o", json_tmpfile ]); + if (!ok) + log_message("Failed to decompile binary rule set file", "error"); + if (ok && !add_json_ruleset_subnets_to_nft_for_section(section, json_tmpfile, "Remote SRS rule set " + as_string(url))) { + log_message("Failed to add subnets from remote SRS list " + as_string(url) + " to nftables", "error"); + ok = false; + } + + remove_files([ binary_tmpfile, json_tmpfile ]); + return ok; +} + +function import_subnets_from_remote_plain_file(url, section, settings) { + let tmpfile = temp_path(); + if (tmpfile == "") + return false; + + if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) { + log_message("Failed to download remote plain subnet list " + as_string(url) + "; skipping it until the next successful update", "warn"); + remove_file(tmpfile); + return true; + } + + convert_crlf_to_lf(tmpfile); + let ruleset_path = remote_ruleset_path(section, "subnets"); + let ok = ensure_ruleset_source(ruleset_path) && + ruleset_module_success([ "import-plain-list", tmpfile, ruleset_path, "ip_cidr", "subnets", "5000" ]); + if (ok) + ok = add_plain_subnet_file_to_nft_for_section(section, tmpfile); + remove_file(tmpfile); + return ok; +} + +function import_subnets_from_remote_subnet_lists(section, settings) { + if (!bool_option(section, "enabled", true)) + return true; + + let references = list_option_values(section, "remote_subnet_lists"); + if (length(references) == 0) + return true; + + log_message("Importing subnets from remote subnet lists for '" + section_name(section) + "' section", "info"); + let ok = true; + for (let url in references) { + log_message("Importing subnets from URL: " + as_string(url), "info"); + let extension = singbox_rulesets_module().file_extension(url); + log_message("Detected file extension: '" + extension + "'", "debug"); + if (extension == "json") { + log_message("Import subnets from a remote JSON list", "info"); + if (!import_subnets_from_remote_json_file(url, section, settings)) + ok = false; + } + else if (extension == "srs") { + log_message("Import subnets from a remote SRS list", "info"); + if (!import_subnets_from_remote_srs_file(url, section, settings)) + ok = false; + } + else { + log_message("Import subnets from a remote plain-text list", "info"); + if (!import_subnets_from_remote_plain_file(url, section, settings)) + ok = false; + } + } + return ok; +} + +function list_update_pid_begin() { + let existing_pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || "")); + let current_pid = owner_pid(); + if (existing_pid != "" && existing_pid != current_pid && runtime_pid_running(existing_pid)) { + log_message("Another lists update is already running, skipping", "info"); + return false; + } + + ensure_parent_dir(LIST_UPDATE_PID_FILE); + write_file(LIST_UPDATE_PID_FILE, current_pid + "\n"); + return true; +} + +function list_update_pid_end() { + remove_file(LIST_UPDATE_PID_FILE); +} + +function dns_probe_passed(proxy_address) { + if (as_string(proxy_address) != "") { + log_message("DNS check skipped because list downloads use service proxy", "info"); + return true; + } + + let attempt = 1; + while (attempt <= 10) { + let output = command_output_from_args([ "dig", "+short", "openwrt.org", "A", "+timeout=3", "+tries=1" ]); + for (let line in split(output, "\n")) { + if (match(as_string(line), /^[0-9]+\./) != null) { + log_message("DNS check passed", "info"); + return true; + } + } + + log_message("DNS is unavailable [" + attempt + "/10]", "info"); + command_success_from_args([ "sleep", "3" ]); + attempt++; + } + + log_message("DNS check failed after 10 attempts; skipping remote lists update until the next attempt", "info"); + return false; +} + +function github_probe(proxy_address) { + let attempt = 1; + let timeout = 5; + while (attempt <= 10) { + let args = [ "curl", "-s", "-m", "" + timeout ]; + if (as_string(proxy_address) != "") { + push(args, "-x"); + push(args, "http://" + as_string(proxy_address)); + } + push(args, "https://github.com"); + + if (command_success_from_args(args)) { + log_message(as_string(proxy_address) != "" ? "GitHub connection check passed (via proxy)" : "GitHub connection check passed", "info"); + return true; + } + + log_message("GitHub is unavailable [" + attempt + "/10] (max-timeout=" + timeout + ")", "info"); + if (timeout < 10) + timeout++; + command_success_from_args([ "sleep", "3" ]); + attempt++; + } + + log_message("GitHub connection check failed after 10 attempts; trying configured list downloads individually", "info"); + return false; +} + +function write_list_update_timestamp(timestamp) { + ensure_dir(RUNTIME_STATE_DIR); + write_file(LIST_UPDATE_STATE_FILE, as_string(timestamp) + "\n"); +} + +function list_update() { + log_message("Starting lists update...", "info"); + if (!list_update_pid_begin()) + exit(0); + + let settings = uci_settings(); + let proxy_address = service_proxy_address(settings, "lists"); + if (!dns_probe_passed(proxy_address)) { + list_update_pid_end(); + exit(0); + } + github_probe(proxy_address); + + log_message("Downloading and processing lists...", "info"); + let sections = uci_sections("section"); + let ok = true; + + for (let section in sections) + if (!rebuild_domain_ip_lists_from_rule(section, settings)) + ok = false; + for (let section in sections) + if (!import_builtin_subnets_from_rule(section, settings)) + ok = false; + for (let section in sections) + if (!import_domains_from_remote_domain_lists(section, settings)) + ok = false; + for (let section in sections) + if (!import_subnets_from_remote_subnet_lists(section, settings)) + ok = false; + for (let section in sections) + if (!import_rule_sets_with_subnets_from_rule(section, settings)) + ok = false; + + if (ok) { + write_list_update_timestamp(now_seconds()); + log_message("Lists update completed successfully", "info"); + } + else { + log_message("Lists update failed", "info"); + } + + list_update_pid_end(); + exit(ok ? 0 : 1); +} + +function list_update_if_due() { + let interval = settings_update_interval(uci_settings()); + if (interval == "") + exit(1); + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + log_message("Invalid update_interval value: " + interval, "error"); + exit(1); + } + + let status = update_due_status(now_seconds(), file_first_line_value(LIST_UPDATE_STATE_FILE), seconds); + if (status == 0) + list_update(); + if (status == 1) + exit(0); + + exit(1); +} + +function stop_list_update() { + let pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || "")); + if (pid != "" && runtime_pid_running(pid)) { + command_success_from_args([ "kill", pid ]); + log_message("Stopped list_update", "info"); + } + remove_file(LIST_UPDATE_PID_FILE); +} + +function subscription_cache_env() { + return { + PODKOP_CONFIG_NAME: CONFIG_NAME, + PODKOP_LIB: LIB_DIR, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + PODKOP_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + PODKOP_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + PODKOP_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + PODKOP_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + PODKOP_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + PODKOP_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + PODKOP_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + PODKOP_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE, + PODKOP_SERVICE_INIT: SERVICE_INIT, + SB_SERVICE_MIXED_INBOUND_ADDRESS, + SB_SERVICE_MIXED_INBOUND_PORT, + SB_VARIANT_STATE_FILE + }; +} + +function module_env_capture(env, args) { + let output_path = temp_path(); + if (output_path == "") + return { status: 1, output: "" }; + + let status = command_status(command_env(env) + " " + module_command(args) + " >" + shell_quote(output_path) + " 2>&1"); + let output = as_string(fs.readfile(output_path) || ""); + remove_file(output_path); + return { status, output }; +} + +function subscription_cache_capture(args) { + let command_args = [ LIB_DIR + "/subscription/cache.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_env_capture(subscription_cache_env(), command_args); +} + +function subscription_cache_success(args) { + let result = subscription_cache_capture(args); + if (result.output != "") + for (let line in split(result.output, "\n")) + if (trim(as_string(line)) != "") + log_message("subscription/cache.uc: " + line, "debug"); + return result.status == 0; +} + +function log_file_lines_from_text(text, level, prefix) { + for (let line in split(as_string(text), "\n")) + if (trim(as_string(line)) != "") + log_message(as_string(prefix) + as_string(line), level); +} + +function singbox_runtime_success(args) { + let command_args = [ LIB_DIR + "/singbox/runtime.uc" ]; + for (let arg in args) + push(command_args, arg); + + let result = module_env_capture(subscription_cache_env(), command_args); + if (result.output != "") + log_file_lines_from_text(result.output, "debug", "singbox/runtime.uc: "); + return result.status == 0; +} + +function write_current_reload_state_clean() { + return service_state_success([ + "write-current-reload-state-clean", + RELOAD_STATE_FILE, + RELOAD_STATE_FORMAT, + RULE_CONDITION_CACHE_DIR + ]); +} + +function mark_pending_reload(reason) { + service_state_success([ "mark-pending-reload", PENDING_RELOAD_FILE, reason ]); +} + +function run_pending_reload_if_requested() { + service_state_success([ "run-pending-reload-if-requested", PENDING_RELOAD_FILE, SERVICE_INIT ]); +} + +function subscription_update_common_locked(force, target_section, target_source_index) { + let result = subscription_cache_capture([ + "update-request", + force ? "1" : "0", + as_string(target_section), + as_string(target_source_index) + ]); + if (result.status != 0) { + log_file_lines_from_text(result.output, "error", "subscription/cache.uc: "); + return false; + } + + let fields = split(trim(result.output), /[ \t\r\n]+/); + let updated = arg_number(fields[0] || "0"); + let failed = arg_number(fields[1] || "0"); + let unchanged = arg_number(fields[2] || "0"); + let superseded = arg_number(fields[3] || "0"); + + if (updated == 0) { + if (superseded > 0) { + log_message("Subscription update was superseded by newer configuration", "info"); + return true; + } + if (failed > 0) { + log_message("Subscription update finished with errors; keeping the last working cache", "info"); + return false; + } + if (unchanged > 0) + log_message("Subscription update completed: no changes detected", "info"); + else + log_message("No subscription rules are due for update", "info"); + return true; + } + + log_message("Reloading sing-box to apply updated subscriptions...", "info"); + if (!module_success([ LIB_DIR + "/server/service.uc", "prepare-all-defaults" ])) + return false; + if (!module_success([ LIB_DIR + "/config/validator.uc", "validate-runtime" ])) { + log_message("Runtime config validation failed. Aborted.", "fatal"); + return false; + } + + if (!singbox_runtime_success([ "configure-service" ])) + return false; + if (!singbox_runtime_success([ "init-config", "0", "1", "1" ])) { + log_message("Failed to rebuild sing-box after subscription update", "info"); + return false; + } + if (!service_state_success([ "reload-sing-box-runtime" ])) + return false; + if (!write_current_reload_state_clean()) + return false; + + if (failed > 0) + log_message("Subscription update applied for changed rules; failed rules kept their previous cache", "info"); + else + log_message("Subscription update completed", "info"); + return true; +} + +function subscription_update_common(force, target_section, target_source_index) { + if (!module_success([ LIB_DIR + "/config/migration.uc", "migrate" ])) { + log_message("Config migration failed. Aborted.", "fatal"); + exit(1); + } + if (!subscription_cache_success([ "ensure-runtime-dirs" ])) + exit(1); + + force = !!force; + if (!acquire_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR, force)) { + log_message("Subscription update is already running", "info"); + if (force) + mark_pending_reload("subscription_update_busy"); + return force ? 1 : 0; + } + + if (!acquire_runtime_lock(RELOAD_LOCK_DIR, force)) { + release_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR); + log_message("Podkop Plus reload is already running; skipping subscription update", "info"); + if (force) + mark_pending_reload("reload_busy"); + return force ? 1 : 0; + } + + let ok = subscription_update_common_locked(force, target_section, target_source_index); + release_runtime_lock(RELOAD_LOCK_DIR); + release_runtime_lock(SUBSCRIPTION_UPDATE_LOCK_DIR); + run_pending_reload_if_requested(); + return ok ? 0 : 1; +} + +function subscription_update_if_due() { + log_message("Starting due subscription update...", "info"); + exit(subscription_update_common(false, "", "")); +} + +function subscription_update(target_section, target_source_index) { + if (as_string(target_section) != "") + log_message("Starting subscription update for rule '" + as_string(target_section) + "'...", "info"); + else + log_message("Starting subscription update...", "info"); + exit(subscription_update_common(true, target_section, target_source_index)); +} + +function fixture_section_list(data, type_name) { + type_name = as_string(type_name || "section"); + let value = object_or_empty(data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function fixture_cron_refresh_plan(path, bin, list_marker, subscription_marker) { + let data = object_or_empty(read_json_file(path)); + cron_refresh_plan( + object_or_empty(data.settings), + fixture_section_list(data), + bin, + list_marker, + subscription_marker + ); +} + +function fixture_cron_refresh_apply(path, existing_crontab_path, bin, list_marker, subscription_marker) { + let data = object_or_empty(read_json_file(path)); + let result = cron_refresh_apply_result( + object_or_empty(data.settings), + fixture_section_list(data), + fs.readfile(as_string(existing_crontab_path)) || "", + bin, + list_marker, + subscription_marker + ); + + write_json({ + crontab: result.crontab, + logs: result.logs + }); + exit(result.status); +} + +function fixture_section_by_name(data, target_name) { + target_name = as_string(target_name); + for (let section in fixture_section_list(data)) + if (section_name(section) == target_name) + return section; + return {}; +} + +function uci_cron_refresh_plan(bin, list_marker, subscription_marker) { + cron_refresh_plan( + uci_settings(), + uci_sections("section"), + bin, + list_marker, + subscription_marker + ); +} + +function uci_refresh_cron(bin, list_marker, subscription_marker) { + refresh_cron_from_sources( + uci_settings(), + uci_sections("section"), + bin, + list_marker, + subscription_marker + ); +} + +function uci_list_update_due_status(timestamp_path, now) { + list_update_due_status(uci_settings(), timestamp_path, now); +} + +function uci_subscription_update_section_due_status(section_name, timestamp_path, now) { + subscription_update_section_due_status(object_or_empty(uci_core.get_all(CONFIG_NAME, section_name)), timestamp_path, now); +} + +function fixture_list_update_due_status(path, timestamp_path, now) { + list_update_due_status(object_or_empty(object_or_empty(read_json_file(path)).settings), timestamp_path, now); +} + +function fixture_subscription_update_section_due_status(path, section_name_value, timestamp_path, now) { + let data = object_or_empty(read_json_file(path)); + subscription_update_section_due_status(fixture_section_by_name(data, section_name_value), timestamp_path, now); +} + +let mode = ARGV[0] || ""; + +if (mode == "json-length") + json_length(ARGV[1]); +else if (mode == "update-is-due") + update_is_due(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "duration-to-seconds") + duration_to_seconds(ARGV[1]); +else if (mode == "due-check-cron-schedule") + due_check_cron_schedule(ARGV[1]); +else if (mode == "list-update-cron-job") + update_cron_job(ARGV[1], "list_update_if_due", ARGV[2], ARGV[3]); +else if (mode == "subscription-update-cron-job") + subscription_update_cron_job(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-update-interval-plan") + subscription_update_interval_plan(); +else if (mode == "cron-refresh-plan") + uci_cron_refresh_plan(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "cron-refresh-plan-fixture") + fixture_cron_refresh_plan(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "refresh-cron-from-uci") + uci_refresh_cron(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "refresh-cron-fixture") + fixture_cron_refresh_apply(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "remove-cron-jobs") + remove_cron_jobs(ARGV[1], ARGV[2]); +else if (mode == "list-update") + list_update(); +else if (mode == "list-update-if-due") + list_update_if_due(); +else if (mode == "stop-list-update") + stop_list_update(); +else if (mode == "list-update-due-status") + uci_list_update_due_status(ARGV[1], ARGV[2]); +else if (mode == "list-update-due-status-fixture") + fixture_list_update_due_status(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-update-section-due-status") + uci_subscription_update_section_due_status(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-update-section-due-status-fixture") + fixture_subscription_update_section_due_status(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "file-first-line") + file_first_line(ARGV[1]); +else if (mode == "stdin-first-ipv4-line") + stdin_first_ipv4_line(); +else if (mode == "filter-cron-markers") + filter_cron_markers([ARGV[1], ARGV[2]]); +else if (mode == "job-pid") + job_pid(ARGV[1]); +else if (mode == "subscription-job-state-path") + subscription_job_state_path(ARGV[1], ARGV[2]); +else if (mode == "subscription-job-json-response") + subscription_job_json_response(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-running-job-state") + subscription_running_job_state(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-job-refresh-plan") + subscription_job_refresh_plan(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "subscription-finished-job-state") + subscription_finished_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +else if (mode == "subscription-stale-job-state") + subscription_stale_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "subscription-status-error") + subscription_status_error(ARGV[1]); +else if (mode == "subscription-cleanup-jobs") + subscription_cleanup_jobs(); +else if (mode == "subscription-update-worker") + subscription_update_worker(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "subscription-update") + subscription_update(ARGV[1], ARGV[2]); +else if (mode == "subscription-update-if-due") + subscription_update_if_due(); +else if (mode == "subscription-update-async") + subscription_update_async(ARGV[1], ARGV[2]); +else if (mode == "subscription-update-status") + subscription_update_status(ARGV[1]); +else if (mode == "component-action-worker") + component_action_worker(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "component-action-async") + component_action_async(ARGV[1], ARGV[2]); +else if (mode == "component-action-status") + component_action_status(ARGV[1]); +else { + warn("Usage: components/updates.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/config/migration.uc b/podkop/files/usr/lib/config/migration.uc new file mode 100644 index 00000000..9e9f96b4 --- /dev/null +++ b/podkop/files/usr/lib/config/migration.uc @@ -0,0 +1,814 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); +let uci_core = require("core.uci"); +let constants_module = require("core.constants"); + +let as_string = common.as_string; +let read_json_file = common.read_json_file; +let write_json = common.write_json; +let option = common.option; +let list_option = common.list_option; +let bool_option = common.bool_option; +let object_or_empty = common.object_or_empty; + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || "/tmp/sing-box/subscriptions"; +const PODKOP_RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const PODKOP_SUBSCRIPTION_LINKS_DIR = getenv("PODKOP_SUBSCRIPTION_LINKS_DIR") || PODKOP_RUNTIME_STATE_DIR + "/subscription-links"; +const PODKOP_SUBSCRIPTION_METADATA_DIR = getenv("PODKOP_SUBSCRIPTION_METADATA_DIR") || PODKOP_RUNTIME_STATE_DIR + "/subscription-metadata"; +const PODKOP_OUTBOUND_METADATA_DIR = getenv("PODKOP_OUTBOUND_METADATA_DIR") || PODKOP_RUNTIME_STATE_DIR + "/outbound-metadata"; +const PODKOP_SECTION_CACHE_DIR = getenv("PODKOP_SECTION_CACHE_DIR") || PODKOP_RUNTIME_STATE_DIR + "/section-cache"; +const PODKOP_RUNTIME_CACHE_FORMAT_FILE = getenv("PODKOP_RUNTIME_CACHE_FORMAT_FILE") || PODKOP_RUNTIME_STATE_DIR + "/cache-format"; +const PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/podkop-plus/subscription-cache"; +const PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD = getenv("PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD") || "/var/run/podkop-plus.internal-config-change"; +const PODKOP_RUNTIME_CACHE_FORMAT = getenv("PODKOP_RUNTIME_CACHE_FORMAT") || "7"; +const SERVER_COUNTRY_METHOD_FLAG_EMOJI = "flag_emoji"; +const SERVER_COUNTRY_METHOD_COUNTRY_IS = "country_is"; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return replace(as_string(data), /[\r\n]+$/g, ""); +} + +function run(command) { + return system(command) == 0; +} + +function constants_context() { + let constants = object_or_empty(constants_module); + return { + zapret_legacy_default_nfqws_opt: as_string(constants.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT), + zapret_default_nfqws_opt: as_string(constants.ZAPRET_DEFAULT_NFQWS_OPT) + }; +} + +function section_name(section) { + return option(section, ".name", ""); +} + +function clone_section(section) { + let result = {}; + for (let key in keys(object_or_empty(section))) + result[key] = section[key]; + return result; +} + +function fixture_section_list(data, type_name) { + let value = object_or_empty(data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function model_from_fixture(path) { + let data = object_or_empty(read_json_file(path)); + let model = { + settings: clone_section(object_or_empty(data.settings)), + rules: [], + sections: [] + }; + + if (model.settings[".name"] == null) + model.settings[".name"] = "settings"; + if (model.settings[".type"] == null) + model.settings[".type"] = "settings"; + + for (let section in fixture_section_list(data, "rule")) + push(model.rules, clone_section(section)); + for (let section in fixture_section_list(data, "section")) + push(model.sections, clone_section(section)); + + return model; +} + +function model_from_uci(cursor) { + let model = { + settings: clone_section(object_or_empty(cursor.get_all(CONFIG_NAME, "settings"))), + rules: [], + sections: [] + }; + + cursor.foreach(CONFIG_NAME, "rule", function(section) { + push(model.rules, clone_section(section)); + }); + cursor.foreach(CONFIG_NAME, "section", function(section) { + push(model.sections, clone_section(section)); + }); + + return model; +} + +function export_model(model) { + let result = { + settings: model.settings, + section: model.sections + }; + if (length(model.rules) > 0) + result.rule = model.rules; + return result; +} + +function migration_context(model) { + return { + model, + operations: [], + removed_caches: [], + added_lists: {}, + changed: false + }; +} + +function record_operation(ctx, op) { + push(ctx.operations, op); + ctx.changed = true; +} + +function option_exists(section, key) { + return object_or_empty(section)[key] != null; +} + +function set_option(ctx, section, key, value) { + value = as_string(value); + if (option(section, key, "") == value && option_exists(section, key)) + return; + + section[key] = value; + record_operation(ctx, { op: "set", section: section_name(section), option: key, value }); +} + +function set_option_if_missing(ctx, section, key, value) { + if (option_exists(section, key)) + return; + set_option(ctx, section, key, value); +} + +function delete_option(ctx, section, key) { + if (!option_exists(section, key)) + return; + + delete section[key]; + record_operation(ctx, { op: "delete", section: section_name(section), option: key }); +} + +function list_contains(section, key, value) { + value = as_string(value); + for (let item in list_option(section, key)) + if (as_string(item) == value) + return true; + return false; +} + +function add_list_unique(ctx, section, key, value) { + value = as_string(value); + if (value == "") + return; + + let list_key = section_name(section) + "." + key + "=" + value; + if (ctx.added_lists[list_key] || list_contains(section, key, value)) + return; + + let current = section[key]; + if (type(current) != "array") + current = list_option(section, key); + push(current, value); + section[key] = current; + ctx.added_lists[list_key] = true; + record_operation(ctx, { op: "add_list", section: section_name(section), option: key, values: current }); +} + +function set_section_type(ctx, section, type_name) { + if (option(section, ".type", "") == type_name) + return; + + section[".type"] = type_name; + record_operation(ctx, { op: "set_type", section: section_name(section), type: type_name }); +} + +function normalize_detect_server_country_method(value) { + value = as_string(value); + if (value == SERVER_COUNTRY_METHOD_COUNTRY_IS) + return SERVER_COUNTRY_METHOD_COUNTRY_IS; + return SERVER_COUNTRY_METHOD_FLAG_EMOJI; +} + +function urltest_filter_mode_filters_enabled(value) { + return value == "exclude" || value == "include" || value == "mixed"; +} + +function migrate_urltest_filter_mode(ctx, section) { + if (option_exists(section, "urltest_filter_mode")) + return; + + if (option_exists(section, "urltest_exclude_countries") || + option_exists(section, "urltest_include_countries") || + option_exists(section, "urltest_exclude_outbounds") || + option_exists(section, "urltest_exclude_regex")) { + set_option(ctx, section, "urltest_filter_mode", "exclude"); + } +} + +function migrate_detect_server_country(ctx, section) { + if (!option_exists(section, "detect_server_country")) + return; + + let value = option(section, "detect_server_country", ""); + if (value != "0" && value != "1") + return; + + if (bool_option(section, "urltest_enabled", false) && + urltest_filter_mode_filters_enabled(option(section, "urltest_filter_mode", "disabled"))) { + set_option(ctx, section, "detect_server_country", normalize_detect_server_country_method(value)); + } + else { + delete_option(ctx, section, "detect_server_country"); + } +} + +function trim_lines(value) { + let result = []; + for (let line in split(as_string(value), "\n")) { + line = trim(replace(as_string(line), /\r/g, "")); + if (line != "") + push(result, line); + } + return result; +} + +function migrate_urltest_link(ctx, section, link) { + add_list_unique(ctx, section, "selector_proxy_links", link); +} + +function migrate_proxy_string(ctx, section) { + let proxy_string = option(section, "proxy_string", ""); + if (proxy_string == "") + return; + + let migrated = false; + for (let link in trim_lines(proxy_string)) { + if (substr(link, 0, 2) == "//") + continue; + add_list_unique(ctx, section, "selector_proxy_links", link); + migrated = true; + } + + if (migrated) + delete_option(ctx, section, "proxy_string"); +} + +function cache_section_safe(section) { + section = as_string(section); + return section != "" && index(section, "/") < 0 && index(section, "..") < 0; +} + +function subscription_cache_paths(section) { + return [ + TMP_SUBSCRIPTION_FOLDER + "/" + section + ".json", + TMP_SUBSCRIPTION_FOLDER + "/" + section + ".url", + TMP_SUBSCRIPTION_FOLDER + "/" + section + ".user_agent", + PODKOP_SUBSCRIPTION_METADATA_DIR + "/" + section + ".json", + PODKOP_SUBSCRIPTION_LINKS_DIR + "/" + section + ".json", + PODKOP_OUTBOUND_METADATA_DIR + "/" + section + ".json", + PODKOP_SECTION_CACHE_DIR + "/" + section + ".json", + TMP_SUBSCRIPTION_FOLDER + "/" + section + "-subscription-*.json", + TMP_SUBSCRIPTION_FOLDER + "/" + section + "-subscription-*.url", + TMP_SUBSCRIPTION_FOLDER + "/" + section + "-subscription-*.user_agent" + ]; +} + +function delete_subscription_cache(ctx, section) { + section = as_string(section); + if (!cache_section_safe(section)) + return; + + for (let path in subscription_cache_paths(section)) + push(ctx.removed_caches, path); +} + +function migrate_subscription_url(ctx, section) { + let subscription_url = option(section, "subscription_url", ""); + if (subscription_url == "") + return; + + let subscription_user_agent = option(section, "subscription_user_agent", ""); + let entry = subscription_user_agent != "" + ? subscription_url + " | " + subscription_user_agent + : subscription_url; + + add_list_unique(ctx, section, "subscription_urls", entry); + delete_option(ctx, section, "subscription_url"); + delete_option(ctx, section, "subscription_user_agent"); + delete_subscription_cache(ctx, section_name(section)); +} + +function migrate_interval_flags(ctx, section, proxy_config_type) { + if (proxy_config_type == "urltest" || proxy_config_type == "subscription") { + if (option(section, "urltest_check_interval_disabled", "") == "1") + set_option(ctx, section, "urltest_enabled", "0"); + else + set_option_if_missing(ctx, section, "urltest_enabled", "1"); + } + else if (proxy_config_type == "url" || proxy_config_type == "selector") { + set_option_if_missing(ctx, section, "urltest_enabled", "0"); + } + + if (proxy_config_type == "subscription") { + if (option(section, "subscription_update_interval_disabled", "") == "1") + set_option(ctx, section, "subscription_update_enabled", "0"); + else + set_option_if_missing(ctx, section, "subscription_update_enabled", "1"); + } + + delete_option(ctx, section, "urltest_check_interval_disabled"); + delete_option(ctx, section, "subscription_update_interval_disabled"); +} + +function migrate_proxy_rule(ctx, section, proxy_config_type) { + if (proxy_config_type == "url") + migrate_proxy_string(ctx, section); + else if (proxy_config_type == "urltest") { + for (let link in list_option(section, "urltest_proxy_links")) + migrate_urltest_link(ctx, section, link); + delete_option(ctx, section, "urltest_proxy_links"); + } + else if (proxy_config_type == "subscription") { + migrate_subscription_url(ctx, section); + delete_subscription_cache(ctx, section_name(section)); + } + + migrate_interval_flags(ctx, section, proxy_config_type); + delete_option(ctx, section, "proxy_config_type"); +} + +function migrated_rule_action(section) { + let action = option(section, "action", ""); + let proxy_config_type = option(section, "proxy_config_type", ""); + let connection_type = option(section, "connection_type", ""); + let iface = option(section, "interface", ""); + let outbound_json = option(section, "outbound_json", ""); + let selector_proxy_links = option(section, "selector_proxy_links", ""); + let subscription_urls = option(section, "subscription_urls", ""); + + if (action == "proxy") { + if (proxy_config_type == "interface") + return "vpn"; + if (proxy_config_type == "outbound") + return "outbound"; + } + + if (action != "") + return action; + + if (connection_type == "proxy") { + if (proxy_config_type == "interface") + return "vpn"; + if (proxy_config_type == "outbound") + return "outbound"; + return "proxy"; + } + if (connection_type == "vpn") + return "vpn"; + if (connection_type == "block") + return "block"; + if (connection_type == "exclusion") + return "direct"; + + if (proxy_config_type == "interface") + return "vpn"; + if (proxy_config_type == "outbound") + return "outbound"; + if (proxy_config_type == "url" || proxy_config_type == "selector" || + proxy_config_type == "urltest" || proxy_config_type == "subscription") + return "proxy"; + + if (outbound_json != "") + return "outbound"; + if (iface != "") + return "vpn"; + if (selector_proxy_links != "" || subscription_urls != "") + return "proxy"; + + return ""; +} + +function migrate_byedpi_cmd_opts(ctx, section) { + let cmd_opts = option(section, "cmd_opts", ""); + if (cmd_opts == "") + return; + + if (option(section, "byedpi_cmd_opts", "") == "") + set_option(ctx, section, "byedpi_cmd_opts", cmd_opts); + delete_option(ctx, section, "cmd_opts"); +} + +function migrate_zapret_nfqws_default(ctx, section, constants) { + if (migrated_rule_action(section) != "zapret") + return; + + let nfqws_opt = option(section, "nfqws_opt", ""); + if (nfqws_opt == "" || nfqws_opt != constants.zapret_legacy_default_nfqws_opt) + return; + + set_option(ctx, section, "nfqws_opt", constants.zapret_default_nfqws_opt); +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function valid_domain(value) { + return match(ascii_lower(value), /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/) != null; +} + +function valid_domain_suffix(value) { + value = as_string(value); + if (substr(value, 0, 1) == ".") + value = substr(value, 1); + return valid_domain(value); +} + +function strip_list_comment(line) { + line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); + return replace(line, /[[:space:]]*#.*$/, ""); +} + +function text_list_values(value, separator_mode) { + let result = []; + separator_mode = as_string(separator_mode); + + for (let line in split(as_string(value), "\n")) { + line = strip_list_comment(line); + line = separator_mode == "comma-space" + ? replace(line, /[ ,]/g, "\n") + : replace(line, /,/g, "\n"); + + for (let item in split(line, "\n")) { + item = trim(replace(item, /\r/g, "")); + if (item != "") + push(result, item); + } + } + + return result; +} + +function filter_domain_values(values) { + let result = []; + for (let value in values) { + let normalized = ascii_lower(value); + if (valid_domain_suffix(normalized)) + push(result, normalized); + } + return result; +} + +function generic_values_from_text(value) { + return text_list_values(value, "comma"); +} + +function legacy_condition_values(kind, text_mode, conditions_text_mode, text_value, list_value) { + if (int(text_mode || 0) == 1 || int(conditions_text_mode || 0) == 1) + return kind == "domains" + ? filter_domain_values(text_list_values(text_value, "comma-space")) + : generic_values_from_text(text_value); + + let result = []; + for (let item in list_option({ value: list_value }, "value")) + push(result, item); + if (length(result) > 0) + return result; + + if (as_string(text_value) != "") + return kind == "domains" + ? filter_domain_values(text_list_values(text_value, "comma-space")) + : generic_values_from_text(text_value); + + return []; +} + +function add_domain_values_with_prefix(ctx, section, option_name, prefix, kind) { + let values = legacy_condition_values( + kind, + option(section, option_name + "_text_mode", "0"), + option(section, "conditions_text_mode", "0"), + option(section, option_name + "_text", ""), + section[option_name] + ); + + for (let value in values) + add_list_unique(ctx, section, "domain_suffix", prefix + value); +} + +function migrate_combined_domain_conditions(ctx, section) { + add_domain_values_with_prefix(ctx, section, "domain", "full:", "domains"); + add_domain_values_with_prefix(ctx, section, "domain_keyword", "keyword:", "generic"); + add_domain_values_with_prefix(ctx, section, "domain_regex", "regex:", "generic"); + + delete_option(ctx, section, "domain"); + delete_option(ctx, section, "domain_keyword"); + delete_option(ctx, section, "domain_regex"); + delete_option(ctx, section, "domain_text"); + delete_option(ctx, section, "domain_keyword_text"); + delete_option(ctx, section, "domain_regex_text"); + delete_option(ctx, section, "domain_text_mode"); + delete_option(ctx, section, "domain_keyword_text_mode"); + delete_option(ctx, section, "domain_regex_text_mode"); +} + +function migrate_rule(ctx, section, converted_from_rule, constants) { + let action = migrated_rule_action(section); + let proxy_config_type = option(section, "proxy_config_type", ""); + let subscription_urls = option(section, "subscription_urls", ""); + + if (action != "") + set_option(ctx, section, "action", action); + + delete_option(ctx, section, "connection_type"); + delete_option(ctx, section, "subscription_group_by_countries"); + delete_option(ctx, section, "group_by_countries"); + delete_option(ctx, section, "subscription_detect_server_countries"); + + if (action == "proxy") { + migrate_urltest_filter_mode(ctx, section); + migrate_detect_server_country(ctx, section); + migrate_proxy_rule(ctx, section, proxy_config_type); + if (converted_from_rule && subscription_urls != "") + delete_subscription_cache(ctx, section_name(section)); + } + else if (action == "vpn" || action == "outbound" || action == "block" || + action == "direct" || action == "zapret" || action == "zapret2" || action == "byedpi") { + delete_option(ctx, section, "proxy_config_type"); + delete_option(ctx, section, "proxy_string"); + delete_option(ctx, section, "urltest_proxy_links"); + delete_option(ctx, section, "subscription_url"); + delete_option(ctx, section, "subscription_user_agent"); + delete_option(ctx, section, "urltest_check_interval_disabled"); + delete_option(ctx, section, "subscription_update_interval_disabled"); + } + + migrate_byedpi_cmd_opts(ctx, section); + migrate_zapret_nfqws_default(ctx, section, constants); +} + +function migrate_rule_section(ctx, section, constants) { + set_section_type(ctx, section, "section"); + migrate_rule(ctx, section, true, constants); + migrate_combined_domain_conditions(ctx, section); + push(ctx.model.sections, section); +} + +function migrate_list_update_enabled(ctx) { + let settings = ctx.model.settings; + if (option_exists(settings, "list_update_enabled")) { + if (bool_option(settings, "list_update_enabled", true) && + option(settings, "update_interval", "") == "") { + set_option(ctx, settings, "update_interval", "1d"); + } + return; + } + + if (option(settings, "update_interval", "") != "") { + set_option(ctx, settings, "list_update_enabled", "1"); + } + else { + set_option(ctx, settings, "list_update_enabled", "0"); + set_option(ctx, settings, "update_interval", "1d"); + } +} + +function migrate_download_via_proxy_flags(ctx) { + let settings = ctx.model.settings; + let enabled = bool_option(settings, "download_lists_via_proxy", false) ? "1" : "0"; + set_option_if_missing(ctx, settings, "download_lists_via_proxy", enabled); + set_option_if_missing(ctx, settings, "download_subscriptions_via_proxy", enabled); + set_option_if_missing(ctx, settings, "download_components_via_proxy", enabled); +} + +function migrate_model(model, constants) { + let ctx = migration_context(model); + constants = object_or_empty(constants); + + migrate_list_update_enabled(ctx); + migrate_download_via_proxy_flags(ctx); + + for (let section in model.rules) + migrate_rule_section(ctx, section, constants); + model.rules = []; + + for (let section in model.sections) { + migrate_rule(ctx, section, false, constants); + migrate_combined_domain_conditions(ctx, section); + } + + return ctx; +} + +function first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function ensure_dir(path) { + run("mkdir -p " + shell_quote(path) + " >/dev/null 2>&1"); +} + +function clear_subscription_runtime_cache() { + run("rm -rf " + + shell_quote(TMP_SUBSCRIPTION_FOLDER) + " " + + shell_quote(PODKOP_SUBSCRIPTION_LINKS_DIR) + " " + + shell_quote(PODKOP_SUBSCRIPTION_METADATA_DIR) + " " + + shell_quote(PODKOP_OUTBOUND_METADATA_DIR) + " " + + shell_quote(PODKOP_SECTION_CACHE_DIR)); +} + +function ensure_runtime_dirs() { + ensure_dir(TMP_SUBSCRIPTION_FOLDER); + ensure_dir(PODKOP_RUNTIME_STATE_DIR); + ensure_dir(PODKOP_SUBSCRIPTION_LINKS_DIR); + ensure_dir(PODKOP_SUBSCRIPTION_METADATA_DIR); + ensure_dir(PODKOP_OUTBOUND_METADATA_DIR); + ensure_dir(PODKOP_SECTION_CACHE_DIR); +} + +function ensure_runtime_cache_format() { + ensure_dir(PODKOP_RUNTIME_STATE_DIR); + + if (first_line(PODKOP_RUNTIME_CACHE_FORMAT_FILE) != PODKOP_RUNTIME_CACHE_FORMAT) { + clear_subscription_runtime_cache(); + ensure_runtime_dirs(); + fs.writefile(PODKOP_RUNTIME_CACHE_FORMAT_FILE, PODKOP_RUNTIME_CACHE_FORMAT + "\n"); + } + + if (first_line(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE) != PODKOP_RUNTIME_CACHE_FORMAT) { + run("rm -rf " + shell_quote(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR)); + ensure_dir(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR); + run("chmod 700 " + shell_quote(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR) + " >/dev/null 2>&1"); + fs.writefile(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, PODKOP_RUNTIME_CACHE_FORMAT + "\n"); + run("chmod 600 " + shell_quote(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE) + " >/dev/null 2>&1"); + } +} + +function remove_legacy_server_country_cache() { + fs.unlink(PODKOP_RUNTIME_STATE_DIR + "/server-country-cache.json"); +} + +function remove_cache_path(path) { + path = as_string(path); + if (index(path, "*") >= 0) + run("rm -f " + path); + else + fs.unlink(path); +} + +function apply_operations(cursor, operations) { + for (let op in operations) { + if (op.op == "set") + cursor.set(CONFIG_NAME, op.section, op.option, op.value); + else if (op.op == "delete") + cursor.delete(CONFIG_NAME, op.section, op.option); + else if (op.op == "add_list") + cursor.set(CONFIG_NAME, op.section, op.option, op.values); + else if (op.op == "set_type") + cursor.set(CONFIG_NAME, op.section, op.type); + } +} + +function runtime_cursor() { + return { + load: function(package_name) { + return uci_core.load(package_name); + }, + get_all: function(package_name, section_name) { + return uci_core.get_all(package_name, section_name); + }, + foreach: function(package_name, type_name, callback) { + for (let section in uci_core.section_objects(package_name, type_name)) + callback(section); + }, + set: function(package_name, section_name, option_name, value) { + if (value == null) + return uci_core.set_section(package_name + "." + section_name, option_name); + return uci_core.set(package_name + "." + section_name + "." + option_name, value); + }, + delete: function(package_name, section_name, option_name) { + return uci_core.delete(package_name + "." + section_name + "." + option_name); + }, + commit: function(package_name) { + return uci_core.commit(package_name); + } + }; +} + +function current_config_hash() { + let config_path = "/etc/config/" + CONFIG_NAME; + if (fs.stat(config_path) == null) + return ""; + + let output = command_output("md5sum " + shell_quote(config_path) + " 2>/dev/null"); + let fields = split(trim(output), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function mark_internal_config_guard() { + let hash = current_config_hash(); + if (hash == "") { + fs.unlink(PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD); + return; + } + + let stamp = clock(); + let tmp_path = PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD + "." + stamp[0] + "." + stamp[1]; + fs.writefile(tmp_path, as_string(stamp[0]) + "\n" + hash + "\n"); + if (!fs.rename(tmp_path, PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD)) + fs.unlink(tmp_path); +} + +function commit_cursor(cursor) { + if (!cursor.commit(CONFIG_NAME)) + return false; + mark_internal_config_guard(); + return true; +} + +function migrate_runtime() { + ensure_runtime_cache_format(); + remove_legacy_server_country_cache(); + + let cursor = runtime_cursor(); + cursor.load(CONFIG_NAME); + let ctx = migrate_model(model_from_uci(cursor), constants_context()); + if (!ctx.changed) + return true; + + apply_operations(cursor, ctx.operations); + for (let path in ctx.removed_caches) + remove_cache_path(path); + + return commit_cursor(cursor); +} + +function commit_runtime() { + let cursor = runtime_cursor(); + cursor.load(CONFIG_NAME); + return commit_cursor(cursor); +} + +function migrate_fixture(path) { + let ctx = migrate_model(model_from_fixture(path), constants_context()); + write_json({ + changed: ctx.changed, + config: export_model(ctx.model), + operations: ctx.operations, + removed_caches: ctx.removed_caches + }); +} + +function module_exports() { + return { + migrate_model, + mark_internal_config_guard + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0] || ""; + +if (mode == "migrate") + exit(migrate_runtime() ? 0 : 1); +else if (mode == "commit") + exit(commit_runtime() ? 0 : 1); +else if (mode == "migrate-fixture") + migrate_fixture(ARGV[1]); +else { + warn("Usage: config/migration.uc migrate\n"); + warn(" config/migration.uc commit\n"); + warn(" config/migration.uc migrate-fixture \n"); + exit(1); +} diff --git a/podkop/files/usr/lib/config/rule.uc b/podkop/files/usr/lib/config/rule.uc new file mode 100644 index 00000000..6b75c77c --- /dev/null +++ b/podkop/files/usr/lib/config/rule.uc @@ -0,0 +1,352 @@ +#!/usr/bin/env ucode + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function strip_list_comment(line) { + line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); + return replace(line, /[[:space:]]*#.*$/, ""); +} + +function text_list_values(value, separator_mode) { + let result = []; + separator_mode = as_string(separator_mode); + + for (let line in split(as_string(value), "\n")) { + line = strip_list_comment(line); + line = separator_mode == "comma-space" + ? replace(line, /[ ,]/g, "\n") + : replace(line, /,/g, "\n"); + + for (let item in split(line, "\n")) { + item = trim(replace(item, /\r/g, "")); + if (item != "") + push(result, item); + } + } + + return result; +} + +function whitespace_fields(value) { + let result = []; + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function valid_ipv4(value) { + value = as_string(value); + if (match(value, /^([0-9]{1,3}\.){3}[0-9]{1,3}$/) == null) + return false; + + let parts = split(value, "."); + if (length(parts) != 4) + return false; + + for (let part in parts) { + if (part == "" || match(part, /^[0-9]+$/) == null) + return false; + let octet = int(part); + if (octet < 0 || octet > 255) + return false; + } + + return true; +} + +function valid_ipv4_cidr(value) { + value = as_string(value); + let slash = index(value, "/"); + if (slash < 0 || index(substr(value, slash + 1), "/") >= 0) + return false; + + let address = substr(value, 0, slash); + let prefix = substr(value, slash + 1); + if (prefix == "" || match(prefix, /^[0-9]+$/) == null) + return false; + + prefix = int(prefix); + return valid_ipv4(address) && prefix >= 0 && prefix <= 32; +} + +function valid_domain_suffix(value) { + value = ascii_lower(value); + if (substr(value, 0, 1) == ".") + value = substr(value, 1); + + return match(value, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/) != null; +} + +function prefixed_domain_value(value, requested_kind) { + value = trim(as_string(value)); + requested_kind = as_string(requested_kind); + + if (value == "") + return null; + + let colon = index(value, ":"); + let prefix = ""; + let body = value; + if (colon > 0) { + let candidate = ascii_lower(substr(value, 0, colon)); + if (candidate == "full" || candidate == "keyword" || candidate == "regex") { + prefix = candidate; + body = substr(value, colon + 1); + } + else { + return null; + } + } + + body = trim(body); + if (body == "") + return null; + + if (prefix == "") { + if (requested_kind != "domain_suffix") + return null; + let normalized = ascii_lower(body); + return valid_domain_suffix(normalized) ? normalized : null; + } + + if (prefix == "full") { + if (requested_kind != "domain") + return null; + let normalized = ascii_lower(body); + return valid_domain_suffix(normalized) ? normalized : null; + } + + if (prefix == "keyword") + return requested_kind == "domain_keyword" ? body : null; + + return requested_kind == "domain_regex" ? body : null; +} + +function normalize_domain_subnet_value(value, kind) { + kind = as_string(kind); + if (kind == "domains") { + let normalized = ascii_lower(value); + return valid_domain_suffix(normalized) ? normalized : null; + } + if (kind == "subnets") + return valid_ipv4(value) || valid_ipv4_cidr(value) ? value : null; + + exit(1); +} + +function filter_domain_subnet_values(values, kind) { + let result = []; + kind = as_string(kind); + + if (kind != "domains" && kind != "subnets") + exit(1); + + for (let value in values) { + let normalized = normalize_domain_subnet_value(value, kind); + if (normalized != null) + push(result, normalized); + } + + return result; +} + +function condition_text_csv_value(value, kind) { + kind = as_string(kind); + + if (kind == "domains") + return join(",", filter_domain_subnet_values(text_list_values(value, "comma-space"), "domains")); + if (kind == "subnets") + return join(",", filter_domain_subnet_values(text_list_values(value, "comma-space"), "subnets")); + + return join(",", text_list_values(value, "comma")); +} + +function legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value) { + if (int(text_mode || 0) == 1 || int(conditions_text_mode || 0) == 1) + return condition_text_csv_value(text_value, kind); + + list_value = as_string(list_value); + if (list_value != "") + return replace(list_value, / /g, ","); + + text_value = as_string(text_value); + if (text_value != "") + return condition_text_csv_value(text_value, kind); + + return replace(list_value, / /g, ","); +} + +function combined_domain_csv_value(value, requested_kind) { + let result = []; + + for (let item in split(as_string(value), ",")) { + let normalized = prefixed_domain_value(item, requested_kind); + if (normalized != null) + push(result, normalized); + } + + return join(",", result); +} + +function combined_domain_text_csv_value(value, requested_kind) { + let result = []; + + for (let item in text_list_values(value, "comma-space")) { + let normalized = prefixed_domain_value(item, requested_kind); + if (normalized != null) + push(result, normalized); + } + + return join(",", result); +} + +function concat_csv_values(first, second) { + first = as_string(first); + second = as_string(second); + if (first != "" && second != "") + return first + "," + second; + return first + second; +} + +function combined_condition_csv_value(requested_key, legacy_kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value) { + let combined_text_values = combined_domain_text_csv_value(combined_text_value, requested_key); + let combined_list_values = combined_domain_csv_value(replace(as_string(combined_list_value), / /g, ","), requested_key); + let combined_values = concat_csv_values(combined_text_values, combined_list_values); + + if (requested_key == "domain_suffix") + return combined_values; + + let legacy_values = legacy_condition_csv_value(legacy_kind, text_mode, conditions_text_mode, text_value, list_value); + return concat_csv_values(combined_values, legacy_values); +} + +function rule_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value) { + if (key == "domain" || key == "domain_suffix" || key == "domain_keyword" || key == "domain_regex") + return combined_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value); + + return legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value); +} + +function normalize_port_number_value(value) { + value = trim(as_string(value)); + if (value == "" || match(value, /^[0-9]+$/) == null) + return null; + + let number = int(value); + return number >= 1 && number <= 65535 ? number : null; +} + +function normalize_port_condition_value(value) { + value = trim(as_string(value)); + if (value == "") + return null; + + let dash = index(value, "-"); + if (dash < 0) { + let number = normalize_port_number_value(value); + return number == null ? null : as_string(number); + } + + let start = normalize_port_number_value(substr(value, 0, dash)); + let end = normalize_port_number_value(substr(value, dash + 1)); + if (start == null || end == null || start > end) + return null; + + return start == end ? as_string(start) : sprintf("%d-%d", start, end); +} + +function normalize_port_range_value(value) { + value = trim(as_string(value)); + let dash = index(value, "-"); + if (dash < 0) + return null; + + let start = normalize_port_number_value(substr(value, 0, dash)); + let end = normalize_port_number_value(substr(value, dash + 1)); + if (start == null || end == null || start > end) + return null; + + return sprintf("%d:%d", start, end); +} + +function append_unique_port(result, seen, item) { + let normalized = normalize_port_condition_value(item); + if (normalized == null || seen[normalized]) + return; + + seen[normalized] = true; + push(result, normalized); +} + +function rule_ports_csv_value(list_values, text_value) { + let result = []; + let seen = {}; + let normalized_list = replace(as_string(list_values), /[[:space:]]+/g, "\n"); + + for (let item in split(normalized_list, "\n")) + append_unique_port(result, seen, item); + + for (let item in text_list_values(text_value, "comma-space")) + append_unique_port(result, seen, item); + + return join(",", result); +} + +let community_subnet_lists = { + twitter: true, + meta: true, + telegram: true, + cloudflare: true, + hetzner: true, + ovh: true, + digitalocean: true, + cloudfront: true, + discord: true, + roblox: true +}; + +function community_service_has_subnet_list(value) { + return community_subnet_lists[as_string(value)] == true; +} + +function filter_community_subnet_lists_value(value) { + let result = []; + + for (let item in whitespace_fields(value)) + if (community_service_has_subnet_list(item)) + push(result, item); + + return join(" ", result); +} + +function has_community_subnet_list(value) { + for (let item in whitespace_fields(value)) + if (community_service_has_subnet_list(item)) + return true; + return false; +} + +return { + normalize_port_number_value, + normalize_port_condition_value, + normalize_port_range_value, + combined_domain_csv_value, + combined_domain_text_csv_value, + legacy_condition_csv_value, + rule_condition_csv_value, + rule_ports_csv_value, + community_service_has_subnet_list, + filter_community_subnet_lists_value, + has_community_subnet_list +}; diff --git a/podkop/files/usr/lib/config/validator.uc b/podkop/files/usr/lib/config/validator.uc new file mode 100644 index 00000000..df37f842 --- /dev/null +++ b/podkop/files/usr/lib/config/validator.uc @@ -0,0 +1,1710 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core_module = null; +let fixture_uci_data = null; +let subscription_parser_module = null; +let zapret_validator_module = null; +let zapret2_validator_module = null; +let byedpi_validator_module = null; +let constants_module = null; + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; + +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_stdin_json() { + let data = read_stdin(); + try { + return json(data); + } + catch (e) { + return null; + } +} + +function read_json_file(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + + try { + return json(data); + } + catch (e) { + return null; + } +} + +function string_starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function string_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 whitespace_values(value) { + let result = []; + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function list_has_value(values, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + for (let value in whitespace_values(values)) + if (value == needle) + return true; + + return false; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function contains(values, needle) { + for (let value in array_or_empty(values)) + if (value == needle) + return true; + return false; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function list_option(section, key) { + let value = object_or_empty(section)[key]; + if (value == null) + return []; + if (type(value) == "array") + return value; + + value = trim(as_string(value)); + return value == "" ? [] : split(value, " "); +} + +function bool_option(section, key, fallback) { + if (fallback == null) + fallback = false; + + let value = option(section, key, fallback ? "1" : "0"); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function file_executable(path) { + let stat = fs.stat(as_string(path)); + if (stat == null || stat.mode == null) + return false; + + return (int(stat.mode) & 73) != 0; +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && stat.size != null && stat.size > 0; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function run_args(args) { + return system(command_from_args(args)) == 0; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_trimmed_output_from_args(args) { + return replace(command_output_from_args(args), /[\r\n]+$/g, ""); +} + +function command_exists(name) { + return system("command -v " + shell_quote(name) + " >/dev/null 2>&1") == 0; +} + +function log_message(message, level) { + level = as_string(level || "info"); + run_args([ "logger", "-t", "podkop-plus", "[" + level + "] " + as_string(message) ]); +} + +function fail_requirement(message, level) { + log_message(message, level || "error"); + exit(1); +} + +function mkdir_p(paths) { + let args = [ "mkdir", "-p" ]; + + for (let path in array_or_empty(paths)) + if (as_string(path) != "") + push(args, path); + + return length(args) <= 2 || run_args(args); +} + +function safe_rm_rf(path) { + path = as_string(path); + if (path == "" || substr(path, 0, length("/var/run/podkop-plus/")) != "/var/run/podkop-plus/") + return true; + + return run_args([ "rm", "-rf", path ]); +} + +function first_line_field_from_text(data, field_index) { + let newline = index(as_string(data), "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + + field_index = int(field_index || 0); + return field_index > 0 && field_index <= length(fields) ? as_string(fields[field_index - 1]) : ""; +} + +function first_line_last_field(data) { + let newline = index(as_string(data), "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[length(fields) - 1]) : ""; +} + +function digit_char(value) { + return match(as_string(value), /^[0-9]$/) != null; +} + +function strip_leading_zeroes(value) { + value = as_string(value); + let i = 0; + while (i < length(value) - 1 && substr(value, i, 1) == "0") + i++; + return substr(value, i); +} + +function version_compare(lhs, rhs) { + lhs = as_string(lhs); + rhs = as_string(rhs); + + let li = 0, ri = 0; + while (li < length(lhs) || ri < length(rhs)) { + if (li >= length(lhs)) + return substr(rhs, ri, 1) == "~" ? 1 : -1; + if (ri >= length(rhs)) + return substr(lhs, li, 1) == "~" ? -1 : 1; + + let lc = substr(lhs, li, 1); + let rc = substr(rhs, ri, 1); + if (lc == rc) { + li++; + ri++; + continue; + } + + if (lc == "~" || rc == "~") + return lc == "~" ? -1 : 1; + + if (digit_char(lc) && digit_char(rc)) { + let ls = li, rs = ri; + while (li < length(lhs) && digit_char(substr(lhs, li, 1))) + li++; + while (ri < length(rhs) && digit_char(substr(rhs, ri, 1))) + ri++; + + let lnum = strip_leading_zeroes(substr(lhs, ls, li - ls)); + let rnum = strip_leading_zeroes(substr(rhs, rs, ri - rs)); + if (length(lnum) != length(rnum)) + return length(lnum) < length(rnum) ? -1 : 1; + if (lnum != rnum) + return lnum < rnum ? -1 : 1; + continue; + } + + return lc < rc ? -1 : 1; + } + + return 0; +} + +function version_at_least(current, required) { + return version_compare(current, required) >= 0; +} + +function constant_value(constants, name) { + return as_string(object_or_empty(constants)[name]); +} + +function uci_show_list_value(value) { + return trim(replace(as_string(value), /['"]/g, "")); +} + +function stdin_first_line_field(field_index) { + let data = read_stdin(); + let newline = index(data, "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + + field_index = int(field_index || 0); + if (field_index > 0 && field_index <= length(fields)) + print(fields[field_index - 1], "\n"); +} + +function country_code_valid(value) { + value = uc(as_string(value)); + return match(value, /^[A-Z][A-Z]$/) != null; +} + +function enum_valid(value, start_index) { + value = as_string(value); + for (let i = start_index; i < length(ARGV); i++) + if (value == as_string(ARGV[i])) + return true; + return false; +} + +function regex_valid(pattern) { + pattern = as_string(pattern); + if (pattern == "") + return true; + + try { + regexp(pattern); + return true; + } + catch (e) { + return false; + } +} + +function strip_list_comment(line) { + line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); + return replace(line, /[[:space:]]*#.*$/, ""); +} + +function text_list_values(value) { + let result = []; + + for (let line in split(as_string(value), "\n")) { + line = replace(strip_list_comment(line), /[ ,]/g, "\n"); + for (let item in split(line, "\n")) { + item = trim(replace(item, /\r/g, "")); + if (item != "") + push(result, item); + } + } + + return result; +} + +function valid_domain_suffix(value) { + value = lc(as_string(value)); + if (substr(value, 0, 1) == ".") + value = substr(value, 1); + + return match(value, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/) != null; +} + +function combined_domain_valid(value) { + value = trim(as_string(value)); + if (value == "") + return true; + + let colon = index(value, ":"); + if (colon < 0) + return valid_domain_suffix(value); + + let prefix = lc(substr(value, 0, colon)); + let body = substr(value, colon + 1); + + if (body == "") + return false; + + if (prefix == "full") + return valid_domain_suffix(body); + + if (prefix == "keyword") + return match(body, /[,[:space:]]/) == null; + + if (prefix == "regex") + return match(body, /[,[:space:]]/) == null && regex_valid(body); + + return false; +} + +function combined_domain_text_valid(value) { + for (let item in text_list_values(value)) + if (!combined_domain_valid(item)) + return false; + return true; +} + +function community_service_valid(value, services) { + return list_has_value(services, value); +} + +function remote_reference(value) { + return string_starts_with(value, "http://") || string_starts_with(value, "https://"); +} + +function absolute_reference_with_extension(value, first_extension, second_extension) { + value = as_string(value); + if (!string_starts_with(value, "/")) + return false; + + return string_ends_with(value, first_extension) || + (second_extension != null && string_ends_with(value, second_extension)); +} + +function ruleset_reference_valid(reference, community_services) { + reference = as_string(reference); + + return reference == "" || + community_service_valid(reference, community_services) || + remote_reference(reference) || + absolute_reference_with_extension(reference, ".srs", ".json"); +} + +function plain_domain_ip_list_reference_valid(reference) { + reference = as_string(reference); + + return reference == "" || + remote_reference(reference) || + absolute_reference_with_extension(reference, ".lst", null); +} + +function valid_outbound() { + let value = read_stdin_json(); + return type(value) == "object" && type(value.type) == "string"; +} + +function valid_inbound() { + let value = read_stdin_json(); + return type(value) == "object" && type(value.type) == "string"; +} + +function outbound_detour_supported() { + let value = read_stdin_json(); + if (type(value) != "object" || type(value.type) != "string") + return false; + + let outbound_type = lc(as_string(value.type)); + return outbound_type != "selector" && + outbound_type != "urltest" && + outbound_type != "block" && + outbound_type != "dns"; +} + +function bool_flag(value) { + value = as_string(value); + return value == "1" || value == "true"; +} + +function outbound_detour_source_action(action) { + action = as_string(action); + return action == "proxy" || action == "outbound"; +} + +function outbound_detour_target_action(action) { + action = as_string(action); + return action == "proxy" || action == "vpn" || action == "outbound"; +} + +function outbound_detour_supported_json(value) { + try { + value = json(as_string(value)); + } + catch (e) { + return false; + } + + if (type(value) != "object" || type(value.type) != "string") + return false; + + let outbound_type = lc(as_string(value.type)); + return outbound_type != "selector" && + outbound_type != "urltest" && + outbound_type != "block" && + outbound_type != "dns"; +} + +function outbound_detour_rows() { + let rows = []; + + for (let line in split(read_stdin(), "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (trim(line) == "") + continue; + + let fields = split(line, "\t"); + let row = { + section: as_string(fields[0]), + enabled: bool_flag(fields[1]), + action: as_string(fields[2]), + detour_enabled: bool_flag(fields[3]), + detour_section: as_string(fields[4]), + outbound_json: as_string(fields[5]) + }; + + push(rows, row); + } + + return rows; +} + +function outbound_detour_rows_by_section(rows) { + let by_section = {}; + + for (let row in array_or_empty(rows)) + if (row.section != "") + by_section[row.section] = row; + + return { + rows: rows, + by_section: by_section + }; +} + +function outbound_detour_chain_reaches_source(by_section, source_section, current_section) { + let seen = {}; + + while (current_section != "") { + if (current_section == source_section) + return true; + + if (seen[current_section]) + return false; + seen[current_section] = true; + + let row = by_section[current_section]; + if (type(row) != "object" || !row.enabled || !outbound_detour_source_action(row.action)) + return false; + + if (!row.detour_enabled) + return false; + + current_section = row.detour_section; + } + + return false; +} + +function fail_outbound_detour(message) { + print(message, "\n"); + exit(1); +} + +function fail_validation(message) { + print(message, "\n"); + exit(1); +} + +function validate_outbound_detours_rows(rows) { + let parsed = outbound_detour_rows_by_section(rows); + + for (let row in parsed.rows) { + if (!row.enabled || !row.detour_enabled) + continue; + + if (!outbound_detour_source_action(row.action)) + fail_outbound_detour("Outbound cascade is supported only for proxy and JSON outbound rules, but rule '" + + row.section + "' uses action '" + row.action + "'. Aborted."); + + if (row.detour_section == "") + fail_outbound_detour("Outbound cascade is enabled for rule '" + row.section + + "', but no intermediate rule is selected. Aborted."); + + if (row.detour_section == row.section) + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' cannot point to itself. Aborted."); + + let target = parsed.by_section[row.detour_section]; + if (type(target) != "object") + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references missing rule '" + + row.detour_section + "'. Select an enabled proxy/VPN/JSON outbound rule or disable cascade connection. Aborted."); + + if (!target.enabled) + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references disabled rule '" + + row.detour_section + "'. Select an enabled proxy/VPN/JSON outbound rule or disable cascade connection. Aborted."); + + if (!outbound_detour_target_action(target.action)) + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' references rule '" + + row.detour_section + "', but it is not a proxy/VPN/JSON outbound rule. Select an enabled proxy/VPN/JSON outbound rule or disable cascade connection. Aborted."); + + if (outbound_detour_chain_reaches_source(parsed.by_section, row.section, row.detour_section)) + fail_outbound_detour("Outbound cascade for rule '" + row.section + "' creates a cycle through '" + + row.detour_section + "'. Aborted."); + + if (row.action == "outbound" && !outbound_detour_supported_json(row.outbound_json)) + fail_outbound_detour("JSON outbound rule '" + row.section + + "' cannot use outbound cascade because its sing-box outbound type does not support Dial Fields. Aborted."); + } +} + +function validate_outbound_detours() { + validate_outbound_detours_rows(outbound_detour_rows()); +} + +function basic_rule_rows() { + let rows = []; + + for (let line in split(read_stdin(), "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (trim(line) == "") + continue; + + let fields = split(line, "\t"); + push(rows, { + section: as_string(fields[0]), + enabled: bool_flag(fields[1]), + action: as_string(fields[2]) + }); + } + + return rows; +} + +function download_section_action_available(action, byedpi_installed, zapret_installed, zapret2_installed) { + action = as_string(action); + if (action == "proxy" || action == "vpn" || action == "outbound") + return true; + if (action == "byedpi") + return bool_flag(byedpi_installed); + if (action == "zapret") + return bool_flag(zapret_installed); + if (action == "zapret2") + return bool_flag(zapret2_installed); + + return false; +} + +function validate_download_section_rows(target_section, byedpi_installed, zapret_installed, zapret2_installed, rows) { + target_section = as_string(target_section); + + if (target_section == "") + fail_validation("Downloading external resources through a section is enabled, but no download section is selected. Aborted."); + + let found = false; + let enabled = false; + let outbound = false; + + for (let row in array_or_empty(rows)) { + if (row.section != target_section) + continue; + + found = true; + if (!row.enabled) + continue; + + enabled = true; + if (download_section_action_available(row.action, byedpi_installed, zapret_installed, zapret2_installed)) + outbound = true; + } + + if (!found) + fail_validation("Downloading external resources through a section references missing rule '" + target_section + + "'. Select an enabled rule that can provide an outbound or disable the option. Aborted."); + + if (!enabled) + fail_validation("Downloading external resources through a section references disabled rule '" + target_section + + "'. Select an enabled rule that can provide an outbound or disable the option. Aborted."); + + if (!outbound) + fail_validation("Downloading external resources through a section references rule '" + target_section + + "', but it cannot provide an outbound. Select an enabled proxy, JSON outbound, VPN, Zapret, Zapret2, or ByeDPI rule with its provider installed, or disable the option. Aborted."); +} + +function validate_download_section(target_section, byedpi_installed, zapret_installed, zapret2_installed) { + validate_download_section_rows(target_section, byedpi_installed, zapret_installed, zapret2_installed, basic_rule_rows()); +} + +function dhcp_has_https_dns_proxy_options(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + + return index(data, "doh_backup_noresolv") >= 0 || + index(data, "doh_backup_server") >= 0 || + index(data, "doh_server") >= 0; +} + +function dhcp_has_https_dns_proxy_options_exit(path) { + let result = dhcp_has_https_dns_proxy_options(path); + exit(result == null ? 2 : (result ? 0 : 1)); +} + +function mwan3_has_enabled_interface_text(data) { + let prefix = "mwan3."; + let sections = {}; + let enabled = {}; + + for (let line in split(as_string(data), "\n")) { + let equals = index(as_string(line), "="); + if (equals < 0) + continue; + + let key = substr(line, 0, equals); + let value = uci_show_list_value(substr(line, equals + 1)); + if (!string_starts_with(key, prefix)) + continue; + + let rest = substr(key, length(prefix)); + if (index(rest, ".") < 0) { + if (value == "interface") + sections[key] = true; + continue; + } + + let option_dot = rindex(key, "."); + let section = substr(key, 0, option_dot); + let option = substr(key, option_dot + 1); + if (option == "enabled") + enabled[section] = value; + } + + for (let section, _ in sections) + if (enabled[section] == "1") + return true; + + return false; +} + +function mwan3_has_enabled_interface() { + return mwan3_has_enabled_interface_text(read_stdin()); +} + +function mwan3_has_enabled_interface_from_sections() { + for (let section in uci_core().section_objects("mwan3", "interface")) + if (option(section, "enabled", "0") == "1") + return true; + return false; +} + +function mwan3_is_active() { + if (!file_executable("/etc/init.d/mwan3") || !file_nonempty("/etc/config/mwan3")) + return false; + + if (!mwan3_has_enabled_interface_from_sections()) + return false; + + return command_success_from_args([ "/etc/init.d/mwan3", "status" ]) || + command_success_from_args([ "/etc/init.d/mwan3", "enabled" ]); +} + +function hex_digit_value(value) { + let pos = index("0123456789abcdef", lc(as_string(value))); + return pos >= 0 ? pos : null; +} + +function parse_number(value) { + value = lc(trim(as_string(value))); + if (value == "") + return null; + + if (substr(value, 0, 2) == "0x") { + value = substr(value, 2); + if (value == "") + return null; + + let result = 0; + for (let i = 0; i < length(value); i++) { + let digit = hex_digit_value(substr(value, i, 1)); + if (digit == null) + return null; + result = result * 16 + digit; + } + return result; + } + + return match(value, /^[0-9]+$/) == null ? null : int(value); +} + +function subscription_parser() { + if (subscription_parser_module == null) + subscription_parser_module = require("subscription.parser"); + return subscription_parser_module; +} + +function zapret_validator() { + if (zapret_validator_module == null) + zapret_validator_module = require("providers.zapret.validator"); + return zapret_validator_module; +} + +function zapret2_validator() { + if (zapret2_validator_module == null) + zapret2_validator_module = require("providers.zapret2.validator"); + return zapret2_validator_module; +} + +function byedpi_validator() { + if (byedpi_validator_module == null) + byedpi_validator_module = require("providers.byedpi.validator"); + return byedpi_validator_module; +} + +function runtime_constants() { + if (constants_module == null) + constants_module = require("core.constants"); + return constants_module; +} + +function uci_core() { + if (uci_core_module == null) + uci_core_module = require("core.uci"); + return uci_core_module; +} + +function fixture_section_list(type_name) { + let value = object_or_empty(fixture_uci_data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(fixture_uci_data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function fixture_get_section(section_name) { + let fixture = object_or_empty(fixture_uci_data); + if (section_name == "settings" && type(fixture.settings) == "object") + return fixture.settings; + + for (let type_name in [ "settings", "server", "section" ]) { + for (let section in fixture_section_list(type_name)) { + if (as_string(section[".name"]) == section_name) + return section; + } + } + + return {}; +} + +function use_fixture_cursor(path) { + fixture_uci_data = object_or_empty(read_json_file(path)); +} + +function settings_section() { + if (fixture_uci_data != null) + return object_or_empty(fixture_get_section("settings")); + return object_or_empty(uci_core().get_all(CONFIG_NAME, "settings")); +} + +function sections_by_type(type_name) { + if (fixture_uci_data != null) + return fixture_section_list(type_name); + + let result = []; + for (let section in uci_core().section_objects(CONFIG_NAME, type_name)) + push(result, object_or_empty(section)); + return result; +} + +function section_name(section) { + return option(section, ".name", ""); +} + +function section_enabled(section) { + return bool_option(section, "enabled", true); +} + +function server_enabled(section) { + return bool_option(section, "enabled", false); +} + +function rule_action(section) { + return option(section, "action", ""); +} + +function duration_to_seconds_value(value) { + let rest = as_string(value); + if (rest == "") + return null; + + let total = 0.0; + let multipliers = { + ns: 0.000000001, + us: 0.000001, + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return null; + + let token = as_string(matched[0]); + let amount = matched[1] * 1; + let unit = matched[3]; + total += amount * multipliers[unit]; + rest = substr(rest, length(token)); + } + + return total <= 0 ? null : int(total + 0.5); +} + +function validate_duration_option(value, label) { + if (as_string(value) == "") + return; + + if (duration_to_seconds_value(value) != null) + return; + + fail_validation("Invalid duration value for " + label + ": " + value + ". Use sing-box duration format like 1d, 12h or 30m. Aborted."); +} + +function validate_required_duration_option(value, label) { + if (as_string(value) == "") + fail_validation("Missing duration value for " + label + ". Use sing-box duration format like 1d, 12h or 30m. Aborted."); + + validate_duration_option(value, label); +} + +function normalize_port_number_value(value) { + value = trim(as_string(value)); + if (value == "" || match(value, /^[0-9]+$/) == null) + return null; + + let number = int(value); + return number >= 1 && number <= 65535 ? number : null; +} + +function normalize_port_condition_value(value) { + value = trim(as_string(value)); + if (value == "") + return null; + + let dash = index(value, "-"); + if (dash < 0) + return normalize_port_number_value(value); + + let start = normalize_port_number_value(substr(value, 0, dash)); + let end = normalize_port_number_value(substr(value, dash + 1)); + if (start == null || end == null || start > end) + return null; + + return start == end ? start : sprintf("%d-%d", start, end); +} + +function validate_port_condition_value(value, section) { + if (normalize_port_condition_value(value) != null) + return; + + fail_validation("Invalid port condition '" + value + "' in rule '" + section + "'. Use a single port or range like 80 or 1000-2000. Aborted."); +} + +function validate_subscription_source_entry_value(entry, section) { + let parsed = subscription_parser().parse_subscription_source_entry(entry); + if (parsed.valid) + return; + + fail_validation("Invalid subscription URL in rule '" + section + "': " + parsed.error + ". Aborted."); +} + +function validate_country_code_value(value, section) { + if (as_string(value) == "" || country_code_valid(value)) + return; + + fail_validation("Invalid country code '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_urltest_filter_mode_value(value, section) { + if (as_string(value) == "" || contains([ "disabled", "exclude", "include", "mixed" ], value)) + return; + + fail_validation("Invalid URLTest filter mode '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_detect_server_country_value(value, section) { + if (as_string(value) == "" || contains([ "flag_emoji", "country_is" ], value)) + return; + + fail_validation("Invalid server country detection mode '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_urltest_regex_value(value, section) { + if (as_string(value) == "" || regex_valid(value)) + return; + + fail_validation("Invalid URLTest regular expression '" + value + "' in rule '" + section + "'. Aborted."); +} + +function validate_combined_domain_value(value, section) { + if (as_string(value) == "" || combined_domain_valid(value)) + return; + + fail_validation("Invalid domain condition '" + value + "' in rule '" + section + "'. Use plain domains or full:, keyword:, regex: prefixes. Aborted."); +} + +function validate_combined_domain_text_value(value, section) { + if (as_string(value) == "" || combined_domain_text_valid(value)) + return; + + fail_validation("Invalid domain conditions in rule '" + section + "'. Use plain domains or full:, keyword:, regex: prefixes. Aborted."); +} + +function validate_service_value(service, context) { + if (community_service_valid(service, context.community_services)) + return; + + fail_validation("Invalid service in community lists: " + service + ". Check config and LuCI cache. Aborted."); +} + +function validate_ruleset_reference_value(reference, context) { + if (ruleset_reference_valid(reference, context.community_services)) + return; + + fail_validation("Unknown rule set reference '" + reference + "'. Aborted."); +} + +function validate_plain_domain_ip_list_reference_value(reference) { + if (plain_domain_ip_list_reference_valid(reference)) + return; + + fail_validation("Unknown plain list reference '" + reference + "'. Aborted."); +} + +function validate_common_rule_references(section, context) { + for (let value in list_option(section, "community_lists")) + validate_service_value(value, context); + for (let value in list_option(section, "rule_set")) + validate_ruleset_reference_value(value, context); + for (let value in list_option(section, "rule_set_with_subnets")) + validate_ruleset_reference_value(value, context); + for (let value in list_option(section, "domain_ip_lists")) + validate_plain_domain_ip_list_reference_value(value); +} + +function valid_outbound_json(value) { + try { + value = json(as_string(value)); + } + catch (e) { + return false; + } + + return type(value) == "object" && type(value.type) == "string"; +} + +function validate_outbound_json_rule(section) { + let name = section_name(section); + let outbound_json = option(section, "outbound_json", ""); + + if (outbound_json == "") + fail_validation("JSON outbound rule '" + name + "' has empty outbound_json. Aborted."); + + if (!valid_outbound_json(outbound_json)) + fail_validation("JSON outbound rule '" + name + "' must contain a valid sing-box outbound JSON object with a type field. Aborted."); +} + +function rule_has_subscription_urls(section) { + return length(list_option(section, "subscription_urls")) > 0 || option(section, "subscription_urls", "") != ""; +} + +function subscription_update_interval_for_rule(section) { + if (!rule_has_subscription_urls(section)) + return ""; + if (!bool_option(section, "subscription_update_enabled", true)) + return ""; + + let value = option(section, "subscription_update_interval", ""); + return value != "" ? value : "1h"; +} + +function urltest_check_interval_for_rule(section) { + if (!bool_option(section, "urltest_enabled", false)) + return ""; + + let value = option(section, "urltest_check_interval", ""); + return value != "" ? value : "3m"; +} + +function validate_provider_strategy(kind, section, context) { + let name = section_name(section); + + if (kind == "zapret") { + let raw = zapret_validator().strategy_or_default(option(section, "nfqws_opt", ""), context.zapret_default_nfqws_opt); + let result = zapret_validator().validate_strategy("nfqws", raw, context.zapret_legacy_default_nfqws_opt); + if (!result.valid) + fail_validation("Zapret rule '" + name + "' uses invalid NFQWS strategy: " + result.message + " Aborted."); + return; + } + + if (kind == "zapret2") { + let raw = zapret2_validator().strategy_or_default(option(section, "nfqws2_opt", ""), context.zapret2_default_nfqws2_opt); + let result = zapret2_validator().validate_strategy("nfqws2", raw, ""); + if (!result.valid) + fail_validation("Zapret2 rule '" + name + "' uses invalid NFQWS2 strategy: " + result.message + " Aborted."); + return; + } + + let raw = byedpi_validator().strategy_or_default(option(section, "byedpi_cmd_opts", ""), context.byedpi_default_cmd_opts); + let result = byedpi_validator().validate_byedpi_strategy(raw); + if (!result.valid) + fail_validation("Invalid ByeDPI strategy for rule '" + name + "': " + result.message); +} + +function validate_rule(section, context) { + if (!section_enabled(section)) + return; + + let name = section_name(section); + let action = rule_action(section); + if (action == "") + fail_validation("Enabled rule '" + name + "' has no action. Aborted."); + + for (let value in list_option(section, "ports")) + validate_port_condition_value(value, name); + + if (action == "zapret") { + if (!context.zapret_installed) { + validate_common_rule_references(section, context); + return; + } + validate_provider_strategy("zapret", section, context); + } + + if (action == "zapret2") { + if (!context.zapret2_installed) { + validate_common_rule_references(section, context); + return; + } + validate_provider_strategy("zapret2", section, context); + } + + if (action == "byedpi") { + if (!context.byedpi_installed) { + validate_common_rule_references(section, context); + return; + } + validate_provider_strategy("byedpi", section, context); + } + + if (action == "proxy") { + if (bool_option(section, "urltest_enabled", false)) { + let urltest_filter_mode = option(section, "urltest_filter_mode", "disabled"); + validate_urltest_filter_mode_value(urltest_filter_mode, name); + if (contains([ "exclude", "include", "mixed" ], urltest_filter_mode)) + validate_detect_server_country_value(option(section, "detect_server_country", "flag_emoji"), name); + + validate_required_duration_option(urltest_check_interval_for_rule(section), "rule." + name + ".urltest_check_interval"); + + if (contains([ "include", "mixed" ], urltest_filter_mode)) { + for (let value in list_option(section, "urltest_include_regex")) + validate_urltest_regex_value(value, name); + for (let value in list_option(section, "urltest_include_countries")) + validate_country_code_value(value, name); + } + if (contains([ "exclude", "mixed" ], urltest_filter_mode)) { + for (let value in list_option(section, "urltest_exclude_countries")) + validate_country_code_value(value, name); + for (let value in list_option(section, "urltest_exclude_regex")) + validate_urltest_regex_value(value, name); + } + } + + if (rule_has_subscription_urls(section)) { + for (let value in list_option(section, "subscription_urls")) + validate_subscription_source_entry_value(value, name); + let subscription_update_interval = subscription_update_interval_for_rule(section); + if (subscription_update_interval != "") + validate_required_duration_option(subscription_update_interval, "rule." + name + ".subscription_update_interval"); + } + } + + if (action == "outbound") + validate_outbound_json_rule(section); + + for (let value in list_option(section, "domain_suffix")) + validate_combined_domain_value(value, name); + validate_combined_domain_text_value(option(section, "domain_suffix_text", ""), name); + validate_common_rule_references(section, context); +} + +function download_via_proxy_any_enabled(settings) { + return bool_option(settings, "download_lists_via_proxy", false) || + bool_option(settings, "download_subscriptions_via_proxy", false) || + bool_option(settings, "download_components_via_proxy", false); +} + +function basic_rows_from_sections(sections) { + let rows = []; + for (let section in sections) { + push(rows, { + section: section_name(section), + enabled: section_enabled(section), + action: rule_action(section) + }); + } + return rows; +} + +function detour_rows_from_sections(sections) { + let rows = []; + for (let section in sections) { + push(rows, { + section: section_name(section), + enabled: section_enabled(section), + action: rule_action(section), + detour_enabled: bool_option(section, "outbound_detour_enabled", false), + detour_section: option(section, "outbound_detour_section", ""), + outbound_json: option(section, "outbound_json", "") + }); + } + return rows; +} + +function validate_list_update_settings(settings) { + if (!bool_option(settings, "list_update_enabled", true)) + return; + + let update_interval = option(settings, "update_interval", "1d"); + if (update_interval == "") + update_interval = "1d"; + validate_required_duration_option(update_interval, "settings.update_interval"); +} + +function validate_runtime_mark_ranges_context(context) { + let fakeip_mark = parse_number(context.nft_fakeip_mark); + let outbound_mark = parse_number(context.nft_outbound_mark); + let ranges = [ + [ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, fakeip_mark, "FakeIP mark " + context.nft_fakeip_mark ], + [ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ], + [ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, fakeip_mark, "FakeIP mark " + context.nft_fakeip_mark ], + [ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ] + ]; + + for (let range in ranges) { + let label = as_string(range[0]); + let base = parse_number(range[1]); + let range_size = parse_number(range[2]); + let mark_mask = range[3]; + let mark_mask_label = as_string(range[4]); + + if (base == null || range_size == null || mark_mask == null) + fail_validation(label + " route mark range contains invalid numeric constants. Aborted."); + + for (let index_value = 1; index_value <= range_size; index_value++) { + let mark_value = base + index_value; + if ((mark_value & mark_mask) != 0) + fail_validation(label + " route mark range overlaps " + mark_mask_label + ". Aborted."); + } + } +} + +function validate_runtime_config(context) { + let settings = settings_section(); + let sections = sections_by_type("section"); + + validate_runtime_mark_ranges_context(context); + validate_list_update_settings(settings); + + if (download_via_proxy_any_enabled(settings)) { + validate_download_section_rows( + option(settings, "download_lists_via_proxy_section", ""), + context.byedpi_installed, + context.zapret_installed, + context.zapret2_installed, + basic_rows_from_sections(sections) + ); + } + + validate_outbound_detours_rows(detour_rows_from_sections(sections)); + + for (let section in sections) + validate_rule(section, context); +} + +function context_from_runtime() { + let constants = object_or_empty(runtime_constants()); + + return { + constants, + community_services: constant_value(constants, "COMMUNITY_SERVICES"), + byedpi_default_cmd_opts: constant_value(constants, "BYEDPI_DEFAULT_CMD_OPTS"), + zapret_default_nfqws_opt: constant_value(constants, "ZAPRET_DEFAULT_NFQWS_OPT"), + zapret_legacy_default_nfqws_opt: constant_value(constants, "ZAPRET_LEGACY_DEFAULT_NFQWS_OPT"), + zapret2_default_nfqws2_opt: constant_value(constants, "ZAPRET2_DEFAULT_NFQWS2_OPT"), + byedpi_installed: file_executable(constant_value(constants, "BYEDPI_BIN")), + zapret_installed: file_executable(constant_value(constants, "ZAPRET_PROVIDER_NFQWS_BIN")), + zapret2_installed: file_executable(constant_value(constants, "ZAPRET2_PROVIDER_NFQWS2_BIN")), + zapret_provider_nfqws_bin: constant_value(constants, "ZAPRET_PROVIDER_NFQWS_BIN"), + zapret2_provider_nfqws2_bin: constant_value(constants, "ZAPRET2_PROVIDER_NFQWS2_BIN"), + zapret_route_mark_base: constant_value(constants, "ZAPRET_ROUTE_MARK_BASE"), + zapret_queue_range_size: constant_value(constants, "ZAPRET_QUEUE_RANGE_SIZE"), + zapret2_route_mark_base: constant_value(constants, "ZAPRET2_ROUTE_MARK_BASE"), + zapret2_queue_range_size: constant_value(constants, "ZAPRET2_QUEUE_RANGE_SIZE"), + nft_fakeip_mark: constant_value(constants, "NFT_FAKEIP_MARK"), + nft_outbound_mark: constant_value(constants, "NFT_OUTBOUND_MARK"), + coreutils_base64_required_version: constant_value(constants, "COREUTILS_BASE64_REQUIRED_VERSION"), + sing_box_required_version: constant_value(constants, "SB_REQUIRED_VERSION"), + sing_box_variant_state_file: constant_value(constants, "SB_VARIANT_STATE_FILE"), + sing_box_version_state_file: constant_value(constants, "SB_VERSION_STATE_FILE"), + sing_box_managed_service_marker: constant_value(constants, "SB_MANAGED_SERVICE_MARKER"), + zapret_legacy_runtime_base_dir: constant_value(constants, "ZAPRET_LEGACY_RUNTIME_BASE_DIR"), + zapret_state_dir: constant_value(constants, "ZAPRET_STATE_DIR"), + zapret_pid_dir: constant_value(constants, "ZAPRET_PID_DIR"), + zapret_child_pid_dir: constant_value(constants, "ZAPRET_CHILD_PID_DIR"), + zapret_log_dir: constant_value(constants, "ZAPRET_LOG_DIR"), + zapret2_state_dir: constant_value(constants, "ZAPRET2_STATE_DIR"), + zapret2_pid_dir: constant_value(constants, "ZAPRET2_PID_DIR"), + zapret2_child_pid_dir: constant_value(constants, "ZAPRET2_CHILD_PID_DIR"), + zapret2_log_dir: constant_value(constants, "ZAPRET2_LOG_DIR"), + byedpi_bin: constant_value(constants, "BYEDPI_BIN"), + byedpi_state_dir: constant_value(constants, "BYEDPI_STATE_DIR"), + byedpi_pid_dir: constant_value(constants, "BYEDPI_PID_DIR"), + byedpi_child_pid_dir: constant_value(constants, "BYEDPI_CHILD_PID_DIR"), + byedpi_log_dir: constant_value(constants, "BYEDPI_LOG_DIR") + }; +} + +function sing_box_variant_marker(ctx, value) { + let path = as_string(ctx.sing_box_variant_state_file); + if (path == "") + path = "/etc/podkop-plus/sing-box-variant"; + + let data = fs.readfile(path); + return trim(as_string(data)) == as_string(value); +} + +function sing_box_compressed_marker_set(ctx) { + return sing_box_variant_marker(ctx, "extended-compressed"); +} + +function sing_box_extended_marker_set(ctx) { + return sing_box_variant_marker(ctx, "extended"); +} + +function sing_box_version_state(ctx) { + let path = as_string(ctx.sing_box_version_state_file); + if (path == "") + path = "/etc/podkop-plus/sing-box-version"; + + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : as_string(data); +} + +function get_sing_box_version(ctx) { + if (!command_exists("sing-box")) + return ""; + + if (sing_box_compressed_marker_set(ctx)) + return sing_box_version_state(ctx); + + return first_line_last_field(command_output_from_args([ "sing-box", "version" ])); +} + +function sing_box_version_is_extended(version) { + return index(as_string(version), "extended") >= 0; +} + +function sing_box_is_extended(ctx, version) { + if (as_string(version) == "" && command_exists("sing-box") && + (sing_box_compressed_marker_set(ctx) || sing_box_extended_marker_set(ctx))) + return true; + + return sing_box_version_is_extended(version != null ? version : get_sing_box_version(ctx)); +} + +function sing_box_output_has_build_tag(output, tag) { + tag = as_string(tag); + if (tag == "") + return false; + + for (let token in split(replace(as_string(output), /[,: \t\r\n]+/g, " "), " ")) + if (token == tag) + return true; + + return false; +} + +function sing_box_supports_tailscale(ctx, version, version_output) { + if (command_exists("sing-box") && sing_box_compressed_marker_set(ctx)) + return true; + + if (sing_box_is_extended(ctx, version)) + return true; + + if (as_string(version_output) == "") + version_output = command_output_from_args([ "sing-box", "version" ]); + + return sing_box_output_has_build_tag(version_output, "with_tailscale"); +} + +function managed_sing_box_service_script(marker) { + marker = as_string(marker); + if (marker == "") + marker = "Podkop Plus managed sing-box service for binary variants"; + + return "#!/bin/sh /etc/rc.common\n" + + "# " + marker + "\n" + + "\n" + + "USE_PROCD=1\n" + + "START=99\n" + + "PROG=\"/usr/bin/sing-box\"\n" + + "\n" + + "start_service() {\n" + + " config_load \"sing-box\"\n" + + " local enabled config_file working_directory\n" + + " local log_stderr\n" + + "\n" + + " config_get_bool enabled \"main\" \"enabled\" \"0\"\n" + + " [ \"$enabled\" -eq \"1\" ] || return 0\n" + + "\n" + + " config_get config_file \"main\" \"conffile\" \"/etc/sing-box/config.json\"\n" + + " config_get working_directory \"main\" \"workdir\" \"/usr/share/sing-box\"\n" + + " config_get_bool log_stderr \"main\" \"log_stderr\" \"1\"\n" + + "\n" + + " procd_open_instance\n" + + " procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" + + " procd_set_param file \"$config_file\"\n" + + " procd_set_param stderr \"$log_stderr\"\n" + + " procd_set_param limits core=\"unlimited\"\n" + + " procd_set_param limits nofile=\"1000000 1000000\"\n" + + " procd_set_param respawn\n" + + " procd_close_instance\n" + + "}\n" + + "\n" + + "service_triggers() {\n" + + " procd_add_reload_trigger \"sing-box\"\n" + + "}\n"; +} + +function install_managed_sing_box_service_script(ctx) { + let stamp = clock(); + let tmp_file = sprintf("/etc/init.d/sing-box.podkop-plus.%d.%d", stamp[0], stamp[1]); + + if (!fs.writefile(tmp_file, managed_sing_box_service_script(ctx.sing_box_managed_service_marker))) + return false; + + if (!run_args([ "chmod", "0755", tmp_file ]) || !run_args([ "mv", "-f", tmp_file, "/etc/init.d/sing-box" ])) { + fs.unlink(tmp_file); + return false; + } + + return true; +} + +function service_exists(service) { + return file_executable("/etc/init.d/" + as_string(service)); +} + +function validate_extended_server_features(ctx, sing_box_version, sing_box_version_output) { + if (sing_box_is_extended(ctx, sing_box_version)) + return; + + for (let section in sections_by_type("server")) { + if (!server_enabled(section)) + continue; + + let name = section_name(section); + let protocol = option(section, "protocol", "vless"); + let transport = option(section, "transport", "tcp"); + + if (protocol == "mtproto") + fail_requirement("Server '" + name + "' uses MTProto proxy, but sing-box-extended is not installed. Install sing-box-extended or disable this server. Aborted.", "fatal"); + + if (protocol == "tailscale" && !sing_box_supports_tailscale(ctx, sing_box_version, sing_box_version_output)) + fail_requirement("Server '" + name + "' uses Tailscale, but the installed sing-box binary was built without Tailscale support. Install full sing-box or sing-box-extended, or disable this server. Aborted.", "fatal"); + + if (transport == "xhttp") + fail_requirement("Server '" + name + "' uses XHTTP transport, but sing-box-extended is not installed. Install sing-box-extended or change the transport. Aborted.", "fatal"); + } +} + +function has_outbound_section(ctx) { + for (let section in sections_by_type("section")) { + if (!section_enabled(section)) + continue; + + let action = rule_action(section); + if (action == "byedpi" && ctx.byedpi_installed) + return true; + if (action == "zapret" && ctx.zapret_installed) + return true; + if (action == "zapret2" && ctx.zapret2_installed) + return true; + + if (option(section, "selector_proxy_links", "") != "" || + option(section, "subscription_urls", "") != "" || + option(section, "outbound_json", "") != "" || + option(section, "interface", "") != "") + return true; + } + + return false; +} + +function has_enabled_rule_action(action) { + action = as_string(action); + + for (let section in sections_by_type("section")) + if (section_enabled(section) && rule_action(section) == action) + return true; + + return false; +} + +function cleanup_legacy_zapret_runtime(ctx) { + let base = as_string(ctx.zapret_legacy_runtime_base_dir); + if (base == "") + return; + + let needle = base + "/nfq/nfqws"; + for (let line in split(command_output_from_args([ "ps", "w" ]), "\n")) { + if (index(as_string(line), needle) < 0) + continue; + + let fields = split(trim(as_string(line)), /[ \t]+/); + let pid = length(fields) > 0 ? as_string(fields[0]) : ""; + if (match(pid, /^[0-9]+$/) != null) + run_args([ "kill", pid ]); + } + + safe_rm_rf(base); +} + +function check_provider_requirement(action, display_name, bin_path, dirs, missing_message, prepare_failure_message) { + if (!has_enabled_rule_action(action)) + return; + + if (!file_executable(bin_path)) { + log_message(missing_message, "error"); + return; + } + + if (!mkdir_p(dirs)) + fail_requirement(prepare_failure_message, "fatal"); +} + +function check_provider_requirements(ctx) { + cleanup_legacy_zapret_runtime(ctx); + + check_provider_requirement( + "zapret", + "Zapret", + ctx.zapret_provider_nfqws_bin, + [ ctx.zapret_state_dir, ctx.zapret_pid_dir, ctx.zapret_child_pid_dir, ctx.zapret_log_dir ], + "Zapret provider is not available at " + ctx.zapret_provider_nfqws_bin + ". Rules with action 'zapret' will be skipped until the zapret provider is installed.", + "Failed to prepare the Podkop Plus zapret state directory in " + ctx.zapret_state_dir + ". Aborted." + ); + + check_provider_requirement( + "zapret2", + "Zapret2", + ctx.zapret2_provider_nfqws2_bin, + [ ctx.zapret2_state_dir, ctx.zapret2_pid_dir, ctx.zapret2_child_pid_dir, ctx.zapret2_log_dir ], + "Zapret2 provider is not available at " + ctx.zapret2_provider_nfqws2_bin + ". Rules with action 'zapret2' will be skipped until the zapret2 provider is installed.", + "Failed to prepare the Podkop Plus zapret2 state directory in " + ctx.zapret2_state_dir + ". Aborted." + ); + + check_provider_requirement( + "byedpi", + "ByeDPI", + ctx.byedpi_bin, + [ ctx.byedpi_state_dir, ctx.byedpi_pid_dir, ctx.byedpi_child_pid_dir, ctx.byedpi_log_dir ], + "ByeDPI provider is not available at " + ctx.byedpi_bin + ". Rules with action 'byedpi' will be skipped until the byedpi package is installed.", + "Failed to prepare the Podkop Plus ByeDPI state directory in " + ctx.byedpi_state_dir + ". Aborted." + ); +} + +function check_runtime_requirements() { + log_message("Check Requirements", "info"); + + let ctx = context_from_runtime(); + let sing_box_version_output = command_exists("sing-box") ? command_output_from_args([ "sing-box", "version" ]) : ""; + let sing_box_version = sing_box_compressed_marker_set(ctx) ? sing_box_version_state(ctx) : first_line_last_field(sing_box_version_output); + let coreutils_base64_version = first_line_field_from_text(command_output("base64 --version 2>/dev/null"), 4); + + if (sing_box_version == "") { + if (!command_exists("sing-box") || !sing_box_compressed_marker_set(ctx)) + fail_requirement("Package 'sing-box' is not installed. Aborted.", "error"); + } + else if (!version_at_least(sing_box_version, ctx.sing_box_required_version)) { + fail_requirement("Package 'sing-box' version (" + sing_box_version + ") is lower than the required minimum (" + ctx.sing_box_required_version + "). Update sing-box: opkg update && opkg remove sing-box && opkg install sing-box. Aborted.", "error"); + } + + if (!service_exists("sing-box") && sing_box_compressed_marker_set(ctx)) + install_managed_sing_box_service_script(ctx); + + if (!service_exists("sing-box")) + fail_requirement("Service 'sing-box' is missing. Install a sing-box package or reinstall the compressed sing-box-extended binary variant. Aborted.", "error"); + + validate_extended_server_features(ctx, sing_box_version, sing_box_version_output); + + if (coreutils_base64_version == "") + fail_requirement("Package 'coreutils-base64' is not installed. Aborted.", "error"); + else if (!version_at_least(coreutils_base64_version, ctx.coreutils_base64_required_version)) + log_message("Package 'coreutils-base64' version (" + coreutils_base64_version + ") is lower than the required minimum (" + ctx.coreutils_base64_required_version + "). This may cause issues when decoding base64 streams with missing padding, as automatic padding support is not available in older versions.", "warn"); + + if (dhcp_has_https_dns_proxy_options("/etc/config/dhcp") === true) + log_message("Detected https-dns-proxy in DHCP config. Edit /etc/config/dhcp", "error"); + + if (has_outbound_section(ctx)) + log_message("Outbound proxy section found", "debug"); + else + log_message("No proxy outbound sections found. Podkop Plus will use direct and/or provider-only routing.", "warn"); + + check_provider_requirements(ctx); +} + +function context_override_value(overrides, defaults, key) { + let value = object_or_empty(overrides)[key]; + if (value != null) + return as_string(value); + return as_string(object_or_empty(defaults)[key]); +} + +function context_override_bool(overrides, defaults, key) { + let value = object_or_empty(overrides)[key]; + if (value != null) + return bool_flag(value); + return bool_flag(object_or_empty(defaults)[key]); +} + +function context_from_json(value, defaults) { + try { + value = json(as_string(value)); + } + catch (e) { + value = {}; + } + + value = object_or_empty(value); + defaults = object_or_empty(defaults); + + return { + community_services: context_override_value(value, defaults, "community_services"), + byedpi_default_cmd_opts: context_override_value(value, defaults, "byedpi_default_cmd_opts"), + zapret_default_nfqws_opt: context_override_value(value, defaults, "zapret_default_nfqws_opt"), + zapret_legacy_default_nfqws_opt: context_override_value(value, defaults, "zapret_legacy_default_nfqws_opt"), + zapret2_default_nfqws2_opt: context_override_value(value, defaults, "zapret2_default_nfqws2_opt"), + byedpi_installed: context_override_bool(value, defaults, "byedpi_installed"), + zapret_installed: context_override_bool(value, defaults, "zapret_installed"), + zapret2_installed: context_override_bool(value, defaults, "zapret2_installed"), + zapret_route_mark_base: context_override_value(value, defaults, "zapret_route_mark_base"), + zapret_queue_range_size: context_override_value(value, defaults, "zapret_queue_range_size"), + zapret2_route_mark_base: context_override_value(value, defaults, "zapret2_route_mark_base"), + zapret2_queue_range_size: context_override_value(value, defaults, "zapret2_queue_range_size"), + nft_fakeip_mark: context_override_value(value, defaults, "nft_fakeip_mark"), + nft_outbound_mark: context_override_value(value, defaults, "nft_outbound_mark") + }; +} + +let mode = ARGV[0] || ""; + +if (mode == "stdin-first-line-field") + stdin_first_line_field(ARGV[1]); +else if (mode == "country-code-valid") + exit(country_code_valid(ARGV[1]) ? 0 : 1); +else if (mode == "enum-valid") + exit(enum_valid(ARGV[1], 2) ? 0 : 1); +else if (mode == "regex-valid") + exit(regex_valid(ARGV[1]) ? 0 : 1); +else if (mode == "combined-domain-valid") + exit(combined_domain_valid(ARGV[1]) ? 0 : 1); +else if (mode == "combined-domain-text-valid") + exit(combined_domain_text_valid(ARGV[1]) ? 0 : 1); +else if (mode == "community-service-valid") + exit(community_service_valid(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "ruleset-reference-valid") + exit(ruleset_reference_valid(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "plain-domain-ip-list-reference-valid") + exit(plain_domain_ip_list_reference_valid(ARGV[1]) ? 0 : 1); +else if (mode == "valid-outbound") + exit(valid_outbound() ? 0 : 1); +else if (mode == "valid-inbound") + exit(valid_inbound() ? 0 : 1); +else if (mode == "outbound-detour-supported") + exit(outbound_detour_supported() ? 0 : 1); +else if (mode == "validate-outbound-detours") + validate_outbound_detours(); +else if (mode == "validate-download-section") + validate_download_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "dhcp-has-https-dns-proxy-options") + dhcp_has_https_dns_proxy_options_exit(ARGV[1]); +else if (mode == "mwan3-has-enabled-interface") + exit(mwan3_has_enabled_interface() ? 0 : 1); +else if (mode == "mwan3-is-active") + exit(mwan3_is_active() ? 0 : 1); +else if (mode == "check-requirements") + check_runtime_requirements(); +else if (mode == "validate-runtime") + validate_runtime_config(context_from_runtime()); +else if (mode == "validate-runtime-fixture") { + use_fixture_cursor(ARGV[1]); + validate_runtime_config(context_from_json(ARGV[2], context_from_runtime())); +} +else { + warn("Usage: config/validator.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/config_validation.sh b/podkop/files/usr/lib/config_validation.sh deleted file mode 100644 index ae842209..00000000 --- a/podkop/files/usr/lib/config_validation.sh +++ /dev/null @@ -1,1375 +0,0 @@ -# shellcheck shell=ash - -config_validation_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/config_validation.uc" "$@" -} - -is_download_section_action() { - local action="$1" - - case "$action" in - proxy | vpn | outbound) - return 0 - ;; - byedpi) - is_byedpi_installed - return $? - ;; - zapret) - is_zapret_installed - return $? - ;; - zapret2) - is_zapret2_installed - return $? - ;; - esac - - return 1 -} - -_validate_download_lists_via_proxy_section_handler() { - local section="$1" - local action - - [ "$section" = "$PODKOP_VALIDATE_DOWNLOAD_SECTION" ] || return 0 - - PODKOP_VALIDATE_DOWNLOAD_SECTION_FOUND=1 - if ! rule_is_enabled "$section"; then - return 0 - fi - - PODKOP_VALIDATE_DOWNLOAD_SECTION_ENABLED=1 - action="$(get_rule_action "$section")" - if is_download_section_action "$action"; then - PODKOP_VALIDATE_DOWNLOAD_SECTION_OUTBOUND=1 - fi -} - -validate_download_lists_via_proxy_section() { - local download_lists_via_proxy_section - - download_via_proxy_any_enabled || return 0 - - config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section" - if [ -z "$download_lists_via_proxy_section" ]; then - log "Downloading external resources through a section is enabled, but no download section is selected. Aborted." "fatal" - exit 1 - fi - - PODKOP_VALIDATE_DOWNLOAD_SECTION="$download_lists_via_proxy_section" - PODKOP_VALIDATE_DOWNLOAD_SECTION_FOUND=0 - PODKOP_VALIDATE_DOWNLOAD_SECTION_ENABLED=0 - PODKOP_VALIDATE_DOWNLOAD_SECTION_OUTBOUND=0 - config_foreach _validate_download_lists_via_proxy_section_handler "section" - - if [ "$PODKOP_VALIDATE_DOWNLOAD_SECTION_FOUND" -eq 0 ]; then - log "Downloading external resources through a section references missing rule '$download_lists_via_proxy_section'. Select an enabled rule that can provide an outbound or disable the option. Aborted." "fatal" - exit 1 - fi - - if [ "$PODKOP_VALIDATE_DOWNLOAD_SECTION_ENABLED" -eq 0 ]; then - log "Downloading external resources through a section references disabled rule '$download_lists_via_proxy_section'. Select an enabled rule that can provide an outbound or disable the option. Aborted." "fatal" - exit 1 - fi - - if [ "$PODKOP_VALIDATE_DOWNLOAD_SECTION_OUTBOUND" -eq 0 ]; then - log "Downloading external resources through a section references rule '$download_lists_via_proxy_section', but it cannot provide an outbound. Select an enabled proxy, JSON outbound, VPN, Zapret, Zapret2, or ByeDPI rule with its provider installed, or disable the option. Aborted." "fatal" - exit 1 - fi -} - -is_outbound_detour_source_action() { - local action="$1" - - [ "$action" = "proxy" ] || [ "$action" = "outbound" ] -} - -is_outbound_detour_target_action() { - local action="$1" - - [ "$action" = "proxy" ] || [ "$action" = "vpn" ] || [ "$action" = "outbound" ] -} - -_validate_outbound_detour_section_handler() { - local section="$1" - local action - - [ "$section" = "$PODKOP_VALIDATE_DETOUR_SECTION" ] || return 0 - - PODKOP_VALIDATE_DETOUR_SECTION_FOUND=1 - if ! rule_is_enabled "$section"; then - return 0 - fi - - PODKOP_VALIDATE_DETOUR_SECTION_ENABLED=1 - action="$(get_rule_action "$section")" - if is_outbound_detour_target_action "$action"; then - PODKOP_VALIDATE_DETOUR_SECTION_OUTBOUND=1 - fi -} - -validate_outbound_detour_target_section() { - local section="$1" - local detour_section="$2" - - PODKOP_VALIDATE_DETOUR_SECTION="$detour_section" - PODKOP_VALIDATE_DETOUR_SECTION_FOUND=0 - PODKOP_VALIDATE_DETOUR_SECTION_ENABLED=0 - PODKOP_VALIDATE_DETOUR_SECTION_OUTBOUND=0 - config_foreach _validate_outbound_detour_section_handler "section" - - if [ "$PODKOP_VALIDATE_DETOUR_SECTION_FOUND" -eq 0 ]; then - log "Outbound cascade for rule '$section' references missing rule '$detour_section'. Select an enabled proxy/VPN/JSON outbound rule or disable cascade connection. Aborted." "fatal" - exit 1 - fi - - if [ "$PODKOP_VALIDATE_DETOUR_SECTION_ENABLED" -eq 0 ]; then - log "Outbound cascade for rule '$section' references disabled rule '$detour_section'. Select an enabled proxy/VPN/JSON outbound rule or disable cascade connection. Aborted." "fatal" - exit 1 - fi - - if [ "$PODKOP_VALIDATE_DETOUR_SECTION_OUTBOUND" -eq 0 ]; then - log "Outbound cascade for rule '$section' references rule '$detour_section', but it is not a proxy/VPN/JSON outbound rule. Select an enabled proxy/VPN/JSON outbound rule or disable cascade connection. Aborted." "fatal" - exit 1 - fi -} - -outbound_detour_chain_reaches_section() { - local source_section="$1" - local current_section="$2" - local seen_sections="" action detour_enabled next_section - - while [ -n "$current_section" ]; do - [ "$current_section" = "$source_section" ] && return 0 - - if list_has_item "$seen_sections" "$current_section"; then - return 1 - fi - seen_sections="$seen_sections $current_section" - - rule_is_enabled "$current_section" || return 1 - action="$(get_rule_action "$current_section")" - is_outbound_detour_source_action "$action" || return 1 - - config_get_bool detour_enabled "$current_section" "outbound_detour_enabled" 0 - [ "$detour_enabled" -eq 1 ] || return 1 - - config_get next_section "$current_section" "outbound_detour_section" - current_section="$next_section" - done - - return 1 -} - -validate_outbound_detour_rule() { - local section="$1" - local action detour_enabled detour_section outbound_json - - config_get_bool detour_enabled "$section" "outbound_detour_enabled" 0 - [ "$detour_enabled" -eq 1 ] || return 0 - - action="$(get_rule_action "$section")" - if ! is_outbound_detour_source_action "$action"; then - log "Outbound cascade is supported only for proxy and JSON outbound rules, but rule '$section' uses action '$action'. Aborted." "fatal" - exit 1 - fi - - config_get detour_section "$section" "outbound_detour_section" - if [ -z "$detour_section" ]; then - log "Outbound cascade is enabled for rule '$section', but no intermediate rule is selected. Aborted." "fatal" - exit 1 - fi - - if [ "$detour_section" = "$section" ]; then - log "Outbound cascade for rule '$section' cannot point to itself. Aborted." "fatal" - exit 1 - fi - - validate_outbound_detour_target_section "$section" "$detour_section" - - if outbound_detour_chain_reaches_section "$section" "$detour_section"; then - log "Outbound cascade for rule '$section' creates a cycle through '$detour_section'. Aborted." "fatal" - exit 1 - fi - - if [ "$action" = "outbound" ]; then - config_get outbound_json "$section" "outbound_json" - if ! printf '%s' "$outbound_json" | config_validation_ucode outbound-detour-supported >/dev/null 2>&1; then - log "JSON outbound rule '$section' cannot use outbound cascade because its sing-box outbound type does not support Dial Fields. Aborted." "fatal" - exit 1 - fi - fi -} - -validate_list_update_settings() { - local enabled update_interval - - config_get_bool enabled "settings" "list_update_enabled" 1 - [ "$enabled" -eq 1 ] || return 0 - - config_get update_interval "settings" "update_interval" "1d" - [ -n "$update_interval" ] || update_interval="1d" - validate_required_sing_box_duration_option "$update_interval" "settings.update_interval" -} - -validate_runtime_settings() { - validate_runtime_mark_ranges - validate_list_update_settings - validate_download_lists_via_proxy_section -} - -validate_route_mark_range() { - local label="$1" - local base="$2" - local range_size="$3" - local mark_mask="$4" - local mark_mask_label="$5" - local base_value range_size_value mark_mask_value index mark_value - - base_value=$((base)) - range_size_value=$((range_size)) - mark_mask_value=$((mark_mask)) - - index=1 - while [ "$index" -le "$range_size_value" ]; do - mark_value=$((base_value + index)) - if [ $((mark_value & mark_mask_value)) -ne 0 ]; then - log "$label route mark range overlaps $mark_mask_label. Aborted." "fatal" - exit 1 - fi - index=$((index + 1)) - done -} - -validate_runtime_mark_ranges() { - validate_route_mark_range "Zapret" "$ZAPRET_ROUTE_MARK_BASE" "$ZAPRET_QUEUE_RANGE_SIZE" "$NFT_FAKEIP_MARK" "FakeIP mark $NFT_FAKEIP_MARK" - validate_route_mark_range "Zapret" "$ZAPRET_ROUTE_MARK_BASE" "$ZAPRET_QUEUE_RANGE_SIZE" "$NFT_OUTBOUND_MARK" "outbound mark $NFT_OUTBOUND_MARK" - validate_route_mark_range "Zapret2" "$ZAPRET2_ROUTE_MARK_BASE" "$ZAPRET2_QUEUE_RANGE_SIZE" "$NFT_FAKEIP_MARK" "FakeIP mark $NFT_FAKEIP_MARK" - validate_route_mark_range "Zapret2" "$ZAPRET2_ROUTE_MARK_BASE" "$ZAPRET2_QUEUE_RANGE_SIZE" "$NFT_OUTBOUND_MARK" "outbound mark $NFT_OUTBOUND_MARK" -} - -podkop_current_config_hash() { - local config_file hash - - config_file="/etc/config/$PODKOP_CONFIG_NAME" - [ -r "$config_file" ] || return 1 - - hash="$(md5sum "$config_file" 2>/dev/null)" - hash="${hash%% *}" - [ -n "$hash" ] || return 1 - - printf "%s\n" "$hash" -} - -mark_internal_config_guard() { - local now config_hash tmp_file - - config_hash="$(podkop_current_config_hash)" || { - rm -f "$PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD" 2>/dev/null - return 0 - } - - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) - now=0 - ;; - esac - - tmp_file="$PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD.$$" - { - printf "%s\n" "$now" - printf "%s\n" "$config_hash" - } > "$tmp_file" 2>/dev/null && - mv "$tmp_file" "$PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD" 2>/dev/null - rm -f "$tmp_file" 2>/dev/null -} - -commit_podkop_config() { - local status - - uci commit "$PODKOP_CONFIG_NAME" - status="$?" - if [ "$status" -eq 0 ]; then - mark_internal_config_guard - config_load "$PODKOP_CONFIG_NAME" - fi - - return "$status" -} - -podkop_uci_option_exists() { - local section="$1" - local option="$2" - - uci -q get "$PODKOP_CONFIG_NAME.$section.$option" >/dev/null 2>&1 -} - -podkop_uci_set_option() { - local section="$1" - local option="$2" - local value="$3" - local current - - current="$(uci -q get "$PODKOP_CONFIG_NAME.$section.$option" 2>/dev/null)" - [ "$current" = "$value" ] && return 0 - - uci set "$PODKOP_CONFIG_NAME.$section.$option=$value" - podkop_config_migration_changed=1 -} - -podkop_uci_set_option_if_missing() { - local section="$1" - local option="$2" - local value="$3" - - podkop_uci_option_exists "$section" "$option" && return 0 - podkop_uci_set_option "$section" "$option" "$value" -} - -podkop_uci_migrate_urltest_filter_mode() { - local section="$1" - - podkop_uci_option_exists "$section" "urltest_filter_mode" && return 0 - - if podkop_uci_option_exists "$section" "urltest_exclude_countries" || - podkop_uci_option_exists "$section" "urltest_include_countries" || - podkop_uci_option_exists "$section" "urltest_exclude_outbounds" || - podkop_uci_option_exists "$section" "urltest_exclude_regex"; then - podkop_uci_set_option "$section" "urltest_filter_mode" "exclude" - fi -} - -urltest_filter_mode_filters_enabled() { - case "$1" in - exclude | include | mixed) - return 0 - ;; - esac - - return 1 -} - -normalize_detect_server_country_method() { - local value="${1:-}" - - case "$value" in - "$SERVER_COUNTRY_METHOD_COUNTRY_IS") - printf '%s\n' "$SERVER_COUNTRY_METHOD_COUNTRY_IS" - ;; - 0 | 1 | "" | "$SERVER_COUNTRY_METHOD_FLAG_EMOJI") - printf '%s\n' "$SERVER_COUNTRY_METHOD_FLAG_EMOJI" - ;; - *) - printf '%s\n' "$SERVER_COUNTRY_METHOD_FLAG_EMOJI" - ;; - esac -} - -podkop_uci_migrate_detect_server_country() { - local section="$1" - local value urltest_enabled urltest_filter_mode - - podkop_uci_option_exists "$section" "detect_server_country" || return 0 - - config_get value "$section" "detect_server_country" - config_get_bool urltest_enabled "$section" "urltest_enabled" 0 - config_get urltest_filter_mode "$section" "urltest_filter_mode" "disabled" - - case "$value" in - 0 | 1) - if [ "$urltest_enabled" -eq 1 ] && urltest_filter_mode_filters_enabled "$urltest_filter_mode"; then - podkop_uci_set_option "$section" "detect_server_country" "$SERVER_COUNTRY_METHOD_FLAG_EMOJI" - else - podkop_uci_delete_option "$section" "detect_server_country" - fi - ;; - esac -} - -podkop_uci_delete_option() { - local section="$1" - local option="$2" - - podkop_uci_option_exists "$section" "$option" || return 0 - - uci -q delete "$PODKOP_CONFIG_NAME.$section.$option" || true - podkop_config_migration_changed=1 -} - -podkop_uci_list_contains_handler() { - local item="$1" - local expected="$2" - - [ "$item" = "$expected" ] && UCI_LIST_CONTAINS=1 -} - -podkop_uci_add_list_unique() { - local section="$1" - local option="$2" - local value="$3" - local quoted_value list_key - - [ -n "$value" ] || return 0 - list_key="$section.$option=$value" - - UCI_LIST_CONTAINS=0 - config_list_foreach "$section" "$option" podkop_uci_list_contains_handler "$value" - [ "$UCI_LIST_CONTAINS" -eq 1 ] && return 0 - case " -$podkop_config_migration_added_lists -" in - *" -$list_key -"*) - return 0 - ;; - esac - - quoted_value="$(config_validation_ucode shell-single-quote "$value")" - printf 'add_list %s.%s.%s=%s\n' "$PODKOP_CONFIG_NAME" "$section" "$option" "$quoted_value" | uci -q batch - podkop_config_migration_added_lists="${podkop_config_migration_added_lists} -${list_key}" - podkop_config_migration_changed=1 -} - -migrate_0_7_17_8_urltest_link() { - local link="$1" - local section="$2" - - podkop_uci_add_list_unique "$section" "selector_proxy_links" "$link" -} - -migrate_0_7_17_8_proxy_string() { - local section="$1" - local proxy_string link migrated - - config_get proxy_string "$section" "proxy_string" - [ -n "$proxy_string" ] || return 0 - - migrated=0 - while IFS= read -r link || [ -n "$link" ]; do - link="$(printf '%s' "$link" | trim_string)" - [ -n "$link" ] || continue - case "$link" in - '//'*) continue ;; - esac - - podkop_uci_add_list_unique "$section" "selector_proxy_links" "$link" - migrated=1 - done </dev/null 2>&1 || ! is_sing_box_compressed_marker_set; then - log "Package 'sing-box' is not installed. Aborted." "error" - exit 1 - fi - elif ! is_min_package_version "$sing_box_version" "$SB_REQUIRED_VERSION"; then - log "Package 'sing-box' version ($sing_box_version) is lower than the required minimum ($SB_REQUIRED_VERSION). Update sing-box: opkg update && opkg remove sing-box && opkg install sing-box. Aborted." "error" - exit 1 - fi - - if ! service_exists "sing-box" && is_sing_box_compressed_marker_set && - command -v sing_box_install_managed_service_script >/dev/null 2>&1; then - sing_box_install_managed_service_script >/dev/null 2>&1 || true - fi - - if ! service_exists "sing-box"; then - log "Service 'sing-box' is missing. Install a sing-box package or reinstall the compressed sing-box-extended binary variant. Aborted." "error" - exit 1 - fi - - validate_extended_server_features "$sing_box_version" - - if [ -z "$coreutils_base64_version" ]; then - log "Package 'coreutils-base64' is not installed. Aborted." "error" - exit 1 - elif ! is_min_package_version "$coreutils_base64_version" "$COREUTILS_BASE64_REQUIRED_VERSION"; then - log "Package 'coreutils-base64' version ($coreutils_base64_version) is lower than the required minimum ($COREUTILS_BASE64_REQUIRED_VERSION). This may cause issues when decoding base64 streams with missing padding, as automatic padding support is not available in older versions." "warn" - fi - - if config_validation_ucode dhcp-has-https-dns-proxy-options /etc/config/dhcp >/dev/null 2>&1; then - log "Detected https-dns-proxy in DHCP config. Edit /etc/config/dhcp" "error" - fi - - if has_outbound_section; then - log "Outbound proxy section found" "debug" - else - log "No proxy outbound sections found. Podkop Plus will use direct and/or provider-only routing." "warn" - fi - - check_zapret_requirements - check_zapret2_requirements - check_byedpi_requirements -} - -_check_outbound_node() { - local section="$1" - local action interface outbound_json selector_proxy_links subscription_urls - - rule_is_enabled "$section" || return 0 - - action="$(get_rule_action "$section")" - config_get selector_proxy_links "$section" "selector_proxy_links" - config_get subscription_urls "$section" "subscription_urls" - config_get outbound_json "$section" "outbound_json" - config_get interface "$section" "interface" - - if [ "$action" = "byedpi" ] && is_byedpi_installed; then - section_exists=0 - return 0 - fi - - if [ "$action" = "zapret" ] && is_zapret_installed; then - section_exists=0 - return 0 - fi - - if [ "$action" = "zapret2" ] && is_zapret2_installed; then - section_exists=0 - return 0 - fi - - if [ -n "$selector_proxy_links" ] || [ -n "$subscription_urls" ] || - [ -n "$outbound_json" ] || [ -n "$interface" ]; then - section_exists=0 - fi -} - -has_outbound_section() { - local section_exists=1 - - config_foreach _check_outbound_node "section" - - return $section_exists -} - -mwan3_has_enabled_interface() { - [ -s /etc/config/mwan3 ] || return 1 - - uci -q show mwan3 2>/dev/null | - config_validation_ucode mwan3-has-enabled-interface >/dev/null 2>&1 -} - -mwan3_is_active() { - [ -x /etc/init.d/mwan3 ] || return 1 - mwan3_has_enabled_interface || return 1 - - /etc/init.d/mwan3 status >/dev/null 2>&1 || - /etc/init.d/mwan3 enabled >/dev/null 2>&1 -} - -# Migrations and validation funcs -migration() { - podkop_config_migration_changed=0 - podkop_config_migration_added_lists="" - - ensure_runtime_cache_format - remove_legacy_server_country_cache - migrate_list_update_enabled - migrate_download_via_proxy_flags - - config_foreach migrate_0_7_17_8_rule_section "rule" - config_foreach migrate_0_7_17_8_rule "section" - config_foreach migrate_combined_domain_conditions "section" - - if [ "$podkop_config_migration_changed" -eq 1 ]; then - log "Migrated Podkop Plus UCI config" - commit_podkop_config - fi -} - -validate_service() { - local service="$1" - - list_has_item "$COMMUNITY_SERVICES" "$service" && return 0 - - log "Invalid service in community lists: $service. Check config and LuCI cache. Aborted." "fatal" - exit 1 -} - -validate_ruleset_reference() { - local reference="$1" - - if [ -z "$reference" ]; then - return 0 - fi - - if list_has_item "$COMMUNITY_SERVICES" "$reference"; then - return 0 - fi - - case "$reference" in - http://* | https://*) - return 0 - ;; - /*.srs | /*.json) - return 0 - ;; - esac - - log "Unknown rule set reference '$reference'. Aborted." "fatal" - exit 1 -} - -validate_plain_domain_ip_list_reference() { - local reference="$1" - - if [ -z "$reference" ]; then - return 0 - fi - - case "$reference" in - http://* | https://*) - return 0 - ;; - /*.lst) - return 0 - ;; - esac - - log "Unknown plain list reference '$reference'. Aborted." "fatal" - exit 1 -} - -validate_sing_box_duration_option() { - local value="$1" - local label="$2" - - [ -z "$value" ] && return 0 - - if duration_to_seconds "$value" >/dev/null 2>&1; then - return 0 - fi - - log "Invalid duration value for $label: $value. Use sing-box duration format like 1d, 12h or 30m. Aborted." "fatal" - exit 1 -} - -validate_required_sing_box_duration_option() { - local value="$1" - local label="$2" - - if [ -z "$value" ]; then - log "Missing duration value for $label. Use sing-box duration format like 1d, 12h or 30m. Aborted." "fatal" - exit 1 - fi - - validate_sing_box_duration_option "$value" "$label" -} - -validate_subscription_source_entry() { - local entry="$1" - local section="$2" - - if ! parse_subscription_source_entry "$entry"; then - log "Invalid subscription URL in rule '$section': $SUBSCRIPTION_SOURCE_PARSE_ERROR. Aborted." "fatal" - exit 1 - fi -} - -validate_country_code_option() { - local value="$1" - local section="$2" - - [ -n "$value" ] || return 0 - if config_validation_ucode country-code-valid "$value" >/dev/null 2>&1; then - return 0 - fi - - log "Invalid country code '$value' in rule '$section'. Aborted." "fatal" - exit 1 -} - -validate_urltest_filter_mode_option() { - local value="$1" - local section="$2" - - [ -n "$value" ] || return 0 - if config_validation_ucode enum-valid "$value" disabled exclude include mixed >/dev/null 2>&1; then - return 0 - fi - - log "Invalid URLTest filter mode '$value' in rule '$section'. Aborted." "fatal" - exit 1 -} - -validate_detect_server_country_option() { - local value="$1" - local section="$2" - - [ -n "$value" ] || return 0 - if config_validation_ucode enum-valid "$value" "$SERVER_COUNTRY_METHOD_FLAG_EMOJI" "$SERVER_COUNTRY_METHOD_COUNTRY_IS" >/dev/null 2>&1; then - return 0 - fi - - log "Invalid server country detection mode '$value' in rule '$section'. Aborted." "fatal" - exit 1 -} - -validate_urltest_regex_option() { - local value="$1" - local section="$2" - - [ -n "$value" ] || return 0 - if config_validation_ucode regex-valid "$value" >/dev/null 2>&1; then - return 0 - fi - - log "Invalid URLTest regular expression '$value' in rule '$section'. Aborted." "fatal" - exit 1 -} - -validate_combined_domain_option() { - local value="$1" - local section="$2" - - [ -n "$value" ] || return 0 - if config_validation_ucode combined-domain-valid "$value" >/dev/null 2>&1; then - return 0 - fi - - log "Invalid domain condition '$value' in rule '$section'. Use plain domains or full:, keyword:, regex: prefixes. Aborted." "fatal" - exit 1 -} - -validate_combined_domain_text_option() { - local section="$1" - local text_value - - config_get text_value "$section" "domain_suffix_text" - [ -n "$text_value" ] || return 0 - if config_validation_ucode combined-domain-text-valid "$text_value" >/dev/null 2>&1; then - return 0 - fi - - log "Invalid domain conditions in rule '$section'. Use plain domains or full:, keyword:, regex: prefixes. Aborted." "fatal" - exit 1 -} - -ruleset_kind_from_reference_hint() { - local reference="$1" - - case "$reference" in - *geosite*|*domain*|*domains*|*adguard*|*filter*) - echo "domains" - ;; - *geoip*|*subnet*|*subnets*|*cidr*) - echo "subnets" - ;; - *) - echo "unknown" - ;; - esac -} - -json_ruleset_has_domain_matchers() { - local filepath="$1" - - ucode "$PODKOP_LIB/rulesets.uc" has-domain-matchers "$filepath" >/dev/null 2>&1 -} - -detect_local_binary_ruleset_kind() { - local filepath="$1" - local json_tmpfile kind - - kind="$(ruleset_kind_from_reference_hint "$filepath")" - if [ "$kind" != "unknown" ]; then - echo "$kind" - return 0 - fi - - if ! file_exists "$filepath"; then - echo "unknown" - return 0 - fi - - json_tmpfile="$(mktemp)" - if ! decompile_binary_ruleset "$filepath" "$json_tmpfile"; then - rm -f "$json_tmpfile" - echo "unknown" - return 0 - fi - - if json_ruleset_has_domain_matchers "$json_tmpfile"; then - kind="domains" - else - kind="subnets" - fi - - rm -f "$json_tmpfile" - echo "$kind" -} - -detect_local_source_ruleset_kind() { - local filepath="$1" - local kind - - kind="$(ruleset_kind_from_reference_hint "$filepath")" - if [ "$kind" != "unknown" ]; then - echo "$kind" - return 0 - fi - - if ! file_exists "$filepath"; then - echo "unknown" - return 0 - fi - - if json_ruleset_has_domain_matchers "$filepath"; then - echo "domains" - else - echo "subnets" - fi -} - -detect_inline_ruleset_reference_kind() { - local reference="$1" - local kind - local extension - - kind="$(ruleset_kind_from_reference_hint "$reference")" - if [ "$kind" != "unknown" ]; then - echo "$kind" - return 0 - fi - - case "$reference" in - /*.srs) - detect_local_binary_ruleset_kind "$reference" - ;; - /*.json) - detect_local_source_ruleset_kind "$reference" - ;; - http://* | https://*) - extension="$(url_get_file_extension "$reference")" - [ "$extension" = "srs" ] || [ "$extension" = "json" ] || [ -z "$extension" ] || log "Unknown rule set URL extension '$extension' for '$reference'; assuming binary format" "debug" - [ "$kind" = "unknown" ] && kind="domains" - echo "$kind" - ;; - *) - echo "unknown" - ;; - esac -} - -get_inline_remote_ruleset_format() { - local reference="$1" - local extension - - extension="$(url_get_file_extension "$reference")" - case "$extension" in - json) - echo "source" - ;; - srs | "") - echo "binary" - ;; - *) - log "Unknown rule set URL extension '$extension' for '$reference'; assuming binary format" "debug" - echo "binary" - ;; - esac -} - -validate_outbound_json_rule() { - local section="$1" - local outbound_json - - config_get outbound_json "$section" "outbound_json" - if [ -z "$outbound_json" ]; then - log "JSON outbound rule '$section' has empty outbound_json. Aborted." "fatal" - exit 1 - fi - - if ! printf '%s' "$outbound_json" | config_validation_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 -} - -validate_port_condition() { - local value="$1" - local section="$2" - - if ! is_port_condition "$value"; then - log "Invalid port condition '$value' in rule '$section'. Use a single port or range like 80 or 1000-2000. Aborted." "fatal" - exit 1 - fi -} - -process_validate_rule() { - local section="$1" - local action urltest_check_interval subscription_update_interval - - rule_is_enabled "$section" || return 0 - - action="$(get_rule_action "$section")" - if [ -z "$action" ]; then - log "Enabled rule '$section' has no action. Aborted." "fatal" - exit 1 - fi - - config_list_foreach "$section" "ports" validate_port_condition "$section" - - if [ "$action" = "zapret" ]; then - if ! is_zapret_installed; then - if [ "$PODKOP_ZAPRET_MISSING_WARNING_EMITTED" -eq 0 ]; then - log "There are enabled rules with action 'zapret', but the zapret package is not installed. These rules will be skipped." "error" - PODKOP_ZAPRET_MISSING_WARNING_EMITTED=1 - fi - config_list_foreach "$section" "community_lists" validate_service - config_list_foreach "$section" "rule_set" validate_ruleset_reference - config_list_foreach "$section" "rule_set_with_subnets" validate_ruleset_reference - config_list_foreach "$section" "domain_ip_lists" validate_plain_domain_ip_list_reference - return 0 - fi - - validate_rule_nfqws_opt "$section" - fi - - if [ "$action" = "zapret2" ]; then - if ! is_zapret2_installed; then - if [ "$PODKOP_ZAPRET2_MISSING_WARNING_EMITTED" -eq 0 ]; then - log "There are enabled rules with action 'zapret2', but the zapret2 package is not installed. These rules will be skipped." "error" - PODKOP_ZAPRET2_MISSING_WARNING_EMITTED=1 - fi - config_list_foreach "$section" "community_lists" validate_service - config_list_foreach "$section" "rule_set" validate_ruleset_reference - config_list_foreach "$section" "rule_set_with_subnets" validate_ruleset_reference - config_list_foreach "$section" "domain_ip_lists" validate_plain_domain_ip_list_reference - return 0 - fi - - validate_rule_nfqws2_opt "$section" - fi - - if [ "$action" = "byedpi" ]; then - if ! is_byedpi_installed; then - if [ "$PODKOP_BYEDPI_MISSING_WARNING_EMITTED" -eq 0 ]; then - log "There are enabled rules with action 'byedpi', but the byedpi package is not installed. These rules will be skipped." "error" - PODKOP_BYEDPI_MISSING_WARNING_EMITTED=1 - fi - config_list_foreach "$section" "community_lists" validate_service - config_list_foreach "$section" "rule_set" validate_ruleset_reference - config_list_foreach "$section" "rule_set_with_subnets" validate_ruleset_reference - config_list_foreach "$section" "domain_ip_lists" validate_plain_domain_ip_list_reference - return 0 - fi - - validate_byedpi_strategy "$(get_rule_byedpi_cmd_opts "$section")" "Invalid ByeDPI strategy for rule '$section'" - fi - - if [ "$action" = "proxy" ]; then - local urltest_enabled detect_server_country urltest_filter_mode - config_get_bool urltest_enabled "$section" "urltest_enabled" 0 - if [ "$urltest_enabled" -eq 1 ]; then - config_get urltest_filter_mode "$section" "urltest_filter_mode" "disabled" - validate_urltest_filter_mode_option "$urltest_filter_mode" "$section" - if urltest_filter_mode_filters_enabled "$urltest_filter_mode"; then - config_get detect_server_country "$section" "detect_server_country" "$SERVER_COUNTRY_METHOD_FLAG_EMOJI" - validate_detect_server_country_option "$detect_server_country" "$section" - fi - urltest_check_interval="$(get_urltest_check_interval_for_rule "$section")" - validate_required_sing_box_duration_option "$urltest_check_interval" "rule.$section.urltest_check_interval" - case "$urltest_filter_mode" in - include | mixed) - config_list_foreach "$section" "urltest_include_regex" validate_urltest_regex_option "$section" - config_list_foreach "$section" "urltest_include_countries" validate_country_code_option "$section" - ;; - esac - case "$urltest_filter_mode" in - exclude | mixed) - config_list_foreach "$section" "urltest_exclude_countries" validate_country_code_option "$section" - config_list_foreach "$section" "urltest_exclude_regex" validate_urltest_regex_option "$section" - ;; - esac - fi - - if rule_has_subscription_urls "$section"; then - config_list_foreach "$section" "subscription_urls" validate_subscription_source_entry "$section" - subscription_update_interval="$(get_subscription_update_interval_for_rule "$section")" - if [ -n "$subscription_update_interval" ]; then - validate_required_sing_box_duration_option "$subscription_update_interval" "rule.$section.subscription_update_interval" - fi - fi - fi - - if [ "$action" = "outbound" ]; then - validate_outbound_json_rule "$section" - fi - - validate_outbound_detour_rule "$section" - - config_list_foreach "$section" "domain_suffix" validate_combined_domain_option "$section" - validate_combined_domain_text_option "$section" - config_list_foreach "$section" "community_lists" validate_service - config_list_foreach "$section" "rule_set" validate_ruleset_reference - config_list_foreach "$section" "rule_set_with_subnets" validate_ruleset_reference - config_list_foreach "$section" "domain_ip_lists" validate_plain_domain_ip_list_reference -} diff --git a/podkop/files/usr/lib/config_validation.uc b/podkop/files/usr/lib/config_validation.uc deleted file mode 100644 index 75e0a6ac..00000000 --- a/podkop/files/usr/lib/config_validation.uc +++ /dev/null @@ -1,247 +0,0 @@ -#!/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_stdin_json() { - let data = read_stdin(); - try { - return json(data); - } - catch (e) { - return null; - } -} - -function string_starts_with(value, prefix) { - value = as_string(value); - prefix = as_string(prefix); - return substr(value, 0, length(prefix)) == prefix; -} - -function uci_show_list_value(value) { - return trim(replace(as_string(value), /['"]/g, "")); -} - -function stdin_first_line_field(field_index) { - let data = read_stdin(); - let newline = index(data, "\n"); - let line = newline >= 0 ? substr(data, 0, newline) : data; - let fields = split(trim(as_string(line)), /[ \t\r\n]+/); - - field_index = int(field_index || 0); - if (field_index > 0 && field_index <= length(fields)) - print(fields[field_index - 1], "\n"); -} - -function shell_single_quote(value) { - value = as_string(value); - if (value == "") - return; - print("'", replace(value, /'/g, "'\\''"), "'\n"); -} - -function country_code_valid(value) { - value = uc(as_string(value)); - return match(value, /^[A-Z][A-Z]$/) != null; -} - -function enum_valid(value, start_index) { - value = as_string(value); - for (let i = start_index; i < length(ARGV); i++) - if (value == as_string(ARGV[i])) - return true; - return false; -} - -function regex_valid(pattern) { - pattern = as_string(pattern); - if (pattern == "") - return true; - - try { - regexp(pattern); - return true; - } - catch (e) { - return false; - } -} - -function strip_list_comment(line) { - line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); - return replace(line, /[[:space:]]*#.*$/, ""); -} - -function text_list_values(value) { - let result = []; - - for (let line in split(as_string(value), "\n")) { - line = replace(strip_list_comment(line), /[ ,]/g, "\n"); - for (let item in split(line, "\n")) { - item = trim(replace(item, /\r/g, "")); - if (item != "") - push(result, item); - } - } - - return result; -} - -function valid_domain_suffix(value) { - value = lc(as_string(value)); - if (substr(value, 0, 1) == ".") - value = substr(value, 1); - - return match(value, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/) != null; -} - -function combined_domain_valid(value) { - value = trim(as_string(value)); - if (value == "") - return true; - - let colon = index(value, ":"); - if (colon < 0) - return valid_domain_suffix(value); - - let prefix = lc(substr(value, 0, colon)); - let body = substr(value, colon + 1); - - if (body == "") - return false; - - if (prefix == "full") - return valid_domain_suffix(body); - - if (prefix == "keyword") - return match(body, /[,[:space:]]/) == null; - - if (prefix == "regex") - return match(body, /[,[:space:]]/) == null && regex_valid(body); - - return false; -} - -function combined_domain_text_valid(value) { - for (let item in text_list_values(value)) - if (!combined_domain_valid(item)) - return false; - return true; -} - -function valid_outbound() { - let value = read_stdin_json(); - return type(value) == "object" && type(value.type) == "string"; -} - -function valid_inbound() { - let value = read_stdin_json(); - return type(value) == "object" && type(value.type) == "string"; -} - -function outbound_detour_supported() { - let value = read_stdin_json(); - if (type(value) != "object" || type(value.type) != "string") - return false; - - let outbound_type = lc(as_string(value.type)); - return outbound_type != "selector" && - outbound_type != "urltest" && - outbound_type != "block" && - outbound_type != "dns"; -} - -function dhcp_has_https_dns_proxy_options(path) { - let data = fs.readfile(as_string(path)); - if (data == null) - return null; - - return index(data, "doh_backup_noresolv") >= 0 || - index(data, "doh_backup_server") >= 0 || - index(data, "doh_server") >= 0; -} - -function dhcp_has_https_dns_proxy_options_exit(path) { - let result = dhcp_has_https_dns_proxy_options(path); - exit(result == null ? 2 : (result ? 0 : 1)); -} - -function mwan3_has_enabled_interface() { - let prefix = "mwan3."; - let sections = {}; - let enabled = {}; - - for (let line in split(read_stdin(), "\n")) { - let equals = index(as_string(line), "="); - if (equals < 0) - continue; - - let key = substr(line, 0, equals); - let value = uci_show_list_value(substr(line, equals + 1)); - if (!string_starts_with(key, prefix)) - continue; - - let rest = substr(key, length(prefix)); - if (index(rest, ".") < 0) { - if (value == "interface") - sections[key] = true; - continue; - } - - let option_dot = rindex(key, "."); - let section = substr(key, 0, option_dot); - let option = substr(key, option_dot + 1); - if (option == "enabled") - enabled[section] = value; - } - - for (let section, _ in sections) - if (enabled[section] == "1") - return true; - - return false; -} - -let mode = ARGV[0] || ""; - -if (mode == "stdin-first-line-field") - stdin_first_line_field(ARGV[1]); -else if (mode == "shell-single-quote") - shell_single_quote(ARGV[1]); -else if (mode == "country-code-valid") - exit(country_code_valid(ARGV[1]) ? 0 : 1); -else if (mode == "enum-valid") - exit(enum_valid(ARGV[1], 2) ? 0 : 1); -else if (mode == "regex-valid") - exit(regex_valid(ARGV[1]) ? 0 : 1); -else if (mode == "combined-domain-valid") - exit(combined_domain_valid(ARGV[1]) ? 0 : 1); -else if (mode == "combined-domain-text-valid") - exit(combined_domain_text_valid(ARGV[1]) ? 0 : 1); -else if (mode == "valid-outbound") - exit(valid_outbound() ? 0 : 1); -else if (mode == "valid-inbound") - exit(valid_inbound() ? 0 : 1); -else if (mode == "outbound-detour-supported") - exit(outbound_detour_supported() ? 0 : 1); -else if (mode == "dhcp-has-https-dns-proxy-options") - dhcp_has_https_dns_proxy_options_exit(ARGV[1]); -else if (mode == "mwan3-has-enabled-interface") - exit(mwan3_has_enabled_interface() ? 0 : 1); -else { - warn("Usage: config_validation.uc ...\n"); - exit(1); -} diff --git a/podkop/files/usr/lib/constants.sh b/podkop/files/usr/lib/constants.sh deleted file mode 100644 index e3fea5f9..00000000 --- a/podkop/files/usr/lib/constants.sh +++ /dev/null @@ -1,134 +0,0 @@ -# shellcheck disable=SC2034 - -PODKOP_VERSION="__COMPILED_VERSION_VARIABLE__" -PODKOP_CONFIG_NAME="podkop-plus" -PODKOP_CONFIG="/etc/config/$PODKOP_CONFIG_NAME" -PODKOP_BIN="/usr/bin/podkop-plus" -PODKOP_SERVICE_NAME="podkop-plus" -PODKOP_SERVICE_INIT="/etc/init.d/podkop-plus" -PODKOP_RELEASE_REPO="ushan0v/podkop-plus" -PODKOP_LUCI_VIEW_NAMESPACE="podkop_plus" -PODKOP_LUCI_VIEW_DIR="/www/luci-static/resources/view/$PODKOP_LUCI_VIEW_NAMESPACE" -PODKOP_LUCI_I18N_DOMAIN="podkop_plus" -## Common -RESOLV_CONF="/etc/resolv.conf" -CHECK_PROXY_IP_DOMAIN="ip.podkop.fyi" -FAKEIP_TEST_DOMAIN="fakeip.podkop.fyi" -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 -COREUTILS_BASE64_REQUIRED_VERSION="9.7" -RT_TABLE_NAME="podkopplus" - -## nft -NFT_TABLE_NAME="PodkopPlusTable" -NFT_LOCALV4_SET_NAME="localv4" -NFT_COMMON_SET_NAME="podkop_plus_subnets" -NFT_PORT_SET_NAME="podkop_plus_ports" -NFT_IP_PORT_SET_NAME="podkop_plus_ip_ports" -NFT_DISCORD_SET_NAME="podkop_plus_discord_subnets" -NFT_INTERFACE_SET_NAME="podkop_plus_interfaces" -NFT_FAKEIP_MARK="0x00100000" -NFT_OUTBOUND_MARK="0x00200000" - -## sing-box -SB_REQUIRED_VERSION="1.12.0" -SB_MANAGED_SERVICE_MARKER="Podkop Plus managed sing-box service for binary variants" -# DNS -SB_DNS_SERVER_TAG="dns-server" -SB_FAKEIP_DNS_SERVER_TAG="fakeip-server" -SB_FAKEIP_INET4_RANGE="198.18.0.0/15" -SB_BOOTSTRAP_SERVER_TAG="bootstrap-dns-server" -SB_FAKEIP_DNS_RULE_TAG="fakeip-dns-rule-tag" -SB_FAKEIP_RULESET_DNS_RULE_TAG="fakeip-ruleset-dns-rule-tag" -SB_SERVICE_FAKEIP_DNS_RULE_TAG="service-fakeip-dns-rule-tag" -# Inbounds -SB_TPROXY_INBOUND_TAG="tproxy-in" -SB_TPROXY_INBOUND_ADDRESS="0.0.0.0" -SB_TPROXY_INBOUND_PORT=1602 -SB_DNS_INBOUND_TAG="dns-in" -SB_DNS_INBOUND_ADDRESS="127.0.0.42" -SB_DNS_INBOUND_PORT=53 -SB_SERVICE_MIXED_INBOUND_TAG="service-mixed-in" -SB_SERVICE_MIXED_INBOUND_ADDRESS="127.0.0.1" -SB_SERVICE_MIXED_INBOUND_PORT=4534 -# Outbounds -SB_DIRECT_OUTBOUND_TAG="direct-out" -# Experimental -SB_CLASH_API_CONTROLLER_PORT=9090 -SB_VARIANT_STATE_FILE="/etc/podkop-plus/sing-box-variant" -SB_VERSION_STATE_FILE="/etc/podkop-plus/sing-box-version" - -## Lists -GITHUB_RAW_URL="https://raw.githubusercontent.com/itdoginfo/allow-domains/main" -SRS_MAIN_URL="https://github.com/itdoginfo/allow-domains/releases/latest/download" -SRS_ADS_HAGEZI_PRO_URL="https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs" -SRS_SUPERCELL_URL="https://raw.githubusercontent.com/ushan0v/sing-box-supercell-ruleset/main/supercell.srs" -SUBNETS_TWITTER="${GITHUB_RAW_URL}/Subnets/IPv4/twitter.lst" -SUBNETS_META="${GITHUB_RAW_URL}/Subnets/IPv4/meta.lst" -SUBNETS_DISCORD="${GITHUB_RAW_URL}/Subnets/IPv4/discord.lst" -SUBNETS_ROBLOX="${GITHUB_RAW_URL}/Subnets/IPv4/roblox.lst" -SUBNETS_TELERAM="${GITHUB_RAW_URL}/Subnets/IPv4/telegram.lst" -SUBNETS_CLOUDFLARE="${GITHUB_RAW_URL}/Subnets/IPv4/cloudflare.lst" -SUBNETS_HETZNER="${GITHUB_RAW_URL}/Subnets/IPv4/hetzner.lst" -SUBNETS_OVH="${GITHUB_RAW_URL}/Subnets/IPv4/ovh.lst" -SUBNETS_DIGITALOCEAN="${GITHUB_RAW_URL}/Subnets/IPv4/digitalocean.lst" -SUBNETS_CLOUDFRONT="${GITHUB_RAW_URL}/Subnets/IPv4/cloudfront.lst" -COMMUNITY_SERVICES="russia_inside russia_outside ukraine_inside geoblock block porn news anime youtube hdrezka tiktok google_ai google_play hodca discord meta twitter cloudflare cloudfront digitalocean hetzner ovh telegram roblox ads_hagezi_pro supercell" - -## Zapret -ZAPRET_PROVIDER_BASE_DIR="/opt/zapret" -ZAPRET_PROVIDER_NFQWS_BIN="$ZAPRET_PROVIDER_BASE_DIR/nfq/nfqws" -ZAPRET_PROVIDER_FILES_DIR="$ZAPRET_PROVIDER_BASE_DIR/files" -ZAPRET_PROVIDER_IPSET_DIR="$ZAPRET_PROVIDER_BASE_DIR/ipset" -ZAPRET_LEGACY_RUNTIME_BASE_DIR="/var/run/podkop-plus/zapret-runtime" -ZAPRET_NFQWS_BIN="$ZAPRET_PROVIDER_NFQWS_BIN" -ZAPRET_STATE_DIR="/var/run/podkop-plus/zapret" -ZAPRET_PID_DIR="$ZAPRET_STATE_DIR/pid" -ZAPRET_CHILD_PID_DIR="$ZAPRET_STATE_DIR/child-pid" -ZAPRET_LOG_DIR="$ZAPRET_STATE_DIR/log" -ZAPRET_HOSTLIST_DIR="$ZAPRET_STATE_DIR/hostlist" -ZAPRET_ROUTE_MARK_BASE="0x01000000" -# Podkop Plus uses a dedicated NFQUEUE range so its managed nfqws processes can -# coexist with standalone zapret/luci-app-zapret instances on the same router. -ZAPRET_QUEUE_BASE=4000 -ZAPRET_QUEUE_RANGE_SIZE=256 -ZAPRET_NFQWS_RESPAWN_DELAY=5 -ZAPRET_DESYNC_MARK="0x40000000" -ZAPRET_DESYNC_MARK_POSTNAT="0x20000000" -ZAPRET_LEGACY_DEFAULT_NFQWS_OPT="--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1" -ZAPRET_DEFAULT_NFQWS_OPT="--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin" - -## Zapret2 -ZAPRET2_PROVIDER_BASE_DIR="/opt/zapret2" -ZAPRET2_PROVIDER_NFQWS2_BIN="$ZAPRET2_PROVIDER_BASE_DIR/nfq2/nfqws2" -ZAPRET2_PROVIDER_FILES_DIR="$ZAPRET2_PROVIDER_BASE_DIR/files" -ZAPRET2_PROVIDER_IPSET_DIR="$ZAPRET2_PROVIDER_BASE_DIR/ipset" -ZAPRET2_PROVIDER_LUA_DIR="$ZAPRET2_PROVIDER_BASE_DIR/lua" -ZAPRET2_NFQWS2_BIN="$ZAPRET2_PROVIDER_NFQWS2_BIN" -ZAPRET2_STATE_DIR="/var/run/podkop-plus/zapret2" -ZAPRET2_PID_DIR="$ZAPRET2_STATE_DIR/pid" -ZAPRET2_CHILD_PID_DIR="$ZAPRET2_STATE_DIR/child-pid" -ZAPRET2_LOG_DIR="$ZAPRET2_STATE_DIR/log" -ZAPRET2_ROUTE_MARK_BASE="0x01010000" -# Keep zapret2 queues outside the zapret provider range (4000-4255). -ZAPRET2_QUEUE_BASE=4300 -ZAPRET2_QUEUE_RANGE_SIZE=256 -ZAPRET2_NFQWS2_RESPAWN_DELAY=5 -ZAPRET2_DESYNC_MARK="0x40000000" -ZAPRET2_DESYNC_MARK_POSTNAT="0x20000000" -ZAPRET2_DEFAULT_NFQWS2_OPT="--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6" - -## ByeDPI -BYEDPI_BIN="/usr/bin/ciadpi" -BYEDPI_SERVICE_INIT="/etc/init.d/byedpi" -BYEDPI_STATE_DIR="/var/run/podkop-plus/byedpi" -BYEDPI_PID_DIR="$BYEDPI_STATE_DIR/pid" -BYEDPI_CHILD_PID_DIR="$BYEDPI_STATE_DIR/child-pid" -BYEDPI_LOG_DIR="$BYEDPI_STATE_DIR/log" -BYEDPI_LISTEN_ADDRESS="127.0.0.1" -BYEDPI_PORT_BASE=1080 -BYEDPI_RESPAWN_DELAY=5 -BYEDPI_OPEN_FILES_LIMIT=4096 -BYEDPI_DEFAULT_CMD_OPTS="-o 2 --auto=t,r,a,s -d 2" diff --git a/podkop/files/usr/lib/core/common.uc b/podkop/files/usr/lib/core/common.uc new file mode 100644 index 00000000..2cea2072 --- /dev/null +++ b/podkop/files/usr/lib/core/common.uc @@ -0,0 +1,148 @@ +#!/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_compact_string_array(values) { + print("["); + for (let i = 0; i < length(values); i++) { + if (i > 0) + print(","); + print(sprintf("%J", as_string(values[i]))); + } + print("]\n"); +} + +function csv_to_json_array(value) { + value = as_string(value); + write_compact_string_array(value == "" ? [] : split(value, ",")); +} + +function write_json_file(path, value) { + return fs.writefile(path, sprintf("%J\n", value)); +} + +function strip_internal_fields(value) { + if (type(value) == "array") { + for (let i = 0; i < length(value); i++) + value[i] = strip_internal_fields(value[i]); + return value; + } + + if (type(value) == "object") { + for (let key in keys(value)) { + if (substr(key, 0, 2) == "__") { + delete value[key]; + continue; + } + value[key] = strip_internal_fields(value[key]); + } + } + + return value; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function object_key_count(value) { + return type(value) == "object" ? length(keys(value)) : 0; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function list_option(section, key) { + let value = object_or_empty(section)[key]; + if (value == null) + return []; + if (type(value) == "array") + return value; + let text = trim(as_string(value)); + return text == "" ? [] : split(text, " "); +} + +function bool_option(section, key, fallback) { + if (fallback == null) + fallback = false; + let value = option(section, key, fallback ? "1" : "0"); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function int_option(section, key, fallback) { + let value = option(section, key, fallback); + if (match(value, /[^0-9]/)) + return int(fallback, 10); + return int(value, 10); +} + +return { + as_string, + read_json_file, + read_stdin, + read_stdin_json, + write_json, + write_compact_string_array, + csv_to_json_array, + write_json_file, + strip_internal_fields, + array_or_empty, + object_or_empty, + object_key_count, + option, + list_option, + bool_option, + int_option +}; diff --git a/podkop/files/usr/lib/core/constants.uc b/podkop/files/usr/lib/core/constants.uc new file mode 100644 index 00000000..3e340323 --- /dev/null +++ b/podkop/files/usr/lib/core/constants.uc @@ -0,0 +1,166 @@ +#!/usr/bin/env ucode + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function env(name, fallback) { + let value = getenv(name); + return value == null ? as_string(fallback) : as_string(value); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function constants_map() { + let c = {}; + + c.PODKOP_VERSION = env("PODKOP_VERSION", "__COMPILED_VERSION_VARIABLE__"); + c.PODKOP_CONFIG_NAME = env("PODKOP_CONFIG_NAME", "podkop-plus"); + c.PODKOP_CONFIG = env("PODKOP_CONFIG", "/etc/config/" + c.PODKOP_CONFIG_NAME); + c.PODKOP_BIN = env("PODKOP_BIN", "/usr/bin/podkop-plus"); + c.PODKOP_SERVICE_NAME = env("PODKOP_SERVICE_NAME", "podkop-plus"); + c.PODKOP_SERVICE_INIT = env("PODKOP_SERVICE_INIT", "/etc/init.d/podkop-plus"); + c.PODKOP_RELEASE_REPO = env("PODKOP_RELEASE_REPO", "ushan0v/podkop-plus"); + c.PODKOP_LUCI_VIEW_NAMESPACE = env("PODKOP_LUCI_VIEW_NAMESPACE", "podkop_plus"); + c.PODKOP_LUCI_VIEW_DIR = env("PODKOP_LUCI_VIEW_DIR", "/www/luci-static/resources/view/" + c.PODKOP_LUCI_VIEW_NAMESPACE); + c.PODKOP_LUCI_I18N_DOMAIN = env("PODKOP_LUCI_I18N_DOMAIN", "podkop_plus"); + + c.RESOLV_CONF = env("RESOLV_CONF", "/etc/resolv.conf"); + c.CHECK_PROXY_IP_DOMAIN = env("CHECK_PROXY_IP_DOMAIN", "ip.podkop.fyi"); + c.FAKEIP_TEST_DOMAIN = env("FAKEIP_TEST_DOMAIN", "fakeip.podkop.fyi"); + c.TMP_SING_BOX_FOLDER = env("TMP_SING_BOX_FOLDER", "/tmp/sing-box"); + c.TMP_RULESET_FOLDER = env("TMP_RULESET_FOLDER", c.TMP_SING_BOX_FOLDER + "/rulesets"); + c.TMP_SUBSCRIPTION_FOLDER = env("TMP_SUBSCRIPTION_FOLDER", c.TMP_SING_BOX_FOLDER + "/subscriptions"); + c.CLOUDFLARE_OCTETS = env("CLOUDFLARE_OCTETS", "8.47 162.159 188.114"); + c.COREUTILS_BASE64_REQUIRED_VERSION = env("COREUTILS_BASE64_REQUIRED_VERSION", "9.7"); + c.RT_TABLE_NAME = env("RT_TABLE_NAME", "podkop"); + + c.NFT_TABLE_NAME = env("NFT_TABLE_NAME", "PodkopPlusTable"); + c.NFT_LOCALV4_SET_NAME = env("NFT_LOCALV4_SET_NAME", "localv4"); + c.NFT_COMMON_SET_NAME = env("NFT_COMMON_SET_NAME", "podkop_plus_subnets"); + c.NFT_PORT_SET_NAME = env("NFT_PORT_SET_NAME", "podkop_plus_ports"); + c.NFT_IP_PORT_SET_NAME = env("NFT_IP_PORT_SET_NAME", "podkop_plus_ip_ports"); + c.NFT_DISCORD_SET_NAME = env("NFT_DISCORD_SET_NAME", "podkop_plus_discord_subnets"); + c.NFT_INTERFACE_SET_NAME = env("NFT_INTERFACE_SET_NAME", "podkop_plus_interfaces"); + c.NFT_FAKEIP_MARK = env("NFT_FAKEIP_MARK", "0x00100000"); + c.NFT_OUTBOUND_MARK = env("NFT_OUTBOUND_MARK", "0x00200000"); + + c.SB_REQUIRED_VERSION = env("SB_REQUIRED_VERSION", "1.12.0"); + c.SB_MANAGED_SERVICE_MARKER = env("SB_MANAGED_SERVICE_MARKER", "Podkop Plus managed sing-box service for binary variants"); + c.SB_DNS_SERVER_TAG = env("SB_DNS_SERVER_TAG", "dns-server"); + c.SB_FAKEIP_DNS_SERVER_TAG = env("SB_FAKEIP_DNS_SERVER_TAG", "fakeip-server"); + c.SB_FAKEIP_INET4_RANGE = env("SB_FAKEIP_INET4_RANGE", "198.18.0.0/15"); + c.SB_BOOTSTRAP_SERVER_TAG = env("SB_BOOTSTRAP_SERVER_TAG", "bootstrap-dns-server"); + c.SB_FAKEIP_DNS_RULE_TAG = env("SB_FAKEIP_DNS_RULE_TAG", "fakeip-dns-rule-tag"); + c.SB_FAKEIP_RULESET_DNS_RULE_TAG = env("SB_FAKEIP_RULESET_DNS_RULE_TAG", "fakeip-ruleset-dns-rule-tag"); + c.SB_SERVICE_FAKEIP_DNS_RULE_TAG = env("SB_SERVICE_FAKEIP_DNS_RULE_TAG", "service-fakeip-dns-rule-tag"); + c.SB_TPROXY_INBOUND_TAG = env("SB_TPROXY_INBOUND_TAG", "tproxy-in"); + c.SB_TPROXY_INBOUND_ADDRESS = env("SB_TPROXY_INBOUND_ADDRESS", "0.0.0.0"); + c.SB_TPROXY_INBOUND_PORT = env("SB_TPROXY_INBOUND_PORT", "1602"); + c.SB_DNS_INBOUND_TAG = env("SB_DNS_INBOUND_TAG", "dns-in"); + c.SB_DNS_INBOUND_ADDRESS = env("SB_DNS_INBOUND_ADDRESS", "127.0.0.42"); + c.SB_DNS_INBOUND_PORT = env("SB_DNS_INBOUND_PORT", "53"); + c.SB_SERVICE_MIXED_INBOUND_TAG = env("SB_SERVICE_MIXED_INBOUND_TAG", "service-mixed-in"); + c.SB_SERVICE_MIXED_INBOUND_ADDRESS = env("SB_SERVICE_MIXED_INBOUND_ADDRESS", "127.0.0.1"); + c.SB_SERVICE_MIXED_INBOUND_PORT = env("SB_SERVICE_MIXED_INBOUND_PORT", "4534"); + c.SB_DIRECT_OUTBOUND_TAG = env("SB_DIRECT_OUTBOUND_TAG", "direct-out"); + c.SB_CLASH_API_CONTROLLER_PORT = env("SB_CLASH_API_CONTROLLER_PORT", "9090"); + c.SB_VARIANT_STATE_FILE = env("SB_VARIANT_STATE_FILE", "/etc/podkop-plus/sing-box-variant"); + c.SB_VERSION_STATE_FILE = env("SB_VERSION_STATE_FILE", "/etc/podkop-plus/sing-box-version"); + + c.GITHUB_RAW_URL = env("GITHUB_RAW_URL", "https://raw.githubusercontent.com/itdoginfo/allow-domains/main"); + c.SRS_MAIN_URL = env("SRS_MAIN_URL", "https://github.com/itdoginfo/allow-domains/releases/latest/download"); + c.SRS_ADS_HAGEZI_PRO_URL = env("SRS_ADS_HAGEZI_PRO_URL", "https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs"); + c.SRS_SUPERCELL_URL = env("SRS_SUPERCELL_URL", "https://raw.githubusercontent.com/ushan0v/sing-box-supercell-ruleset/main/supercell.srs"); + c.SUBNETS_TWITTER = env("SUBNETS_TWITTER", c.GITHUB_RAW_URL + "/Subnets/IPv4/twitter.lst"); + c.SUBNETS_META = env("SUBNETS_META", c.GITHUB_RAW_URL + "/Subnets/IPv4/meta.lst"); + c.SUBNETS_DISCORD = env("SUBNETS_DISCORD", c.GITHUB_RAW_URL + "/Subnets/IPv4/discord.lst"); + c.SUBNETS_ROBLOX = env("SUBNETS_ROBLOX", c.GITHUB_RAW_URL + "/Subnets/IPv4/roblox.lst"); + c.SUBNETS_TELERAM = env("SUBNETS_TELERAM", c.GITHUB_RAW_URL + "/Subnets/IPv4/telegram.lst"); + c.SUBNETS_CLOUDFLARE = env("SUBNETS_CLOUDFLARE", c.GITHUB_RAW_URL + "/Subnets/IPv4/cloudflare.lst"); + c.SUBNETS_HETZNER = env("SUBNETS_HETZNER", c.GITHUB_RAW_URL + "/Subnets/IPv4/hetzner.lst"); + c.SUBNETS_OVH = env("SUBNETS_OVH", c.GITHUB_RAW_URL + "/Subnets/IPv4/ovh.lst"); + c.SUBNETS_DIGITALOCEAN = env("SUBNETS_DIGITALOCEAN", c.GITHUB_RAW_URL + "/Subnets/IPv4/digitalocean.lst"); + c.SUBNETS_CLOUDFRONT = env("SUBNETS_CLOUDFRONT", c.GITHUB_RAW_URL + "/Subnets/IPv4/cloudfront.lst"); + c.COMMUNITY_SERVICES = env("COMMUNITY_SERVICES", "russia_inside russia_outside ukraine_inside geoblock block porn news anime youtube hdrezka tiktok google_ai google_play hodca discord meta twitter cloudflare cloudfront digitalocean hetzner ovh telegram roblox ads_hagezi_pro supercell"); + + c.ZAPRET_PROVIDER_BASE_DIR = env("ZAPRET_PROVIDER_BASE_DIR", "/opt/zapret"); + c.ZAPRET_PROVIDER_NFQWS_BIN = env("ZAPRET_PROVIDER_NFQWS_BIN", c.ZAPRET_PROVIDER_BASE_DIR + "/nfq/nfqws"); + c.ZAPRET_PROVIDER_FILES_DIR = env("ZAPRET_PROVIDER_FILES_DIR", c.ZAPRET_PROVIDER_BASE_DIR + "/files"); + c.ZAPRET_PROVIDER_IPSET_DIR = env("ZAPRET_PROVIDER_IPSET_DIR", c.ZAPRET_PROVIDER_BASE_DIR + "/ipset"); + c.ZAPRET_LEGACY_RUNTIME_BASE_DIR = env("ZAPRET_LEGACY_RUNTIME_BASE_DIR", "/var/run/podkop-plus/zapret-runtime"); + c.ZAPRET_NFQWS_BIN = env("ZAPRET_NFQWS_BIN", c.ZAPRET_PROVIDER_NFQWS_BIN); + c.ZAPRET_STATE_DIR = env("ZAPRET_STATE_DIR", "/var/run/podkop-plus/zapret"); + c.ZAPRET_PID_DIR = env("ZAPRET_PID_DIR", c.ZAPRET_STATE_DIR + "/pid"); + c.ZAPRET_CHILD_PID_DIR = env("ZAPRET_CHILD_PID_DIR", c.ZAPRET_STATE_DIR + "/child-pid"); + c.ZAPRET_LOG_DIR = env("ZAPRET_LOG_DIR", c.ZAPRET_STATE_DIR + "/log"); + c.ZAPRET_HOSTLIST_DIR = env("ZAPRET_HOSTLIST_DIR", c.ZAPRET_STATE_DIR + "/hostlist"); + c.ZAPRET_ROUTE_MARK_BASE = env("ZAPRET_ROUTE_MARK_BASE", "0x01000000"); + c.ZAPRET_QUEUE_BASE = env("ZAPRET_QUEUE_BASE", "4000"); + c.ZAPRET_QUEUE_RANGE_SIZE = env("ZAPRET_QUEUE_RANGE_SIZE", "256"); + c.ZAPRET_NFQWS_RESPAWN_DELAY = env("ZAPRET_NFQWS_RESPAWN_DELAY", "5"); + c.ZAPRET_DESYNC_MARK = env("ZAPRET_DESYNC_MARK", "0x40000000"); + c.ZAPRET_DESYNC_MARK_POSTNAT = env("ZAPRET_DESYNC_MARK_POSTNAT", "0x20000000"); + c.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT = env("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1"); + c.ZAPRET_DEFAULT_NFQWS_OPT = env("ZAPRET_DEFAULT_NFQWS_OPT", "--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin"); + + c.ZAPRET2_PROVIDER_BASE_DIR = env("ZAPRET2_PROVIDER_BASE_DIR", "/opt/zapret2"); + c.ZAPRET2_PROVIDER_NFQWS2_BIN = env("ZAPRET2_PROVIDER_NFQWS2_BIN", c.ZAPRET2_PROVIDER_BASE_DIR + "/nfq2/nfqws2"); + c.ZAPRET2_PROVIDER_FILES_DIR = env("ZAPRET2_PROVIDER_FILES_DIR", c.ZAPRET2_PROVIDER_BASE_DIR + "/files"); + c.ZAPRET2_PROVIDER_IPSET_DIR = env("ZAPRET2_PROVIDER_IPSET_DIR", c.ZAPRET2_PROVIDER_BASE_DIR + "/ipset"); + c.ZAPRET2_PROVIDER_LUA_DIR = env("ZAPRET2_PROVIDER_LUA_DIR", c.ZAPRET2_PROVIDER_BASE_DIR + "/lua"); + c.ZAPRET2_NFQWS2_BIN = env("ZAPRET2_NFQWS2_BIN", c.ZAPRET2_PROVIDER_NFQWS2_BIN); + c.ZAPRET2_STATE_DIR = env("ZAPRET2_STATE_DIR", "/var/run/podkop-plus/zapret2"); + c.ZAPRET2_PID_DIR = env("ZAPRET2_PID_DIR", c.ZAPRET2_STATE_DIR + "/pid"); + c.ZAPRET2_CHILD_PID_DIR = env("ZAPRET2_CHILD_PID_DIR", c.ZAPRET2_STATE_DIR + "/child-pid"); + c.ZAPRET2_LOG_DIR = env("ZAPRET2_LOG_DIR", c.ZAPRET2_STATE_DIR + "/log"); + c.ZAPRET2_ROUTE_MARK_BASE = env("ZAPRET2_ROUTE_MARK_BASE", "0x01010000"); + c.ZAPRET2_QUEUE_BASE = env("ZAPRET2_QUEUE_BASE", "4300"); + c.ZAPRET2_QUEUE_RANGE_SIZE = env("ZAPRET2_QUEUE_RANGE_SIZE", "256"); + c.ZAPRET2_NFQWS2_RESPAWN_DELAY = env("ZAPRET2_NFQWS2_RESPAWN_DELAY", "5"); + c.ZAPRET2_DESYNC_MARK = env("ZAPRET2_DESYNC_MARK", "0x40000000"); + c.ZAPRET2_DESYNC_MARK_POSTNAT = env("ZAPRET2_DESYNC_MARK_POSTNAT", "0x20000000"); + c.ZAPRET2_DEFAULT_NFQWS2_OPT = env("ZAPRET2_DEFAULT_NFQWS2_OPT", "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=fake_default_quic:repeats=6"); + + c.BYEDPI_BIN = env("BYEDPI_BIN", "/usr/bin/ciadpi"); + c.BYEDPI_SERVICE_INIT = env("BYEDPI_SERVICE_INIT", "/etc/init.d/byedpi"); + c.BYEDPI_STATE_DIR = env("BYEDPI_STATE_DIR", "/var/run/podkop-plus/byedpi"); + c.BYEDPI_PID_DIR = env("BYEDPI_PID_DIR", c.BYEDPI_STATE_DIR + "/pid"); + c.BYEDPI_CHILD_PID_DIR = env("BYEDPI_CHILD_PID_DIR", c.BYEDPI_STATE_DIR + "/child-pid"); + c.BYEDPI_LOG_DIR = env("BYEDPI_LOG_DIR", c.BYEDPI_STATE_DIR + "/log"); + c.BYEDPI_LISTEN_ADDRESS = env("BYEDPI_LISTEN_ADDRESS", "127.0.0.1"); + c.BYEDPI_PORT_BASE = env("BYEDPI_PORT_BASE", "1080"); + c.BYEDPI_RESPAWN_DELAY = env("BYEDPI_RESPAWN_DELAY", "5"); + c.BYEDPI_OPEN_FILES_LIMIT = env("BYEDPI_OPEN_FILES_LIMIT", "4096"); + c.BYEDPI_DEFAULT_CMD_OPTS = env("BYEDPI_DEFAULT_CMD_OPTS", "-o 2 --auto=t,r,a,s -d 2"); + + return c; +} + +function print_shell_env(constants) { + for (let name in sort(keys(constants))) + print(name, "=", shell_quote(constants[name]), "\n"); +} + +function module_exports() { + return constants_map(); +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0] || ""; +let constants = constants_map(); + +if (mode == "shell-env") + print_shell_env(constants); +else if (mode == "json") + print(sprintf("%J", constants), "\n"); +else if (mode == "get") + print(as_string(constants[ARGV[1]]), "\n"); +else { + warn("Usage: core/constants.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/helpers.uc b/podkop/files/usr/lib/core/helpers.uc similarity index 84% rename from podkop/files/usr/lib/helpers.uc rename to podkop/files/usr/lib/core/helpers.uc index 800d2255..de280a99 100644 --- a/podkop/files/usr/lib/helpers.uc +++ b/podkop/files/usr/lib/core/helpers.uc @@ -2,6 +2,17 @@ let fs = require("fs"); +const SB_DNS_SERVER_TAG = getenv("SB_DNS_SERVER_TAG") || "dns-server"; +const SB_FAKEIP_DNS_SERVER_TAG = getenv("SB_FAKEIP_DNS_SERVER_TAG") || "fakeip-server"; +const SB_BOOTSTRAP_SERVER_TAG = getenv("SB_BOOTSTRAP_SERVER_TAG") || "bootstrap-dns-server"; +const SB_FAKEIP_DNS_RULE_TAG = getenv("SB_FAKEIP_DNS_RULE_TAG") || "fakeip-dns-rule-tag"; +const SB_FAKEIP_RULESET_DNS_RULE_TAG = getenv("SB_FAKEIP_RULESET_DNS_RULE_TAG") || "fakeip-ruleset-dns-rule-tag"; +const SB_SERVICE_FAKEIP_DNS_RULE_TAG = getenv("SB_SERVICE_FAKEIP_DNS_RULE_TAG") || "service-fakeip-dns-rule-tag"; +const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || "tproxy-in"; +const SB_DNS_INBOUND_TAG = getenv("SB_DNS_INBOUND_TAG") || "dns-in"; +const SB_SERVICE_MIXED_INBOUND_TAG = getenv("SB_SERVICE_MIXED_INBOUND_TAG") || "service-mixed-in"; +const SB_DIRECT_OUTBOUND_TAG = getenv("SB_DIRECT_OUTBOUND_TAG") || "direct-out"; + function as_string(value) { return value == null ? "" : "" + value; } @@ -446,6 +457,19 @@ function whitespace_list_contains(list, needle) { return false; } +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + + if (purpose == "lists") + print("download_lists_via_proxy\n"); + else if (purpose == "subscriptions") + print("download_subscriptions_via_proxy\n"); + else if (purpose == "components") + print("download_components_via_proxy\n"); + else + exit(1); +} + function md5sum_hex_prefix(prefix_length) { let input = read_stdin(); let newline = index(input, "\n"); @@ -481,10 +505,10 @@ function tag_is_reserved(tag, reserved) { return false; } -function allocate_runtime_tag(base, postfix) { +function allocate_runtime_tag_value(base, postfix, reserved) { base = as_string(base); postfix = as_string(postfix); - let reserved = slice(ARGV, 3); + reserved = type(reserved) == "array" ? reserved : []; let candidate = base + "-" + postfix; let suffix = 1; @@ -502,7 +526,30 @@ function allocate_runtime_tag(base, postfix) { suffix++; } - print(candidate, "\n"); + return candidate; +} + +function default_reserved_runtime_tags() { + return [ + SB_DNS_SERVER_TAG, + SB_FAKEIP_DNS_SERVER_TAG, + SB_BOOTSTRAP_SERVER_TAG, + SB_FAKEIP_DNS_RULE_TAG, + SB_FAKEIP_RULESET_DNS_RULE_TAG, + SB_SERVICE_FAKEIP_DNS_RULE_TAG, + SB_TPROXY_INBOUND_TAG, + SB_DNS_INBOUND_TAG, + SB_SERVICE_MIXED_INBOUND_TAG, + SB_DIRECT_OUTBOUND_TAG + ]; +} + +function print_runtime_tag(base, postfix, reserved) { + print(allocate_runtime_tag_value(base, postfix, reserved), "\n"); +} + +function allocate_runtime_tag(base, postfix) { + print_runtime_tag(base, postfix, slice(ARGV, 3)); } function sing_box_version_is_extended(value) { @@ -535,12 +582,24 @@ else if (mode == "stdin-trim-string") stdin_trim_string(); else if (mode == "whitespace-list-contains") exit(whitespace_list_contains(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "download-via-proxy-option-for-purpose") + download_via_proxy_option_for_purpose(ARGV[1]); else if (mode == "md5sum-hex-prefix") md5sum_hex_prefix(ARGV[1]); else if (mode == "md5sum-hwid") md5sum_hwid(); else if (mode == "allocate-runtime-tag") allocate_runtime_tag(ARGV[1], ARGV[2]); +else if (mode == "inbound-tag") + print_runtime_tag(ARGV[1], "in", default_reserved_runtime_tags()); +else if (mode == "server-inbound-tag") + print_runtime_tag("server-" + as_string(ARGV[1]), "in", default_reserved_runtime_tags()); +else if (mode == "tailscale-dns-tag") + print_runtime_tag("server-" + as_string(ARGV[1]), "tailscale-dns", default_reserved_runtime_tags()); +else if (mode == "outbound-tag") + print_runtime_tag(ARGV[1], "out", default_reserved_runtime_tags()); +else if (mode == "domain-resolver-tag") + print_runtime_tag(ARGV[1], "domain-resolver", default_reserved_runtime_tags()); else if (mode == "sing-box-version-is-extended") exit(sing_box_version_is_extended(ARGV[1]) ? 0 : 1); else if (mode == "url-get-scheme") @@ -564,6 +623,6 @@ else if (mode == "normalize-strategy-whitespace") else if (mode == "text-list-to-lines") text_list_to_lines(ARGV[1], ARGV[2]); else { - warn("Usage: helpers.uc ...\n"); + warn("Usage: core/helpers.uc ...\n"); exit(1); } diff --git a/podkop/files/usr/lib/core/packages.uc b/podkop/files/usr/lib/core/packages.uc new file mode 100644 index 00000000..c94da186 --- /dev/null +++ b/podkop/files/usr/lib/core/packages.uc @@ -0,0 +1,141 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_success(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_output(args) { + let pipe = fs.popen(command_from_args(args), "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_exists(name) { + return command_success([ "command", "-v", name ]); +} + +function apk_installed(package_name) { + return command_exists("apk") && command_success([ "apk", "info", "-e", as_string(package_name) ]); +} + +function opkg_installed(package_name) { + package_name = as_string(package_name); + if (!command_exists("opkg")) + return false; + + let prefix = package_name + " - "; + for (let line in split(command_output([ "opkg", "list-installed" ]), "\n")) { + line = trim(as_string(line)); + if (substr(line, 0, length(prefix)) == prefix) + return true; + } + return false; +} + +function installed(package_name) { + return apk_installed(package_name) || opkg_installed(package_name); +} + +function apk_manifest_version(package_name, output) { + package_name = as_string(package_name); + let found = false; + + for (let line in split(as_string(output), "\n")) { + line = trim(as_string(line)); + if (substr(line, 0, 2) == "P:") + found = substr(line, 2) == package_name; + else if (found && substr(line, 0, 2) == "V:") + return substr(line, 2); + } + return ""; +} + +function apk_info_version(package_name, output) { + package_name = as_string(package_name); + let prefix = package_name + "-"; + + for (let line in split(as_string(output), "\n")) { + line = trim(as_string(line)); + if (line == "") + continue; + return substr(line, 0, length(prefix)) == prefix ? substr(line, length(prefix)) : line; + } + return ""; +} + +function apk_version(package_name) { + package_name = as_string(package_name); + if (!apk_installed(package_name)) + return ""; + + let version = apk_manifest_version(package_name, command_output([ "apk", "list", "--installed", "--manifest", package_name ])); + if (version != "") + return version; + + version = apk_manifest_version(package_name, command_output([ "apk", "list", "--installed", "--manifest" ])); + if (version != "") + return version; + + return apk_info_version(package_name, command_output([ "apk", "info", "-v", package_name ])); +} + +function opkg_version(package_name) { + package_name = as_string(package_name); + if (!command_exists("opkg")) + return ""; + + let prefix = package_name + " - "; + for (let line in split(command_output([ "opkg", "list-installed" ]), "\n")) { + line = trim(as_string(line)); + if (substr(line, 0, length(prefix)) == prefix) + return substr(line, length(prefix)); + } + return ""; +} + +function version(package_name) { + let value = apk_version(package_name); + return value != "" ? value : opkg_version(package_name); +} + +let mode = ARGV[0] || ""; + +if (mode == "installed") + exit(installed(ARGV[1]) ? 0 : 1); +else if (mode == "apk-installed") + exit(apk_installed(ARGV[1]) ? 0 : 1); +else if (mode == "opkg-installed") + exit(opkg_installed(ARGV[1]) ? 0 : 1); +else if (mode == "version") + print(version(ARGV[1]), "\n"); +else if (mode == "apk-version") + print(apk_version(ARGV[1]), "\n"); +else if (mode == "opkg-version") + print(opkg_version(ARGV[1]), "\n"); +else { + warn("Usage: core/packages.uc \n"); + exit(1); +} diff --git a/podkop/files/usr/lib/core/uci.uc b/podkop/files/usr/lib/core/uci.uc new file mode 100644 index 00000000..52ad670c --- /dev/null +++ b/podkop/files/usr/lib/core/uci.uc @@ -0,0 +1,500 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); + +let as_string = common.as_string; + +const UCI_STATE_FILE = getenv("PODKOP_UCI_STATE_FILE") || getenv("UCI_STATE") || ""; +const UCI_LOG_FILE = getenv("PODKOP_UCI_LOG_FILE") || getenv("UCI_LOG") || ""; + +let runtime_cursor = false; + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function path_parts(path) { + path = as_string(path); + let first = index(path, "."); + if (first < 0) + return null; + + let package_name = substr(path, 0, first); + let rest = substr(path, first + 1); + let second = index(rest, "."); + if (second < 0) + return { package: package_name, section: rest, option: "" }; + + return { + package: package_name, + section: substr(rest, 0, second), + option: substr(rest, second + 1) + }; +} + +function state_lines() { + let data = fs.readfile(UCI_STATE_FILE); + if (data == null || data == "") + return []; + return split(replace(data, /\r/g, ""), "\n"); +} + +function state_write_lines(lines) { + return fs.writefile(UCI_STATE_FILE, join("\n", lines) + "\n") != null; +} + +function state_get(path) { + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == path) + return equals >= 0 ? substr(line, equals + 1) : ""; + } + return ""; +} + +function state_exists(path) { + let prefix = as_string(path) + "."; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == path || substr(key, 0, length(prefix)) == prefix) + return true; + } + return false; +} + +function state_delete(path) { + let prefix = as_string(path) + "."; + let changed = false; + let lines = []; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == path || substr(key, 0, length(prefix)) == prefix) { + changed = true; + continue; + } + push(lines, line); + } + return !changed || state_write_lines(lines); +} + +function state_set(path, value) { + let lines = []; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key != path) + push(lines, line); + } + push(lines, as_string(path) + "=" + as_string(value)); + return state_write_lines(lines); +} + +function state_add_list(path, value) { + let current = state_get(path); + return state_set(path, current == "" ? value : current + " " + as_string(value)); +} + +function state_del_list(path, value) { + let current = state_get(path); + if (current == "") + return false; + + let values = []; + let removed = false; + for (let item in words(current)) { + if (item == value) { + removed = true; + continue; + } + push(values, item); + } + + if (!removed) + return false; + if (length(values) == 0) + return state_delete(path); + return state_set(path, join(" ", values)); +} + +function state_commit(package_name) { + if (UCI_LOG_FILE == "") + return true; + let existing = fs.readfile(UCI_LOG_FILE); + existing = existing == null ? "" : as_string(existing); + return fs.writefile(UCI_LOG_FILE, existing + "commit " + as_string(package_name) + "\n") != null; +} + +function state_sections(package_name, type_name) { + let result = []; + let prefix = as_string(package_name) + "."; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + if (equals < 0) + continue; + + let key = substr(line, 0, equals); + let value = substr(line, equals + 1); + if (value != type_name || substr(key, 0, length(prefix)) != prefix) + continue; + + let section = substr(key, length(prefix)); + if (index(section, ".") < 0) + push(result, section); + } + return result; +} + +function state_get_all(package_name, section_name) { + package_name = as_string(package_name); + section_name = as_string(section_name); + + let result = {}; + let section_type = state_get(package_name + "." + section_name); + if (section_type != "") { + result[".name"] = section_name; + result[".type"] = section_type; + } + + let prefix = package_name + "." + section_name + "."; + for (let line in state_lines()) { + if (line == "") + continue; + let equals = index(line, "="); + if (equals < 0) + continue; + + let key = substr(line, 0, equals); + if (substr(key, 0, length(prefix)) != prefix) + continue; + + let option = substr(key, length(prefix)); + if (option != "") + result[option] = substr(line, equals + 1); + } + + return length(keys(result)) > 0 ? result : null; +} + +function fixture_enabled() { + return UCI_STATE_FILE != ""; +} + +function cursor() { + if (runtime_cursor !== false) + return runtime_cursor; + + try { + runtime_cursor = require("uci").cursor(); + } + catch (e) { + runtime_cursor = null; + } + return runtime_cursor; +} + +function available() { + return fixture_enabled() || cursor() != null; +} + +function load(package_name) { + if (fixture_enabled()) + return true; + + let c = cursor(); + if (c == null) + return false; + + try { + c.load(as_string(package_name)); + return true; + } + catch (e) { + return false; + } +} + +function value_to_string(value) { + if (value == null) + return ""; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function value_to_list(value) { + if (value == null) + return []; + if (type(value) == "array") + return value; + return words(value); +} + +function get(path) { + path = as_string(path); + if (fixture_enabled()) + return state_get(path); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option == "") + return ""; + if (!load(parts.package)) + return ""; + + return value_to_string(c.get(parts.package, parts.section, parts.option)); +} + +function get_all(package_name, section_name) { + if (fixture_enabled()) + return state_get_all(package_name, section_name); + + let c = cursor(); + if (c == null) + return null; + + try { + load(package_name); + return c.get_all(as_string(package_name), as_string(section_name)); + } + catch (e) { + return null; + } +} + +function exists(path) { + path = as_string(path); + if (fixture_enabled()) + return state_exists(path); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null) + return false; + if (!load(parts.package)) + return false; + + if (parts.option == "") + return c.get_all(parts.package, parts.section) != null; + return c.get(parts.package, parts.section, parts.option) != null; +} + +function delete_path(path) { + path = as_string(path); + if (fixture_enabled()) + return state_delete(path); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null) + return false; + + try { + if (parts.option == "") + c.delete(parts.package, parts.section); + else + c.delete(parts.package, parts.section, parts.option); + return true; + } + catch (e) { + return false; + } +} + +function set_section(path, type_name) { + path = as_string(path); + if (fixture_enabled()) + return state_set(path, type_name); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option != "") + return false; + + try { + c.set(parts.package, parts.section, as_string(type_name)); + return true; + } + catch (e) { + return false; + } +} + +function set(path, value) { + path = as_string(path); + if (fixture_enabled()) + return state_set(path, value_to_string(value)); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option == "") + return false; + + try { + c.set(parts.package, parts.section, parts.option, type(value) == "array" ? value : as_string(value)); + return true; + } + catch (e) { + return false; + } +} + +function add_list(path, value) { + path = as_string(path); + if (fixture_enabled()) + return state_add_list(path, value); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option == "") + return false; + + try { + let values = value_to_list(c.get(parts.package, parts.section, parts.option)); + push(values, as_string(value)); + c.set(parts.package, parts.section, parts.option, values); + return true; + } + catch (e) { + return false; + } +} + +function del_list(path, value) { + path = as_string(path); + if (fixture_enabled()) + return state_del_list(path, value); + + let parts = path_parts(path); + let c = cursor(); + if (c == null || parts == null || parts.option == "") + return false; + + let values = []; + let removed = false; + for (let item in value_to_list(c.get(parts.package, parts.section, parts.option))) { + if (item == value) { + removed = true; + continue; + } + push(values, item); + } + + if (!removed) + return false; + + try { + if (length(values) == 0) + c.delete(parts.package, parts.section, parts.option); + else + c.set(parts.package, parts.section, parts.option, values); + return true; + } + catch (e) { + return false; + } +} + +function commit(package_name) { + if (fixture_enabled()) + return state_commit(package_name); + + let c = cursor(); + if (c == null) + return false; + + try { + return c.commit(package_name) != false; + } + catch (e) { + return false; + } +} + +function section_name(section) { + if (type(section) == "object") + return as_string(section[".name"] || ""); + return as_string(section); +} + +function sections(package_name, type_name) { + if (fixture_enabled()) + return state_sections(package_name, type_name); + + let c = cursor(); + if (c == null) + return []; + + let result = []; + try { + load(package_name); + c.foreach(package_name, as_string(type_name), function(section) { + let name = section_name(section); + if (name != "") + push(result, name); + }); + } + catch (e) { + return []; + } + return result; +} + +function section_objects(package_name, type_name) { + if (fixture_enabled()) { + let result = []; + for (let name in state_sections(package_name, type_name)) { + let section = state_get_all(package_name, name); + if (type(section) == "object") + push(result, section); + } + return result; + } + + let c = cursor(); + if (c == null) + return []; + + let result = []; + try { + load(package_name); + c.foreach(package_name, as_string(type_name), function(section) { + if (type(section) == "object") + push(result, section); + }); + } + catch (e) { + return []; + } + return result; +} + +return { + available, + load, + get, + get_all, + exists, + delete: delete_path, + set_section, + set, + add_list, + del_list, + commit, + sections, + section_objects +}; diff --git a/podkop/files/usr/lib/core/url.uc b/podkop/files/usr/lib/core/url.uc new file mode 100644 index 00000000..10dafcdb --- /dev/null +++ b/podkop/files/usr/lib/core/url.uc @@ -0,0 +1,181 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); + +let as_string = common.as_string; + +function str_last_index(value, needle) { + value = as_string(value); + needle = as_string(needle); + if (needle == "") + return length(value); + + for (let i = length(value) - length(needle); i >= 0; i--) + if (substr(value, i, length(needle)) == needle) + return i; + + return -1; +} + +function first_index_any(value, needles, start) { + value = as_string(value); + start = int(start || 0); + + for (let i = start; i < length(value); i++) { + let c = substr(value, i, 1); + for (let needle in needles) + if (c == needle) + return i; + } + + return -1; +} + +function hex_digit_value(value) { + value = ord(lc(as_string(value))); + if (value >= 48 && value <= 57) + return value - 48; + if (value >= 97 && value <= 102) + return value - 87; + return -1; +} + +function decode(value) { + value = as_string(value); + let result = ""; + + for (let i = 0; i < length(value); i++) { + let c = substr(value, i, 1); + if (c == "+") { + result += " "; + continue; + } + if (c == "%") { + let high = i + 1 < length(value) ? hex_digit_value(substr(value, i + 1, 1)) : -1; + let low = i + 2 < length(value) ? hex_digit_value(substr(value, i + 2, 1)) : -1; + if (high >= 0 && low >= 0) { + result += chr(high * 16 + low); + i += 2; + continue; + } + } + result += c; + } + + return result; +} + +function scheme(value) { + value = as_string(value); + let marker = index(value, "://"); + return marker >= 0 ? lc(substr(value, 0, marker)) : ""; +} + +function fragment(value) { + value = as_string(value); + let marker = index(value, "#"); + return marker >= 0 ? decode(substr(value, marker + 1)) : ""; +} + +function strip_fragment(value) { + value = as_string(value); + let marker = index(value, "#"); + return marker >= 0 ? substr(value, 0, marker) : value; +} + +function strip_anchored_scheme(value) { + value = as_string(value); + let marker = index(value, "://"); + if (marker < 0) + return value; + + let prefix = substr(value, 0, marker); + if (index(prefix, "/") >= 0 || index(prefix, "?") >= 0) + return value; + + return substr(value, marker + 3); +} + +function strip_first_scheme_marker(value) { + value = as_string(value); + let marker = index(value, "://"); + return marker >= 0 ? substr(value, marker + 3) : value; +} + +function authority(value) { + value = strip_first_scheme_marker(value); + let at = index(value, "@"); + if (at >= 0) + value = substr(value, at + 1); + + let end = first_index_any(value, ["/", "?", "#"], 0); + return end >= 0 ? substr(value, 0, end) : value; +} + +function host(value) { + let value_authority = authority(value); + let colon = index(value_authority, ":"); + return colon >= 0 ? substr(value_authority, 0, colon) : value_authority; +} + +function port(value) { + let value_authority = authority(value); + let colon = index(value_authority, ":"); + return colon >= 0 ? substr(value_authority, colon + 1) : ""; +} + +function userinfo(value) { + let value_authority = strip_first_scheme_marker(strip_fragment(value)); + let end = first_index_any(value_authority, ["/", "?", "#"], 0); + value_authority = end >= 0 ? substr(value_authority, 0, end) : value_authority; + let at = str_last_index(value_authority, "@"); + return at >= 0 ? decode(substr(value_authority, 0, at)) : ""; +} + +function path(value) { + value = strip_anchored_scheme(value); + let slash = index(value, "/"); + value = slash >= 0 ? substr(value, slash) : ""; + + let query = index(value, "?"); + if (query >= 0) + value = substr(value, 0, query); + + return value; +} + +function query_params(value) { + let result = {}; + value = as_string(value); + let question = index(value, "?"); + if (question < 0) + return result; + + let query = substr(value, question + 1); + let hash = index(query, "#"); + if (hash >= 0) + query = substr(query, 0, hash); + + for (let pair in split(query, "&")) { + if (pair == "") + continue; + let equals = index(pair, "="); + let key = decode(equals >= 0 ? substr(pair, 0, equals) : pair); + let value = equals >= 0 ? decode(substr(pair, equals + 1)) : ""; + if (key != "") + result[key] = value; + } + return result; +} + +return { + decode, + scheme, + fragment, + strip_fragment, + host, + port, + userinfo, + path, + query_params +}; diff --git a/podkop/files/usr/lib/diagnostics/runtime.uc b/podkop/files/usr/lib/diagnostics/runtime.uc new file mode 100644 index 00000000..662ea456 --- /dev/null +++ b/podkop/files/usr/lib/diagnostics/runtime.uc @@ -0,0 +1,1875 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || constants.PODKOP_CONFIG_NAME || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const PODKOP_VERSION = getenv("PODKOP_VERSION") || constants.PODKOP_VERSION || ""; +const PODKOP_CONFIG = getenv("PODKOP_CONFIG") || constants.PODKOP_CONFIG || "/etc/config/" + CONFIG_NAME; +const PODKOP_SERVICE_NAME = getenv("PODKOP_SERVICE_NAME") || constants.PODKOP_SERVICE_NAME || "podkop-plus"; +const PODKOP_RELEASE_REPO = getenv("PODKOP_RELEASE_REPO") || constants.PODKOP_RELEASE_REPO || "ushan0v/podkop-plus"; +const PODKOP_LUCI_VIEW_DIR = getenv("PODKOP_LUCI_VIEW_DIR") || constants.PODKOP_LUCI_VIEW_DIR || "/www/luci-static/resources/view/podkop_plus"; +const RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const SYSTEM_INFO_CACHE_FILE = getenv("PODKOP_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json"; +const SYSTEM_INFO_CACHE_TTL = int(getenv("PODKOP_SYSTEM_INFO_CACHE_TTL") || "3600"); +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || constants.TMP_SING_BOX_FOLDER || "/tmp/sing-box"; +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || constants.TMP_RULESET_FOLDER || TMP_SING_BOX_FOLDER + "/rulesets"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || constants.TMP_SUBSCRIPTION_FOLDER || TMP_SING_BOX_FOLDER + "/subscriptions"; +const SUBSCRIPTION_LINKS_DIR = getenv("PODKOP_SUBSCRIPTION_LINKS_DIR") || RUNTIME_STATE_DIR + "/subscription-links"; +const SUBSCRIPTION_METADATA_DIR = getenv("PODKOP_SUBSCRIPTION_METADATA_DIR") || RUNTIME_STATE_DIR + "/subscription-metadata"; +const OUTBOUND_METADATA_DIR = getenv("PODKOP_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata"; +const SECTION_CACHE_DIR = getenv("PODKOP_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const CHECK_PROXY_IP_DOMAIN = getenv("CHECK_PROXY_IP_DOMAIN") || constants.CHECK_PROXY_IP_DOMAIN || "ip.podkop.fyi"; +const FAKEIP_TEST_DOMAIN = getenv("FAKEIP_TEST_DOMAIN") || constants.FAKEIP_TEST_DOMAIN || "fakeip.podkop.fyi"; +const RT_TABLE_NAME = getenv("RT_TABLE_NAME") || constants.RT_TABLE_NAME || "podkop"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || constants.NFT_TABLE_NAME || "PodkopPlusTable"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || constants.NFT_FAKEIP_MARK || "0x00100000"; +const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || constants.NFT_COMMON_SET_NAME || "podkop_plus_subnets"; +const NFT_PORT_SET_NAME = getenv("NFT_PORT_SET_NAME") || constants.NFT_PORT_SET_NAME || "podkop_plus_ports"; +const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || constants.NFT_IP_PORT_SET_NAME || "podkop_plus_ip_ports"; +const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || constants.NFT_INTERFACE_SET_NAME || "podkop_plus_interfaces"; +const NFT_DISCORD_SET_NAME = getenv("NFT_DISCORD_SET_NAME") || constants.NFT_DISCORD_SET_NAME || "podkop_plus_discord_subnets"; +const NFT_LOCALV4_SET_NAME = getenv("NFT_LOCALV4_SET_NAME") || constants.NFT_LOCALV4_SET_NAME || "localv4"; +const SB_DNS_INBOUND_ADDRESS = getenv("SB_DNS_INBOUND_ADDRESS") || constants.SB_DNS_INBOUND_ADDRESS || "127.0.0.42"; +const SB_TPROXY_INBOUND_PORT = getenv("SB_TPROXY_INBOUND_PORT") || constants.SB_TPROXY_INBOUND_PORT || "1602"; +const SB_CLASH_API_CONTROLLER_PORT = getenv("SB_CLASH_API_CONTROLLER_PORT") || constants.SB_CLASH_API_CONTROLLER_PORT || "9090"; +const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || constants.SB_VARIANT_STATE_FILE || "/etc/podkop-plus/sing-box-variant"; +const CLOUDFLARE_OCTETS = getenv("CLOUDFLARE_OCTETS") || constants.CLOUDFLARE_OCTETS || "8.47 162.159 188.114"; +const ZAPRET_LEGACY_DEFAULT_NFQWS_OPT = getenv("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT") || constants.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT || ""; + +const STATUS_UC = LIB_DIR + "/diagnostics/status.uc"; +const HELPERS_UC = LIB_DIR + "/core/helpers.uc"; +const PACKAGES_UC = LIB_DIR + "/core/packages.uc"; +const DNS_APPLY_UC = LIB_DIR + "/dns/apply.uc"; +const SERVICE_STATE_UC = LIB_DIR + "/service/state.uc"; +const SERVICE_UI_UC = LIB_DIR + "/service/ui.uc"; +const SUBSCRIPTION_CACHE_UC = LIB_DIR + "/subscription/cache.uc"; +const PROVIDERS_STATUS_UC = LIB_DIR + "/providers/status.uc"; +const SINGBOX_RUNTIME_UC = LIB_DIR + "/singbox/runtime.uc"; +const ZAPRET_RUNTIME_UC = LIB_DIR + "/providers/zapret/runtime.uc"; +const ZAPRET2_RUNTIME_UC = LIB_DIR + "/providers/zapret2/runtime.uc"; +const BYEDPI_RUNTIME_UC = LIB_DIR + "/providers/byedpi/runtime.uc"; +const ZAPRET_VALIDATOR_UC = LIB_DIR + "/providers/zapret/validator.uc"; +const ZAPRET2_VALIDATOR_UC = LIB_DIR + "/providers/zapret2/validator.uc"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function arg_number(value) { + value = as_string(value); + return value == "" || match(value, /[^0-9-]/) != null ? 0 : int(value, 10); +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function command_status(command) { + return normalize_status(system(command)); +} + +function command_capture(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return { status: 1, output: "" }; + + let data = pipe.read("all"); + let status = normalize_status(pipe.close()); + return { status, output: data == null ? "" : as_string(data) }; +} + +function command_output(command) { + let result = command_capture(command); + return result.status == 0 ? result.output : ""; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_output_from_args(args) { + return command_output(command_from_args(args) + " 2>/dev/null"); +} + +function command_success_from_args(args) { + return command_success(command_from_args(args)); +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", as_string(name) ]); +} + +function module_args(module_path, args) { + let result = [ "ucode", "-L", LIB_DIR, module_path ]; + for (let arg in args) + push(result, arg); + return result; +} + +function module_capture(module_path, args) { + return command_capture(command_from_args(module_args(module_path, args))); +} + +function module_capture_stdin(module_path, args, input) { + let tmp = trim(command_output_from_args([ "mktemp" ])); + if (tmp == "") + return { status: 1, output: "" }; + + if (!fs.writefile(tmp, as_string(input))) { + fs.unlink(tmp); + return { status: 1, output: "" }; + } + + let result = command_capture(command_from_args(module_args(module_path, args)) + " < " + shell_quote(tmp)); + fs.unlink(tmp); + return result; +} + +function module_output(module_path, args) { + let result = module_capture(module_path, args); + return result.status == 0 ? result.output : ""; +} + +function module_output_stdin(module_path, args, input) { + let result = module_capture_stdin(module_path, args, input); + return result.status == 0 ? result.output : ""; +} + +function module_success(module_path, args) { + return command_success(command_from_args(module_args(module_path, args))); +} + +function module_passthrough(module_path, args) { + let result = module_capture(module_path, args); + if (result.output != "") + print(result.output); + return result.status; +} + +function status_capture(args, input) { + if (input != null) + return module_capture_stdin(STATUS_UC, args, input); + return command_capture(command_from_args(module_args(STATUS_UC, args))); +} + +function status_output(args, input) { + let result = status_capture(args, input); + return result.status == 0 ? result.output : ""; +} + +function status_success(args, input) { + return status_capture(args, input).status == 0; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function read_stdin() { + let input = fs.open("/dev/stdin", "r"); + if (!input) + return ""; + let data = input.read("all"); + input.close(); + return data == null ? "" : as_string(data); +} + +function read_json_file(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + try { + return json(data); + } + catch (e) { + return null; + } +} + +function parse_json_or_null(value) { + try { + return json(as_string(value)); + } + catch (e) { + return null; + } +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + let value = object_or_empty(section)[key]; + if (value == null) + return as_string(fallback); + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function list_option(section, key) { + let value = object_or_empty(section)[key]; + if (type(value) == "array") + return value; + if (as_string(value) != "") + return [ as_string(value) ]; + return []; +} + +function bool_option(section, key, fallback) { + return arg_bool(option(section, key, fallback ? "1" : "0")); +} + +function settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, type_name); +} + +function uci_get(path) { + return uci_core.get(path); +} + +function uci_show(path) { + return uci_core.exists(path); +} + +function append_unique(values, value) { + value = as_string(value); + if (value == "") + return; + for (let item in values) + if (item == value) + return; + push(values, value); +} + +function config_section_types(config_path) { + let data = as_string(fs.readfile(config_path) || ""); + let result = []; + + for (let line in split(data, "\n")) { + line = trim(as_string(line)); + if (substr(line, 0, 7) != "config ") + continue; + + let fields = split(line, /[ \t\r\n]+/); + if (length(fields) >= 2) + append_unique(result, replace(as_string(fields[1]), /['"]/g, "")); + } + + return result; +} + +function uci_show_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function append_uci_show_option(lines, package_name, section_name, key, value) { + if (key == ".name" || key == ".type") + return; + + let path = as_string(package_name) + "." + as_string(section_name) + "." + as_string(key) + "="; + if (type(value) == "array") { + for (let item in value) + push(lines, path + uci_show_quote(item)); + } + else { + push(lines, path + uci_show_quote(value)); + } +} + +function uci_show_data(package_name, config_path) { + let lines = []; + package_name = as_string(package_name); + for (let type_name in config_section_types(config_path)) { + for (let section in uci_core.section_objects(package_name, type_name)) { + let name = as_string(section[".name"] || ""); + if (name == "") + continue; + push(lines, package_name + "." + name + "=" + as_string(section[".type"] || type_name)); + for (let key, value in section) + append_uci_show_option(lines, package_name, name, key, value); + } + } + return join("\n", lines) + "\n"; +} + +function network_show_data() { + return uci_show_data("network", "/etc/config/network"); +} + +function firewall_show_data() { + return uci_show_data("firewall", "/etc/config/firewall"); +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function file_executable(path) { + return command_success_from_args([ "test", "-x", as_string(path) ]); +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", as_string(path) ]); +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function first_line_value(path, fallback) { + let data = fs.readfile(as_string(path)); + if (data == null) + return as_string(fallback); + let line = split(as_string(data), "\n")[0]; + line = replace(as_string(line), /\r$/, ""); + return line != "" ? line : as_string(fallback); +} + +function stdout_is_tty() { + return command_success_from_args([ "test", "-t", "1" ]); +} + +function nolog(message) { + if (!stdout_is_tty()) + return; + let timestamp = replace(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), /[\r\n]+$/g, ""); + print("\033[0;36m[", timestamp, "]\033[0m \033[0;32m", as_string(message), "\033[0m\n"); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "podkop-plus", "[" + level + "] " + as_string(message) ]); +} + +function valid_ipv4_octet(value) { + value = as_string(value); + if (match(value, /^[0-9]+$/) == null) + return false; + let number = int(value, 10); + return number >= 0 && number <= 255; +} + +function valid_ipv4(value) { + value = as_string(value); + if (length(value) > 0 && substr(value, length(value) - 1) == ".") + value = substr(value, 0, length(value) - 1); + let parts = split(value, "."); + if (length(parts) != 4) + return false; + for (let part in parts) + if (!valid_ipv4_octet(part)) + return false; + return true; +} + +function valid_public_ipv4(value) { + value = as_string(value); + if (!valid_ipv4(value)) + return false; + + let parts = split(value, "."); + let a = int(parts[0], 10); + let b = int(parts[1], 10); + + if (a == 0 || a == 10 || a == 127 || a >= 224) + return false; + if (a == 169 && b == 254) + return false; + if (a == 192 && (b == 168 || b == 0 || b == 2)) + return false; + if (a == 198 && (b == 18 || b == 19 || b == 51)) + return false; + if (a == 203 && b == 0) + return false; + if (a == 100 && b >= 64 && b <= 127) + return false; + if (a == 172 && b >= 16 && b <= 31) + return false; + + return true; +} + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function network_status_ipv4_address(data) { + let value = parse_json_or_null(data); + let addresses = type(value) == "object" ? value["ipv4-address"] : null; + if (type(addresses) == "array" && length(addresses) > 0 && type(addresses[0]) == "object") + return as_string(addresses[0].address || ""); + return ""; +} + +function get_wan_ipv4_address() { + for (let interface in [ "wan", "wwan" ]) { + let ip = network_status_ipv4_address(command_output_from_args([ + "ubus", "-S", "call", "network.interface." + interface, "status" + ])); + if (ip != "") + return ip; + } + + let route = command_output_from_args([ "ip", "-4", "route", "show", "default" ]); + let fields = words(route); + let iface = ""; + for (let i = 0; i + 1 < length(fields); i++) { + if (fields[i] == "dev") { + iface = fields[i + 1]; + break; + } + } + if (iface == "") + return ""; + + let addr = command_output_from_args([ "ip", "-4", "addr", "show", "dev", iface ]); + for (let line in split(addr, "\n")) { + line = trim(as_string(line)); + let matched = match(line, /^inet[ \t]+([0-9.]+)\//); + if (matched != null) + return as_string(matched[1]); + } + + return ""; +} + +function helper_output(mode, args) { + let full = [ mode ]; + for (let arg in args) + push(full, arg); + return replace(module_output(HELPERS_UC, full), /[\r\n]+$/g, ""); +} + +function server_inbound_tag(section) { + return helper_output("server-inbound-tag", [ section ]); +} + +function server_required_inbound_proto(protocol) { + protocol = as_string(protocol); + if (protocol == "json_inbound") + return ""; + return protocol == "hysteria2" ? "udp" : "tcp"; +} + +function server_runtime_type_for_protocol(protocol) { + protocol = as_string(protocol); + if (protocol == "json_inbound") + return ""; + if (protocol == "mtproto") + return "mtproxy"; + return protocol; +} + +function server_listen_requires_firewall(listen, wan_ip) { + listen = as_string(listen); + return listen == "0.0.0.0" || (as_string(wan_ip) != "" && listen == as_string(wan_ip)) || valid_public_ipv4(listen); +} + +function firewall_required_protocols_open(port, required_proto) { + let firewall = firewall_show_data(); + return status_success([ "firewall-required-protocols-open", port, required_proto ], firewall); +} + +function server_required_port_conflict_owners(listen, port, required_proto) { + return replace(status_output( + [ "server-required-port-conflict-owners", listen, port, required_proto ], + command_output_from_args([ "netstat", "-lnp" ]) + ), /[\r\n]+$/g, ""); +} + +function server_required_ports_listening(listen, port, required_proto) { + return status_success( + [ "server-required-ports-listening", listen, port, required_proto ], + command_output_from_args([ "netstat", "-ln" ]) + ); +} + +function resolve_public_host_ipv4s(host) { + host = as_string(host); + if (host == "") + return ""; + if (valid_ipv4(host)) + return host; + + let seen = {}; + for (let line in split(command_output_from_args([ + "dig", "+short", "A", host, "+timeout=2", "+tries=1" + ]), "\n")) { + line = trim(as_string(line)); + if (valid_ipv4(line)) + seen[line] = true; + } + + return join(" ", sort(keys(seen))); +} + +function public_host_flags(public_host, public_host_ips, wan_ip, wan_public) { + return replace(status_output( + [ "public-host-flags", public_host, public_host_ips, wan_ip, wan_public ], + null + ), /[\r\n]+$/g, ""); +} + +function check_inbounds_config() { + let count = 0; + for (let section in uci_sections("server")) + if (bool_option(section, "enabled", false)) + count++; + write_json({ enabled_count: count }); + return 0; +} + +function check_inbounds() { + let cfg = settings(); + let sing_box_config_path = option(cfg, "config_path", ""); + let wan_ip = get_wan_ipv4_address(); + let wan_public = valid_public_ipv4(wan_ip) ? 1 : 0; + let items = []; + let enabled_count = 0; + + for (let section in uci_sections("server")) { + if (!bool_option(section, "enabled", false)) + continue; + enabled_count++; + + let section_name = as_string(section[".name"] || ""); + let label = option(section, "label", section_name); + let protocol = option(section, "protocol", "vless"); + let listen = option(section, "listen", "0.0.0.0"); + let listen_port = option(section, "listen_port", ""); + let public_host = option(section, "public_host", ""); + let routing_mode = option(section, "routing_mode", "rules"); + let inbound_tag = server_inbound_tag(section_name); + let expected_type = server_runtime_type_for_protocol(protocol); + let required_proto = server_required_inbound_proto(protocol); + let runtime_json = protocol == "tailscale" + ? module_output(PROVIDERS_STATUS_UC, [ "endpoint-summary", sing_box_config_path, inbound_tag ]) + : module_output(PROVIDERS_STATUS_UC, [ "inbound-summary", sing_box_config_path, inbound_tag ]); + + let listening = -1; + let firewall_required = 0; + let firewall_open = -1; + let port_conflict = 0; + let port_conflict_owners = ""; + if (protocol != "tailscale" && protocol != "json_inbound") { + port_conflict_owners = server_required_port_conflict_owners(listen, listen_port, required_proto); + if (port_conflict_owners != "") + port_conflict = 1; + listening = server_required_ports_listening(listen, listen_port, required_proto) ? 1 : 0; + if (server_listen_requires_firewall(listen, wan_ip)) { + firewall_required = 1; + firewall_open = firewall_required_protocols_open(listen_port, required_proto) ? 1 : 0; + } + } + + let routes_configured = module_success(PROVIDERS_STATUS_UC, [ + "has-route-rule-for-inbound", sing_box_config_path, inbound_tag + ]) ? 1 : 0; + + let public_host_ips = protocol == "json_inbound" ? "" : resolve_public_host_ipv4s(public_host); + let flags = words(public_host_flags(public_host, public_host_ips, wan_ip, wan_public)); + while (length(flags) < 3) + push(flags, "-1"); + + let item_json = status_output([ + "inbound-item-json", + runtime_json, + section_name, + label, + protocol, + routing_mode, + inbound_tag, + listen, + listen_port, + public_host, + public_host_ips, + expected_type, + required_proto, + listening, + firewall_required, + firewall_open, + port_conflict, + port_conflict_owners, + routes_configured, + flags[0], + flags[1], + flags[2] + ], null); + let item = parse_json_or_null(item_json); + push(items, type(item) == "object" ? item : {}); + } + + write_json({ + enabled_count, + config_path: sing_box_config_path, + wan_ip, + wan_public, + items + }); + return 0; +} + +function cleanup_check_proxy_dir(dir) { + dir = as_string(dir); + let prefix = TMP_SING_BOX_FOLDER + "/check-proxy-"; + if (substr(dir, 0, length(prefix)) == prefix) + command_success_from_args([ "rm", "-rf", dir ]); +} + +function check_proxy() { + let sing_box_config_path = option(settings(), "config_path", ""); + if (!command_exists("sing-box")) { + nolog("sing-box is not installed"); + return 1; + } + if (!file_exists(sing_box_config_path)) { + nolog("Configuration file not found"); + return 1; + } + + nolog("Checking sing-box configuration..."); + if (!command_success_from_args([ "sing-box", "-c", sing_box_config_path, "check" ])) { + nolog("Invalid configuration"); + return 1; + } + + print(status_output([ "mask-sing-box-config", sing_box_config_path ], null)); + nolog("Checking proxy connection..."); + + let check_proxy_dir = TMP_SING_BOX_FOLDER + "/check-proxy-" + clock()[0] + "-" + clock()[1]; + let check_proxy_config = check_proxy_dir + "/config.json"; + let check_proxy_cache = check_proxy_dir + "/cache.db"; + + cleanup_check_proxy_dir(check_proxy_dir); + ensure_dir(check_proxy_dir); + if (!status_success([ "prepare-check-proxy-config", sing_box_config_path, check_proxy_config, check_proxy_cache ], null)) { + nolog("Failed to prepare temporary configuration"); + cleanup_check_proxy_dir(check_proxy_dir); + return 1; + } + + let outbound_tag = replace(status_output( + [ "check-proxy-outbound-tag", check_proxy_config, CHECK_PROXY_IP_DOMAIN ], + null + ), /[\r\n]+$/g, ""); + + let response = ""; + for (let attempt = 1; attempt <= 5; attempt++) { + let args = [ "sing-box", "tools", "fetch", "ifconfig.me", "-c", check_proxy_config, "-D", check_proxy_dir, "--disable-color" ]; + if (outbound_tag != "") { + push(args, "-o"); + push(args, outbound_tag); + } + response = command_output(command_from_args(args) + " 2>/dev/null"); + if (status_success([ "proxy-response-is-retryable-error" ], response)) + continue; + + let masked_response_ip = replace(status_output([ "proxy-response-ip-mask" ], response), /[\r\n]+$/g, ""); + if (masked_response_ip != "") { + nolog(masked_response_ip + " - should match proxy IP"); + cleanup_check_proxy_dir(check_proxy_dir); + return 0; + } + + if (attempt == 5) { + nolog("Failed to get valid IP address after 5 attempts"); + nolog(response == "" ? "Error: Empty response" : "Error response: " + response); + cleanup_check_proxy_dir(check_proxy_dir); + return 1; + } + } + + cleanup_check_proxy_dir(check_proxy_dir); + return 1; +} + +function domain_lists_contain_cloud_provider() { + for (let section in uci_sections("section")) { + if (!bool_option(section, "domain_list_enabled", false)) + continue; + for (let value in list_option(section, "domain_list")) + if (value == "hetzner" || value == "ovh") + return true; + } + return false; +} + +function check_nft() { + if (!command_exists("nft")) { + nolog("nft is not installed"); + return 1; + } + + nolog("Checking " + NFT_TABLE_NAME + " rules..."); + if (!command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) { + nolog("❌ " + NFT_TABLE_NAME + " not found"); + return 1; + } + + if (domain_lists_contain_cloud_provider()) { + nolog("Sets statistics:"); + for (let set_name in [ + NFT_COMMON_SET_NAME, + NFT_PORT_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_DISCORD_SET_NAME, + NFT_LOCALV4_SET_NAME + ]) { + if (!command_success_from_args([ "nft", "list", "set", "inet", NFT_TABLE_NAME, set_name ])) + continue; + let count = replace(status_output( + [ "nft-set-element-count" ], + command_output_from_args([ "nft", "-j", "list", "set", "inet", NFT_TABLE_NAME, set_name ]) + ), /[\r\n]+$/g, ""); + print("- ", set_name, ": ", count, " elements\n"); + } + + nolog("Chain configurations:"); + print(status_output( + [ "nft-chain-config-blocks", "mangle", "proxy" ], + command_output_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ]) + )); + } + else { + nolog("Sets configuration:"); + print(command_output_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])); + } + + nolog("NFT check completed"); + return 0; +} + +function check_logs() { + if (!command_exists("logread")) { + nolog("Error: logread command not found"); + return 1; + } + let rendered = status_capture([ "podkop-logs" ], command_output_from_args([ "logread" ])); + if (rendered.output != "") + print(rendered.output); + if (rendered.status != 0) { + nolog("Logs not found"); + return 1; + } + return 0; +} + +function check_sing_box_logs() { + if (!command_exists("logread")) { + nolog("Error: logread command not found"); + return 1; + } + let rendered = status_capture([ "matching-log-tail", "sing-box", "100" ], command_output_from_args([ "logread" ])); + if (rendered.output != "") + print(rendered.output); + if (rendered.status != 0) { + nolog("sing-box logs not found"); + return 1; + } + return 0; +} + +function podkop_logs_fixture() { + let rendered = status_capture([ "podkop-logs" ], read_stdin()); + if (rendered.output != "") + print(rendered.output); + return rendered.status; +} + +function show_sing_box_config(visibility) { + visibility = as_string(visibility || "masked"); + let sing_box_config_path = option(settings(), "config_path", ""); + nolog("Current sing-box configuration:"); + if (!file_exists(sing_box_config_path)) { + nolog("Configuration file not found"); + return 1; + } + if (visibility == "raw") + print(as_string(fs.readfile(sing_box_config_path))); + else + print(status_output([ "mask-sing-box-config", sing_box_config_path ], null)); + return 0; +} + +function show_config(visibility) { + visibility = as_string(visibility || "masked"); + if (!file_exists(PODKOP_CONFIG)) { + nolog("Configuration file not found"); + return 1; + } + if (visibility == "raw") + print(as_string(fs.readfile(PODKOP_CONFIG))); + else + print(status_output([ "podkop-config-masked", PODKOP_CONFIG ], null)); + return 0; +} + +function show_version() { + print(PODKOP_VERSION, "\n"); + return 0; +} + +function show_sing_box_version() { + print(replace(module_output(SINGBOX_RUNTIME_UC, [ "version" ]), /[\r\n]+$/g, ""), "\n"); + return 0; +} + +function get_luci_app_version() { + let path = PODKOP_LUCI_VIEW_DIR + "/main.js"; + let data = fs.readfile(path); + if (data == null) + return "not installed"; + + for (let line in split(as_string(data), "\n")) { + let matched = match(line, /^[ \t]*var[ \t]+([^ \t=]+)[ \t]*=[ \t]*"([^"]*)"/); + if (matched != null && matched[1] == "PODKOP_LUCI_APP_VERSION") + return as_string(matched[2]); + } + return ""; +} + +function system_info_cache_is_valid() { + let cache = read_json_file(SYSTEM_INFO_CACHE_FILE); + if (type(cache) != "object") + return false; + let now = int(clock()[0]); + let generated_at = arg_number(cache.generated_at || 0); + if (now > 0 && generated_at > 0 && SYSTEM_INFO_CACHE_TTL > 0 && now - generated_at >= SYSTEM_INFO_CACHE_TTL) + return false; + return cache.podkop_version == PODKOP_VERSION && cache.luci_app_version == get_luci_app_version(); +} + +function ensure_subscription_runtime_dirs() { + module_success(SUBSCRIPTION_CACHE_UC, [ + "ensure-runtime-dirs" + ]); + ensure_dir(RUNTIME_STATE_DIR); +} + +function write_system_info_cache(value) { + ensure_subscription_runtime_dirs(); + let tmpfile = SYSTEM_INFO_CACHE_FILE + "." + clock()[0] + "." + clock()[1] + ".tmp"; + if (fs.writefile(tmpfile, as_string(value) + "\n") == null) + return false; + remove_file(SYSTEM_INFO_CACHE_FILE); + if (!fs.rename(tmpfile, SYSTEM_INFO_CACHE_FILE)) { + remove_file(tmpfile); + return false; + } + return true; +} + +function sing_box_marker_is(expected) { + return module_success(SINGBOX_RUNTIME_UC, [ "marker-is", expected ]); +} + +function sing_box_component_action_running() { + return module_success(SERVICE_UI_UC, [ "component-action-running-for", "sing_box" ]); +} + +function sing_box_live_probe_disabled() { + return sing_box_marker_is("extended-compressed") || sing_box_component_action_running(); +} + +function sing_box_tiny_package_installed() { + return module_success(PACKAGES_UC, [ "installed", "sing-box-tiny" ]); +} + +function sing_box_capability_flags(sing_box_version, sing_box_version_output) { + let extended = 0; + let tiny = 0; + let tailscale = 0; + + if (sing_box_marker_is("extended") || + sing_box_marker_is("extended-compressed") || + module_success(SINGBOX_RUNTIME_UC, [ "is-extended", sing_box_version ])) + extended = 1; + + if (extended == 0 && (sing_box_tiny_package_installed() || sing_box_marker_is("tiny"))) + tiny = 1; + + if (extended == 1) + tailscale = 1; + else if (as_string(sing_box_version_output) != "") { + if (module_success(SINGBOX_RUNTIME_UC, [ "supports-tailscale", sing_box_version, sing_box_version_output ])) + tailscale = 1; + } + else if (tiny == 0 && sing_box_component_action_running()) + tailscale = 1; + + return { extended, tiny, tailscale }; +} + +function provider_installed(runtime_uc) { + return module_success(runtime_uc, [ "installed" ]); +} + +function provider_version(runtime_uc) { + let value = replace(module_output(runtime_uc, [ "package-version" ]), /[\r\n]+$/g, ""); + return value != "" ? value : "unknown"; +} + +function openwrt_release() { + let data = fs.readfile("/etc/os-release"); + if (data == null) + return "unknown"; + for (let line in split(as_string(data), "\n")) { + if (substr(line, 0, length("OPENWRT_RELEASE=")) != "OPENWRT_RELEASE=") + continue; + let value = substr(line, length("OPENWRT_RELEASE=")); + if (length(value) >= 2) { + let quote = substr(value, 0, 1); + if ((quote == "\"" || quote == "'") && substr(value, length(value) - 1) == quote) + value = substr(value, 1, length(value) - 2); + } + return value != "" ? value : "unknown"; + } + return "unknown"; +} + +function build_system_info() { + let podkop_latest_version = first_line_value("/tmp/podkop-plus.latest-version.cache", "unknown"); + let luci_app_version = get_luci_app_version(); + let sing_box_version = ""; + let sing_box_version_output = ""; + + if (command_exists("sing-box")) { + if (sing_box_live_probe_disabled()) { + sing_box_version = replace(module_output(SINGBOX_RUNTIME_UC, [ "read-version-state" ]), /[\r\n]+$/g, ""); + sing_box_version_output = ""; + } + else { + sing_box_version_output = module_output(SINGBOX_RUNTIME_UC, [ "version-output" ]); + sing_box_version = replace(module_output_stdin(SINGBOX_RUNTIME_UC, [ "version-from-output" ], sing_box_version_output), /[\r\n]+$/g, ""); + } + if (sing_box_version == "") + sing_box_version = "unknown"; + } + else { + sing_box_version = "not installed"; + sing_box_version_output = ""; + } + + let flags = sing_box_capability_flags(sing_box_version, sing_box_version_output); + let sing_box_compressed = flags.extended == 1 && sing_box_marker_is("extended-compressed") ? 1 : 0; + + let zapret_installed = provider_installed(ZAPRET_RUNTIME_UC) ? 1 : 0; + let zapret_version = zapret_installed ? provider_version(ZAPRET_RUNTIME_UC) : "not installed"; + let zapret2_installed = provider_installed(ZAPRET2_RUNTIME_UC) ? 1 : 0; + let zapret2_version = zapret2_installed ? provider_version(ZAPRET2_RUNTIME_UC) : "not installed"; + let byedpi_installed = provider_installed(BYEDPI_RUNTIME_UC) ? 1 : 0; + let byedpi_version = byedpi_installed ? provider_version(BYEDPI_RUNTIME_UC) : "not installed"; + let device_model = first_line_value("/tmp/sysinfo/model", "unknown"); + + return { + podkop_version: PODKOP_VERSION, + podkop_latest_version: podkop_latest_version || "unknown", + luci_app_version, + sing_box_version, + sing_box_extended: flags.extended, + sing_box_tiny: flags.tiny, + sing_box_compressed, + sing_box_tailscale: flags.tailscale, + zapret_version, + zapret_installed, + zapret2_version, + zapret2_installed, + byedpi_version, + byedpi_installed, + openwrt_version: openwrt_release(), + device_model, + generated_at: int(clock()[0]) + }; +} + +function get_system_info() { + if (system_info_cache_is_valid()) { + print(as_string(fs.readfile(SYSTEM_INFO_CACHE_FILE))); + return 0; + } + + let system_info = sprintf("%J", build_system_info()); + write_system_info_cache(system_info); + print(system_info, "\n"); + return 0; +} + +function get_server_capabilities() { + let sing_box_version_output = ""; + let sing_box_version = ""; + if (sing_box_live_probe_disabled()) + sing_box_version = replace(module_output(SINGBOX_RUNTIME_UC, [ "read-version-state" ]), /[\r\n]+$/g, ""); + else { + sing_box_version_output = module_output(SINGBOX_RUNTIME_UC, [ "version-output" ]); + sing_box_version = replace(module_output_stdin(SINGBOX_RUNTIME_UC, [ "version-from-output" ], sing_box_version_output), /[\r\n]+$/g, ""); + } + let flags = sing_box_capability_flags(sing_box_version, sing_box_version_output); + write_json({ + sing_box_extended: flags.extended, + sing_box_tiny: flags.tiny, + sing_box_tailscale: flags.tailscale + }); + return 0; +} + +function neutralize_zapret_defaults() { + log_message("Standalone zapret is not neutralized automatically; Podkop Plus uses /opt/zapret/nfq/nfqws as an external provider and manages only its own NFQUEUE range.", "info"); + return 0; +} + +function sing_box_process_is_running() { + return command_success_from_args([ "pgrep", "-x", "sing-box" ]) || + command_success_from_args([ "pgrep", "-f", "^/usr/bin/sing-box[[:space:]]" ]); +} + +function service_status_label(running, enabled) { + if (arg_number(running) == 1) + return arg_number(enabled) == 1 ? "running & enabled" : "running but disabled"; + return arg_number(enabled) == 1 ? "stopped but enabled" : "stopped & disabled"; +} + +function write_service_status(running, enabled, dns_configured) { + write_json({ + running, + enabled, + status: service_status_label(running, enabled), + dns_configured + }); +} + +function dnsmasq_has_podkop_dns() { + return module_success(DNS_APPLY_UC, [ "has-podkop-dns" ]); +} + +function get_sing_box_status() { + let running = sing_box_process_is_running() ? 1 : 0; + let enabled = file_executable("/etc/rc.d/S99sing-box") ? 1 : 0; + let dns_configured = dnsmasq_has_podkop_dns() ? 1 : 0; + write_service_status(running, enabled, dns_configured); + return 0; +} + +function get_status() { + let running = module_success(SERVICE_STATE_UC, [ + "podkop-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK + ]) ? 1 : 0; + let enabled = file_executable("/etc/rc.d/S99" + PODKOP_SERVICE_NAME) ? 1 : 0; + let dns_configured = dnsmasq_has_podkop_dns() ? 1 : 0; + write_service_status(running, enabled, dns_configured); + return 0; +} + +function subscription_cache(args) { + return module_capture(SUBSCRIPTION_CACHE_UC, args); +} + +function print_subscription_result(result, fallback) { + if (result.status == 0 && result.output != "") { + print(result.output); + return 0; + } + print(as_string(fallback)); + return 0; +} + +function section_safe(section) { + section = as_string(section); + return section != "" && index(section, "/") < 0 && index(section, "..") < 0; +} + +function get_outbound_link(section, outbound_tag) { + subscription_cache([ "ensure-runtime-dirs" ]); + if (!section_safe(section)) + return print_subscription_result(subscription_cache([ "empty-link" ]), ""); + let result = subscription_cache([ "get-link", SECTION_CACHE_DIR, TMP_SUBSCRIPTION_FOLDER, section, outbound_tag, SUBSCRIPTION_LINKS_DIR ]); + if (result.status != 0) + result = subscription_cache([ "empty-link" ]); + return print_subscription_result(result, ""); +} + +function get_outbound_link_states(section) { + subscription_cache([ "ensure-runtime-dirs" ]); + if (!section_safe(section)) { + print("{}\n"); + return 0; + } + let result = subscription_cache([ "get-link-states", SECTION_CACHE_DIR, section, SUBSCRIPTION_LINKS_DIR ]); + return print_subscription_result(result, "{}\n"); +} + +function get_outbound_metadata(section) { + subscription_cache([ "ensure-runtime-dirs" ]); + if (!section_safe(section)) + return print_subscription_result(subscription_cache([ "empty-outbound-metadata" ]), ""); + let metadata_path = replace(module_output(SUBSCRIPTION_CACHE_UC, [ "outbound-metadata-path", section ]), /[\r\n]+$/g, ""); + if (metadata_path == "") + return print_subscription_result(subscription_cache([ "empty-outbound-metadata" ]), ""); + let result = subscription_cache([ "get-outbound-metadata", SECTION_CACHE_DIR, section, metadata_path ]); + if (result.status != 0) + result = subscription_cache([ "empty-outbound-metadata" ]); + return print_subscription_result(result, ""); +} + +function get_subscription_metadata(section) { + subscription_cache([ "ensure-runtime-dirs" ]); + if (!section_safe(section)) { + print("{}\n"); + return 0; + } + let metadata_path = replace(module_output(SUBSCRIPTION_CACHE_UC, [ "subscription-metadata-path", section ]), /[\r\n]+$/g, ""); + if (metadata_path == "") { + print("{}\n"); + return 0; + } + let result = subscription_cache([ "get-subscription-metadata", SECTION_CACHE_DIR, section, metadata_path ]); + return print_subscription_result(result, "{}\n"); +} + +function validate_nfqws_strategy_json(raw_opt) { + let result = module_capture(ZAPRET_VALIDATOR_UC, [ + "validate-json", "nfqws", as_string(raw_opt), ZAPRET_LEGACY_DEFAULT_NFQWS_OPT + ]); + if (result.output != "") + print(result.output); + return 0; +} + +function validate_nfqws2_strategy_json(raw_opt) { + let result = module_capture(ZAPRET2_VALIDATOR_UC, [ "validate-json", "nfqws2", as_string(raw_opt) ]); + if (result.output != "") + print(result.output); + return 0; +} + +function url_host(value) { + return helper_output("url-get-host", [ value ]); +} + +function url_port(value) { + return helper_output("url-get-port", [ value ]); +} + +function url_path(value) { + return helper_output("url-get-path", [ value ]); +} + +function dns_check_resolve_host(host, resolver) { + host = as_string(host); + resolver = as_string(resolver); + if (host == "") + return ""; + if (valid_ipv4(host)) + return host; + if (resolver == "") + return ""; + + for (let line in split(command_output_from_args([ + "dig", "@" + resolver, host, "A", "+short", "+timeout=2", "+tries=1" + ]), "\n")) { + line = trim(as_string(line)); + if (valid_ipv4(line)) + return line; + } + return ""; +} + +function dns_check_dig_server_available(dns_type, dns_server, bootstrap_dns_server, domain) { + let dns_host = url_host(dns_server); + if (dns_host == "") + dns_host = as_string(dns_server); + let server_port = url_port(dns_server); + let probe_server = ""; + let tls_hostname = ""; + + if (valid_ipv4(dns_host)) + probe_server = dns_host; + else { + probe_server = dns_check_resolve_host(dns_host, bootstrap_dns_server); + if (probe_server == "") + return false; + tls_hostname = dns_host; + } + + let args = [ "dig" ]; + if (server_port != "") { + push(args, "-p"); + push(args, server_port); + } + push(args, "@" + probe_server); + push(args, domain); + if (dns_type == "dot") { + push(args, "+tls"); + if (tls_hostname != "") + push(args, "+tls-hostname=" + tls_hostname); + } + else if (dns_type != "udp") + return false; + push(args, "+timeout=2"); + push(args, "+tries=1"); + return command_success_from_args(args); +} + +function dns_check_doh_server_available(dns_server, bootstrap_dns_server) { + let dns_host = url_host(dns_server); + if (dns_host == "") + dns_host = as_string(dns_server); + if (dns_host == "") + return false; + + let server_port = url_port(dns_server); + if (server_port == "") + server_port = "443"; + + let doh_path = url_path(dns_server); + if (doh_path == "" || doh_path == "/") + doh_path = "/dns-query"; + + let doh_query = "AAABAAABAAAAAAAABmdvb2dsZQNjb20AAAEAAQ"; + let url = "https://" + dns_host + ":" + server_port + doh_path + "?dns=" + doh_query; + let args = [ "curl", "-sS", "--max-time", "3", "-o", "/dev/null", "-w", "%{http_code}", "-H", "accept: application/dns-message" ]; + if (!valid_ipv4(dns_host)) { + let resolved_ip = dns_check_resolve_host(dns_host, bootstrap_dns_server); + if (resolved_ip == "") + return false; + push(args, "--resolve"); + push(args, dns_host + ":" + server_port + ":" + resolved_ip); + } + push(args, url); + return replace(command_output(command_from_args(args) + " 2>/dev/null"), /[\r\n]+$/g, "") == "200"; +} + +function device_ipv4_address(interface) { + let value = replace(module_output(SINGBOX_RUNTIME_UC, [ "device-ipv4-address", interface ]), /[\r\n]+$/g, ""); + if (value != "") + return value; + + let output = command_output_from_args([ "ip", "-4", "addr", "show", "dev", interface ]); + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + let matched = match(line, /^inet[ \t]+([0-9.]+)\//); + if (matched != null) + return as_string(matched[1]); + } + return ""; +} + +function dns_check_router_resolver_available(domain) { + for (let address in [ "127.0.0.1", SB_DNS_INBOUND_ADDRESS ]) { + if (address != "" && command_success_from_args([ "dig", "@" + address, domain, "+timeout=2", "+tries=1" ])) + return true; + } + + let listen_address = replace(module_output(SINGBOX_RUNTIME_UC, [ "service-listen-address" ]), /[\r\n]+$/g, ""); + if (listen_address != "" && command_success_from_args([ "dig", "@" + listen_address, domain, "+timeout=2", "+tries=1" ])) + return true; + + let source_interfaces = option(settings(), "source_network_interfaces", "br-lan"); + for (let interface in words(source_interfaces)) { + let address = device_ipv4_address(interface); + if (address != "" && command_success_from_args([ "dig", "@" + address, domain, "+timeout=2", "+tries=1" ])) + return true; + } + + return false; +} + +function check_dns_available() { + let cfg = settings(); + let dns_type = option(cfg, "dns_type", ""); + let dns_server = option(cfg, "dns_server", ""); + let bootstrap_dns_server = option(cfg, "bootstrap_dns_server", ""); + let dont_touch_dhcp = bool_option(cfg, "dont_touch_dhcp", false) ? 1 : 0; + let domain = "google.com"; + let dns_status = 0; + let dns_on_router = 0; + let bootstrap_dns_status = 0; + let dhcp_config_status = 1; + + if (dns_type == "doh") + dns_status = dns_check_doh_server_available(dns_server, bootstrap_dns_server) ? 1 : 0; + else if (dns_type == "dot" || dns_type == "udp") + dns_status = dns_check_dig_server_available(dns_type, dns_server, bootstrap_dns_server, domain) ? 1 : 0; + + if (dns_check_router_resolver_available(domain)) + dns_on_router = 1; + + let dns_server_host = url_host(dns_server); + if (dns_server_host == "") + dns_server_host = dns_server; + if (bootstrap_dns_server != "") { + let bootstrap_check_domain = domain; + if (dns_server_host != "" && !valid_ipv4(dns_server_host)) + bootstrap_check_domain = dns_server_host; + if (dns_check_resolve_host(bootstrap_check_domain, bootstrap_dns_server) != "") + bootstrap_dns_status = 1; + } + + if (!module_success(DNS_APPLY_UC, [ "default-config-complete" ])) + dhcp_config_status = 0; + + let display_dns_server = replace(status_output([ "mask-dns-server", dns_server ], null), /[\r\n]+$/g, ""); + write_json({ + dns_type, + dns_server: display_dns_server, + dns_status, + dns_on_router, + bootstrap_dns_server, + bootstrap_dns_status, + dhcp_config_status, + dont_touch_dhcp + }); + return 0; +} + +function nft_chain_counter_status(chain) { + let output = command_output_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, chain ]); + let status = words(status_output([ "nft-chain-counter-status" ], output)); + while (length(status) < 2) + push(status, "0"); + return [ arg_number(status[0]), arg_number(status[1]) ]; +} + +function nft_table_has_other_mark_rules(family, table_name) { + let output = command_output_from_args([ "nft", "list", "table", family, table_name ]); + return status_success([ "stdin-contains", "meta mark set" ], output); +} + +function check_nft_rules() { + command_status("sh -c " + shell_quote( + "curl -m 3 -s " + shell_quote("https://" + CHECK_PROXY_IP_DOMAIN + "/check") + " >/dev/null 2>&1 & pid1=$!; " + + "curl -m 3 -s " + shell_quote("https://" + FAKEIP_TEST_DOMAIN + "/check") + " >/dev/null 2>&1 & pid2=$!; " + + "wait $pid1 2>/dev/null; wait $pid2 2>/dev/null; sleep 1" + )); + + let table_exist = 0; + let rules_mangle_exist = 0; + let rules_mangle_counters = 0; + let rules_mangle_output_exist = 0; + let rules_mangle_output_counters = 0; + let rules_proxy_exist = 0; + let rules_proxy_counters = 0; + let rules_other_mark_exist = 0; + + if (command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) { + table_exist = 1; + if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "mangle" ])) { + let status = nft_chain_counter_status("mangle"); + rules_mangle_exist = status[0]; + rules_mangle_counters = status[1]; + } + if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "mangle_output" ])) { + let status = nft_chain_counter_status("mangle_output"); + rules_mangle_output_exist = status[0]; + rules_mangle_output_counters = status[1]; + } + if (command_success_from_args([ "nft", "list", "chain", "inet", NFT_TABLE_NAME, "proxy" ])) { + let status = nft_chain_counter_status("proxy"); + rules_proxy_exist = status[0]; + rules_proxy_counters = status[1]; + } + } + + for (let line in split(command_output_from_args([ "nft", "list", "tables" ]), "\n")) { + let fields = words(line); + if (length(fields) < 3) + continue; + let family = fields[1]; + let table_name = fields[2]; + if (table_name == NFT_TABLE_NAME) + continue; + if (nft_table_has_other_mark_rules(family, table_name)) { + rules_other_mark_exist = 1; + break; + } + } + + write_json({ + 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 + }); + return 0; +} + +function strip_leading_v(value) { + value = as_string(value); + return substr(value, 0, 1) == "v" ? substr(value, 1) : value; +} + +function sing_box_standard_ports_listening(netstat) { + netstat = as_string(netstat); + let port_53_ok = index(netstat, "127.0.0.42:53") >= 0; + let tproxy_suffix = ":" + SB_TPROXY_INBOUND_PORT; + let port_1602_ok = index(netstat, "0.0.0.0" + tproxy_suffix) >= 0 || + index(netstat, "127.0.0.1" + tproxy_suffix) >= 0; + return port_53_ok && port_1602_ok; +} + +function sing_box_standard_ports_listening_fixture() { + exit(sing_box_standard_ports_listening(read_stdin()) ? 0 : 1); +} + +function check_sing_box() { + let sing_box_installed = 0; + let sing_box_version_ok = 0; + let sing_box_extended = 0; + let sing_box_service_exist = 0; + let sing_box_autostart_disabled = 0; + let sing_box_process_running = 0; + let sing_box_ports_listening = 0; + + if (command_exists("sing-box")) { + sing_box_installed = 1; + let version = strip_leading_v(replace(module_output(SINGBOX_RUNTIME_UC, [ "version" ]), /[\r\n]+$/g, "")); + if (version != "") { + if (sing_box_marker_is("extended-compressed") || module_success(SINGBOX_RUNTIME_UC, [ "is-extended", version ])) + sing_box_extended = 1; + if (module_success(HELPERS_UC, [ "version-at-least", version, "1.12.4" ])) + sing_box_version_ok = 1; + } + else if (sing_box_marker_is("extended-compressed")) + sing_box_extended = 1; + } + + if (file_exists("/etc/init.d/sing-box")) { + sing_box_service_exist = 1; + if (!command_success_from_args([ "/etc/init.d/sing-box", "enabled" ])) + sing_box_autostart_disabled = 1; + } + + if (sing_box_process_is_running()) + sing_box_process_running = 1; + + if (sing_box_standard_ports_listening(command_output_from_args([ "netstat", "-ln" ]))) + sing_box_ports_listening = 1; + + write_json({ + 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 + }); + return 0; +} + +function check_fakeip() { + let fakeip_address = ""; + for (let line in split(command_output_from_args([ + "dig", "+short", "@" + SB_DNS_INBOUND_ADDRESS, FAKEIP_TEST_DOMAIN, "A", "+timeout=2", "+tries=1" + ]), "\n")) { + line = trim(as_string(line)); + if (valid_ipv4(line)) { + fakeip_address = line; + break; + } + } + write_json({ + fakeip: match(fakeip_address, /^198\.(18|19)\./) != null, + IP: fakeip_address + }); + return 0; +} + +function clash_json_output(args) { + print(status_output([ "stdin-json" ], command_output(command_from_args(args)))); + return 0; +} + +function clash_api_url() { + let address = replace(module_output(SINGBOX_RUNTIME_UC, [ "service-listen-address" ]), /[\r\n]+$/g, ""); + if (address == "") + address = "127.0.0.1"; + return address + ":" + SB_CLASH_API_CONTROLLER_PORT; +} + +function clash_auth_args() { + let cfg = settings(); + if (!bool_option(cfg, "enable_yacd_wan_access", false)) + return []; + return [ "--header", "Authorization: Bearer " + option(cfg, "yacd_secret_key", "") ]; +} + +function clash_urlencode(value) { + return replace(status_output([ "url-encode", value ], null), /[\r\n]+$/g, ""); +} + +function clash_json_error(message) { + let result = status_capture([ "json-error", message ], null); + if (result.output != "") + print(result.output); + return 1; +} + +function clash_api(action, arg1, arg2) { + let base_url = clash_api_url(); + let test_url = "https://www.gstatic.com/generate_204"; + let auth = clash_auth_args(); + + if (action == "get_proxies") { + let args = [ "curl", "-s" ]; + for (let item in auth) push(args, item); + push(args, base_url + "/proxies"); + return clash_json_output(args); + } + + if (action == "get_connections") { + let args = [ "curl", "-s" ]; + for (let item in auth) push(args, item); + push(args, base_url + "/connections"); + return clash_json_output(args); + } + + if (action == "get_proxy_latency") { + if (as_string(arg1) == "") + return clash_json_error("proxy_tag required"); + let args = [ "curl", "-G", "-s", base_url + "/proxies/" + clash_urlencode(arg1) + "/delay" ]; + for (let item in auth) push(args, item); + push(args, "--data-urlencode"); + push(args, "url=" + test_url); + push(args, "--data-urlencode"); + push(args, "timeout=" + as_string(arg2 || "2000")); + return clash_json_output(args); + } + + if (action == "get_proxy_latencies") { + if (as_string(arg1) == "") + return clash_json_error("proxy_tags_json required"); + let tags = status_capture([ "clash-proxy-tags-lines", arg1 ], null); + if (tags.status != 0) + return clash_json_error("proxy_tags_json must be a JSON array of non-empty strings"); + let count = 0; + let failed = 0; + for (let proxy_tag in split(tags.output, "\n")) { + proxy_tag = as_string(proxy_tag); + if (proxy_tag == "") + continue; + let args = [ "curl", "-G", "-s", base_url + "/proxies/" + clash_urlencode(proxy_tag) + "/delay" ]; + for (let item in auth) push(args, item); + push(args, "--data-urlencode"); + push(args, "url=" + test_url); + push(args, "--data-urlencode"); + push(args, "timeout=" + as_string(arg2 || "5000")); + if (status_capture([ "stdin-json" ], command_output(command_from_args(args))).status != 0) + failed = 1; + count++; + } + let result = status_capture([ "clash-proxy-latencies-result", count, failed ], null); + if (result.output != "") + print(result.output); + return result.status; + } + + if (action == "get_group_latency") { + if (as_string(arg1) == "") + return clash_json_error("group_tag required"); + let args = [ "curl", "-G", "-s", base_url + "/group/" + clash_urlencode(arg1) + "/delay" ]; + for (let item in auth) push(args, item); + push(args, "--data-urlencode"); + push(args, "url=" + test_url); + push(args, "--data-urlencode"); + push(args, "timeout=" + as_string(arg2 || "5000")); + return clash_json_output(args); + } + + if (action == "set_group_proxy") { + if (as_string(arg1) == "" || as_string(arg2) == "") + return clash_json_error("group_tag and proxy_tag required"); + let payload = status_output([ "clash-set-group-proxy-payload", arg2 ], null); + let args = [ "curl", "-X", "PUT", "-s", "-w", "\n%{http_code}", base_url + "/proxies/" + clash_urlencode(arg1) ]; + for (let item in auth) push(args, item); + push(args, "--data-raw"); + push(args, payload); + let result = status_capture([ "clash-set-group-proxy-result", arg1, arg2 ], command_output(command_from_args(args))); + if (result.output != "") + print(result.output); + return result.status; + } + + if (action == "close_connection") { + if (as_string(arg1) == "") + return clash_json_error("connection_id required"); + let args = [ "curl", "-X", "DELETE", "-s", "-w", "\n%{http_code}", base_url + "/connections/" + clash_urlencode(arg1) ]; + for (let item in auth) push(args, item); + let result = status_capture([ "clash-close-connection-result", arg1 ], command_output(command_from_args(args))); + if (result.output != "") + print(result.output); + return result.status; + } + + if (action == "close_all_connections") { + let args = [ "curl", "-X", "DELETE", "-s", "-w", "\n%{http_code}", base_url + "/connections" ]; + for (let item in auth) push(args, item); + let result = status_capture([ "clash-close-all-connections-result" ], command_output(command_from_args(args))); + if (result.output != "") + print(result.output); + return result.status; + } + + let unknown = status_capture([ "clash-unknown-action" ], null); + if (unknown.output != "") + print(unknown.output); + return 1; +} + +function print_global(message) { + print(as_string(message), "\n"); +} + +function render_or_fail(mode_args, input, fail_message, ok_statuses) { + let result = status_capture(mode_args, input); + if (result.output != "") + print(result.output); + for (let status in ok_statuses) + if (result.status == status) + return result.status; + print_global(fail_message); + return result.status; +} + +function global_check(arg1, arg2) { + let visibility = as_string(arg2 || "masked"); + if (as_string(arg1) == "raw" || as_string(arg1) == "masked") + visibility = as_string(arg1); + + print_global("📡 Global check run!"); + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("🛠️ System info"); + + let system_info_json = sprintf("%J", build_system_info()); + render_or_fail([ "global-system-info" ], system_info_json, "❌ Failed to parse system info", [ 0 ]); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("➡️ DNS status"); + + let dns_check_capture = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-dns-available" ]))); + if (dns_check_capture.output != "") { + let dns_render = render_or_fail( + [ "global-dns-check", bool_option(settings(), "dont_touch_dhcp", false) ? "1" : "0" ], + dns_check_capture.output, + "❌ Failed to parse DNS info", + [ 0, 10 ] + ); + if (dns_render == 10) + print(status_output([ "dhcp-dnsmasq-config", "/etc/config/dhcp" ], null)); + } + else + print_global("❌ Failed to get DNS info"); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("📦 Sing-box status"); + let singbox_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-sing-box" ]))).output; + if (singbox_check_json != "") + render_or_fail([ "global-sing-box-check" ], singbox_check_json, "❌ Failed to parse sing-box info", [ 0 ]); + else + print_global("❌ Failed to get sing-box info"); + + print_global("---------------------------"); + print_global("Inbounds checks"); + let inbounds_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-inbounds" ]))).output; + if (inbounds_check_json != "") + render_or_fail([ "global-inbounds-check" ], inbounds_check_json, "[FAIL] Failed to parse inbounds check details", [ 0 ]); + else + print_global("[FAIL] Failed to get inbounds info"); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("🧱 NFT rules status"); + let nft_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-nft-rules" ]))).output; + if (nft_check_json != "") { + let nft_render = render_or_fail([ "global-nft-check" ], nft_check_json, "❌ Failed to parse NFT rules info", [ 0 ]); + if (nft_render == 0 && status_success([ "global-nft-other-mark-exists" ], nft_check_json)) + print(status_output([ "nft-ruleset-other-mark-lines", NFT_TABLE_NAME ], command_output_from_args([ "nft", "list", "ruleset" ]))); + } + else + print_global("❌ Failed to get NFT rules info"); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("📄 Podkop Plus config"); + show_config(visibility); + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("📄 WAN config"); + if (uci_show("network.wan")) { + if (visibility == "raw") + print(as_string(fs.readfile("/etc/config/network"))); + else + print(status_output([ "wan-config-masked", "/etc/config/network" ], null)); + } + else + print_global("❌ WAN configuration not found"); + + let network_show = network_show_data(); + for (let line in split(status_output([ "network-endpoint-host-warnings", CLOUDFLARE_OCTETS ], network_show), "\n")) { + if (line == "") + continue; + let fields = split(line, "\t"); + if (length(fields) < 2) + continue; + if (fields[0] == "engage") + print_global("⚠️ WARP detected: " + fields[1]); + else if (fields[0] == "prefix") { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ WARP detected: " + fields[1]); + } + } + + for (let peer_section in split(status_output([ "network-wireguard-route-allowed-peers" ], network_show), "\n")) { + peer_section = as_string(peer_section); + if (peer_section == "") + continue; + if (uci_get(peer_section + ".allowed_ips") == "0.0.0.0/0") { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ WG Route allowed IP enabled with 0.0.0.0/0"); + } + } + + if (file_executable("/etc/init.d/zapret") && command_success_from_args([ "/etc/init.d/zapret", "status" ])) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ Standalone zapret service is active. Podkop Plus uses separate queues, but packet-level policy overlap is possible."); + } + else if (file_executable("/etc/init.d/zapret") && command_success_from_args([ "/etc/init.d/zapret", "enabled" ])) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ Standalone zapret autostart is enabled. Podkop Plus will not modify /etc/config/zapret."); + } + + if (file_executable("/etc/init.d/zapret2") && command_success_from_args([ "/etc/init.d/zapret2", "status" ])) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ Standalone zapret2 service is active. Podkop Plus uses separate queues, but packet-level policy overlap is possible."); + } + else if (file_executable("/etc/init.d/zapret2") && command_success_from_args([ "/etc/init.d/zapret2", "enabled" ])) { + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("⚠️ Standalone zapret2 autostart is enabled. Podkop Plus will not modify /etc/config/zapret2."); + } + + print_global("━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + print_global("🥸 FakeIP status"); + let fakeip_check_json = command_capture(command_from_args(module_args(LIB_DIR + "/diagnostics/runtime.uc", [ "check-fakeip" ]))).output; + if (fakeip_check_json != "") + render_or_fail([ "global-fakeip-check" ], fakeip_check_json, "❌ Failed to parse FakeIP info", [ 0 ]); + else + print_global("❌ Failed to get FakeIP info"); + + return 0; +} + +let mode = ARGV[0] || ""; + +if (mode == "check-proxy") + exit(check_proxy()); +else if (mode == "check-nft") + exit(check_nft()); +else if (mode == "check-nft-rules") + exit(check_nft_rules()); +else if (mode == "check-sing-box") + exit(check_sing_box()); +else if (mode == "sing-box-standard-ports-listening-fixture") + sing_box_standard_ports_listening_fixture(); +else if (mode == "check-inbounds-config") + exit(check_inbounds_config()); +else if (mode == "check-inbounds") + exit(check_inbounds()); +else if (mode == "check-logs") + exit(check_logs()); +else if (mode == "check-sing-box-logs") + exit(check_sing_box_logs()); +else if (mode == "podkop-logs-fixture") + exit(podkop_logs_fixture()); +else if (mode == "check-fakeip") + exit(check_fakeip()); +else if (mode == "check-zapret-runtime") + exit(module_passthrough(ZAPRET_RUNTIME_UC, [ "check" ])); +else if (mode == "check-zapret2-runtime") + exit(module_passthrough(ZAPRET2_RUNTIME_UC, [ "check" ])); +else if (mode == "check-byedpi-runtime") + exit(module_passthrough(BYEDPI_RUNTIME_UC, [ "check" ])); +else if (mode == "neutralize-zapret-defaults") + exit(neutralize_zapret_defaults()); +else if (mode == "clash-api") + exit(clash_api(ARGV[1], ARGV[2], ARGV[3])); +else if (mode == "show-config") + exit(show_config(ARGV[1] || "masked")); +else if (mode == "show-version") + exit(show_version()); +else if (mode == "show-sing-box-config") + exit(show_sing_box_config(ARGV[1] || "masked")); +else if (mode == "show-sing-box-version") + exit(show_sing_box_version()); +else if (mode == "get-status") + exit(get_status()); +else if (mode == "get-outbound-link") + exit(get_outbound_link(ARGV[1], ARGV[2])); +else if (mode == "get-outbound-link-states") + exit(get_outbound_link_states(ARGV[1])); +else if (mode == "get-outbound-metadata") + exit(get_outbound_metadata(ARGV[1])); +else if (mode == "get-subscription-metadata") + exit(get_subscription_metadata(ARGV[1])); +else if (mode == "get-sing-box-status") + exit(get_sing_box_status()); +else if (mode == "get-zapret-status") + exit(module_passthrough(ZAPRET_RUNTIME_UC, [ "status" ])); +else if (mode == "get-zapret2-status") + exit(module_passthrough(ZAPRET2_RUNTIME_UC, [ "status" ])); +else if (mode == "get-byedpi-status") + exit(module_passthrough(BYEDPI_RUNTIME_UC, [ "status" ])); +else if (mode == "get-system-info") + exit(get_system_info()); +else if (mode == "get-server-capabilities") + exit(get_server_capabilities()); +else if (mode == "check-dns-available") + exit(check_dns_available()); +else if (mode == "global-check") + exit(global_check(ARGV[1] || "", ARGV[2] || "")); +else if (mode == "validate-nfqws-strategy-json") + exit(validate_nfqws_strategy_json(ARGV[1] || "")); +else if (mode == "validate-nfqws2-strategy-json") + exit(validate_nfqws2_strategy_json(ARGV[1] || "")); +else { + warn("Usage: diagnostics/runtime.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/status_diagnostics.uc b/podkop/files/usr/lib/diagnostics/status.uc similarity index 92% rename from podkop/files/usr/lib/status_diagnostics.uc rename to podkop/files/usr/lib/diagnostics/status.uc index 60157300..c57d2f27 100644 --- a/podkop/files/usr/lib/status_diagnostics.uc +++ b/podkop/files/usr/lib/diagnostics/status.uc @@ -500,8 +500,8 @@ function firewall_proto_spec_contains(spec, proto) { return false; } -function firewall_port_open_for_proto(port, proto) { - for (let rule in firewall_rules_from_uci_show(read_stdin())) { +function firewall_rules_allow_port_proto(rules, port, proto) { + for (let rule in rules) { let enabled = as_string(rule.enabled); if (enabled != "" && enabled != "1") continue; @@ -532,6 +532,20 @@ function firewall_port_open_for_proto(port, proto) { return false; } +function firewall_port_open_for_proto(port, proto) { + return firewall_rules_allow_port_proto(firewall_rules_from_uci_show(read_stdin()), port, proto); +} + +function firewall_required_protocols_open(port, required_proto) { + let rules = firewall_rules_from_uci_show(read_stdin()); + + for (let proto in whitespace_values(required_proto)) + if (!firewall_rules_allow_port_proto(rules, port, proto)) + return false; + + return true; +} + function server_required_inbound_proto(protocol) { protocol = as_string(protocol); if (protocol == "json_inbound") @@ -569,6 +583,13 @@ function flag_is_true(value) { return value == true || as_string(value) == "true" || as_string(value) == "1"; } +function server_listen_requires_firewall(listen, wan_ip, listen_is_public) { + listen = as_string(listen); + wan_ip = as_string(wan_ip); + + return listen == "0.0.0.0" || (wan_ip != "" && listen == wan_ip) || arg_bool(listen_is_public); +} + function object_value(object, key) { return type(object) == "object" && object[key] != null ? as_string(object[key]) : ""; } @@ -641,6 +662,53 @@ function ipv4_like(value) { return match(as_string(value), /^[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+$/) != null; } +function valid_ipv4_octet(value) { + value = as_string(value); + if (value == "" || match(value, /^[0-9]+$/) == null) + return false; + let number = int(value); + return number >= 0 && number <= 255; +} + +function valid_ipv4(value) { + let parts = split(as_string(value), "."); + if (length(parts) != 4) + return false; + + for (let part in parts) + if (!valid_ipv4_octet(part)) + return false; + + return true; +} + +function valid_public_ipv4(value) { + value = as_string(value); + if (!valid_ipv4(value)) + return false; + + let parts = split(value, "."); + let a = int(parts[0]); + let b = int(parts[1]); + + if (a == 0 || a == 10 || a == 127 || a >= 224) + return false; + if (a == 169 && b == 254) + return false; + if (a == 192 && (b == 168 || b == 0 || b == 2)) + return false; + if (a == 198 && (b == 18 || b == 19 || b == 51)) + return false; + if (a == 203 && b == 0) + return false; + if (a == 100 && b >= 64 && b <= 127) + return false; + if (a == 172 && b >= 16 && b <= 31) + return false; + + return true; +} + function netstat_addr_matches(addr, listen, port) { addr = as_string(addr); listen = as_string(listen); @@ -658,12 +726,12 @@ function netstat_addr_matches(addr, listen, port) { return host == listen; } -function netstat_server_port_listening(listen, port, proto) { +function netstat_server_port_listening_in_data(data, listen, port, proto) { listen = as_string(listen); port = as_string(port); proto = as_string(proto); - for (let line in split(read_stdin(), "\n")) { + for (let line in split(as_string(data), "\n")) { let fields = netstat_fields(line); if (length(fields) < 4 || !str_startswith(fields[0], proto)) continue; @@ -681,6 +749,20 @@ function netstat_server_port_listening(listen, port, proto) { return false; } +function netstat_server_port_listening(listen, port, proto) { + return netstat_server_port_listening_in_data(read_stdin(), listen, port, proto); +} + +function server_required_ports_listening(listen, port, required_proto) { + let data = read_stdin(); + + for (let proto in whitespace_values(required_proto)) + if (!netstat_server_port_listening_in_data(data, listen, port, proto)) + return false; + + return true; +} + function sorted_unique_strings(values) { let result = []; for (let value in values) { @@ -718,12 +800,37 @@ function stdin_sorted_unique_space_list() { print(replace(join(" ", sorted_unique_lines(lines)), /[ \t\r\n]+$/, ""), "\n"); } -function netstat_server_port_conflict_owners(listen, port, proto) { +function public_host_flags(public_host, public_host_ips, wan_ip, wan_public) { + let resolved = -1; + let public_ip = -1; + let matches_wan = -1; + let ips = whitespace_values(public_host_ips); + + if (as_string(public_host) != "") { + if (length(ips) > 0) { + resolved = 1; + public_ip = 1; + for (let ip in ips) + if (!valid_public_ipv4(ip)) + public_ip = 0; + + if (as_string(wan_public) == "1") + matches_wan = contains(ips, wan_ip) ? 1 : 0; + } + else { + resolved = 0; + } + } + + print(resolved, " ", public_ip, " ", matches_wan, "\n"); +} + +function netstat_server_port_conflict_owner_list(data, listen, port, proto) { let owners = []; let line_number = 0; let owner_supported = false; - for (let line in split(read_stdin(), "\n")) { + for (let line in split(as_string(data), "\n")) { line_number++; if (line_number == 2) { owner_supported = index(as_string(line), "PID/Program") >= 0; @@ -744,6 +851,22 @@ function netstat_server_port_conflict_owners(listen, port, proto) { push(owners, owner); } + return sorted_unique_strings(owners); +} + +function netstat_server_port_conflict_owners(listen, port, proto) { + print(join(" ", netstat_server_port_conflict_owner_list(read_stdin(), listen, port, proto)), "\n"); +} + +function server_required_port_conflict_owners(listen, port, required_proto) { + let data = read_stdin(); + let owners = []; + + for (let proto in whitespace_values(required_proto)) + for (let owner in netstat_server_port_conflict_owner_list(data, listen, port, proto)) + if (!contains(owners, owner)) + push(owners, owner); + print(join(" ", sorted_unique_strings(owners)), "\n"); } @@ -1027,11 +1150,24 @@ function write_ui_capabilities_json(sing_box_extended, sing_box_tiny, sing_box_c }); } -function write_service_status_json(running, enabled, status, dns_configured) { +function service_status_label(running, enabled) { + running = arg_number(running); + enabled = arg_number(enabled); + + if (running == 1) + return enabled == 1 ? "running & enabled" : "running but disabled"; + + return enabled == 1 ? "stopped but enabled" : "stopped & disabled"; +} + +function write_service_status_json(running, enabled, status_or_dns_configured, dns_configured) { + if (dns_configured == null) + dns_configured = status_or_dns_configured; + write_json({ running: arg_number(running), enabled: arg_number(enabled), - status: as_string(status), + status: service_status_label(running, enabled), dns_configured: arg_number(dns_configured) }); } @@ -1740,6 +1876,8 @@ else if (mode == "stdin-first-line") stdin_first_line(); else if (mode == "stdin-sorted-unique-space-list") stdin_sorted_unique_space_list(); +else if (mode == "public-host-flags") + public_host_flags(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); else if (mode == "stdin-first-ipv4-line") stdin_first_ipv4_line(); else if (mode == "stdin-contains") @@ -1764,14 +1902,22 @@ else if (mode == "firewall-proto-spec-contains") exit(firewall_proto_spec_contains(ARGV[1], ARGV[2]) ? 0 : 1); else if (mode == "firewall-port-open") exit(firewall_port_open_for_proto(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "firewall-required-protocols-open") + exit(firewall_required_protocols_open(ARGV[1], ARGV[2]) ? 0 : 1); else if (mode == "server-required-inbound-proto") server_required_inbound_proto(ARGV[1]); else if (mode == "server-runtime-type-for-protocol") server_runtime_type_for_protocol(ARGV[1]); +else if (mode == "server-listen-requires-firewall") + exit(server_listen_requires_firewall(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); else if (mode == "server-port-listening") exit(netstat_server_port_listening(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "server-required-ports-listening") + exit(server_required_ports_listening(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); else if (mode == "server-port-conflict-owners") netstat_server_port_conflict_owners(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "server-required-port-conflict-owners") + server_required_port_conflict_owners(ARGV[1], ARGV[2], ARGV[3]); else if (mode == "json-error") json_error(ARGV[1]); else if (mode == "inbound-item-json") @@ -1863,6 +2009,6 @@ else if (mode == "clash-proxy-latencies-result") else if (mode == "clash-unknown-action") clash_unknown_action(); else { - warn("Usage: status_diagnostics.uc ...\n"); + warn("Usage: diagnostics/status.uc ...\n"); exit(1); } diff --git a/podkop/files/usr/lib/dns/apply.uc b/podkop/files/usr/lib/dns/apply.uc new file mode 100644 index 00000000..5071f5af --- /dev/null +++ b/podkop/files/usr/lib/dns/apply.uc @@ -0,0 +1,316 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const SB_DNS_INBOUND_ADDRESS = getenv("SB_DNS_INBOUND_ADDRESS") || "127.0.0.42"; +const DNSMASQ_INIT = getenv("DNSMASQ_INIT") || "/etc/init.d/dnsmasq"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function run(command) { + return system(command) == 0; +} + +function output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return replace(as_string(data), /[\r\n]+$/g, ""); +} + +function uci_available() { + return uci.available(); +} + +function uci_get(path) { + return uci.get(path); +} + +function uci_exists(path) { + return uci.exists(path); +} + +function uci_delete(path) { + uci.delete(path); +} + +function uci_set(path, value) { + uci.set(path, value); +} + +function uci_add_list(path, value) { + uci.add_list(path, value); +} + +function uci_del_list(path, value) { + return uci.del_list(path, value); +} + +function uci_commit(package_name) { + uci.commit(package_name); +} + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function truthy(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function list_has(values, needle) { + for (let value in words(values)) + if (value == needle) + return true; + return false; +} + +function log(message, level) { + level = as_string(level || "info"); + run("logger -t " + shell_quote("podkop-plus") + " " + shell_quote("[" + level + "] " + as_string(message))); +} + +function restart_dnsmasq() { + return run("[ -x " + shell_quote(DNSMASQ_INIT) + " ] && " + shell_quote(DNSMASQ_INIT) + " restart"); +} + +function dnsmasq_legacy_instance_exists() { + return uci_exists("dhcp.podkop_plus"); +} + +function dnsmasq_default_servers() { + return uci_get("dhcp.@dnsmasq[0].server"); +} + +function dnsmasq_default_has_podkop_dns() { + return list_has(dnsmasq_default_servers(), SB_DNS_INBOUND_ADDRESS); +} + +function dnsmasq_has_podkop_dns() { + return dnsmasq_default_has_podkop_dns() || dnsmasq_legacy_instance_exists(); +} + +function dnsmasq_has_podkop_managed_state() { + return uci_get("dhcp.@dnsmasq[0].podkop_server") != "" || + uci_get("dhcp.@dnsmasq[0].podkop_noresolv") != "" || + uci_get("dhcp.@dnsmasq[0].podkop_cachesize") != "" || + uci_get("dhcp.@dnsmasq[0].podkop_notinterface") != "" || + dnsmasq_legacy_instance_exists(); +} + +function dnsmasq_management_disabled() { + return truthy(uci_get(CONFIG_NAME + ".settings.dont_touch_dhcp")); +} + +function dnsmasq_default_config_is_complete() { + return dnsmasq_default_has_podkop_dns() && + uci_get("dhcp.@dnsmasq[0].noresolv") == "1" && + uci_get("dhcp.@dnsmasq[0].cachesize") == "0" && + !dnsmasq_legacy_instance_exists(); +} + +function dnsmasq_legacy_interfaces() { + let legacy_dnsmasq_section = "podkop_plus"; + let legacy_interfaces = uci_get("dhcp." + legacy_dnsmasq_section + ".interface"); + if (legacy_interfaces == "") + legacy_interfaces = uci_get(CONFIG_NAME + ".settings.source_network_interfaces"); + if (legacy_interfaces == "") + legacy_interfaces = "br-lan"; + + return legacy_interfaces; +} + +function backup_dnsmasq_config_option(key, backup_key) { + if (uci_get("dhcp.@dnsmasq[0]." + backup_key) != "") + return; + + let value = uci_get("dhcp.@dnsmasq[0]." + key); + if (value != "") + uci_set("dhcp.@dnsmasq[0]." + backup_key, value); +} + +function backup_dnsmasq_server_list() { + if (uci_get("dhcp.@dnsmasq[0].podkop_server") != "") + return; + + for (let server in words(dnsmasq_default_servers())) { + if (server != SB_DNS_INBOUND_ADDRESS) + uci_add_list("dhcp.@dnsmasq[0].podkop_server", server); + } +} + +function restore_dnsmasq_config_option(key, backup_key, default_value) { + let value = uci_get("dhcp.@dnsmasq[0]." + backup_key); + if (value != "") { + uci_set("dhcp.@dnsmasq[0]." + key, value); + uci_delete("dhcp.@dnsmasq[0]." + backup_key); + } + else if (as_string(default_value) != "") { + uci_set("dhcp.@dnsmasq[0]." + key, default_value); + } + else { + uci_delete("dhcp.@dnsmasq[0]." + key); + } +} + +function dnsmasq_cleanup_legacy_instance() { + let legacy_instance_present = dnsmasq_legacy_instance_exists(); + let legacy_interfaces = legacy_instance_present ? dnsmasq_legacy_interfaces() : ""; + + uci_delete("dhcp.podkop_plus"); + + let backup_notinterfaces = uci_get("dhcp.@dnsmasq[0].podkop_notinterface"); + if (backup_notinterfaces != "") { + uci_delete("dhcp.@dnsmasq[0].notinterface"); + for (let value in words(backup_notinterfaces)) + uci_add_list("dhcp.@dnsmasq[0].notinterface", value); + uci_delete("dhcp.@dnsmasq[0].podkop_notinterface"); + return; + } + + if (legacy_instance_present) { + for (let value in words(legacy_interfaces)) + uci_del_list("dhcp.@dnsmasq[0].notinterface", value); + } + + uci_delete("dhcp.@dnsmasq[0].podkop_notinterface"); +} + +function dnsmasq_configure_default_instance() { + let default_has_podkop_dns = dnsmasq_default_has_podkop_dns(); + + backup_dnsmasq_server_list(); + if (!default_has_podkop_dns) { + backup_dnsmasq_config_option("noresolv", "podkop_noresolv"); + backup_dnsmasq_config_option("cachesize", "podkop_cachesize"); + } + + uci_delete("dhcp.@dnsmasq[0].server"); + uci_add_list("dhcp.@dnsmasq[0].server", SB_DNS_INBOUND_ADDRESS); + uci_set("dhcp.@dnsmasq[0].noresolv", "1"); + uci_set("dhcp.@dnsmasq[0].cachesize", "0"); +} + +function dnsmasq_restore_default_instance() { + let server_list = dnsmasq_default_servers(); + let backup_servers = uci_get("dhcp.@dnsmasq[0].podkop_server"); + let managed_global_dns = list_has(server_list, SB_DNS_INBOUND_ADDRESS); + + uci_delete("dhcp.@dnsmasq[0].server"); + if (backup_servers != "") { + for (let value in words(backup_servers)) + uci_add_list("dhcp.@dnsmasq[0].server", value); + uci_delete("dhcp.@dnsmasq[0].podkop_server"); + } + else { + for (let value in words(server_list)) { + if (value != SB_DNS_INBOUND_ADDRESS) + uci_add_list("dhcp.@dnsmasq[0].server", value); + } + } + uci_delete("dhcp.@dnsmasq[0].podkop_server"); + + let noresolv = uci_get("dhcp.@dnsmasq[0].podkop_noresolv"); + if (noresolv != "") + restore_dnsmasq_config_option("noresolv", "podkop_noresolv", ""); + else if (managed_global_dns) + uci_set("dhcp.@dnsmasq[0].noresolv", "0"); + + let cachesize = uci_get("dhcp.@dnsmasq[0].podkop_cachesize"); + if (cachesize != "") + restore_dnsmasq_config_option("cachesize", "podkop_cachesize", ""); + else if (managed_global_dns) + uci_set("dhcp.@dnsmasq[0].cachesize", "150"); +} + +function dnsmasq_configure(force) { + if (!uci_available()) + return true; + + if (as_string(force) != "force" && uci_get(CONFIG_NAME + ".settings.shutdown_correctly") == "0") { + if (dnsmasq_default_config_is_complete()) { + log("Previous shutdown of Podkop Plus was not correct, dnsmasq is already configured", "info"); + return true; + } + log("Previous shutdown of Podkop Plus was not correct, but dnsmasq is not configured correctly. Applying Podkop Plus DNS settings", "info"); + } + + log("Configure dnsmasq for sing-box", "info"); + dnsmasq_cleanup_legacy_instance(); + dnsmasq_configure_default_instance(); + uci_commit("dhcp"); + + return restart_dnsmasq(); +} + +function dnsmasq_restore(force) { + if (!uci_available()) + return true; + + log("Restoring the dnsmasq configuration", "info"); + if (as_string(force) != "force" && uci_get(CONFIG_NAME + ".settings.shutdown_correctly") == "1") { + if (!dnsmasq_has_podkop_dns()) { + log("Previous shutdown of Podkop Plus was correct, reconfiguration of dnsmasq is not required", "info"); + return true; + } + log("Previous shutdown of Podkop Plus was correct, but Podkop Plus DNS is still present. Restoring dnsmasq", "info"); + } + + dnsmasq_cleanup_legacy_instance(); + dnsmasq_restore_default_instance(); + uci_commit("dhcp"); + + return restart_dnsmasq(); +} + +function failsafe_restore() { + if (!uci_available()) + return true; + + if (dnsmasq_management_disabled()) { + if (!dnsmasq_has_podkop_managed_state()) { + log("Fail-safe: dont_touch_dhcp is enabled, leaving dnsmasq unchanged", "warn"); + return true; + } + + log("Fail-safe: dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes", "warn"); + } + + log("Fail-safe: restoring dnsmasq away from Podkop Plus DNS", "warn"); + dnsmasq_restore("force"); + return true; +} + +let mode = ARGV[0] || ""; + +if (mode == "configure") + exit(dnsmasq_configure(ARGV[1]) ? 0 : 1); +else if (mode == "restore") + exit(dnsmasq_restore(ARGV[1]) ? 0 : 1); +else if (mode == "failsafe-restore") + exit(failsafe_restore() ? 0 : 1); +else if (mode == "has-podkop-dns") + exit(dnsmasq_has_podkop_dns() ? 0 : 1); +else if (mode == "has-managed-state") + exit(dnsmasq_has_podkop_managed_state() ? 0 : 1); +else if (mode == "default-config-complete") + exit(dnsmasq_default_config_is_complete() ? 0 : 1); + +warn("Usage: dns/apply.uc \n"); +exit(1); diff --git a/podkop/files/usr/lib/dnsmasq_failsafe_restore.sh b/podkop/files/usr/lib/dnsmasq_failsafe_restore.sh deleted file mode 100644 index 0ff4fbf8..00000000 --- a/podkop/files/usr/lib/dnsmasq_failsafe_restore.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/bin/sh - -PODKOP_CONFIG_NAME="${PODKOP_CONFIG_NAME:-podkop-plus}" -SB_DNS_INBOUND_ADDRESS="${SB_DNS_INBOUND_ADDRESS:-127.0.0.42}" - -podkop_dnsmasq_failsafe_restore() { - local legacy_dnsmasq_section legacy_interfaces default_servers backup_servers backup_notinterfaces value - local default_has_podkop_dns noresolv cachesize changed - local legacy_instance_present dont_touch_dhcp podkop_managed_state - - command -v uci >/dev/null 2>&1 || return 0 - dont_touch_dhcp=0 - case "$(uci -q get "$PODKOP_CONFIG_NAME.settings.dont_touch_dhcp" 2>/dev/null)" in - 1|true|yes|on) - dont_touch_dhcp=1 - ;; - esac - - legacy_dnsmasq_section="podkop_plus" - changed=0 - default_has_podkop_dns=0 - legacy_instance_present=0 - legacy_interfaces="$(uci -q get "dhcp.$legacy_dnsmasq_section.interface" 2>/dev/null)" - [ -n "$legacy_interfaces" ] || - legacy_interfaces="$(uci -q get "$PODKOP_CONFIG_NAME.settings.source_network_interfaces" 2>/dev/null)" - [ -n "$legacy_interfaces" ] || legacy_interfaces="br-lan" - - default_servers="$(uci -q get 'dhcp.@dnsmasq[0].server' 2>/dev/null)" - for value in $default_servers; do - [ "$value" = "$SB_DNS_INBOUND_ADDRESS" ] && default_has_podkop_dns=1 - done - - podkop_managed_state=0 - [ -n "$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" ] && podkop_managed_state=1 - [ -n "$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" ] && podkop_managed_state=1 - [ -n "$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" ] && podkop_managed_state=1 - [ -n "$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" ] && podkop_managed_state=1 - - if uci -q show "dhcp.$legacy_dnsmasq_section" >/dev/null 2>&1; then - legacy_instance_present=1 - podkop_managed_state=1 - changed=1 - fi - [ "$dont_touch_dhcp" -eq 0 ] || [ "$podkop_managed_state" -eq 1 ] || return 0 - - uci -q delete "dhcp.$legacy_dnsmasq_section" >/dev/null 2>&1 || true - - backup_notinterfaces="$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" - if [ -n "$backup_notinterfaces" ]; then - uci -q delete 'dhcp.@dnsmasq[0].notinterface' >/dev/null 2>&1 || true - for value in $backup_notinterfaces; do - uci -q add_list "dhcp.@dnsmasq[0].notinterface=$value" >/dev/null 2>&1 || true - done - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - changed=1 - else - if [ "$legacy_instance_present" -eq 1 ]; then - for value in $legacy_interfaces; do - uci -q del_list "dhcp.@dnsmasq[0].notinterface=$value" >/dev/null 2>&1 && changed=1 - done - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - fi - - backup_servers="$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" - if [ -n "$backup_servers" ]; then - uci -q delete 'dhcp.@dnsmasq[0].server' >/dev/null 2>&1 || true - for value in $backup_servers; do - uci -q add_list "dhcp.@dnsmasq[0].server=$value" >/dev/null 2>&1 || true - done - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - changed=1 - else - uci -q del_list "dhcp.@dnsmasq[0].server=$SB_DNS_INBOUND_ADDRESS" >/dev/null 2>&1 && changed=1 - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - fi - - noresolv="$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" - if [ -n "$noresolv" ]; then - uci -q set "dhcp.@dnsmasq[0].noresolv=$noresolv" >/dev/null 2>&1 || true - uci -q delete 'dhcp.@dnsmasq[0].podkop_noresolv' >/dev/null 2>&1 || true - changed=1 - elif [ "$default_has_podkop_dns" -eq 1 ]; then - uci -q set 'dhcp.@dnsmasq[0].noresolv=0' >/dev/null 2>&1 || true - changed=1 - fi - - cachesize="$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" - if [ -n "$cachesize" ]; then - uci -q set "dhcp.@dnsmasq[0].cachesize=$cachesize" >/dev/null 2>&1 || true - uci -q delete 'dhcp.@dnsmasq[0].podkop_cachesize' >/dev/null 2>&1 || true - changed=1 - elif [ "$default_has_podkop_dns" -eq 1 ]; then - uci -q set 'dhcp.@dnsmasq[0].cachesize=150' >/dev/null 2>&1 || true - changed=1 - fi - - [ "$changed" -eq 1 ] || return 0 - - uci -q commit dhcp >/dev/null 2>&1 || true - [ -x /etc/init.d/dnsmasq ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true -} - -podkop_dnsmasq_failsafe_restore -exit 0 diff --git a/podkop/files/usr/lib/dnsmasq_runtime.sh b/podkop/files/usr/lib/dnsmasq_runtime.sh deleted file mode 100644 index a4bdbe2b..00000000 --- a/podkop/files/usr/lib/dnsmasq_runtime.sh +++ /dev/null @@ -1,273 +0,0 @@ -# shellcheck shell=ash - -uci_del_list_value() { - local package="$1" - local section="$2" - local option="$3" - local value="$4" - - uci -q del_list "$package.$section.$option=$value" >/dev/null 2>&1 -} - -uci_remove_quiet() { - local package="$1" - local section="$2" - local option="${3:-}" - - uci -q delete "$package.$section${option:+.$option}" >/dev/null 2>&1 -} - -dnsmasq_server_list_has_podkop_dns() { - local server_list="$1" - local server - - for server in $server_list; do - [ "$server" = "$SB_DNS_INBOUND_ADDRESS" ] && return 0 - done - - return 1 -} - -dnsmasq_default_has_podkop_dns() { - dnsmasq_server_list_has_podkop_dns "$(uci_get "dhcp" "@dnsmasq[0]" "server")" -} - -dnsmasq_legacy_instance_exists() { - uci -q show "dhcp.podkop_plus" >/dev/null 2>&1 -} - -dnsmasq_has_podkop_dns() { - dnsmasq_default_has_podkop_dns || dnsmasq_legacy_instance_exists -} - -dnsmasq_has_podkop_managed_state() { - [ -n "$(uci_get "dhcp" "@dnsmasq[0]" "podkop_server")" ] && return 0 - [ -n "$(uci_get "dhcp" "@dnsmasq[0]" "podkop_noresolv")" ] && return 0 - [ -n "$(uci_get "dhcp" "@dnsmasq[0]" "podkop_cachesize")" ] && return 0 - [ -n "$(uci_get "dhcp" "@dnsmasq[0]" "podkop_notinterface")" ] && return 0 - dnsmasq_legacy_instance_exists && return 0 - - return 1 -} - -dnsmasq_management_disabled() { - local dont_touch_dhcp - - config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0 - [ "$dont_touch_dhcp" -eq 1 ] -} - -dnsmasq_default_config_is_complete() { - local cachesize noresolv - - noresolv="$(uci_get "dhcp" "@dnsmasq[0]" "noresolv")" - cachesize="$(uci_get "dhcp" "@dnsmasq[0]" "cachesize")" - - dnsmasq_default_has_podkop_dns || return 1 - [ "$noresolv" = "1" ] || return 1 - [ "$cachesize" = "0" ] || return 1 - ! dnsmasq_legacy_instance_exists || return 1 - - return 0 -} - -backup_dnsmasq_config_option() { - local key="$1" - local backup_key="$2" - local value - - [ -n "$(uci_get "dhcp" "@dnsmasq[0]" "$backup_key")" ] && return 0 - - value="$(uci_get "dhcp" "@dnsmasq[0]" "$key")" - [ -n "$value" ] || return 0 - - uci_set "dhcp" "@dnsmasq[0]" "$backup_key" "$value" -} - -backup_dnsmasq_server_list() { - local server backup_servers - - backup_servers="$(uci_get "dhcp" "@dnsmasq[0]" "podkop_server")" - [ -n "$backup_servers" ] && return 0 - - for server in $(uci_get "dhcp" "@dnsmasq[0]" "server"); do - [ "$server" = "$SB_DNS_INBOUND_ADDRESS" ] && continue - uci_add_list "dhcp" "@dnsmasq[0]" "podkop_server" "$server" - done -} - -restore_dnsmasq_config_option() { - local key="$1" - local backup_key="$2" - local default_value="${3:-}" - local value - - value="$(uci_get "dhcp" "@dnsmasq[0]" "$backup_key")" - if [ -n "$value" ]; then - uci_set "dhcp" "@dnsmasq[0]" "$key" "$value" - uci_remove_quiet "dhcp" "@dnsmasq[0]" "$backup_key" - elif [ -n "$default_value" ]; then - uci_set "dhcp" "@dnsmasq[0]" "$key" "$default_value" - else - uci_remove_quiet "dhcp" "@dnsmasq[0]" "$key" - fi -} - -dnsmasq_legacy_interfaces() { - local interfaces - - interfaces="$(uci_get "dhcp" "podkop_plus" "interface")" - if [ -z "$interfaces" ]; then - config_get interfaces "settings" "source_network_interfaces" "br-lan" - fi - - printf '%s\n' "$interfaces" -} - -dnsmasq_cleanup_legacy_instance() { - local legacy_instance_present legacy_interfaces backup_notinterfaces value - - legacy_instance_present=0 - legacy_interfaces="" - if dnsmasq_legacy_instance_exists; then - legacy_instance_present=1 - legacy_interfaces="$(dnsmasq_legacy_interfaces)" - fi - - uci_remove_quiet "dhcp" "podkop_plus" - - backup_notinterfaces="$(uci_get "dhcp" "@dnsmasq[0]" "podkop_notinterface")" - if [ -n "$backup_notinterfaces" ]; then - uci_remove_quiet "dhcp" "@dnsmasq[0]" "notinterface" - for value in $backup_notinterfaces; do - uci_add_list "dhcp" "@dnsmasq[0]" "notinterface" "$value" - done - uci_remove_quiet "dhcp" "@dnsmasq[0]" "podkop_notinterface" - return 0 - fi - - [ "$legacy_instance_present" -eq 1 ] || return 0 - for value in $legacy_interfaces; do - uci_del_list_value "dhcp" "@dnsmasq[0]" "notinterface" "$value" - done - uci_remove_quiet "dhcp" "@dnsmasq[0]" "podkop_notinterface" -} - -dnsmasq_configure_default_instance() { - local default_has_podkop_dns - - default_has_podkop_dns=0 - dnsmasq_default_has_podkop_dns && default_has_podkop_dns=1 - - backup_dnsmasq_server_list - if [ "$default_has_podkop_dns" -eq 0 ]; then - backup_dnsmasq_config_option "noresolv" "podkop_noresolv" - backup_dnsmasq_config_option "cachesize" "podkop_cachesize" - fi - - uci_remove_quiet "dhcp" "@dnsmasq[0]" "server" - uci_add_list "dhcp" "@dnsmasq[0]" "server" "$SB_DNS_INBOUND_ADDRESS" - uci_set "dhcp" "@dnsmasq[0]" "noresolv" 1 - uci_set "dhcp" "@dnsmasq[0]" "cachesize" 0 -} - -dnsmasq_restore_default_instance() { - local server_list backup_servers server managed_global_dns noresolv cachesize - - server_list="$(uci_get "dhcp" "@dnsmasq[0]" "server")" - backup_servers="$(uci_get "dhcp" "@dnsmasq[0]" "podkop_server")" - managed_global_dns=0 - dnsmasq_server_list_has_podkop_dns "$server_list" && managed_global_dns=1 - - uci_remove_quiet "dhcp" "@dnsmasq[0]" "server" - if [ -n "$backup_servers" ]; then - for server in $backup_servers; do - uci_add_list "dhcp" "@dnsmasq[0]" "server" "$server" - done - uci_remove_quiet "dhcp" "@dnsmasq[0]" "podkop_server" - else - for server in $server_list; do - [ "$server" = "$SB_DNS_INBOUND_ADDRESS" ] && continue - uci_add_list "dhcp" "@dnsmasq[0]" "server" "$server" - done - fi - uci_remove_quiet "dhcp" "@dnsmasq[0]" "podkop_server" - - noresolv="$(uci_get "dhcp" "@dnsmasq[0]" "podkop_noresolv")" - if [ -n "$noresolv" ]; then - restore_dnsmasq_config_option "noresolv" "podkop_noresolv" - elif [ "$managed_global_dns" -eq 1 ]; then - uci_set "dhcp" "@dnsmasq[0]" "noresolv" 0 - fi - - cachesize="$(uci_get "dhcp" "@dnsmasq[0]" "podkop_cachesize")" - if [ -n "$cachesize" ]; then - restore_dnsmasq_config_option "cachesize" "podkop_cachesize" - elif [ "$managed_global_dns" -eq 1 ]; then - uci_set "dhcp" "@dnsmasq[0]" "cachesize" 150 - fi -} - -dnsmasq_configure() { - local force="${1:-0}" - local shutdown_correctly - config_get shutdown_correctly "settings" "shutdown_correctly" - if [ "$force" != "force" ] && [ "$shutdown_correctly" -eq 0 ]; then - if dnsmasq_default_config_is_complete; then - log "Previous shutdown of Podkop Plus was not correct, dnsmasq is already configured" - return 0 - else - log "Previous shutdown of Podkop Plus was not correct, but dnsmasq is not configured correctly. Applying Podkop Plus DNS settings" - fi - fi - - log "Configure dnsmasq for sing-box" - dnsmasq_cleanup_legacy_instance - dnsmasq_configure_default_instance - uci_commit "dhcp" - - /etc/init.d/dnsmasq restart -} - -dnsmasq_restore() { - local force="${1:-0}" - log "Restoring the dnsmasq configuration" - local shutdown_correctly - config_get shutdown_correctly "settings" "shutdown_correctly" - if [ "$force" != "force" ] && [ "$shutdown_correctly" -eq 1 ]; then - if ! dnsmasq_has_podkop_dns; then - log "Previous shutdown of Podkop Plus was correct, reconfiguration of dnsmasq is not required" - return 0 - fi - - log "Previous shutdown of Podkop Plus was correct, but Podkop Plus DNS is still present. Restoring dnsmasq" - fi - - dnsmasq_cleanup_legacy_instance - dnsmasq_restore_default_instance - - uci_commit "dhcp" - - /etc/init.d/dnsmasq restart -} - -dnsmasq_restore_fail_safe() { - if dnsmasq_management_disabled; then - if ! dnsmasq_has_podkop_managed_state; then - log "Fail-safe: dont_touch_dhcp is enabled, leaving dnsmasq unchanged" "warn" - return 0 - fi - - log "Fail-safe: dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes" "warn" - fi - - log "Fail-safe: restoring dnsmasq away from Podkop Plus DNS" "warn" - - dnsmasq_restore force >/dev/null 2>&1 || true - - if dnsmasq_has_podkop_dns; then - podkop_dnsmasq_failsafe_restore_raw - fi - - return 0 -} diff --git a/podkop/files/usr/lib/helpers.sh b/podkop/files/usr/lib/helpers.sh deleted file mode 100644 index 592b4e6f..00000000 --- a/podkop/files/usr/lib/helpers.sh +++ /dev/null @@ -1,674 +0,0 @@ -# shellcheck shell=ash - -helpers_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/helpers.uc" "$@" -} - -download_via_proxy_option_for_purpose() { - case "${1:-lists}" in - lists) - printf '%s\n' "download_lists_via_proxy" - ;; - subscriptions) - printf '%s\n' "download_subscriptions_via_proxy" - ;; - components) - printf '%s\n' "download_components_via_proxy" - ;; - *) - return 1 - ;; - esac -} - -download_via_proxy_enabled_for_purpose() { - local option enabled - - option="$(download_via_proxy_option_for_purpose "$1")" || return 1 - config_get_bool enabled "settings" "$option" 0 - [ "$enabled" -eq 1 ] -} - -download_via_proxy_any_enabled() { - download_via_proxy_enabled_for_purpose lists || - download_via_proxy_enabled_for_purpose subscriptions || - download_via_proxy_enabled_for_purpose components -} - -trim_string() { - helpers_ucode stdin-trim-string -} - -# Check if string is valid IPv4 -is_ipv4() { - helpers_ucode valid-ipv4 "$1" >/dev/null 2>&1 -} - -# Check if string is valid IPv4 with CIDR mask -is_ipv4_cidr() { - helpers_ucode valid-ipv4-cidr "$1" >/dev/null 2>&1 -} - -is_ipv4_ip_or_ipv4_cidr() { - is_ipv4 "$1" || is_ipv4_cidr "$1" -} - -is_domain() { - helpers_ucode valid-domain "$1" >/dev/null 2>&1 -} - -is_domain_suffix() { - helpers_ucode valid-domain-suffix "$1" >/dev/null 2>&1 -} - -# Compares the current package version with the required minimum -is_min_package_version() { - local current="$1" - local required="$2" - - helpers_ucode version-at-least "$current" "$required" >/dev/null 2>&1 -} - -get_apk_installed_package_version() { - local package_name="$1" - local version - - version="$( - apk list --installed --manifest "$package_name" 2>/dev/null | - updates_ucode updates-apk-manifest-package-version "$package_name" - )" - - if [ -z "$version" ]; then - version="$( - apk list --installed --manifest 2>/dev/null | - updates_ucode updates-apk-manifest-package-version "$package_name" - )" - fi - - if [ -n "$version" ]; then - echo "$version" - return 0 - fi - - apk info -v "$package_name" 2>/dev/null | - updates_ucode updates-apk-info-package-version "$package_name" -} - -# Checks if the given file exists -file_exists() { - local filepath="$1" - - if [ -f "$filepath" ]; then - return 0 - else - return 1 - fi -} - -# Checks if a service script exists in /etc/init.d -service_exists() { - local service="$1" - - if [ -x "/etc/init.d/$service" ]; then - return 0 - else - return 1 - fi -} - -list_has_item() { - local list="$1" - local needle="$2" - local item - - [ -n "$needle" ] || return 1 - - for item in $list; do - [ "$item" = "$needle" ] && return 0 - done - - return 1 -} - -allocate_runtime_tag() { - local base="$1" - local postfix="$2" - - helpers_ucode allocate-runtime-tag \ - "$base" \ - "$postfix" \ - "${SB_DNS_SERVER_TAG:-dns-server}" \ - "${SB_FAKEIP_DNS_SERVER_TAG:-fakeip-server}" \ - "${SB_BOOTSTRAP_SERVER_TAG:-bootstrap-dns-server}" \ - "${SB_FAKEIP_DNS_RULE_TAG:-fakeip-dns-rule-tag}" \ - "${SB_FAKEIP_RULESET_DNS_RULE_TAG:-fakeip-ruleset-dns-rule-tag}" \ - "${SB_SERVICE_FAKEIP_DNS_RULE_TAG:-service-fakeip-dns-rule-tag}" \ - "${SB_TPROXY_INBOUND_TAG:-tproxy-in}" \ - "${SB_DNS_INBOUND_TAG:-dns-in}" \ - "${SB_SERVICE_MIXED_INBOUND_TAG:-service-mixed-in}" \ - "${SB_DIRECT_OUTBOUND_TAG:-direct-out}" -} - -# Returns the inbound tag name by appending the postfix to the given section -get_inbound_tag_by_section() { - local section="$1" - - allocate_runtime_tag "$section" "in" -} - -get_server_inbound_tag_by_section() { - local section="$1" - - allocate_runtime_tag "server-$section" "in" -} - -get_tailscale_dns_server_tag_by_section() { - local section="$1" - - allocate_runtime_tag "server-$section" "tailscale-dns" -} - -# Returns the outbound tag name by appending the postfix to the given section -get_outbound_tag_by_section() { - local section="$1" - - allocate_runtime_tag "$section" "out" -} - -# Constructs and returns a domain resolver tag by appending a fixed postfix to the given section -get_domain_resolver_tag() { - local section="$1" - - allocate_runtime_tag "$section" "domain-resolver" -} - -config_list_to_json() { - local section="$1" - local option="$2" - local value - - CONFIG_LIST_VALUES_SEEN=0 - { - config_list_foreach "$section" "$option" config_list_print_value - if [ "$CONFIG_LIST_VALUES_SEEN" -eq 0 ]; then - config_get value "$section" "$option" - if [ -n "$value" ]; then - printf '%s\n' "$value" - fi - fi - } | helpers_ucode stdin-lines-to-json-array -} - -config_list_print_value() { - local value="$1" - - CONFIG_LIST_VALUES_SEEN=1 - printf '%s\n' "$value" -} - -# Returns the scheme (protocol) part of a URL -url_get_scheme() { - helpers_ucode url-get-scheme "$1" -} - -# Extracts the userinfo (username[:password]) part from a URL -url_get_userinfo() { - helpers_ucode url-get-userinfo "$1" -} - -# Extracts the host part from a URL -url_get_host() { - helpers_ucode url-get-host "$1" -} - -# Extracts the port number from a URL -url_get_port() { - helpers_ucode url-get-port "$1" -} - -# Extracts the path from a URL (without query or fragment; returns "/" if empty) -url_get_path() { - helpers_ucode url-get-path "$1" -} - -# Extracts the value of a specific query parameter from a URL -url_get_query_param() { - helpers_ucode url-get-query-param "$1" "$2" -} - -# Extracts and returns the file extension from the given URL -url_get_file_extension() { - helpers_ucode url-file-extension "$1" -} - -# Remove url fragment (everything after the first '#') -url_strip_fragment() { - helpers_ucode url-strip-fragment "$1" -} - -# Decodes and returns a base64-encoded string -base64_decode() { - local str="$1" - local decoded_url - - decoded_url="$(echo "$str" | base64 -d 2> /dev/null)" - - echo "$decoded_url" -} - -# Generates a unique 16-character ID based on the current timestamp, process ID, and system entropy -gen_id() { - { - printf '%s%s' "$(date +%s)" "$$" - head -c 16 /dev/urandom 2>/dev/null || true - } | md5sum | helpers_ucode md5sum-hex-prefix 16 -} - -# Download URL to file -download_to_file() { - local url="$1" - local filepath="$2" - local http_proxy_address="$3" - local retries="${4:-3}" - local wait="${5:-2}" - local attempt - - attempt=1 - while [ "$attempt" -le "$retries" ]; do - if [ -n "$http_proxy_address" ]; then - http_proxy="http://$http_proxy_address" https_proxy="http://$http_proxy_address" wget -O "$filepath" "$url" && return 0 - else - wget -O "$filepath" "$url" && return 0 - fi - - log "Attempt $attempt/$retries to download $url failed" "warn" - sleep "$wait" - attempt=$((attempt + 1)) - done - - return 1 -} - -get_device_model() { - local model="" - - if [ -f /tmp/sysinfo/model ]; then - model="$(cat /tmp/sysinfo/model 2>/dev/null)" - fi - - echo "${model:-OpenWrt Router}" -} - -get_kernel_version() { - uname -r -} - -get_sing_box_version() { - local version="" - - if [ "${PODKOP_SING_BOX_VERSION_CACHED:-0}" = "1" ]; then - printf '%s\n' "$PODKOP_SING_BOX_VERSION" - return 0 - fi - - if command -v sing-box >/dev/null 2>&1; then - if is_sing_box_compressed_marker_set; then - version="$(read_sing_box_version_state 2>/dev/null || true)" - PODKOP_SING_BOX_VERSION="$version" - PODKOP_SING_BOX_VERSION_CACHED=1 - printf '%s\n' "$version" - return 0 - fi - - version="$(sing_box_version_output | sing_box_version_from_output)" - fi - - PODKOP_SING_BOX_VERSION="$version" - PODKOP_SING_BOX_VERSION_CACHED=1 - printf '%s\n' "$version" -} - -sing_box_version_from_output() { - local line field last="" - - IFS= read -r line || return 0 - for field in $line; do - last="$field" - done - printf '%s\n' "$last" -} - -sing_box_version_output() { - command -v sing-box >/dev/null 2>&1 || return 1 - if [ "${PODKOP_SING_BOX_VERSION_OUTPUT_CACHED:-0}" != "1" ]; then - PODKOP_SING_BOX_VERSION_OUTPUT="$(sing-box version 2>/dev/null || true)" - PODKOP_SING_BOX_VERSION_OUTPUT_CACHED=1 - fi - printf '%s\n' "$PODKOP_SING_BOX_VERSION_OUTPUT" -} - -sing_box_output_has_build_tag() { - local output="$1" - local tag="$2" - - [ -n "$tag" ] || return 1 - printf '%s\n' "$output" | grep -Eq "(^|[,:[:space:]])${tag}([,[:space:]]|$)" -} - -sing_box_has_build_tag() { - local tag="$1" - - [ -n "$tag" ] || return 1 - sing_box_version_output | grep -Eq "(^|[,:[:space:]])${tag}([,[:space:]]|$)" -} - -is_sing_box_extended() { - local version="${1:-}" - - if [ -z "$version" ] && command -v sing-box >/dev/null 2>&1 && - { is_sing_box_compressed_marker_set || is_sing_box_extended_marker_set; }; then - return 0 - fi - - [ -n "$version" ] || version="$(get_sing_box_version)" - helpers_ucode sing-box-version-is-extended "$version" >/dev/null 2>&1 -} - -is_sing_box_tiny_package_installed() { - if command -v apk >/dev/null 2>&1; then - apk info -e sing-box-tiny >/dev/null 2>&1 - return $? - fi - - opkg_package_is_installed sing-box-tiny -} - -is_sing_box_full_package_installed() { - if command -v apk >/dev/null 2>&1; then - apk info -e sing-box >/dev/null 2>&1 - return $? - fi - - opkg_package_is_installed sing-box -} - -is_sing_box_compressed_marker_set() { - [ -r "${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" ] || return 1 - [ "$(cat "${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" 2>/dev/null)" = "extended-compressed" ] -} - -is_sing_box_extended_marker_set() { - [ -r "${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" ] || return 1 - [ "$(cat "${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" 2>/dev/null)" = "extended" ] -} - -read_sing_box_version_state() { - local state_file="${SB_VERSION_STATE_FILE:-/etc/podkop-plus/sing-box-version}" - - [ -r "$state_file" ] || return 1 - sed -n '1p' "$state_file" 2>/dev/null -} - -is_sing_box_tiny_marker_set() { - [ -r "${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" ] || return 1 - [ "$(cat "${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" 2>/dev/null)" = "tiny" ] -} - -is_sing_box_tiny() { - local version="${1:-}" - local version_output="${2:-}" - - if command -v sing-box >/dev/null 2>&1 && is_sing_box_compressed_marker_set; then - return 1 - fi - - if [ -n "$version" ]; then - is_sing_box_extended "$version" && return 1 - else - is_sing_box_extended && return 1 - fi - - is_sing_box_tiny_package_installed && return 0 - is_sing_box_tiny_marker_set || return 1 - sing_box_supports_tailscale "$version" "$version_output" && return 1 - return 0 -} - -sing_box_supports_tailscale() { - local version="${1:-}" - local version_output="${2:-}" - - if command -v sing-box >/dev/null 2>&1 && is_sing_box_compressed_marker_set; then - return 0 - fi - - if [ -n "$version" ]; then - is_sing_box_extended "$version" && return 0 - else - is_sing_box_extended && return 0 - fi - - if [ -n "$version_output" ]; then - sing_box_output_has_build_tag "$version_output" with_tailscale - return $? - fi - - sing_box_has_build_tag with_tailscale -} - -get_sing_box_variant() { - local version - - if ! command -v sing-box >/dev/null 2>&1; then - printf '%s\n' "not-installed" - return 0 - fi - - if is_sing_box_compressed_marker_set; then - printf '%s\n' "extended-compressed" - return 0 - fi - - version="$(get_sing_box_version)" - - if is_sing_box_extended "$version"; then - if is_sing_box_compressed_marker_set; then - printf '%s\n' "extended-compressed" - else - printf '%s\n' "extended" - fi - return 0 - fi - - if is_sing_box_tiny "$version"; then - printf '%s\n' "tiny" - return 0 - fi - - printf '%s\n' "stable" -} - -get_subscription_user_agent() { - local custom_user_agent="${1:-}" - - if [ -n "$custom_user_agent" ]; then - printf '%s' "$custom_user_agent" - return 0 - fi - - local sing_box_version - - sing_box_version="$(get_sing_box_version)" - [ -n "$sing_box_version" ] || sing_box_version="unknown" - - printf 'sing-box/%s' "$sing_box_version" -} - -normalize_strategy_whitespace() { - helpers_ucode normalize-strategy-whitespace "$1" -} - -generate_hwid() { - local mac="" model="" - - if [ -f /sys/class/net/eth0/address ]; then - mac="$(cat /sys/class/net/eth0/address 2>/dev/null)" - elif [ -f /sys/class/net/br-lan/address ]; then - mac="$(cat /sys/class/net/br-lan/address 2>/dev/null)" - fi - - model="$(get_device_model)" - printf '%s-%s' "$mac" "$model" | md5sum | helpers_ucode md5sum-hwid -} - -download_subscription() { - local url="$1" - local filepath="$2" - local http_proxy_address="$3" - local retries="${4:-3}" - local wait="${5:-2}" - local timeout="${6:-10}" - local headers_filepath="${7:-}" - local custom_user_agent="${8:-}" - local tmpfile headers_tmpfile attempt wget_status user_agent resolution_failed - - tmpfile="${filepath}.part.$$" - headers_tmpfile="" - user_agent="$(get_subscription_user_agent "$custom_user_agent")" - resolution_failed=0 - [ -n "$headers_filepath" ] && headers_tmpfile="${headers_filepath}.part.$$" - rm -f "$tmpfile" - [ -n "$headers_tmpfile" ] && rm -f "$headers_tmpfile" - - attempt=1 - while [ "$attempt" -le "$retries" ]; do - if [ -n "$http_proxy_address" ]; then - if [ -n "$headers_tmpfile" ]; then - curl -fL -sS \ - --connect-timeout "$timeout" \ - --speed-time "$timeout" \ - --speed-limit 1 \ - -x "http://$http_proxy_address" \ - -D "$headers_tmpfile" \ - -o "$tmpfile" \ - -H "User-Agent: $user_agent" \ - -H "X-HWID: $(generate_hwid)" \ - -H "X-Device-OS: OpenWrt Linux" \ - -H "X-Device-Model: $(get_device_model)" \ - -H "X-Ver-OS: $(get_kernel_version)" \ - -H "Accept-Language: ru-RU,en,*" \ - -H "X-Device-Locale: EN" \ - "$url" - else - http_proxy="http://$http_proxy_address" https_proxy="http://$http_proxy_address" \ - wget -T "$timeout" -O "$tmpfile" \ - --header "User-Agent: $user_agent" \ - --header "X-HWID: $(generate_hwid)" \ - --header "X-Device-OS: OpenWrt Linux" \ - --header "X-Device-Model: $(get_device_model)" \ - --header "X-Ver-OS: $(get_kernel_version)" \ - --header "Accept-Language: ru-RU,en,*" \ - --header "X-Device-Locale: EN" \ - "$url" - fi - wget_status=$? - else - if [ -n "$headers_tmpfile" ]; then - curl -fL -sS \ - --connect-timeout "$timeout" \ - --speed-time "$timeout" \ - --speed-limit 1 \ - -D "$headers_tmpfile" \ - -o "$tmpfile" \ - -H "User-Agent: $user_agent" \ - -H "X-HWID: $(generate_hwid)" \ - -H "X-Device-OS: OpenWrt Linux" \ - -H "X-Device-Model: $(get_device_model)" \ - -H "X-Ver-OS: $(get_kernel_version)" \ - -H "Accept-Language: ru-RU,en,*" \ - -H "X-Device-Locale: EN" \ - "$url" - else - wget -T "$timeout" -O "$tmpfile" \ - --header "User-Agent: $user_agent" \ - --header "X-HWID: $(generate_hwid)" \ - --header "X-Device-OS: OpenWrt Linux" \ - --header "X-Device-Model: $(get_device_model)" \ - --header "X-Ver-OS: $(get_kernel_version)" \ - --header "Accept-Language: ru-RU,en,*" \ - --header "X-Device-Locale: EN" \ - "$url" - fi - wget_status=$? - fi - - if [ "$wget_status" -eq 0 ] && [ -s "$tmpfile" ]; then - mv "$tmpfile" "$filepath" - if [ -n "$headers_tmpfile" ]; then - if [ -s "$headers_tmpfile" ]; then - mv "$headers_tmpfile" "$headers_filepath" - else - rm -f "$headers_filepath" "$headers_tmpfile" - fi - fi - return 0 - fi - - rm -f "$tmpfile" - [ -n "$headers_tmpfile" ] && rm -f "$headers_tmpfile" - log "Attempt $attempt/$retries to download subscription failed" "warn" - - if [ "$wget_status" -eq 5 ] || [ "$wget_status" -eq 6 ]; then - resolution_failed=1 - break - fi - - sleep "$wait" - attempt=$((attempt + 1)) - done - - rm -f "$tmpfile" - [ -n "$headers_tmpfile" ] && rm -f "$headers_tmpfile" - [ "$resolution_failed" -eq 1 ] && return 6 - return 1 -} - -updates_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/updater.uc" "$@" -} - -opkg_package_is_installed() { - local package_name="$1" - - command -v opkg >/dev/null 2>&1 || return 1 - opkg list-installed 2>/dev/null | updates_ucode updates-opkg-package-installed "$package_name" -} - -get_opkg_installed_package_version() { - local package_name="$1" - - command -v opkg >/dev/null 2>&1 || return 0 - opkg list-installed 2>/dev/null | updates_ucode updates-opkg-package-version "$package_name" -} - -validate_subscription_file() { - local filepath="$1" - - [ -s "$filepath" ] || return 1 - - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/subscription_parser.uc" 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) -convert_crlf_to_lf() { - local filepath="$1" - - if helpers_ucode file-has-cr "$filepath" >/dev/null 2>&1; then - log "File '$filepath' contains CRLF line endings. Converting to LF..." "debug" - helpers_ucode file-remove-cr "$filepath" >/dev/null 2>&1 - fi -} diff --git a/podkop/files/usr/lib/logging.sh b/podkop/files/usr/lib/logging.sh deleted file mode 100644 index 5573a989..00000000 --- a/podkop/files/usr/lib/logging.sh +++ /dev/null @@ -1,32 +0,0 @@ -COLOR_CYAN="\033[0;36m" -COLOR_GREEN="\033[0;32m" -COLOR_RESET="\033[0m" - -log() { - local message="$1" - local level="$2" - - if [ "$level" == "" ]; then - level="info" - fi - - logger -t "podkop-plus" "[$level] $message" -} - -nolog() { - local message="$1" - local timestamp - timestamp=$(date +"%Y-%m-%d %H:%M:%S") - - if [ -t 1 ]; then - echo -e "${COLOR_CYAN}[$timestamp]${COLOR_RESET} ${COLOR_GREEN}$message${COLOR_RESET}" - fi -} - -echolog() { - local message="$1" - local level="$2" - - log "$message" "$level" - nolog "$message" -} diff --git a/podkop/files/usr/lib/nft.sh b/podkop/files/usr/lib/nft.sh deleted file mode 100644 index 16e04bf4..00000000 --- a/podkop/files/usr/lib/nft.sh +++ /dev/null @@ -1,114 +0,0 @@ -# Create an nftables table in the inet family -nft_create_table() { - local name="$1" - - nft add table inet "$name" -} - -# Create a set within a table for storing IPv4 addresses -nft_create_ipv4_set() { - local table="$1" - local name="$2" - - nft add set inet "$table" "$name" '{ type ipv4_addr; flags interval; auto-merge; }' -} - -nft_create_inet_service_set() { - local table="$1" - local name="$2" - - nft add set inet "$table" "$name" '{ type inet_service; flags interval; auto-merge; }' -} - -nft_create_ipv4_port_set() { - local table="$1" - local name="$2" - - nft add set inet "$table" "$name" '{ type ipv4_addr . inet_service; flags interval; auto-merge; }' -} - -nft_create_ifname_set() { - local table="$1" - local name="$2" - - nft add set inet "$table" "$name" '{ type ifname; flags interval; }' -} - -# Add one or more elements to a set -nft_add_set_elements() { - local table="$1" - local set="$2" - local elements="$3" - - nft add element inet "$table" "$set" "{ $elements }" -} - -nft_log_prepared_invalid_elements() { - local invalid_filepath="$1" - - [ -s "$invalid_filepath" ] || return 0 - - while IFS="$(printf '\t')" read -r value message || [ -n "$value" ]; do - [ -n "$value" ] || continue - log "'$value' $message" "debug" - done < "$invalid_filepath" -} - -nft_add_prepared_chunks_to_set() { - local chunks_filepath="$1" - local nft_table_name="$2" - local nft_set_name="$3" - local count elements - - [ -s "$chunks_filepath" ] || return 0 - - while read -r count elements || [ -n "$count" ]; do - [ -n "$elements" ] || continue - log "Adding $count elements to nft set $nft_set_name" "debug" - nft_add_set_elements "$nft_table_name" "$nft_set_name" "$elements" - done < "$chunks_filepath" -} - -nft_add_file_chunks_to_set() { - local filepath="$1" - local nft_table_name="$2" - local nft_set_name="$3" - local kind="$4" - local ports="$5" - local chunk_size="$6" - - local chunks_filepath invalid_filepath status - chunks_filepath="$(mktemp)" || return 1 - invalid_filepath="$(mktemp)" || { - rm -f "$chunks_filepath" - return 1 - } - - if ! rules_nft_runtime_ucode nft-prepare-chunks "$filepath" "$kind" "$ports" "$chunk_size" "$chunks_filepath" "$invalid_filepath"; then - rm -f "$chunks_filepath" "$invalid_filepath" - return 1 - fi - - nft_log_prepared_invalid_elements "$invalid_filepath" - nft_add_prepared_chunks_to_set "$chunks_filepath" "$nft_table_name" "$nft_set_name" - status=$? - - rm -f "$chunks_filepath" "$invalid_filepath" - return "$status" -} - -nft_add_port_set_elements_from_file_chunked() { - nft_add_file_chunks_to_set "$1" "$2" "$3" "ports" "" "${4:-5000}" -} - -nft_add_ip_port_set_elements_from_file_chunked() { - nft_add_file_chunks_to_set "$1" "$2" "$3" "ip-ports" "" "${4:-5000}" -} - -nft_add_ip_port_set_elements_from_ip_file_chunked() { - nft_add_file_chunks_to_set "$1" "$2" "$3" "ip-port-from-ip" "$4" "${5:-5000}" -} - -nft_add_set_elements_from_file_chunked() { - nft_add_file_chunks_to_set "$1" "$2" "$3" "ips" "" "${4:-5000}" -} diff --git a/podkop/files/usr/lib/nft/apply.uc b/podkop/files/usr/lib/nft/apply.uc new file mode 100644 index 00000000..7d39b18e --- /dev/null +++ b/podkop/files/usr/lib/nft/apply.uc @@ -0,0 +1,1560 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); +let uci_core = require("core.uci"); +let rule_config = require("config.rule"); +let routing_rulesets = require("routing.rulesets"); +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; + +let common_read_json_file = common.read_json_file; +let list_option = common.list_option; +let bool_option = common.bool_option; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes"; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function uci_section(section_name) { + return object_or_empty(uci_core.get_all(CONFIG_NAME, section_name)); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, type_name); +} + +function uci_settings() { + return uci_section("settings"); +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function write_compact_string_array(values) { + print("["); + for (let i = 0; i < length(values); i++) { + if (i > 0) + print(","); + print(sprintf("%J", as_string(values[i]))); + } + print("]\n"); +} + +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 write_text_file(path, text) { + let result = fs.writefile(path, as_string(text)); + if (result == null) + return false; + if (type(result) == "boolean" && !result) + return false; + return true; +} + +function file_executable(path) { + let stat = fs.stat(as_string(path)); + if (stat == null || stat.mode == null) + return false; + + return (int(stat.mode) & 73) != 0; +} + +function unlink_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function run_args(args) { + return system(command_from_args(args)) == 0; +} + +function run_args_quiet(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_output_from_args(args) { + let pipe = fs.popen(command_from_args(args), "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function log_debug(message) { + run_args([ "logger", "-t", "podkop-plus", "[debug] " + as_string(message) ]); +} + +function log_fatal(message) { + run_args([ "logger", "-t", "podkop-plus", "[fatal] " + as_string(message) ]); +} + +function strip_list_comment(line) { + line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); + return replace(line, /[[:space:]]*#.*$/, ""); +} + +function print_csv(values) { + for (let i = 0; i < length(values); i++) { + if (i > 0) + print(","); + print(as_string(values[i])); + } + if (length(values) > 0) + print("\n"); +} + +function text_list_values(value, separator_mode) { + let result = []; + separator_mode = as_string(separator_mode); + + for (let line in split(as_string(value), "\n")) { + line = strip_list_comment(line); + line = separator_mode == "comma-space" + ? replace(line, /[ ,]/g, "\n") + : replace(line, /,/g, "\n"); + + for (let item in split(line, "\n")) { + item = trim(replace(item, /\r/g, "")); + if (item != "") + push(result, item); + } + } + + return result; +} + +function text_list_to_csv(value, separator_mode) { + print_csv(text_list_values(value, separator_mode)); +} + +function csv_to_json_array(value) { + value = as_string(value); + if (value == "") { + print("[]\n"); + return; + } + + write_compact_string_array(split(value, ",")); +} + +function csv_list_contains(value, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + for (let item in split(as_string(value), ",")) { + if (item == needle) + return true; + } + + return false; +} + +function cache_key_is_safe(value) { + value = as_string(value); + return value != "" && match(value, /^[A-Za-z0-9_]+$/) != null; +} + +function cache_path(enabled, cache_dir, namespace, section, key, kind) { + if (as_string(enabled) != "1") + exit(1); + + cache_dir = as_string(cache_dir); + if (cache_dir == "") + exit(1); + + if (!cache_key_is_safe(namespace) || !cache_key_is_safe(section) || + !cache_key_is_safe(key) || !cache_key_is_safe(kind)) + exit(1); + + print(cache_dir, "/", namespace, "_", section, "_", key, "_", kind, "\n"); +} + +function valid_ipv4(value) { + value = as_string(value); + if (match(value, /^([0-9]{1,3}\.){3}[0-9]{1,3}$/) == null) + return false; + + let parts = split(value, "."); + if (length(parts) != 4) + return false; + + for (let part in parts) { + if (part == "" || match(part, /^[0-9]+$/) == null) + return false; + let octet = int(part); + if (octet < 0 || octet > 255) + return false; + } + + return true; +} + +function valid_ipv4_cidr(value) { + value = as_string(value); + let slash = index(value, "/"); + if (slash < 0 || index(substr(value, slash + 1), "/") >= 0) + return false; + + let address = substr(value, 0, slash); + let prefix = substr(value, slash + 1); + if (prefix == "" || match(prefix, /^[0-9]+$/) == null) + return false; + + prefix = int(prefix); + return valid_ipv4(address) && prefix >= 0 && prefix <= 32; +} + +function valid_domain_suffix(value) { + value = ascii_lower(value); + if (substr(value, 0, 1) == ".") + value = substr(value, 1); + + return match(value, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/) != null; +} + +function decimal_without_leading_zero(value) { + value = as_string(value); + return value != "" && match(value, /^[0-9]+$/) != null && (length(value) == 1 || substr(value, 0, 1) != "0"); +} + +function nft_ipv4(value, allow_trailing_dot) { + value = as_string(value); + if (allow_trailing_dot && length(value) > 0 && substr(value, length(value) - 1, 1) == ".") + value = substr(value, 0, length(value) - 1); + + let parts = split(value, "."); + if (length(parts) != 4) + return false; + + for (let part in parts) { + if (!decimal_without_leading_zero(part)) + return false; + + let octet = int(part); + if (octet < 0 || octet > 255) + return false; + } + + return true; +} + +function nft_ipv4_cidr(value) { + value = as_string(value); + let slash = index(value, "/"); + if (slash <= 0 || index(substr(value, slash + 1), "/") >= 0) + return false; + + let prefix = substr(value, slash + 1); + if (!decimal_without_leading_zero(prefix)) + return false; + + let prefix_number = int(prefix); + return nft_ipv4(substr(value, 0, slash), false) && prefix_number >= 0 && prefix_number <= 32; +} + +function nft_ip_or_cidr(value) { + return nft_ipv4(value, true) || nft_ipv4_cidr(value); +} + +function domain_subnet_line_values(data) { + let result = []; + + for (let line in split(as_string(data), "\n")) { + line = trim(replace(strip_list_comment(line), /\r/g, "")); + if (line != "") + push(result, line); + } + + return result; +} + +function domain_subnet_value_valid(value, kind) { + kind = as_string(kind); + if (kind == "domains") + return valid_domain_suffix(value); + if (kind == "subnets") + return valid_ipv4(value) || valid_ipv4_cidr(value); + + exit(1); +} + +function normalize_domain_subnet_value(value, kind) { + kind = as_string(kind); + if (kind == "domains") { + let normalized = ascii_lower(value); + return valid_domain_suffix(normalized) ? normalized : null; + } + if (kind == "subnets") + return valid_ipv4(value) || valid_ipv4_cidr(value) ? value : null; + + exit(1); +} + +function filter_domain_subnet_values(values, kind) { + let result = []; + kind = as_string(kind); + + if (kind != "domains" && kind != "subnets") + exit(1); + + for (let value in values) { + let normalized = normalize_domain_subnet_value(value, kind); + if (normalized != null) + push(result, normalized); + } + + return result; +} + +function combined_domain_text_csv(value, requested_kind) { + let result = rule_config.combined_domain_text_csv_value(value, requested_kind); + if (result != "") + print(result, "\n"); +} + +function combined_domain_csv(value, requested_kind) { + let result = rule_config.combined_domain_csv_value(value, requested_kind); + if (result != "") + print(result, "\n"); +} + +function list_value_csv(value) { + value = as_string(value); + if (value != "") + print(replace(value, / /g, ","), "\n"); +} + +function legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value) { + return rule_config.legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value); +} + +function rule_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value) { + return rule_config.rule_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value); +} + +function rule_condition_csv(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value) { + let value = rule_condition_csv_value(key, kind, text_mode, conditions_text_mode, text_value, list_value, combined_text_value, combined_list_value); + + if (value != "") + print(value, "\n"); +} + +function legacy_condition_csv(kind, text_mode, conditions_text_mode, text_value, list_value) { + let value = legacy_condition_csv_value(kind, text_mode, conditions_text_mode, text_value, list_value); + if (value != "") + print(value, "\n"); +} + +function domain_subnet_text_csv(value, kind) { + print_csv(filter_domain_subnet_values(text_list_values(value, "comma-space"), kind)); +} + +function domain_subnet_file_csv(path, kind) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + print_csv(filter_domain_subnet_values(domain_subnet_line_values(data), kind)); +} + +function split_domain_subnet_file(path, domains_path, subnets_path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let domains = []; + let subnets = []; + + for (let value in domain_subnet_line_values(data)) { + let domain = normalize_domain_subnet_value(value, "domains"); + if (domain != null) + push(domains, domain); + else if (valid_ipv4(value) || valid_ipv4_cidr(value)) + push(subnets, value); + } + + if (!write_text_file(domains_path, length(domains) > 0 ? join("\n", domains) + "\n" : "")) + exit(1); + if (!write_text_file(subnets_path, length(subnets) > 0 ? join("\n", subnets) + "\n" : "")) + exit(1); +} + +function normalize_port_number_value(value) { + return rule_config.normalize_port_number_value(value); +} + +function normalize_port_condition_value(value) { + return rule_config.normalize_port_condition_value(value); +} + +function normalize_port_condition_for_nft(value) { + let normalized = normalize_port_condition_value(value); + if (normalized == null) + exit(1); + print(normalized, "\n"); +} + +function normalize_port_range_value(value) { + return rule_config.normalize_port_range_value(value); +} + +function rule_ports_csv_value(list_values, text_value) { + return rule_config.rule_ports_csv_value(list_values, text_value); +} + +function rule_ports_csv(list_values, text_value) { + let value = rule_ports_csv_value(list_values, text_value); + if (value != "") + print(value, "\n"); +} + +function rule_port_values(csv) { + let result = []; + + for (let item in split(as_string(csv), ",")) { + if (index(item, "-") >= 0) + continue; + + let port = normalize_port_number_value(item); + if (port != null) + push(result, port); + } + + return result; +} + +function rule_port_ranges(csv) { + let result = []; + + for (let item in split(as_string(csv), ",")) { + if (index(item, "-") < 0) + continue; + + let range = normalize_port_range_value(item); + if (range != null) + push(result, range); + } + + return result; +} + +function csv_to_lines_file(csv, path) { + if (!fs.writefile(path, replace(as_string(csv) + "\n", /,/g, "\n"))) + exit(1); +} + +function nft_create_table(name) { + return run_args([ "nft", "add", "table", "inet", name ]); +} + +function nft_create_set(table, name, definition) { + return run_args([ "nft", "add", "set", "inet", table, name, definition ]); +} + +function nft_create_ipv4_set(table, name) { + return nft_create_set(table, name, "{ type ipv4_addr; flags interval; auto-merge; }"); +} + +function nft_create_inet_service_set(table, name) { + return nft_create_set(table, name, "{ type inet_service; flags interval; auto-merge; }"); +} + +function nft_create_ipv4_port_set(table, name) { + return nft_create_set(table, name, "{ type ipv4_addr . inet_service; flags interval; auto-merge; }"); +} + +function nft_create_ifname_set(table, name) { + return nft_create_set(table, name, "{ type ifname; flags interval; }"); +} + +function nft_add_set_elements(table, set_name, elements) { + return run_args([ "nft", "add", "element", "inet", table, set_name, "{ " + as_string(elements) + " }" ]); +} + +function whitespace_values(value) { + let result = []; + + for (let item in split(replace(as_string(value), /[[:space:]]+/g, " "), " ")) { + item = trim(item); + if (item != "") + push(result, item); + } + + return result; +} + +function nft_create_chain(table, name, definition) { + return run_args([ "nft", "add", "chain", "inet", table, name, definition ]); +} + +function nft_add_rule(table, chain, args) { + let command = [ "nft", "add", "rule", "inet", table, chain ]; + for (let arg in args) + push(command, arg); + return run_args(command); +} + +function nft_insert_rule(table, chain, args) { + let command = [ "nft", "insert", "rule", "inet", table, chain ]; + for (let arg in args) + push(command, arg); + return run_args(command); +} + +let LOCALV4_RANGES = [ + "0.0.0.0/8", + "10.0.0.0/8", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0-255.255.255.255" +]; + +function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_port_set, interface_set, source_interfaces, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, exclude_ntp) { + if (!nft_create_table(table) || + !nft_create_ipv4_set(table, localv4_set) || + !nft_add_set_elements(table, localv4_set, join(",", LOCALV4_RANGES)) || + !nft_create_ipv4_set(table, common_set) || + !nft_create_inet_service_set(table, port_set) || + !nft_create_ipv4_port_set(table, ip_port_set) || + !nft_create_ifname_set(table, interface_set)) + return false; + + for (let interface in whitespace_values(source_interfaces)) + if (!nft_add_set_elements(table, interface_set, interface)) + return false; + + if (!nft_create_chain(table, "mangle", "{ type filter hook prerouting priority -150; policy accept; }") || + !nft_create_chain(table, "mangle_output", "{ type route hook output priority -150; policy accept; }") || + !nft_create_chain(table, "proxy", "{ type filter hook prerouting priority -100; policy accept; }")) + return false; + + if (!nft_add_rule(table, "mangle", [ "ct", "status", "dnat", "return" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(localv4_set), "return" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", ".", "tcp", "dport", "@" + as_string(ip_port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", ".", "udp", "dport", "@" + as_string(ip_port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "!=", "@" + as_string(localv4_set), "tcp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "!=", "@" + as_string(localv4_set), "udp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", fakeip_range, "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", fakeip_range, "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) || + !nft_add_rule(table, "proxy", [ "meta", "mark", "&", fakeip_mark, "==", fakeip_mark, "meta", "l4proto", "tcp", "tproxy", "ip", "to", ":" + as_string(tproxy_port), "counter" ]) || + !nft_add_rule(table, "proxy", [ "meta", "mark", "&", fakeip_mark, "==", fakeip_mark, "meta", "l4proto", "udp", "tproxy", "ip", "to", ":" + as_string(tproxy_port), "counter" ]) || + !nft_add_rule(table, "mangle_output", [ "ip", "daddr", "@" + as_string(localv4_set), "return" ]) || + !nft_add_rule(table, "mangle_output", [ "meta", "mark", outbound_mark, "counter", "return" ])) + return false; + + if (arg_bool(exclude_ntp) && !nft_insert_rule(table, "mangle", [ "udp", "dport", "123", "return" ])) + return false; + + return true; +} + +function nft_create_runtime_base_from_uci(table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port) { + let settings = uci_settings(); + + return nft_create_runtime_base( + table, + localv4_set, + common_set, + port_set, + ip_port_set, + interface_set, + option(settings, "source_network_interfaces", "br-lan"), + fakeip_mark, + outbound_mark, + fakeip_range, + tproxy_port, + option(settings, "exclude_ntp", "0") + ); +} + +function nft_create_runtime_output_rules(table, localv4_set, common_set, port_set, ip_port_set, fakeip_mark, fakeip_range) { + return ( + nft_add_rule(table, "mangle_output", [ "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", ".", "tcp", "dport", "@" + as_string(ip_port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", ".", "udp", "dport", "@" + as_string(ip_port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "tcp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "udp", "dport", "@" + as_string(port_set), "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", fakeip_range, "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) && + nft_add_rule(table, "mangle_output", [ "ip", "daddr", fakeip_range, "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) + ); +} + +function hex_digit_value(value) { + let pos = index("0123456789abcdef", lc(as_string(value))); + return pos >= 0 ? pos : null; +} + +function parse_mark_number(value) { + value = lc(trim(as_string(value))); + if (value == "") + return null; + + if (substr(value, 0, 2) == "0x") { + value = substr(value, 2); + if (value == "") + return null; + + let result = 0; + for (let i = 0; i < length(value); i++) { + let digit = hex_digit_value(substr(value, i, 1)); + if (digit == null) + return null; + result = result * 16 + digit; + } + return result; + } + + return match(value, /^[0-9]+$/) == null ? null : int(value); +} + +function nft_provider_mark_hex(route_mark_base, index) { + let base = parse_mark_number(route_mark_base); + index = int(index || 0); + if (base == null || index < 1) + return ""; + + return sprintf("0x%08x", base + index); +} + +function nft_create_provider_output_rules_from_sections(sections, table, action, provider_bin, route_mark_base, queue_base, desync_mark, desync_mark_postnat) { + if (!file_executable(provider_bin)) + return true; + + let index = 0; + let added = false; + + for (let section in sections) { + section = object_or_empty(section); + if (!bool_option(section, "enabled", true) || option(section, "action", "") != action) + continue; + + index++; + let mark_hex = nft_provider_mark_hex(route_mark_base, index); + let queue_number = int(queue_base || 0) + index - 1; + if (mark_hex == "" || queue_number < 0) + return false; + + if (!added) { + if (!nft_add_rule(table, "mangle_output", [ "meta", "mark", "&", desync_mark, "==", desync_mark, "return" ]) || + !nft_add_rule(table, "mangle_output", [ "meta", "mark", "&", desync_mark_postnat, "==", desync_mark_postnat, "return" ])) + return false; + added = true; + } + + if (!nft_add_rule(table, "mangle_output", [ "meta", "mark", mark_hex, "meta", "l4proto", "tcp", "counter", "queue", "num", queue_number, "bypass" ]) || + !nft_add_rule(table, "mangle_output", [ "meta", "mark", mark_hex, "meta", "l4proto", "udp", "counter", "queue", "num", queue_number, "bypass" ])) + return false; + } + + return true; +} + +function nft_write_chunk(chunks, chunk) { + if (length(chunk) > 0) + push(chunks, "" + length(chunk) + "\t" + join(",", chunk)); +} + +function nft_push_chunk_value(chunks, chunk, value, chunk_size) { + push(chunk, value); + if (length(chunk) < chunk_size) + return chunk; + + nft_write_chunk(chunks, chunk); + return []; +} + +function nft_invalid(invalid, value, message) { + push(invalid, as_string(value) + "\t" + message); +} + +function str_last_index(value, needle) { + value = as_string(value); + needle = as_string(needle); + let result = -1; + let start = 0; + + while (true) { + let offset = index(substr(value, start), needle); + if (offset < 0) + return result; + + result = start + offset; + start = result + length(needle); + } +} + +function nft_trimmed_lines(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let result = []; + for (let line in split(as_string(data), "\n")) { + line = trim(replace(as_string(line), /\r/g, "")); + if (line != "") + push(result, line); + } + + return result; +} + +function nft_chunk_size(value) { + value = int(value || 5000); + return value > 0 ? value : 5000; +} + +function nft_csv_values(csv) { + let result = []; + + for (let item in split(as_string(csv), ",")) { + item = trim(replace(as_string(item), /\r/g, "")); + if (item != "") + push(result, item); + } + + return result; +} + +function nft_build_chunks_from_values(values, kind, ports_csv, chunk_size_text) { + let chunk_size = nft_chunk_size(chunk_size_text); + let chunks = []; + let invalid = []; + let chunk = []; + let ports = split(as_string(ports_csv), ","); + + for (let line in values) { + if (kind == "ports") { + let port = normalize_port_condition_value(line); + if (port == null) { + nft_invalid(invalid, line, "is not a valid port or port range"); + continue; + } + chunk = nft_push_chunk_value(chunks, chunk, port, chunk_size); + continue; + } + + if (kind == "ip-ports") { + let separator = index(line, " . "); + let last_separator = str_last_index(line, " . "); + if (separator < 0 || last_separator < 0) { + nft_invalid(invalid, line, "is not an IPv4/CIDR and port nft tuple"); + continue; + } + + let ip = substr(line, 0, separator); + let port = substr(line, last_separator + 3); + let original_port = port; + if (!nft_ip_or_cidr(ip)) { + nft_invalid(invalid, ip, "is not IPv4 or IPv4 CIDR"); + continue; + } + + port = normalize_port_condition_value(port); + if (port == null) { + nft_invalid(invalid, original_port, "is not a valid port or port range"); + continue; + } + + chunk = nft_push_chunk_value(chunks, chunk, ip + " . " + port, chunk_size); + continue; + } + + if (!nft_ip_or_cidr(line)) { + nft_invalid(invalid, line, "is not IPv4 or IPv4 CIDR"); + continue; + } + + if (kind == "ip-port-from-ip") { + for (let port in ports) { + if (port == "") + continue; + + let normalized = normalize_port_condition_value(port); + if (normalized == null) { + nft_invalid(invalid, port, "is not a valid port or port range"); + continue; + } + + chunk = nft_push_chunk_value(chunks, chunk, line + " . " + normalized, chunk_size); + } + } + else if (kind == "ips") { + chunk = nft_push_chunk_value(chunks, chunk, line, chunk_size); + } + else { + exit(1); + } + } + + nft_write_chunk(chunks, chunk); + + return { + chunks: chunks, + invalid: invalid + }; +} + +function nft_build_chunks(path, kind, ports_csv, chunk_size_text) { + return nft_build_chunks_from_values(nft_trimmed_lines(path), kind, ports_csv, chunk_size_text); +} + +function nft_prepare_chunks(path, kind, ports_csv, chunk_size_text, chunks_path, invalid_path) { + let prepared = nft_build_chunks(path, kind, ports_csv, chunk_size_text); + + if (!write_text_file(chunks_path, length(prepared.chunks) > 0 ? join("\n", prepared.chunks) + "\n" : "")) + exit(1); + if (!write_text_file(invalid_path, length(prepared.invalid) > 0 ? join("\n", prepared.invalid) + "\n" : "")) + exit(1); +} + +function nft_log_invalid_elements(invalid) { + for (let item in invalid) { + let separator = index(item, "\t"); + if (separator < 0) + continue; + + let value = substr(item, 0, separator); + let message = substr(item, separator + 1); + if (value != "") + log_debug("'" + value + "' " + message); + } +} + +function nft_add_chunks_to_set(table, set_name, chunks, invalid) { + nft_log_invalid_elements(invalid); + + for (let item in chunks) { + let separator = index(item, "\t"); + if (separator < 0) + continue; + + let count = substr(item, 0, separator); + let elements = substr(item, separator + 1); + if (elements == "") + continue; + + log_debug("Adding " + count + " elements to nft set " + set_name); + if (!nft_add_set_elements(table, set_name, elements)) + return false; + } + + return true; +} + +function nft_add_file_chunks_to_set(path, table, set_name, kind, ports_csv, chunk_size_text) { + let prepared = nft_build_chunks(path, kind, ports_csv, chunk_size_text); + return nft_add_chunks_to_set(table, set_name, prepared.chunks, prepared.invalid); +} + +function nft_add_csv_chunks_to_set(csv, table, set_name, kind, ports_csv, chunk_size_text) { + let prepared = nft_build_chunks_from_values(nft_csv_values(csv), kind, ports_csv, chunk_size_text); + return nft_add_chunks_to_set(table, set_name, prepared.chunks, prepared.invalid); +} + +function nft_add_inline_ip_cidr_matchers(csv, ports_csv, table, common_set, ip_port_set, chunk_size_text) { + if (as_string(csv) == "") + return true; + + if (as_string(ports_csv) != "") + return nft_add_csv_chunks_to_set(csv, table, ip_port_set, "ip-port-from-ip", ports_csv, chunk_size_text); + + return nft_add_csv_chunks_to_set(csv, table, common_set, "ips", "", chunk_size_text); +} + +function nft_insert_fully_routed_ip_rules(source_ip, table, interface_set, localv4_set, mark) { + return ( + run_args([ "nft", "insert", "rule", "inet", table, "mangle", "iifname", "@" + as_string(interface_set), "ip", "saddr", source_ip, "meta", "l4proto", "tcp", "meta", "mark", "set", mark, "counter" ]) && + run_args([ "nft", "insert", "rule", "inet", table, "mangle", "iifname", "@" + as_string(interface_set), "ip", "saddr", source_ip, "meta", "l4proto", "udp", "meta", "mark", "set", mark, "counter" ]) && + run_args([ "nft", "insert", "rule", "inet", table, "mangle", "ip", "saddr", source_ip, "ip", "daddr", "@" + as_string(localv4_set), "return" ]) + ); +} + +function nft_source_ip_display_value(source_ip) { + source_ip = as_string(source_ip); + let suffix = "/32"; + + if (length(source_ip) > length(suffix) && + substr(source_ip, length(source_ip) - length(suffix), length(suffix)) == suffix) { + let address = substr(source_ip, 0, length(source_ip) - length(suffix)); + if (valid_ipv4(address)) + return address; + } + + return source_ip; +} + +function nft_chain_has_source_ip(chain_text, source_ip) { + chain_text = as_string(chain_text); + source_ip = as_string(source_ip); + + if (index(chain_text, "ip saddr " + source_ip) >= 0) + return true; + + let display_source_ip = nft_source_ip_display_value(source_ip); + return display_source_ip != source_ip && index(chain_text, "ip saddr " + display_source_ip) >= 0; +} + +function nft_ensure_fully_routed_ip_rules_from_chain(source_ip, table, interface_set, localv4_set, mark, chain_text, inserted) { + source_ip = as_string(source_ip); + if (source_ip == "") + return true; + + if (inserted[source_ip] || nft_chain_has_source_ip(chain_text, source_ip)) + return true; + + if (!nft_insert_fully_routed_ip_rules(source_ip, table, interface_set, localv4_set, mark)) + return false; + + inserted[source_ip] = true; + return true; +} + +function nft_ensure_fully_routed_ip_rules(source_ip, table, interface_set, localv4_set, mark) { + return nft_ensure_fully_routed_ip_rules_from_chain(source_ip, table, interface_set, localv4_set, mark, read_stdin(), {}); +} + +function nft_ensure_discord_udp_fakeip_rule_from_chain(table, interface_set, discord_set, mark, chain_text) { + if (!nft_create_ipv4_set(table, discord_set)) + return false; + + let needle = "iifname @" + as_string(interface_set) + " ip daddr @" + as_string(discord_set) + " udp dport { 19000-20000, 50000-65535 } meta mark set " + as_string(mark); + if (index(as_string(chain_text), needle) >= 0) + return true; + + return run_args([ "nft", "add", "rule", "inet", table, "mangle", "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(discord_set), "udp", "dport", "{ 19000-20000, 50000-65535 }", "meta", "mark", "set", mark, "counter" ]); +} + +function normalized_fields(line) { + line = trim(replace(as_string(line), /\r/g, "")); + line = replace(line, /[[:space:]]+/g, " "); + return line == "" ? [] : split(line, " "); +} + +function rule_line_has_lookup_table(fields, table) { + table = as_string(table); + + for (let i = 0; i + 1 < length(fields); i++) + if (fields[i] == "lookup" && fields[i + 1] == table) + return true; + + return false; +} + +function rule_line_has_fwmark(fields, expected_mark) { + for (let i = 0; i + 1 < length(fields); i++) { + if (fields[i] != "fwmark") + continue; + + let parts = split(fields[i + 1], "/"); + if (length(parts) != 2) + continue; + + if (parse_mark_number(parts[0]) == expected_mark && parse_mark_number(parts[1]) == expected_mark) + return true; + } + + return false; +} + +function has_tproxy_marking_rule_text(rule_list, table, mark) { + let expected_mark = parse_mark_number(mark); + let has_lookup = false; + let has_fwmark = false; + + if (expected_mark == null) + return false; + + for (let line in split(rule_list, "\n")) { + let fields = normalized_fields(line); + if (length(fields) == 0) + continue; + + if (!has_lookup && rule_line_has_lookup_table(fields, table)) + has_lookup = true; + if (!has_fwmark && rule_line_has_fwmark(fields, expected_mark)) + has_fwmark = true; + + if (has_lookup && has_fwmark) + return true; + } + + return false; +} + +function has_local_default_route_text(route_list) { + for (let line in split(as_string(route_list), "\n")) { + line = trim(replace(as_string(line), /\r/g, "")); + line = replace(line, /[[:space:]]+/g, " "); + if (index(line, "local default dev lo scope host") >= 0) + return true; + } + + return false; +} + +function rt_table_has_entry(text, table_id, table_name) { + table_id = as_string(table_id); + table_name = as_string(table_name); + + for (let line in split(as_string(text), "\n")) { + let fields = normalized_fields(line); + if (length(fields) >= 2 && fields[0] == table_id && fields[1] == table_name) + return true; + } + + return false; +} + +function ensure_rt_table_entry(path, table_id, table_name) { + let data = fs.readfile(path); + if (data != null && rt_table_has_entry(data, table_id, table_name)) + return true; + + data = data == null ? "" : as_string(data); + if (data != "" && substr(data, length(data) - 1, 1) != "\n") + data += "\n"; + + return write_text_file(path, data + as_string(table_id) + " " + as_string(table_name) + "\n"); +} + +function tproxy_route_present(table) { + return has_local_default_route_text(command_output_from_args([ "ip", "route", "list", "table", table ])); +} + +function tproxy_marking_rule_present(table, mark) { + return has_tproxy_marking_rule_text(command_output_from_args([ "ip", "-4", "rule", "list" ]), table, mark); +} + +function tproxy_route_rule_present(table, mark) { + return tproxy_route_present(table) && tproxy_marking_rule_present(table, mark); +} + +function ensure_tproxy_route_rule(table, mark, rt_tables_path) { + rt_tables_path = as_string(rt_tables_path || "/etc/iproute2/rt_tables"); + + if (!ensure_rt_table_entry(rt_tables_path, "105", table)) { + log_fatal("Failed to update route table registry. Aborted."); + return false; + } + + if (!tproxy_route_present(table)) { + log_debug("Added route for tproxy"); + if (!run_args([ "ip", "route", "add", "local", "0.0.0.0/0", "dev", "lo", "table", table ]) && !tproxy_route_present(table)) { + log_fatal("Failed to add route for tproxy. Aborted."); + return false; + } + } + else { + log_debug("Route for tproxy exists"); + } + + if (!tproxy_marking_rule_present(table, mark)) { + log_debug("Create marking rule"); + if (!run_args([ "ip", "-4", "rule", "add", "fwmark", as_string(mark) + "/" + as_string(mark), "table", table, "priority", "105" ]) && !tproxy_marking_rule_present(table, mark)) { + log_fatal("Failed to create marking rule. Aborted."); + return false; + } + } + else { + log_debug("Marking rule exist"); + } + + return true; +} + +function ensure_bridge_netfilter_disabled() { + if (index(command_output_from_args([ "lsmod" ]), "br_netfilter") < 0) + return true; + + if (trim(command_output_from_args([ "sysctl", "-n", "net.bridge.bridge-nf-call-iptables" ])) != "1") + return true; + + log_debug("br_netfilter enabled detected. Disabling"); + return run_args([ "sysctl", "-w", "net.bridge.bridge-nf-call-iptables=0" ]) && + run_args([ "sysctl", "-w", "net.bridge.bridge-nf-call-ip6tables=0" ]); +} + +function section_rule_condition_csv(section, key, kind) { + return rule_condition_csv_value( + key, + kind, + option(section, key + "_text_mode", "0"), + option(section, "conditions_text_mode", "0"), + option(section, key + "_text", ""), + option(section, key, ""), + option(section, "domain_suffix_text", ""), + option(section, "domain_suffix", "") + ); +} + +function section_rule_ports_csv(section) { + return rule_ports_csv_value(option(section, "ports", ""), option(section, "ports_text", "")); +} + +function community_service_has_subnet_list(value) { + return rule_config.community_service_has_subnet_list(value); +} + +function filter_community_subnet_lists_value(value) { + return rule_config.filter_community_subnet_lists_value(value); +} + +function signature_add_value(body, key, value) { + return body + "[" + as_string(key) + "]\n" + as_string(value) + "\n"; +} + +function signature_hash(body) { + let path = trim(command_output_from_args([ "mktemp" ])); + if (path == "") + return ""; + + if (!write_text_file(path, body)) { + unlink_file(path); + return ""; + } + + let hash_line = command_output_from_args([ "md5sum", path ]); + unlink_file(path); + hash_line = trim(hash_line); + + return length(hash_line) >= 32 ? substr(hash_line, 0, 32) : ""; +} + +function nft_rule_signature_body(body, section) { + let section_name = as_string(section[".name"]); + + if (section_name == "" || !bool_option(section, "enabled", true)) + return body; + + body = signature_add_value(body, "rule." + section_name + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets")); + body = signature_add_value(body, "rule." + section_name + ".ports", section_rule_ports_csv(section)); + body = signature_add_value(body, "rule." + section_name + ".fully_routed_ips", option(section, "fully_routed_ips", "")); + body = signature_add_value(body, "rule." + section_name + ".community_subnet_lists", filter_community_subnet_lists_value(option(section, "community_lists", ""))); + body = signature_add_value(body, "rule." + section_name + ".remote_subnet_lists", option(section, "remote_subnet_lists", "")); + body = signature_add_value(body, "rule." + section_name + ".rule_set_with_subnets", option(section, "rule_set_with_subnets", "")); + body = signature_add_value(body, "rule." + section_name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + + return body; +} + +function nft_runtime_signature_from_settings_and_sections(settings, sections) { + let body = ""; + + body = signature_add_value(body, "settings.source_network_interfaces", option(settings, "source_network_interfaces", "br-lan")); + body = signature_add_value(body, "settings.exclude_ntp", bool_option(settings, "exclude_ntp", false) ? "1" : "0"); + + for (let section in sections) + body = nft_rule_signature_body(body, object_or_empty(section)); + + return signature_hash(body); +} + +function print_nft_runtime_signature_from_settings_and_sections(settings, sections) { + let hash = nft_runtime_signature_from_settings_and_sections(settings, sections); + if (hash == "") + return false; + + print(hash, "\n"); + return true; +} + +function section_option_nonempty(section, key) { + return option(section, key, "") != ""; +} + +function section_has_destination_matchers(section) { + return section_rule_condition_csv(section, "domain", "domains") != "" || + section_rule_condition_csv(section, "domain_suffix", "domains") != "" || + section_rule_condition_csv(section, "domain_keyword", "generic") != "" || + section_rule_condition_csv(section, "domain_regex", "generic") != "" || + section_rule_condition_csv(section, "ip_cidr", "subnets") != "" || + section_option_nonempty(section, "community_lists") || + section_option_nonempty(section, "rule_set") || + section_option_nonempty(section, "rule_set_with_subnets") || + section_option_nonempty(section, "domain_ip_lists"); +} + +function word_set(value) { + let result = {}; + for (let item in whitespace_values(value)) + result[item] = true; + return result; +} + +function fixture_section_list(data, type_name) { + let value = object_or_empty(data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function section_by_name(sections, section_name) { + section_name = as_string(section_name); + for (let section in sections) + if (as_string(section[".name"]) == section_name) + return section; + return null; +} + +function nft_create_provider_output_rules_from_uci(table, action, provider_bin, route_mark_base, queue_base, desync_mark, desync_mark_postnat) { + return nft_create_provider_output_rules_from_sections( + uci_sections("section"), + table, + action, + provider_bin, + route_mark_base, + queue_base, + desync_mark, + desync_mark_postnat + ); +} + +function nft_create_full_runtime_from_uci(rt_table, table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat, zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat) { + log_debug("Create nft runtime model"); + + return ensure_tproxy_route_rule(rt_table, fakeip_mark) && + nft_create_runtime_base_from_uci(table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port) && + nft_create_provider_output_rules_from_uci(table, "zapret", zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat) && + nft_create_provider_output_rules_from_uci(table, "zapret2", zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat) && + nft_create_runtime_output_rules(table, localv4_set, common_set, port_set, ip_port_set, fakeip_mark, fakeip_range); +} + +function nft_table_present(table) { + return run_args_quiet([ "nft", "list", "table", "inet", table ]); +} + +function nft_delete_table(table) { + return run_args([ "nft", "delete", "table", "inet", table ]); +} + +function nft_rebuild_runtime_from_uci(rt_table, table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat, zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat) { + log_debug("Rebuilding nft runtime"); + + if (nft_table_present(table) && !nft_delete_table(table)) + return false; + + return nft_create_full_runtime_from_uci(rt_table, table, localv4_set, common_set, port_set, ip_port_set, interface_set, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat, zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat); +} + +function nft_runtime_signature_from_uci() { + return print_nft_runtime_signature_from_settings_and_sections( + uci_settings(), + uci_sections("section") + ); +} + +function fixture_section(path, section_name) { + return section_by_name(fixture_section_list(object_or_empty(common_read_json_file(path)), "section"), section_name); +} + +function fixture_settings(data) { + return object_or_empty(object_or_empty(data).settings); +} + +function nft_runtime_signature_from_fixture(path) { + let data = object_or_empty(common_read_json_file(path)); + return print_nft_runtime_signature_from_settings_and_sections(fixture_settings(data), fixture_section_list(data, "section")); +} + +function nft_mangle_chain_text(context, table) { + if (context.text == null) + context.text = command_output_from_args([ "nft", "list", "chain", "inet", table, "mangle" ]); + return context.text; +} + +function nft_populate_runtime_set_for_section(section, deferred_sections, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, mangle_chain_context, inserted_fully_routed_ips) { + if (!bool_option(section, "enabled", true)) + return true; + + if (deferred_sections[as_string(section[".name"])]) + return true; + + let ports = section_rule_ports_csv(section); + let ip_values = section_rule_condition_csv(section, "ip_cidr", "subnets"); + + if (!nft_add_inline_ip_cidr_matchers(ip_values, ports, table, common_set, ip_port_set, 5000)) + return false; + + if (ports != "" && !section_has_destination_matchers(section) && + !nft_add_set_elements(table, port_set, ports)) + return false; + + for (let source_ip in list_option(section, "fully_routed_ips")) + if (!nft_ensure_fully_routed_ip_rules_from_chain(source_ip, table, interface_set, localv4_set, mark, nft_mangle_chain_text(mangle_chain_context, table), inserted_fully_routed_ips)) + return false; + + return true; +} + +function nft_add_subnet_file_for_section(section, filepath, table, common_set, ip_port_set, chunk_size_text) { + let ports = section_rule_ports_csv(section); + + if (ports != "") + return nft_add_file_chunks_to_set(filepath, table, ip_port_set, "ip-port-from-ip", ports, chunk_size_text); + + return nft_add_file_chunks_to_set(filepath, table, common_set, "ips", "", chunk_size_text); +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && int(stat.size) > 0; +} + +function nft_add_extracted_ruleset_subnets(unscoped_path, scoped_path, label, table, common_set, ip_port_set, chunk_size_text) { + let has_entries = false; + + if (file_nonempty(unscoped_path)) { + if (!nft_add_file_chunks_to_set(unscoped_path, table, common_set, "ips", "", chunk_size_text)) + return false; + has_entries = true; + } + + if (file_nonempty(scoped_path)) { + if (!nft_add_file_chunks_to_set(scoped_path, table, ip_port_set, "ip-ports", "", chunk_size_text)) + return false; + has_entries = true; + } + + if (!has_entries) + run_args([ "logger", "-t", "podkop-plus", "[warn] " + as_string(label) + " has no ip_cidr entries for nftables" ]); + + return true; +} + +function nft_add_json_ruleset_subnets_for_section(section, json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text) { + let ports = section_rule_ports_csv(section); + + routing_rulesets.extract_ip_cidr_nft_elements( + json_path, + unscoped_path, + scoped_path, + sprintf("%J", rule_port_values(ports)), + sprintf("%J", rule_port_ranges(ports)) + ); + + return nft_add_extracted_ruleset_subnets(unscoped_path, scoped_path, label, table, common_set, ip_port_set, chunk_size_text); +} + +function nft_add_community_subnet_file_for_section(section, service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text) { + let ports = section_rule_ports_csv(section); + + if (as_string(service) == "discord" && ports == "") { + if (!nft_ensure_discord_udp_fakeip_rule_from_chain(table, interface_set, discord_set, mark, command_output_from_args([ "nft", "list", "chain", "inet", table, "mangle" ]))) + return false; + return nft_add_file_chunks_to_set(filepath, table, discord_set, "ips", "", chunk_size_text); + } + + return nft_add_subnet_file_for_section(section, filepath, table, common_set, ip_port_set, chunk_size_text); +} + +function nft_add_subnet_file_for_uci_section(section_name, filepath, table, common_set, ip_port_set, chunk_size_text) { + return nft_add_subnet_file_for_section(uci_section(section_name), filepath, table, common_set, ip_port_set, chunk_size_text); +} + +function nft_add_json_ruleset_subnets_for_uci_section(section_name, json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text) { + return nft_add_json_ruleset_subnets_for_section(uci_section(section_name), json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text); +} + +function nft_add_community_subnet_file_for_uci_section(section_name, service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text) { + return nft_add_community_subnet_file_for_section(uci_section(section_name), service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text); +} + +function nft_add_subnet_file_for_fixture_section(fixture_path, section_name, filepath, table, common_set, ip_port_set, chunk_size_text) { + return nft_add_subnet_file_for_section(fixture_section(fixture_path, section_name), filepath, table, common_set, ip_port_set, chunk_size_text); +} + +function nft_add_json_ruleset_subnets_for_fixture_section(fixture_path, section_name, json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text) { + return nft_add_json_ruleset_subnets_for_section(fixture_section(fixture_path, section_name), json_path, label, table, common_set, ip_port_set, unscoped_path, scoped_path, chunk_size_text); +} + +function nft_add_community_subnet_file_for_fixture_section(fixture_path, section_name, service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text) { + return nft_add_community_subnet_file_for_section(fixture_section(fixture_path, section_name), service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text); +} + +function nft_populate_runtime_sets_from_sections(sections, populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark) { + if (!arg_bool(populate_enabled)) + return true; + + let deferred_sections = word_set(deferred_section_names); + let mangle_chain_context = {}; + let inserted_fully_routed_ips = {}; + + for (let section in sections) + if (!nft_populate_runtime_set_for_section(section, deferred_sections, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, mangle_chain_context, inserted_fully_routed_ips)) + return false; + + return true; +} + +function nft_populate_runtime_sets_from_uci(populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark) { + if (!arg_bool(populate_enabled)) + return true; + + return nft_populate_runtime_sets_from_sections(uci_sections("section"), populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark); +} + +function nft_populate_runtime_sets_fixture(path, populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark) { + let data = object_or_empty(common_read_json_file(path)); + return nft_populate_runtime_sets_from_sections(fixture_section_list(data, "section"), populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark); +} + +let mode = ARGV[0] || ""; + +if (mode == "text-list-to-csv") + text_list_to_csv(ARGV[1], ARGV[2]); +else if (mode == "csv-to-json-array") + csv_to_json_array(ARGV[1]); +else if (mode == "cache-path") + cache_path(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); +else if (mode == "list-value-to-csv") + list_value_csv(ARGV[1]); +else if (mode == "csv-list-contains") + exit(csv_list_contains(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "domain-subnet-text-csv") + domain_subnet_text_csv(ARGV[1], ARGV[2]); +else if (mode == "combined-domain-text-csv") + combined_domain_text_csv(ARGV[1], ARGV[2]); +else if (mode == "combined-domain-csv") + combined_domain_csv(ARGV[1], ARGV[2]); +else if (mode == "rule-condition-csv") + rule_condition_csv(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]); +else if (mode == "legacy-condition-csv") + legacy_condition_csv(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "domain-subnet-file-csv") + domain_subnet_file_csv(ARGV[1], ARGV[2]); +else if (mode == "split-domain-subnet-file") + split_domain_subnet_file(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "normalize-port-condition-for-nft") + normalize_port_condition_for_nft(ARGV[1]); +else if (mode == "rule-ports-csv") + rule_ports_csv(ARGV[1], ARGV[2]); +else if (mode == "csv-to-lines-file") + csv_to_lines_file(ARGV[1], ARGV[2]); +else if (mode == "nft-create-runtime-base") + exit(nft_create_runtime_base(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12]) ? 0 : 1); +else if (mode == "nft-create-runtime-base-from-uci") + exit(nft_create_runtime_base_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10]) ? 0 : 1); +else if (mode == "nft-create-runtime-output-rules") + exit(nft_create_runtime_output_rules(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]) ? 0 : 1); +else if (mode == "nft-create-provider-output-rules-from-uci") + exit(nft_create_provider_output_rules_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]) ? 0 : 1); +else if (mode == "nft-create-provider-output-rules-fixture") + exit(nft_create_provider_output_rules_from_sections(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]) ? 0 : 1); +else if (mode == "nft-create-full-runtime-from-uci") + exit(nft_create_full_runtime_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14], ARGV[15], ARGV[16], ARGV[17], ARGV[18], ARGV[19], ARGV[20], ARGV[21]) ? 0 : 1); +else if (mode == "nft-rebuild-runtime-from-uci") + exit(nft_rebuild_runtime_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14], ARGV[15], ARGV[16], ARGV[17], ARGV[18], ARGV[19], ARGV[20], ARGV[21]) ? 0 : 1); +else if (mode == "nft-prepare-chunks") + nft_prepare_chunks(ARGV[1], ARGV[2], ARGV[3] || "", ARGV[4], ARGV[5], ARGV[6]); +else if (mode == "nft-add-file-chunks-to-set") + exit(nft_add_file_chunks_to_set(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5] || "", ARGV[6]) ? 0 : 1); +else if (mode == "nft-add-subnet-file-for-uci-section") + exit(nft_add_subnet_file_for_uci_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]) ? 0 : 1); +else if (mode == "nft-add-json-ruleset-subnets-for-uci-section") + exit(nft_add_json_ruleset_subnets_for_uci_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9]) ? 0 : 1); +else if (mode == "nft-add-community-subnet-file-for-uci-section") + exit(nft_add_community_subnet_file_for_uci_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10]) ? 0 : 1); +else if (mode == "nft-add-subnet-file-for-section-fixture") + exit(nft_add_subnet_file_for_fixture_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]) ? 0 : 1); +else if (mode == "nft-add-json-ruleset-subnets-for-section-fixture") + exit(nft_add_json_ruleset_subnets_for_fixture_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10]) ? 0 : 1); +else if (mode == "nft-add-community-subnet-file-for-section-fixture") + exit(nft_add_community_subnet_file_for_fixture_section(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11]) ? 0 : 1); +else if (mode == "nft-populate-runtime-sets-from-uci") + exit(nft_populate_runtime_sets_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9]) ? 0 : 1); +else if (mode == "nft-populate-runtime-sets-fixture") + exit(nft_populate_runtime_sets_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10]) ? 0 : 1); +else if (mode == "nft-runtime-signature") + exit(nft_runtime_signature_from_uci() ? 0 : 1); +else if (mode == "nft-runtime-signature-fixture") + exit(nft_runtime_signature_from_fixture(ARGV[1]) ? 0 : 1); +else if (mode == "nft-table-present-fixture") + exit(nft_table_present(ARGV[1]) ? 0 : 1); +else if (mode == "ensure-tproxy-route-rule") + exit(ensure_tproxy_route_rule(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "tproxy-route-present") + exit(tproxy_route_present(ARGV[1]) ? 0 : 1); +else if (mode == "tproxy-marking-rule-present") + exit(tproxy_marking_rule_present(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "tproxy-route-rule-present") + exit(tproxy_route_rule_present(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "ensure-bridge-netfilter-disabled") + exit(ensure_bridge_netfilter_disabled() ? 0 : 1); +else { + warn("Usage: nft/apply.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/providers/byedpi/runtime.uc b/podkop/files/usr/lib/providers/byedpi/runtime.uc new file mode 100644 index 00000000..26c49af7 --- /dev/null +++ b/podkop/files/usr/lib/providers/byedpi/runtime.uc @@ -0,0 +1,655 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +let validator_module = null; + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const BYEDPI_BIN = getenv("BYEDPI_BIN") || "/usr/bin/ciadpi"; +const BYEDPI_SERVICE_INIT = getenv("BYEDPI_SERVICE_INIT") || "/etc/init.d/byedpi"; +const BYEDPI_STATE_DIR = getenv("BYEDPI_STATE_DIR") || "/var/run/podkop-plus/byedpi"; +const BYEDPI_PID_DIR = getenv("BYEDPI_PID_DIR") || BYEDPI_STATE_DIR + "/pid"; +const BYEDPI_CHILD_PID_DIR = getenv("BYEDPI_CHILD_PID_DIR") || BYEDPI_STATE_DIR + "/child-pid"; +const BYEDPI_LOG_DIR = getenv("BYEDPI_LOG_DIR") || BYEDPI_STATE_DIR + "/log"; +const BYEDPI_LISTEN_ADDRESS = getenv("BYEDPI_LISTEN_ADDRESS") || "127.0.0.1"; +const BYEDPI_PORT_BASE = getenv("BYEDPI_PORT_BASE") || "1080"; +const BYEDPI_RESPAWN_DELAY = getenv("BYEDPI_RESPAWN_DELAY") || "5"; +const BYEDPI_OPEN_FILES_LIMIT = getenv("BYEDPI_OPEN_FILES_LIMIT") || "4096"; +const BYEDPI_DEFAULT_CMD_OPTS = getenv("BYEDPI_DEFAULT_CMD_OPTS") || "-o 2 --auto=t,r,a,s -d 2"; +const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || "tproxy-in"; +const RESERVED_RUNTIME_TAGS = [ + getenv("SB_DNS_SERVER_TAG") || "dns-server", + getenv("SB_FAKEIP_DNS_SERVER_TAG") || "fakeip-server", + getenv("SB_BOOTSTRAP_SERVER_TAG") || "bootstrap-dns-server", + getenv("SB_FAKEIP_DNS_RULE_TAG") || "fakeip-dns-rule-tag", + getenv("SB_FAKEIP_RULESET_DNS_RULE_TAG") || "fakeip-ruleset-dns-rule-tag", + getenv("SB_SERVICE_FAKEIP_DNS_RULE_TAG") || "service-fakeip-dns-rule-tag", + getenv("SB_TPROXY_INBOUND_TAG") || "tproxy-in", + getenv("SB_DNS_INBOUND_TAG") || "dns-in", + getenv("SB_SERVICE_MIXED_INBOUND_TAG") || "service-mixed-in", + getenv("SB_DIRECT_OUTBOUND_TAG") || "direct-out" +]; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function bool_value(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", name ]); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "podkop-plus", "[" + level + "] " + as_string(message) ]); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + return value == null ? !!fallback : bool_value(value); +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, as_string(type_name)); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function validator() { + if (validator_module == null) + validator_module = require("providers.byedpi.validator"); + return validator_module; +} + +function normalize_strategy(value) { + return validator().strategy_or_default(value, BYEDPI_DEFAULT_CMD_OPTS); +} + +function strategy_words(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + value = replace(value, / $/, ""); + return value == "" ? [] : split(value, " "); +} + +function enabled_byedpi_sections() { + let result = []; + for (let section in uci_sections("section")) + if (bool_option(section, "enabled", true) && option(section, "action", "") == "byedpi") + push(result, section); + return result; +} + +function enabled_rule_count() { + return length(enabled_byedpi_sections()); +} + +function rule_index(section_name_value, sections) { + let index_value = 0; + for (let section in array_or_empty(sections)) { + index_value++; + if (section_name(section) == as_string(section_name_value)) + return index_value; + } + return 0; +} + +function rule_port(index_value) { + return int(BYEDPI_PORT_BASE) + int(index_value) - 1; +} + +function ensure_runtime_dirs() { + return command_success_from_args([ "mkdir", "-p", BYEDPI_STATE_DIR, BYEDPI_PID_DIR, BYEDPI_CHILD_PID_DIR, BYEDPI_LOG_DIR ]); +} + +function provider_available() { + let stat = fs.stat(BYEDPI_BIN); + return stat != null && stat.mode != null && (int(stat.mode) & 73) != 0; +} + +function package_installed() { + if (command_exists("apk") && command_success_from_args([ "apk", "info", "-e", "byedpi" ])) + return true; + if (!command_exists("opkg")) + return false; + + for (let line in split(command_output_from_args([ "opkg", "list-installed" ]), "\n")) { + let matched = match(trim(as_string(line)), /^byedpi[ \t]+-/); + if (matched) + return true; + } + return false; +} + +function first_nonempty_field(value) { + for (let line in split(as_string(value), "\n")) { + line = trim(as_string(line)); + if (line == "") + continue; + let fields = split(line, /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; + } + return ""; +} + +function package_version() { + let output = ""; + + if (command_exists("apk") && command_success_from_args([ "apk", "info", "-e", "byedpi" ])) { + output = command_output_from_args([ "apk", "list", "--installed", "--manifest", "byedpi" ]); + for (let line in split(output, "\n")) { + let matched = match(trim(as_string(line)), /^P:byedpi$/); + if (!matched) + continue; + for (let manifest_line in split(output, "\n")) { + let version_match = match(trim(as_string(manifest_line)), /^V:(.+)$/); + if (version_match) + return as_string(version_match[1]); + } + } + + output = command_output_from_args([ "apk", "info", "-v", "byedpi" ]); + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + if (line != "") + return replace(line, /^byedpi-/, ""); + } + } + + if (command_exists("opkg")) { + output = command_output_from_args([ "opkg", "list-installed" ]); + for (let line in split(output, "\n")) { + let matched = match(trim(as_string(line)), /^byedpi[ \t]+-[ \t]*(.+)$/); + if (matched) + return as_string(matched[1]); + } + } + + if (provider_available()) + return first_nonempty_field(command_output_from_args([ BYEDPI_BIN, "--version" ])); + return ""; +} + +function standalone_service_enabled() { + return fs.stat(BYEDPI_SERVICE_INIT) != null && command_success_from_args([ BYEDPI_SERVICE_INIT, "enabled" ]); +} + +function standalone_service_running() { + return fs.stat(BYEDPI_SERVICE_INIT) != null && command_success_from_args([ BYEDPI_SERVICE_INIT, "status" ]); +} + +function runtime_pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function file_first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function kill_pidfile_process(path, signal) { + let pid = file_first_line(path); + if (pid == "") + return; + if (signal == "9") { + if (runtime_pid_running(pid)) + command_success_from_args([ "kill", "-9", pid ]); + } + else { + command_success_from_args([ "kill", pid ]); + } +} + +function pidfiles_in_dir(path) { + if (fs.stat(path) == null) + return []; + + let output = command_output_from_args([ "find", path, "-maxdepth", "1", "-type", "f", "-name", "*.pid" ]); + let result = []; + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + if (line != "") + push(result, line); + } + return result; +} + +function stop_runtime() { + for (let pidfile in pidfiles_in_dir(BYEDPI_PID_DIR)) + kill_pidfile_process(pidfile, ""); + for (let pidfile in pidfiles_in_dir(BYEDPI_CHILD_PID_DIR)) + kill_pidfile_process(pidfile, ""); + + command_success_from_args([ "sleep", "1" ]); + + for (let pidfile in pidfiles_in_dir(BYEDPI_PID_DIR)) + kill_pidfile_process(pidfile, "9"); + for (let pidfile in pidfiles_in_dir(BYEDPI_CHILD_PID_DIR)) + kill_pidfile_process(pidfile, "9"); + + command_success_from_args([ "rm", "-rf", BYEDPI_PID_DIR, BYEDPI_CHILD_PID_DIR, BYEDPI_LOG_DIR ]); +} + +function supervisor_command(port, raw_opt, child_pidfile) { + let args = [ BYEDPI_BIN, "--ip", BYEDPI_LISTEN_ADDRESS, "--port", as_string(port) ]; + for (let word in strategy_words(raw_opt)) + push(args, word); + + return "ulimit -n " + shell_quote(BYEDPI_OPEN_FILES_LIMIT) + " >/dev/null 2>&1 || true; " + + command_from_args(args) + " & child=$!; echo $child > " + shell_quote(child_pidfile) + "; wait $child; rc=$?; rm -f " + shell_quote(child_pidfile) + "; exit $rc"; +} + +function supervisor(section, port, raw_opt, child_pidfile) { + while (true) { + if (!provider_available()) { + print(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), " Provider ", BYEDPI_BIN, " is not executable; retrying in ", BYEDPI_RESPAWN_DELAY, " seconds\n"); + command_success_from_args([ "sleep", BYEDPI_RESPAWN_DELAY ]); + continue; + } + + let rc = command_status("sh -c " + shell_quote(supervisor_command(port, raw_opt, child_pidfile))); + print(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), " ciadpi for rule ", as_string(section), " exited with code ", rc, "; respawning in ", BYEDPI_RESPAWN_DELAY, " seconds\n"); + command_success_from_args([ "sleep", BYEDPI_RESPAWN_DELAY ]); + } +} + +function start_rule(section, index_value) { + let name = section_name(section); + let port = rule_port(index_value); + let raw_opt = normalize_strategy(option(section, "byedpi_cmd_opts", "")); + let validation = validator().validate_byedpi_strategy(raw_opt); + if (!validation.valid) { + log_message("Invalid ByeDPI strategy for rule '" + name + "': " + validation.message, "fatal"); + exit(1); + } + + let pidfile = BYEDPI_PID_DIR + "/" + name + ".pid"; + let child_pidfile = BYEDPI_CHILD_PID_DIR + "/" + name + ".pid"; + let logfile = BYEDPI_LOG_DIR + "/" + name + ".log"; + + log_message("Starting ciadpi for rule '" + name + "' on " + BYEDPI_LISTEN_ADDRESS + ":" + port, "info"); + let command = command_from_args([ + "ucode", + "-L", LIB_DIR, + LIB_DIR + "/providers/byedpi/runtime.uc", + "supervisor", + name, + "" + port, + raw_opt, + child_pidfile + ]) + " >>" + shell_quote(logfile) + " 2>&1 & echo $!"; + let pid = trim(command_output("sh -c " + shell_quote(command))); + if (pid == "" || !fs.writefile(pidfile, pid + "\n")) { + log_message("ciadpi failed to start for rule '" + name + "'. Check " + logfile + ". Aborted.", "fatal"); + exit(1); + } + + command_success_from_args([ "sleep", "1" ]); + if (!runtime_pid_running(pid)) { + log_message("ciadpi failed to start for rule '" + name + "'. Check " + logfile + ". Aborted.", "fatal"); + exit(1); + } + + let child_pid = file_first_line(child_pidfile); + if (child_pid == "" || !runtime_pid_running(child_pid)) + log_message("ciadpi supervisor started for rule '" + name + "', but ciadpi is not running yet. Check " + logfile + ".", "warn"); +} + +function start_runtime() { + stop_runtime(); + + let sections = enabled_byedpi_sections(); + if (length(sections) == 0 || !provider_available()) + return; + + if (standalone_service_enabled()) + log_message("Standalone byedpi service is enabled. Podkop Plus manages ciadpi itself for action 'byedpi'; disable standalone byedpi autostart to avoid boot-time port conflicts.", "warn"); + + if (standalone_service_running()) { + log_message("Stopping standalone byedpi service before starting Podkop Plus-managed ciadpi runtime", "info"); + command_success_from_args([ BYEDPI_SERVICE_INIT, "stop" ]); + command_success_from_args([ "sleep", "1" ]); + if (standalone_service_running()) { + log_message("Standalone byedpi service is still running and may conflict with Podkop Plus-managed ciadpi runtime. Aborted.", "fatal"); + exit(1); + } + } + + if (!ensure_runtime_dirs()) { + log_message("Failed to prepare the Podkop Plus ByeDPI state directory in " + BYEDPI_STATE_DIR + ". Aborted.", "fatal"); + exit(1); + } + + let index_value = 1; + for (let section in sections) { + start_rule(section, index_value); + index_value++; + } +} + +function live_pid_count(path) { + let count = 0; + for (let pidfile in pidfiles_in_dir(path)) { + let pid = file_first_line(pidfile); + if (runtime_pid_running(pid)) + count++; + else + fs.unlink(pidfile); + } + return count; +} + +function restart_count() { + let count = 0; + if (fs.stat(BYEDPI_LOG_DIR) == null) + return count; + + let output = command_output_from_args([ "find", BYEDPI_LOG_DIR, "-maxdepth", "1", "-type", "f", "-name", "*.log" ]); + for (let path in split(output, "\n")) { + path = trim(as_string(path)); + if (path == "") + continue; + let data = fs.readfile(path); + if (data == null) + continue; + for (let line in split(data, "\n")) + if (match(as_string(line), /ciadpi for rule .* exited with code/) != null) + count++; + } + return count; +} + +function str_last_index(value, needle) { + value = as_string(value); + needle = as_string(needle); + if (needle == "") + return length(value); + + for (let i = length(value) - length(needle); i >= 0; i--) + if (substr(value, i, length(needle)) == needle) + return i; + return -1; +} + +function tag_is_reserved(tag) { + for (let value in RESERVED_RUNTIME_TAGS) + if (tag == as_string(value)) + return true; + return false; +} + +function runtime_tag(base, postfix) { + base = as_string(base); + postfix = as_string(postfix); + let candidate = base + "-" + postfix; + let suffix = 1; + + if (match(base, /-[0-9]/) != null) { + let dash = str_last_index(base, "-"); + let parent = dash >= 0 ? substr(base, 0, dash) : base; + if (tag_is_reserved(parent + "-" + postfix)) { + candidate = base + "-" + suffix + "-" + postfix; + suffix++; + } + } + + while (tag_is_reserved(candidate)) { + candidate = base + "-" + suffix + "-" + postfix; + suffix++; + } + return candidate; +} + +function read_json_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + try { + return json(data); + } + catch (e) { + return null; + } +} + +function value_contains(value, needle) { + if (type(value) == "array") { + for (let item in value) + if (as_string(item) == needle) + return true; + return false; + } + return as_string(value) == needle; +} + +function has_socks_outbound(config, tag, address, 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) == int(port)) + return true; + } + return false; +} + +function has_route_rule(config, inbound, outbound) { + for (let rule in array_or_empty(config && config.route && config.route.rules)) { + if (type(rule) == "object" && + rule.action == "route" && + value_contains(rule.inbound, inbound) && + value_contains(rule.outbound, outbound)) + return true; + } + return false; +} + +function runtime_config_status(sections) { + let config_path = option(uci_settings(), "config_path", ""); + let config = read_json_file(config_path); + let rules_configured = length(sections) > 0; + let outbounds_configured = rules_configured; + let routes_configured = rules_configured; + + let index_value = 1; + for (let section in sections) { + let outbound = runtime_tag(section_name(section), "out"); + let port = rule_port(index_value); + if (!has_socks_outbound(config, outbound, BYEDPI_LISTEN_ADDRESS, port)) + outbounds_configured = false; + if (!has_route_rule(config, SB_TPROXY_INBOUND_TAG, outbound)) + routes_configured = false; + index_value++; + } + + return { + rules_configured, + outbounds_configured, + routes_configured + }; +} + +function status_json() { + let sections = enabled_byedpi_sections(); + let configured = length(sections) > 0; + let provider = provider_available(); + let pkg = package_installed(); + let version = pkg || provider ? package_version() : "not installed"; + if (version == "") + version = "unknown"; + + let expected = length(sections); + let running = live_pid_count(BYEDPI_CHILD_PID_DIR); + let supervisors = live_pid_count(BYEDPI_PID_DIR); + let restarts = restart_count(); + let standalone_enabled = standalone_service_enabled(); + let standalone_running = standalone_service_running(); + let config_state = runtime_config_status(sections); + let conflict = running > expected || standalone_running; + let unstable = configured && restarts > 0; + let ready = configured && + provider && + !standalone_running && + !conflict && + !unstable && + config_state.outbounds_configured && + config_state.routes_configured && + expected > 0 && + running == expected; + + let message = "byedpi provider status is normal"; + if (configured && !provider) + message = "action=byedpi is configured, but ciadpi is not available at " + BYEDPI_BIN; + else if (configured && standalone_running) + message = "standalone byedpi service is active together with Podkop Plus action=byedpi; port conflicts are possible"; + else if (configured && standalone_enabled) + message = "standalone byedpi service autostart is enabled; disable it to avoid boot-time port conflicts with Podkop Plus action=byedpi"; + else if (running > expected || supervisors > expected) + message = "unexpected Podkop Plus-managed ciadpi processes are running without matching action=byedpi rules"; + else if (configured && unstable) + message = "Podkop Plus-managed ciadpi has restarted after exiting; the ByeDPI strategy or traffic load may be unstable"; + else if (configured && !ready) + message = "action=byedpi is configured, but the Podkop Plus-managed ciadpi runtime is not ready"; + else if (!configured && !provider && pkg) + message = "byedpi package is installed, but ciadpi is not available at " + BYEDPI_BIN; + else if (!configured && !provider) + message = "byedpi package is not installed; action=byedpi is unavailable"; + + write_json({ + installed: provider, + package_installed: pkg, + provider_available: provider, + provider_path: BYEDPI_BIN, + version, + configured, + enabled_rule_count: expected, + expected_process_count: expected, + running_process_count: running, + supervisor_process_count: supervisors, + restart_count: restarts, + runtime_unstable: unstable, + standalone_service_enabled: standalone_enabled, + standalone_service_running: standalone_running, + listen_address: BYEDPI_LISTEN_ADDRESS, + port_base: int(BYEDPI_PORT_BASE), + outbounds_configured: config_state.outbounds_configured, + routes_configured: config_state.routes_configured, + ready, + conflict, + status_message: message + }); +} + +function check_json() { + write_json({ + byedpi_installed: provider_available(), + byedpi_package_installed: package_installed(), + byedpi_provider_path: BYEDPI_BIN + }); +} + +let mode = ARGV[0] || ""; + +if (mode == "start-runtime") + start_runtime(); +else if (mode == "stop-runtime") + stop_runtime(); +else if (mode == "supervisor") + supervisor(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "status") + status_json(); +else if (mode == "check") + check_json(); +else if (mode == "installed" || mode == "provider-available") + exit(provider_available() ? 0 : 1); +else if (mode == "package-installed") + exit(package_installed() ? 0 : 1); +else if (mode == "package-version") + print(package_version(), "\n"); +else if (mode == "enabled-rule-count") + print(enabled_rule_count(), "\n"); +else { + warn("Usage: providers/byedpi/runtime.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/providers/byedpi/validator.uc b/podkop/files/usr/lib/providers/byedpi/validator.uc new file mode 100644 index 00000000..12ba4c84 --- /dev/null +++ b/podkop/files/usr/lib/providers/byedpi/validator.uc @@ -0,0 +1,254 @@ +#!/usr/bin/env ucode + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function bool_arg(value) { + value = as_string(value); + return value == "1" || value == "true"; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function normalize_strategy_whitespace(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + return replace(value, / $/, ""); +} + +function strategy_or_default(value, default_value) { + value = as_string(value); + if (value == "") + value = as_string(default_value); + return normalize_strategy_whitespace(value); +} + +function print_strategy_or_default(value, default_value) { + print(strategy_or_default(value, default_value), "\n"); +} + +function words(value) { + value = normalize_strategy_whitespace(value); + return value == "" ? [] : split(value, " "); +} + +function contains(values, needle) { + for (let value in values) + if (value == needle) + return true; + return false; +} + +let long_value_options = [ + "--max-conn", "--conn-ip", "--buf-size", "--debug", "--def-ttl", "--auto", "--auto-mode", + "--cache-ttl", "--cache-dump", "--timeout", "--proto", "--hosts", "--ipset", "--pf", + "--round", "--split", "--disorder", "--oob", "--disoob", "--fake", "--fake-sni", + "--ttl", "--fake-offset", "--fake-data", "--fake-tls-mod", "--oob-data", "--mod-http", + "--tlsrec", "--tlsminor", "--udp-fake" +]; + +let long_flag_options = [ + "--md5sig", "--tfo", "--drop-sack", "--no-domain", "--no-udp" +]; + +let short_value_options = [ + "-c", "-I", "-b", "-x", "-g", "-A", "-L", "-u", "-y", "-T", "-K", "-H", "-j", + "-V", "-R", "-s", "-d", "-o", "-q", "-f", "-n", "-t", "-O", "-l", "-Q", "-e", + "-M", "-r", "-m", "-a" +]; + +let short_flag_options = [ + "-N", "-U", "-F", "-S", "-Y" +]; + +function validation_failure(message, needles) { + needles = type(needles) == "array" ? needles : []; + return { + valid: false, + message: as_string(message), + needle: length(needles) > 0 ? as_string(needles[0]) : "", + needles + }; +} + +function validation_success() { + return { + valid: true, + message: "", + needle: "", + needles: [] + }; +} + +function token_looks_like_option(token) { + token = as_string(token); + return match(token, /^--.+/) != null || match(token, /^-[A-Za-z].*/) != null; +} + +function short_option_name(token) { + token = as_string(token); + return length(token) >= 2 ? substr(token, 0, 2) : token; +} + +function controlled_option_failure(token, next_token) { + let base = split(as_string(token), "=")[0]; + let display = as_string(token); + + if (token == "--ip" || index(token, "--ip=") == 0 || token == "-i" || match(token, /^-i.+/) != null || + token == "--port" || index(token, "--port=") == 0 || token == "-p" || match(token, /^-p.+/) != null) { + if ((token == "--ip" || token == "-i" || token == "--port" || token == "-p") && + as_string(next_token) != "" && substr(as_string(next_token), 0, 1) != "-") + display += " " + as_string(next_token); + return validation_failure( + "ByeDPI listen address and port are assigned by Podkop Plus and must not be set in the strategy: " + display, + [ base ] + ); + } + + if (token == "--transparent" || token == "-E" || match(token, /^-E.+/) != null) + return validation_failure( + "Transparent proxy mode is incompatible with action=byedpi because Podkop Plus connects to ciadpi through SOCKS.", + [ base ] + ); + + if (token == "--daemon" || token == "-D" || match(token, /^-D.+/) != null) + return validation_failure( + "Podkop Plus manages the ciadpi process lifecycle itself, so daemon mode is not allowed here.", + [ base ] + ); + + if (token == "--pidfile" || index(token, "--pidfile=") == 0 || token == "-w" || match(token, /^-w.+/) != null) + return validation_failure( + "Podkop Plus manages ciadpi pid files itself, so pidfile options are not allowed here.", + [ base ] + ); + + if (token == "--help" || token == "-h" || match(token, /^-h.+/) != null || + token == "--version" || token == "-v" || match(token, /^-v.+/) != null) + return validation_failure( + "This field must start a working ciadpi strategy; help/version options exit immediately and are not allowed here.", + [ base ] + ); + + return null; +} + +function validate_token(tokens, index_value) { + let token = as_string(tokens[index_value]); + let next_token = as_string(tokens[index_value + 1]); + let controlled = controlled_option_failure(token, next_token); + if (controlled != null) + return controlled; + + let equals = index(token, "="); + if (index(token, "--") == 0 && equals > 0) { + let base = substr(token, 0, equals); + let value = substr(token, equals + 1); + + if (contains(long_value_options, base)) + return value == "" ? validation_failure("ByeDPI option requires a value: " + base, [ base ]) : null; + if (contains(long_flag_options, base)) + return validation_failure("ByeDPI option does not accept a value: " + base, [ base ]); + return validation_failure("Unknown ByeDPI option: " + base, [ base ]); + } + + if (index(token, "--") == 0) { + if (contains(long_value_options, token)) { + if (next_token == "" || token_looks_like_option(next_token)) + return validation_failure("ByeDPI option requires a value: " + token, [ token ]); + return { consume_next: true }; + } + if (contains(long_flag_options, token)) + return null; + return validation_failure("Unknown ByeDPI option: " + token, [ token ]); + } + + if (substr(token, 0, 1) == "-") { + if (token == "-") + return validation_failure("Unexpected ByeDPI strategy argument: " + token, [ token ]); + + let short = short_option_name(token); + let value = substr(token, length(short)); + if (contains(short_value_options, short)) { + if (token == short) { + if (next_token == "" || token_looks_like_option(next_token)) + return validation_failure("ByeDPI option requires a value: " + short, [ short ]); + return { consume_next: true }; + } + if (value == "") + return validation_failure("ByeDPI option requires a value: " + short, [ short ]); + return null; + } + + if (contains(short_flag_options, short)) { + if (token != short) + return validation_failure("ByeDPI option does not accept a compact value: " + short, [ short ]); + return null; + } + + return validation_failure("Unknown ByeDPI option: " + short, [ short ]); + } + + return validation_failure("Unexpected ByeDPI strategy argument: " + token, [ token ]); +} + +function validate_byedpi_strategy(raw_opt) { + let tokens = words(raw_opt); + + if (length(tokens) == 0) + return validation_failure("ByeDPI strategy cannot be empty.", []); + + for (let i = 0; i < length(tokens); i++) { + let result = validate_token(tokens, i); + if (result == null) + continue; + if (result.consume_next === true) { + i++; + continue; + } + return result; + } + + return validation_success(); +} + +function write_validation_json(result) { + write_json(result); +} + +function validate_exit(raw_opt) { + let result = validate_byedpi_strategy(raw_opt); + if (result.valid) + exit(0); + + print(result.message, "\n"); + exit(1); +} + +function module_exports() { + return { + normalize_strategy_whitespace, + strategy_or_default, + validate_byedpi_strategy + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0] || ""; + +if (mode == "validate-json") + write_validation_json(validate_byedpi_strategy(ARGV[1])); +else if (mode == "validate") + validate_exit(ARGV[1]); +else if (mode == "strategy-or-default") + print_strategy_or_default(ARGV[1], ARGV[2]); +else { + warn("Usage: providers/byedpi/validator.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/zapret_validation.uc b/podkop/files/usr/lib/providers/nfqueue/check.uc similarity index 90% rename from podkop/files/usr/lib/zapret_validation.uc rename to podkop/files/usr/lib/providers/nfqueue/check.uc index 4e3cb500..51c305f7 100644 --- a/podkop/files/usr/lib/zapret_validation.uc +++ b/podkop/files/usr/lib/providers/nfqueue/check.uc @@ -339,25 +339,39 @@ function ps_matching_pids(needle) { } } -let mode = ARGV[0] || ""; +function run(argv, usage_path) { + argv = type(argv) == "array" ? argv : []; + usage_path = as_string(usage_path || "providers/nfqueue/check.uc"); -if (mode == "summary") - validation_summary(ARGV[1]); -else if (mode == "value-hint") - validation_value_hint(ARGV[1]); -else if (mode == "option-hint") - validation_option_hint(ARGV[1]); -else if (mode == "needles") - validation_needles(ARGV[1]); -else if (mode == "nft-queue-overlap") - exit(nft_queue_overlap(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); -else if (mode == "rewrite-legacy-runtime-path") - rewrite_legacy_runtime_path(ARGV[1], ARGV[2]); -else if (mode == "stdin-contains") - exit(stdin_contains(ARGV[1]) ? 0 : 1); -else if (mode == "ps-matching-pids") - ps_matching_pids(ARGV[1]); -else { - warn("Usage: zapret_validation.uc ...\n"); - exit(1); + let mode = argv[0] || ""; + + if (mode == "summary") + validation_summary(argv[1]); + else if (mode == "value-hint") + validation_value_hint(argv[1]); + else if (mode == "option-hint") + validation_option_hint(argv[1]); + else if (mode == "needles") + validation_needles(argv[1]); + else if (mode == "nft-queue-overlap") + exit(nft_queue_overlap(argv[1], argv[2], argv[3]) ? 0 : 1); + else if (mode == "rewrite-legacy-runtime-path") + rewrite_legacy_runtime_path(argv[1], argv[2]); + else if (mode == "stdin-contains") + exit(stdin_contains(argv[1]) ? 0 : 1); + else if (mode == "ps-matching-pids") + ps_matching_pids(argv[1]); + else { + warn("Usage: " + usage_path + " ...\n"); + exit(1); + } } + +if (sourcepath(1) != null && sourcepath(1) != "") { + return { + run, + nft_queue_overlap + }; +} + +run(ARGV, "providers/nfqueue/check.uc"); diff --git a/podkop/files/usr/lib/providers/nfqueue/runtime.uc b/podkop/files/usr/lib/providers/nfqueue/runtime.uc new file mode 100644 index 00000000..d4b20b5e --- /dev/null +++ b/podkop/files/usr/lib/providers/nfqueue/runtime.uc @@ -0,0 +1,767 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || constants.PODKOP_CONFIG_NAME || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || constants.SB_TPROXY_INBOUND_TAG || "tproxy-in"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || constants.NFT_TABLE_NAME || "PodkopPlusTable"; +const RESERVED_RUNTIME_TAGS = [ + getenv("SB_DNS_SERVER_TAG") || constants.SB_DNS_SERVER_TAG || "dns-server", + getenv("SB_FAKEIP_DNS_SERVER_TAG") || constants.SB_FAKEIP_DNS_SERVER_TAG || "fakeip-server", + getenv("SB_BOOTSTRAP_SERVER_TAG") || constants.SB_BOOTSTRAP_SERVER_TAG || "bootstrap-dns-server", + getenv("SB_FAKEIP_DNS_RULE_TAG") || constants.SB_FAKEIP_DNS_RULE_TAG || "fakeip-dns-rule-tag", + getenv("SB_FAKEIP_RULESET_DNS_RULE_TAG") || constants.SB_FAKEIP_RULESET_DNS_RULE_TAG || "fakeip-ruleset-dns-rule-tag", + getenv("SB_SERVICE_FAKEIP_DNS_RULE_TAG") || constants.SB_SERVICE_FAKEIP_DNS_RULE_TAG || "service-fakeip-dns-rule-tag", + SB_TPROXY_INBOUND_TAG, + getenv("SB_DNS_INBOUND_TAG") || constants.SB_DNS_INBOUND_TAG || "dns-in", + getenv("SB_SERVICE_MIXED_INBOUND_TAG") || constants.SB_SERVICE_MIXED_INBOUND_TAG || "service-mixed-in", + getenv("SB_DIRECT_OUTBOUND_TAG") || constants.SB_DIRECT_OUTBOUND_TAG || "direct-out" +]; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function bool_value(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", name ]); +} + +function module_command(args) { + let command_args = [ "ucode", "-L", LIB_DIR ]; + for (let arg in args) + push(command_args, arg); + return command_from_args(command_args); +} + +function module_success(args) { + return command_success(module_command(args)); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "podkop-plus", "[" + level + "] " + as_string(message) ]); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + return value == null ? !!fallback : bool_value(value); +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, as_string(type_name)); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function provider_config(provider) { + let cfg = object_or_empty(provider.config({ + constants, + lib_dir: LIB_DIR + })); + + cfg.kind = as_string(cfg.kind); + cfg.action = as_string(cfg.action || cfg.kind); + cfg.check_path = as_string(cfg.check_path || (LIB_DIR + "/providers/" + cfg.kind + "/check.uc")); + cfg.legacy_runtime_base = as_string(cfg.legacy_runtime_base); + cfg.provider_base_dir = as_string(cfg.provider_base_dir); + cfg.hostlist_dir = as_string(cfg.hostlist_dir); + cfg.base_args = array_or_empty(cfg.base_args); + return cfg; +} + +function normalize_strategy(cfg, value) { + return cfg.validator().strategy_or_default(value, cfg.default_strategy); +} + +function strategy_words(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + value = replace(value, / $/, ""); + return value == "" ? [] : split(value, " "); +} + +function enabled_sections(cfg) { + let result = []; + for (let section in uci_sections("section")) + if (bool_option(section, "enabled", true) && option(section, "action", "") == cfg.action) + push(result, section); + return result; +} + +function rule_index(name, sections) { + let index_value = 0; + for (let section in array_or_empty(sections)) { + index_value++; + if (section_name(section) == as_string(name)) + return index_value; + } + return 0; +} + +function hex_digit_value(value) { + let pos = index("0123456789abcdef", lc(as_string(value))); + return pos >= 0 ? pos : null; +} + +function parse_number(value) { + value = lc(trim(as_string(value))); + if (value == "") + return null; + if (substr(value, 0, 2) == "0x") { + value = substr(value, 2); + let result = 0; + for (let i = 0; i < length(value); i++) { + let digit = hex_digit_value(substr(value, i, 1)); + if (digit == null) + return null; + result = result * 16 + digit; + } + return result; + } + return match(value, /^[0-9]+$/) == null ? null : int(value); +} + +function route_mark_value(cfg, index_value) { + return parse_number(cfg.route_mark_base) + int(index_value); +} + +function route_mark_hex(cfg, index_value) { + return sprintf("0x%08x", route_mark_value(cfg, index_value)); +} + +function queue_number(cfg, index_value) { + return int(cfg.queue_base) + int(index_value) - 1; +} + +function queue_range_end(cfg) { + return int(cfg.queue_base) + int(cfg.queue_range_size) - 1; +} + +function provider_available(cfg) { + let stat = fs.stat(cfg.provider_bin); + return stat != null && stat.mode != null && (int(stat.mode) & 73) != 0; +} + +function package_installed(cfg) { + return module_success([ LIB_DIR + "/core/packages.uc", "installed", cfg.package_name ]); +} + +function package_version_from_manager(cfg) { + return trim(command_output_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/core/packages.uc", "version", cfg.package_name ])); +} + +function first_line_version_field(value) { + let newline = index(value, "\n"); + let line = newline >= 0 ? substr(value, 0, newline) : value; + if (match(line, /^.*version[ \t]*/) == null) + return ""; + let fields = split(trim(replace(line, /^.*version[ \t]*/, "")), /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function package_version(cfg) { + let version = package_version_from_manager(cfg); + if (version == "" && provider_available(cfg)) + version = first_line_version_field(command_output_from_args([ cfg.provider_bin, "--version" ])); + return version; +} + +function standalone_service_enabled(cfg) { + return fs.stat(cfg.service_init) != null && command_success_from_args([ cfg.service_init, "enabled" ]); +} + +function standalone_service_running(cfg) { + return fs.stat(cfg.service_init) != null && command_success_from_args([ cfg.service_init, "status" ]); +} + +function standalone_uci_config_present(cfg) { + return fs.stat("/etc/config/" + cfg.config_name) != null && uci_core.exists(cfg.config_name + ".config"); +} + +function luci_app_installed(cfg) { + return module_success([ LIB_DIR + "/core/packages.uc", "installed", cfg.luci_package ]) || + fs.stat(cfg.luci_menu) != null || + fs.stat(cfg.luci_acl) != null; +} + +function runtime_pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function file_first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function kill_pidfile_process(path, signal) { + let pid = file_first_line(path); + if (pid == "") + return; + if (signal == "9") { + if (runtime_pid_running(pid)) + command_success_from_args([ "kill", "-9", pid ]); + } + else { + command_success_from_args([ "kill", pid ]); + } +} + +function pidfiles_in_dir(path) { + if (fs.stat(path) == null) + return []; + + let output = command_output_from_args([ "find", path, "-maxdepth", "1", "-type", "f", "-name", "*.pid" ]); + let result = []; + for (let line in split(output, "\n")) { + line = trim(as_string(line)); + if (line != "") + push(result, line); + } + return result; +} + +function live_pid_count(path) { + let count = 0; + for (let pidfile in pidfiles_in_dir(path)) { + let pid = file_first_line(pidfile); + if (runtime_pid_running(pid)) + count++; + else { + try { fs.unlink(pidfile); } catch (e) {} + } + } + return count; +} + +function ensure_runtime_dirs(cfg) { + return command_success_from_args([ "mkdir", "-p", cfg.state_dir, cfg.pid_dir, cfg.child_pid_dir, cfg.log_dir ]); +} + +function uci_value_contains(value, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + if (type(value) == "array") { + for (let item in value) + if (uci_value_contains(item, needle)) + return true; + return false; + } + + if (type(value) == "object") { + for (let key in keys(value)) + if (uci_value_contains(value[key], needle)) + return true; + return false; + } + + return index(as_string(value), needle) >= 0; +} + +function legacy_runtime_path_present(cfg) { + if (cfg.legacy_runtime_base == "") + return false; + + if (uci_value_contains(uci_settings(), cfg.legacy_runtime_base)) + return true; + for (let section in uci_sections("section")) + if (uci_value_contains(section, cfg.legacy_runtime_base)) + return true; + + return fs.stat(cfg.legacy_runtime_base) != null; +} + +function stop_legacy_runtime_processes(cfg) { + if (cfg.legacy_runtime_base == "") + return; + let needle = cfg.legacy_runtime_base + "/nfq/nfqws"; + for (let line in split(command_output_from_args([ "ps", "w" ]), "\n")) { + if (index(line, needle) < 0) + continue; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + if (length(fields) > 0) + command_success_from_args([ "kill", fields[0] ]); + } +} + +function cleanup_legacy_runtime(cfg) { + if (cfg.legacy_runtime_base == "") + return; + stop_legacy_runtime_processes(cfg); + command_success_from_args([ "rm", "-rf", cfg.legacy_runtime_base ]); +} + +function expand_strategy(cfg, value) { + value = as_string(value); + if (cfg.legacy_runtime_base == "" || cfg.provider_base_dir == "") + return value; + return replace(value, cfg.legacy_runtime_base, cfg.provider_base_dir); +} + +function base_args(cfg) { + return cfg.base_args; +} + +function raw_strategy(cfg, section) { + return normalize_strategy(cfg, option(section, cfg.strategy_option, "")); +} + +function validate_strategy_or_exit(cfg, section_name_value, raw_opt) { + let result = cfg.validator().validate_strategy(cfg.validator_kind, raw_opt, cfg.legacy_default_strategy); + if (result.valid) + return; + log_message("Invalid " + cfg.binary_name + " strategy for rule '" + section_name_value + "': " + result.message, "fatal"); + exit(1); +} + +function supervisor_command(cfg, queue, raw_opt, child_pidfile) { + let args = [ cfg.binary, "--qnum=" + as_string(queue) ]; + for (let arg in base_args(cfg)) + push(args, arg); + for (let word in strategy_words(raw_opt)) + push(args, word); + + return command_from_args(args) + " & child=$!; echo $child > " + shell_quote(child_pidfile) + "; wait $child; rc=$?; rm -f " + shell_quote(child_pidfile) + "; exit $rc"; +} + +function supervisor(cfg, section, queue, raw_opt, child_pidfile) { + while (true) { + if (!provider_available(cfg)) { + print(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), " Provider ", cfg.binary, " is not executable; retrying in ", cfg.respawn_delay, " seconds\n"); + command_success_from_args([ "sleep", cfg.respawn_delay ]); + continue; + } + + let rc = command_status("sh -c " + shell_quote(supervisor_command(cfg, queue, raw_opt, child_pidfile))); + print(command_output_from_args([ "date", "+%Y-%m-%d %H:%M:%S" ]), " ", cfg.binary_name, " for rule ", as_string(section), " exited with code ", rc, "; respawning in ", cfg.respawn_delay, " seconds\n"); + command_success_from_args([ "sleep", cfg.respawn_delay ]); + } +} + +function stop_runtime(cfg) { + for (let pidfile in pidfiles_in_dir(cfg.pid_dir)) + kill_pidfile_process(pidfile, ""); + for (let pidfile in pidfiles_in_dir(cfg.child_pid_dir)) + kill_pidfile_process(pidfile, ""); + + command_success_from_args([ "sleep", "1" ]); + + for (let pidfile in pidfiles_in_dir(cfg.pid_dir)) + kill_pidfile_process(pidfile, "9"); + for (let pidfile in pidfiles_in_dir(cfg.child_pid_dir)) + kill_pidfile_process(pidfile, "9"); + + let remove_args = [ "rm", "-rf", cfg.pid_dir, cfg.child_pid_dir, cfg.log_dir ]; + if (cfg.hostlist_dir != "") + push(remove_args, cfg.hostlist_dir); + if (cfg.legacy_runtime_base != "") + push(remove_args, cfg.legacy_runtime_base); + command_success_from_args(remove_args); +} + +function start_rule(cfg, section, index_value) { + let name = section_name(section); + let queue = queue_number(cfg, index_value); + let mark = route_mark_hex(cfg, index_value); + let raw_opt = expand_strategy(cfg, raw_strategy(cfg, section)); + validate_strategy_or_exit(cfg, name, raw_opt); + + let pidfile = cfg.pid_dir + "/" + name + ".pid"; + let child_pidfile = cfg.child_pid_dir + "/" + name + ".pid"; + let logfile = cfg.log_dir + "/" + name + ".log"; + + log_message("Starting " + cfg.binary_name + " for rule '" + name + "' on queue " + queue + " with mark " + mark, "info"); + let command = command_from_args([ + "ucode", + "-L", LIB_DIR, + cfg.runtime_path, + "supervisor", + name, + "" + queue, + raw_opt, + child_pidfile + ]) + " >>" + shell_quote(logfile) + " 2>&1 & echo $!"; + let pid = trim(command_output("sh -c " + shell_quote(command))); + if (pid == "" || fs.writefile(pidfile, pid + "\n") == null) { + log_message(cfg.binary_name + " failed to start for rule '" + name + "'. Check " + logfile + ". Aborted.", "fatal"); + exit(1); + } + + command_success_from_args([ "sleep", "1" ]); + if (!runtime_pid_running(pid)) { + log_message(cfg.binary_name + " failed to start for rule '" + name + "'. Check " + logfile + ". Aborted.", "fatal"); + exit(1); + } + + let child_pid = file_first_line(child_pidfile); + if (child_pid == "" || !runtime_pid_running(child_pid)) + log_message(cfg.binary_name + " supervisor started for rule '" + name + "', but " + cfg.binary_name + " is not running yet. Check " + logfile + ".", "warn"); +} + +function start_runtime(cfg) { + stop_runtime(cfg); + + let sections = enabled_sections(cfg); + if (length(sections) == 0 || !provider_available(cfg)) + return; + + cleanup_legacy_runtime(cfg); + if (!ensure_runtime_dirs(cfg)) { + log_message("Failed to prepare the Podkop Plus " + cfg.status_label + " state directory in " + cfg.state_dir + ". Aborted.", "fatal"); + exit(1); + } + + let index_value = 1; + for (let section in sections) { + start_rule(cfg, section, index_value); + index_value++; + } +} + +function str_last_index(value, needle) { + value = as_string(value); + needle = as_string(needle); + if (needle == "") + return length(value); + + for (let i = length(value) - length(needle); i >= 0; i--) + if (substr(value, i, length(needle)) == needle) + return i; + return -1; +} + +function tag_is_reserved(tag) { + for (let value in RESERVED_RUNTIME_TAGS) + if (tag == as_string(value)) + return true; + return false; +} + +function runtime_tag(base, postfix) { + base = as_string(base); + postfix = as_string(postfix); + let candidate = base + "-" + postfix; + let suffix = 1; + + if (match(base, /-[0-9]/) != null) { + let dash = str_last_index(base, "-"); + let parent = dash >= 0 ? substr(base, 0, dash) : base; + if (tag_is_reserved(parent + "-" + postfix)) { + candidate = base + "-" + suffix + "-" + postfix; + suffix++; + } + } + + while (tag_is_reserved(candidate)) { + candidate = base + "-" + suffix + "-" + postfix; + suffix++; + } + return candidate; +} + +function read_json_file(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return null; + try { + return json(data); + } + catch (e) { + return null; + } +} + +function value_contains(value, needle) { + if (type(value) == "array") { + for (let item in value) + if (as_string(item) == needle) + return true; + return false; + } + return as_string(value) == needle; +} + +function has_direct_mark_outbound(config, tag, 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) == int(routing_mark)) + return true; + } + return false; +} + +function has_route_rule(config, inbound, outbound) { + for (let rule in array_or_empty(config && config.route && config.route.rules)) { + if (type(rule) == "object" && + rule.action == "route" && + value_contains(rule.inbound, inbound) && + value_contains(rule.outbound, outbound)) + return true; + } + return false; +} + +function runtime_config_status(cfg, sections) { + let config_path = option(uci_settings(), "config_path", ""); + let config = read_json_file(config_path); + let rules_configured = length(sections) > 0; + let outbounds_configured = rules_configured; + let routes_configured = rules_configured; + + let index_value = 1; + for (let section in sections) { + let outbound = runtime_tag(section_name(section), "out"); + let mark = route_mark_value(cfg, index_value); + if (!has_direct_mark_outbound(config, outbound, mark)) + outbounds_configured = false; + if (!has_route_rule(config, SB_TPROXY_INBOUND_TAG, outbound)) + routes_configured = false; + index_value++; + } + + return { + rules_configured, + outbounds_configured, + routes_configured + }; +} + +function external_queue_overlap(cfg) { + let command = "nft list ruleset 2>/dev/null | " + module_command([ + cfg.check_path, + "nft-queue-overlap", + NFT_TABLE_NAME, + cfg.queue_base, + "" + queue_range_end(cfg) + ]); + return command_success(command); +} + +function status_json(cfg) { + let sections = enabled_sections(cfg); + let configured = length(sections) > 0; + let provider = provider_available(cfg); + let pkg = package_installed(cfg); + let version = pkg || provider ? package_version(cfg) : "not installed"; + if (version == "") + version = "unknown"; + + let expected = length(sections); + let running = live_pid_count(cfg.child_pid_dir); + let supervisors = live_pid_count(cfg.pid_dir); + let standalone_enabled = standalone_service_enabled(cfg); + let standalone_running = standalone_service_running(cfg); + let standalone_config = standalone_uci_config_present(cfg); + let queue_overlap = external_queue_overlap(cfg); + let standalone_conflict = configured && standalone_running; + let legacy_runtime = legacy_runtime_path_present(cfg); + let luci_installed = luci_app_installed(cfg); + let config_state = runtime_config_status(cfg, sections); + let conflict = running > expected || queue_overlap || legacy_runtime; + let ready = configured && + provider && + !conflict && + config_state.outbounds_configured && + config_state.routes_configured && + expected > 0 && + running == expected; + + let message = cfg.status_label + " provider status is normal"; + if (configured && !provider) + message = "action=" + cfg.action + " is configured, but " + cfg.status_label + " provider is not available at " + cfg.provider_bin; + else if (queue_overlap) + message = "external NFQUEUE rules overlap with the Podkop Plus " + cfg.status_label + " range " + cfg.queue_base + "-" + queue_range_end(cfg); + else if (legacy_runtime) + message = "legacy zapret runtime paths are still present and should be migrated"; + else if (running > expected || supervisors > expected) + message = "unexpected Podkop Plus-managed " + cfg.binary_name + " processes are running without matching action=" + cfg.action + " rules"; + else if (configured && !ready) + message = "action=" + cfg.action + " is configured, but the Podkop Plus-managed " + cfg.binary_name + " runtime is not ready"; + else if (standalone_conflict) + message = "standalone " + cfg.status_label + " is active together with Podkop Plus action=" + cfg.action + "; queues are separate, but packet-level policy overlap is possible"; + else if (!configured && !provider && pkg) + message = cfg.status_label + " package is installed, but the provider binary is not available at " + cfg.provider_bin; + else if (!configured && !provider) + message = cfg.status_label + " provider is not installed; action=" + cfg.action + " is unavailable"; + + let value = { + installed: provider, + package_installed: pkg, + provider_available: provider, + provider_path: cfg.provider_bin, + files_available: fs.stat(cfg.provider_files_dir) != null, + ipset_available: fs.stat(cfg.provider_ipset_dir) != null, + version, + configured, + enabled_rule_count: expected, + expected_process_count: expected, + running_process_count: running, + supervisor_process_count: supervisors, + standalone_service_enabled: standalone_enabled, + standalone_service_running: standalone_running, + standalone_config_present: standalone_config, + standalone_conflict, + luci_app_installed: luci_installed, + queue_base: int(cfg.queue_base), + queue_range_end: queue_range_end(cfg), + queue_overlap, + ready, + conflict, + outbounds_configured: config_state.outbounds_configured, + routes_configured: config_state.routes_configured, + status_message: message + }; + + if (cfg.legacy_runtime_base != "") + value.legacy_runtime_present = legacy_runtime; + + write_json(value); +} + +function check_json(cfg) { + let value = {}; + value[cfg.check_prefix + "_installed"] = provider_available(cfg); + value[cfg.check_prefix + "_package_installed"] = package_installed(cfg); + value[cfg.check_prefix + "_provider_path"] = cfg.provider_bin; + write_json(value); +} + +function create_nft_rules(cfg) { + let sections = enabled_sections(cfg); + if (length(sections) == 0 || !provider_available(cfg)) + return; + + command_success_from_args([ "nft", "add", "rule", "inet", NFT_TABLE_NAME, "mangle_output", "meta", "mark", "&", cfg.desync_mark, "==", cfg.desync_mark, "return" ]); + command_success_from_args([ "nft", "add", "rule", "inet", NFT_TABLE_NAME, "mangle_output", "meta", "mark", "&", cfg.desync_mark_postnat, "==", cfg.desync_mark_postnat, "return" ]); + + let index_value = 1; + for (let section in sections) { + let mark = route_mark_hex(cfg, index_value); + let queue = "" + queue_number(cfg, index_value); + command_success_from_args([ "nft", "add", "rule", "inet", NFT_TABLE_NAME, "mangle_output", "meta", "mark", mark, "meta", "l4proto", "tcp", "counter", "queue", "num", queue, "bypass" ]); + command_success_from_args([ "nft", "add", "rule", "inet", NFT_TABLE_NAME, "mangle_output", "meta", "mark", mark, "meta", "l4proto", "udp", "counter", "queue", "num", queue, "bypass" ]); + index_value++; + } +} + +function run(provider, argv) { + argv = type(argv) == "array" ? argv : []; + let mode = argv[0] || ""; + let cfg = provider_config(provider); + let kind = cfg.kind; + + if (mode == "supervisor") + supervisor(cfg, argv[1], argv[2], argv[3], argv[4]); + else if (mode == "start-runtime") + start_runtime(cfg); + else if (mode == "stop-runtime") + stop_runtime(cfg); + else if (mode == "create-nft-rules") + create_nft_rules(cfg); + else if (mode == "status") + status_json(cfg); + else if (mode == "check") + check_json(cfg); + else if (mode == "installed" || mode == "provider-available") + exit(provider_available(cfg) ? 0 : 1); + else if (mode == "package-installed") + exit(package_installed(cfg) ? 0 : 1); + else if (mode == "package-version") + print(package_version(cfg), "\n"); + else if (mode == "enabled-rule-count") + print(length(enabled_sections(cfg)), "\n"); + else { + warn("Usage: providers/" + kind + "/runtime.uc \n"); + exit(1); + } +} + +return { + run +}; diff --git a/podkop/files/usr/lib/providers/nfqueue/validator.uc b/podkop/files/usr/lib/providers/nfqueue/validator.uc new file mode 100644 index 00000000..4d02bad8 --- /dev/null +++ b/podkop/files/usr/lib/providers/nfqueue/validator.uc @@ -0,0 +1,382 @@ +#!/usr/bin/env ucode + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function normalize_strategy_whitespace(value) { + value = replace(as_string(value), /[\t\r\n]/g, " "); + value = replace(value, / +/g, " "); + value = replace(value, /^ /, ""); + return replace(value, / $/, ""); +} + +function strategy_or_default(value, default_value) { + value = as_string(value); + if (value == "") + value = as_string(default_value); + return normalize_strategy_whitespace(value); +} + +function print_strategy_or_default(value, default_value) { + print(strategy_or_default(value, default_value), "\n"); +} + +function words(value) { + value = normalize_strategy_whitespace(value); + return value == "" ? [] : split(value, " "); +} + +function contains(values, needle) { + for (let value in values) + if (value == needle) + return true; + return false; +} + +function starts_with(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function split_option_token(token) { + let equals = index(token, "="); + return equals >= 0 ? substr(token, 0, equals) : token; +} + +function validation_failure(message, needles) { + needles = type(needles) == "array" ? needles : []; + return { + valid: false, + message: as_string(message), + needle: length(needles) > 0 ? as_string(needles[0]) : "", + needles + }; +} + +function validation_success() { + return { + valid: true, + message: "", + needle: "", + needles: [] + }; +} + +let nfqws_optional = [ + "--debug", "--comment", "--synack-split", "--ctrack-disable", "--ipcache-hostname", + "--dup-autottl", "--dup-autottl6", "--dup-tcp-flags-set", "--dup-tcp-flags-unset", + "--dup-replace", "--orig-autottl", "--orig-autottl6", "--orig-tcp-flags-set", + "--orig-tcp-flags-unset", "--dpi-desync-autottl", "--dpi-desync-autottl6", + "--dpi-desync-tcp-flags-set", "--dpi-desync-tcp-flags-unset", + "--dpi-desync-skip-nosni", "--dpi-desync-any-protocol" +]; + +let nfqws_none = [ + "--dry-run", "--version", "--daemon", "--hostcase", "--hostnospace", "--domcase", + "--methodeol", "--new", "--skip", "--bind-fix4", "--bind-fix6" +]; + +let nfqws_required = [ + "--qnum", "--pidfile", "--user", "--uid", "--wsize", "--wssize", "--wssize-cutoff", + "--wssize-forced-cutoff", "--ctrack-timeouts", "--ipcache-lifetime", + "--hostspell", "--ip-id", "--dpi-desync", "--dpi-desync-fwmark", "--dup", + "--dup-ttl", "--dup-ttl6", "--dup-fooling", "--dup-ts-increment", + "--dup-badseq-increment", "--dup-badack-increment", "--dup-ip-id", + "--dup-start", "--dup-cutoff", "--orig-ttl", "--orig-ttl6", + "--orig-mod-start", "--orig-mod-cutoff", "--dpi-desync-ttl", + "--dpi-desync-ttl6", "--dpi-desync-fooling", "--dpi-desync-repeats", + "--dpi-desync-split-pos", "--dpi-desync-split-http-req", + "--dpi-desync-split-tls", "--dpi-desync-split-seqovl", + "--dpi-desync-split-seqovl-pattern", "--dpi-desync-fakedsplit-pattern", + "--dpi-desync-fakedsplit-mod", "--dpi-desync-hostfakesplit-midhost", + "--dpi-desync-hostfakesplit-mod", "--dpi-desync-ipfrag-pos-tcp", + "--dpi-desync-ipfrag-pos-udp", "--dpi-desync-ts-increment", + "--dpi-desync-badseq-increment", "--dpi-desync-badack-increment", + "--dpi-desync-fake-tcp-mod", "--dpi-desync-fake-http", + "--dpi-desync-fake-tls", "--dpi-desync-fake-tls-mod", + "--dpi-desync-fake-unknown", "--dpi-desync-fake-syndata", + "--dpi-desync-fake-quic", "--dpi-desync-fake-wireguard", + "--dpi-desync-fake-dht", "--dpi-desync-fake-discord", + "--dpi-desync-fake-stun", "--dpi-desync-fake-unknown-udp", + "--dpi-desync-udplen-increment", "--dpi-desync-udplen-pattern", + "--dpi-desync-cutoff", "--dpi-desync-start", "--hostlist", + "--hostlist-domains", "--hostlist-exclude", "--hostlist-exclude-domains", + "--hostlist-auto", "--hostlist-auto-fail-threshold", + "--hostlist-auto-fail-time", "--hostlist-auto-retrans-threshold", + "--hostlist-auto-debug", "--filter-l3", "--filter-tcp", "--filter-udp", + "--filter-l7", "--ipset", "--ipset-ip", "--ipset-exclude", + "--ipset-exclude-ip" +]; + +let nfqws2_optional = [ + "--debug", "--comment", "--intercept", "--chdir", "--ctrack-disable", "--payload-disable", + "--server", "--ipcache-hostname", "--reasm-disable", "--writeable", "--new", "--template", + "--hostlist-auto-retrans-reset" +]; + +let nfqws2_none = [ + "--dry-run", "--version", "--daemon", "--skip", "--bind-fix4", "--bind-fix6" +]; + +let nfqws2_required = [ + "--qnum", "--pidfile", "--user", "--uid", "--ctrack-timeouts", "--ipcache-lifetime", + "--fwmark", "--fuzz", "--blob", "--lua-init", "--lua-gc", "--hostlist", + "--hostlist-domains", "--hostlist-exclude", "--hostlist-exclude-domains", + "--hostlist-auto", "--hostlist-auto-fail-threshold", "--hostlist-auto-fail-time", + "--hostlist-auto-retrans-threshold", "--hostlist-auto-retrans-maxseq", + "--hostlist-auto-incoming-maxseq", "--hostlist-auto-udp-in", "--hostlist-auto-udp-out", + "--hostlist-auto-debug", "--name", "--import", "--cookie", "--filter-l3", "--filter-tcp", + "--filter-udp", "--filter-icmp", "--filter-ipp", "--filter-l7", "--filter-ssid", + "--ipset", "--ipset-ip", "--ipset-exclude", "--ipset-exclude-ip", + "--payload", "--in-range", "--out-range", "--lua-desync" +]; + +function option_mode(kind, token) { + if (kind == "nfqws") { + if (contains(nfqws_optional, token)) + return "optional"; + if (contains(nfqws_none, token)) + return "none"; + if (contains(nfqws_required, token)) + return "required"; + return "unknown"; + } + + if (contains(nfqws2_optional, token)) + return "optional"; + if (contains(nfqws2_none, token)) + return "none"; + if (contains(nfqws2_required, token)) + return "required"; + return "unknown"; +} + +function unsupported_reason(kind, token) { + let name = kind == "nfqws2" ? "nfqws2" : "nfqws"; + let action = kind == "nfqws2" ? "zapret2" : "zapret"; + + if (token == "" || token == "") + return "Podkop Plus does not expand " + action + " hostlist templates in per-rule strategies because sing-box already selects the resources before NFQUEUE."; + + let base = split_option_token(token); + if (contains([ + "--hostlist", "--hostlist-domains", "--hostlist-exclude", "--hostlist-exclude-domains", + "--hostlist-auto", "--hostlist-auto-fail-threshold", "--hostlist-auto-fail-time", + "--hostlist-auto-retrans-threshold", "--hostlist-auto-debug" + ], base) || (kind == "nfqws2" && contains([ + "--hostlist-auto-retrans-maxseq", "--hostlist-auto-retrans-reset", + "--hostlist-auto-incoming-maxseq", "--hostlist-auto-udp-in", "--hostlist-auto-udp-out" + ], base))) + return "Hostname-based selection inside " + name + " is incompatible with the Podkop Plus architecture because sing-box already chooses which resources enter action=" + action + "."; + + if (contains([ "--ipset", "--ipset-ip", "--ipset-exclude", "--ipset-exclude-ip" ], base)) + return "IP or CIDR selection inside " + name + " is incompatible with the Podkop Plus architecture because sing-box already chooses which resources enter action=" + action + "."; + + if (base == "--qnum") + return "The NFQUEUE number is assigned by Podkop Plus per rule and must not be overridden in the strategy."; + + if (kind == "nfqws" && base == "--dpi-desync-fwmark") + return "The desync fwmark is managed by Podkop Plus for loop prevention and must not be overridden in the strategy."; + + if (kind == "nfqws2" && (base == "--fwmark" || base == "--dpi-desync-fwmark")) + return "The nfqws2 fwmark is managed by Podkop Plus for loop prevention and must not be overridden in the strategy."; + + if (kind == "nfqws2" && base == "--fuzz") + return "nfqws2 fuzz mode disables normal interception and is incompatible with Podkop Plus-managed action=zapret2 rules."; + + if (kind == "nfqws2" && token == "--intercept=0") + return "nfqws2 interception must stay enabled for Podkop Plus-managed action=zapret2 rules."; + + if (base == "--daemon") + return "Podkop Plus manages the " + name + " process lifecycle itself. The strategy must not daemonize " + name + "."; + if (base == "--dry-run") + return "The strategy must launch a working " + name + " process. --dry-run exits immediately and is not allowed."; + if (base == "--version") + return "The strategy must launch a working " + name + " process. --version exits immediately and is not allowed."; + + return null; +} + +function token_has_external_config_prefix(token) { + return starts_with(token, "@") || starts_with(token, "$"); +} + +function next_is_separate_value(next_token) { + return as_string(next_token) != "" && !starts_with(next_token, "--"); +} + +function label(kind) { + return kind == "nfqws2" ? "NFQWS2" : "NFQWS"; +} + +function validate_strategy(kind, raw_opt, legacy_default) { + kind = as_string(kind); + if (kind != "nfqws" && kind != "nfqws2") + return validation_failure("NFQUEUE validator accepts only nfqws or nfqws2 strategies.", [ kind ]); + + raw_opt = normalize_strategy_whitespace(raw_opt); + + if (kind == "nfqws" && raw_opt == as_string(legacy_default)) + return validation_success(); + + let tokens = words(raw_opt); + let token_count = 0; + + for (let i = 0; i < length(tokens); ) { + let token = as_string(tokens[i]); + let next_token = as_string(tokens[i + 1]); + let base_token = split_option_token(token); + + if (token_count == 0 && token_has_external_config_prefix(token)) + return validation_failure( + "Unsupported " + label(kind) + " token '" + token + "': External " + (kind == "nfqws2" ? "nfqws2" : "nfqws") + " config files bypass Podkop Plus validation and queue management.", + [ token ] + ); + + let reason = unsupported_reason(kind, token); + if (reason != null) { + let display = token; + let mode = option_mode(kind, base_token); + if (base_token == token && mode == "required" && next_is_separate_value(next_token)) { + display += " " + next_token; + return validation_failure("Unsupported " + label(kind) + " token '" + display + "': " + reason, [ base_token, next_token ]); + } + return validation_failure("Unsupported " + label(kind) + " token '" + display + "': " + reason, [ base_token ]); + } + + if (starts_with(token, "--")) { + let mode = option_mode(kind, base_token); + if (mode == "unknown") + return validation_failure("Unknown " + label(kind) + " flag '" + token + "'.", [ base_token ]); + + if (mode == "none") { + if (base_token != token) + return validation_failure(label(kind) + " flag '" + base_token + "' does not accept a value.", [ base_token ]); + i++; + token_count++; + continue; + } + + if (mode == "optional") { + if (base_token == token && next_is_separate_value(next_token)) + return validation_failure( + "Optional value for '" + base_token + "' must be attached as '" + base_token + "=value'. Separate tokens are ignored by " + kind + " here.", + [ base_token, next_token ] + ); + i++; + token_count++; + continue; + } + + if (base_token != token) { + i++; + token_count++; + continue; + } + + if (!next_is_separate_value(next_token)) + return validation_failure(label(kind) + " option '" + base_token + "' requires a value.", [ base_token ]); + + i += 2; + token_count += 2; + continue; + } + + return validation_failure( + "Unexpected standalone " + label(kind) + " token '" + token + "'. Use explicit flags such as --name or --name=value.", + [ token ] + ); + } + + return validation_success(); +} + +function validate_exit(kind, raw_opt, legacy_default) { + let result = validate_strategy(kind, raw_opt, legacy_default); + if (result.valid) + exit(0); + + print(result.message, "\n"); + exit(1); +} + +function validate_json(kind, raw_opt, legacy_default) { + let result = validate_strategy(kind, raw_opt, legacy_default); + write_json(result); + exit(result.valid ? 0 : 1); +} + +function expected_label(expected_kind) { + return expected_kind == "nfqws2" ? "Zapret2" : "Zapret"; +} + +function validate_expected_strategy(expected_kind, kind, raw_opt, legacy_default) { + expected_kind = as_string(expected_kind); + if (as_string(kind) != expected_kind) + return validation_failure(expected_label(expected_kind) + " validator accepts only " + expected_kind + " strategies.", [ as_string(kind) ]); + return validate_strategy(expected_kind, raw_opt, legacy_default); +} + +function validate_expected_exit(expected_kind, kind, raw_opt, legacy_default) { + let result = validate_expected_strategy(expected_kind, kind, raw_opt, legacy_default); + if (result.valid) + exit(0); + + print(result.message, "\n"); + exit(1); +} + +function validate_expected_json(expected_kind, kind, raw_opt, legacy_default) { + let result = validate_expected_strategy(expected_kind, kind, raw_opt, legacy_default); + write_json(result); + exit(result.valid ? 0 : 1); +} + +function run_expected(expected_kind, usage_path, argv) { + argv = type(argv) == "array" ? argv : []; + + let mode = argv[0] || ""; + + if (mode == "validate") + validate_expected_exit(expected_kind, argv[1], argv[2], argv[3]); + else if (mode == "validate-json") + validate_expected_json(expected_kind, argv[1], argv[2], argv[3]); + else if (mode == "strategy-or-default") + print_strategy_or_default(argv[1], argv[2]); + else { + warn("Usage: " + usage_path + "\n"); + exit(1); + } +} + +function module_exports() { + return { + normalize_strategy_whitespace, + strategy_or_default, + validate_strategy, + validate_expected_strategy, + run_expected + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +let mode = ARGV[0] || ""; + +if (mode == "validate") + validate_exit(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "validate-json") + validate_json(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "strategy-or-default") + print_strategy_or_default(ARGV[1], ARGV[2]); +else { + warn("Usage: providers/nfqueue/validator.uc [legacy-default]\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/providers/rules.uc b/podkop/files/usr/lib/providers/rules.uc new file mode 100644 index 00000000..3934539a --- /dev/null +++ b/podkop/files/usr/lib/providers/rules.uc @@ -0,0 +1,170 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; + +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 hex_digit_value(value) { + let pos = index("0123456789abcdef", lc(as_string(value))); + return pos >= 0 ? pos : null; +} + +function parse_number(value) { + value = lc(trim(as_string(value))); + if (value == "") + return null; + + if (substr(value, 0, 2) == "0x") { + value = substr(value, 2); + if (value == "") + return null; + + let result = 0; + for (let i = 0; i < length(value); i++) { + let digit = hex_digit_value(substr(value, i, 1)); + if (digit == null) + return null; + result = result * 16 + digit; + } + return result; + } + + return match(value, /^[0-9]+$/) == null ? null : int(value); +} + +function rule_rows() { + let rows = []; + + for (let line in split(read_stdin(), "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (trim(line) == "") + continue; + + let fields = split(line, "\t"); + push(rows, { + section: as_string(fields[0]), + enabled: as_string(fields[1]) == "1", + action: as_string(fields[2]) + }); + } + + return rows; +} + +function bool_value(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function uci_rule_rows() { + let rows = []; + + for (let section in uci_core.section_objects(CONFIG_NAME, "section")) { + let enabled = section.enabled == null ? true : bool_value(section.enabled); + push(rows, { + section: as_string(section[".name"]), + enabled, + action: as_string(section.action) + }); + } + return rows; +} + +function rows_for_source(source) { + return source == "uci" ? uci_rule_rows() : rule_rows(); +} + +function action_rule_count_from_rows(rows, action) { + let count = 0; + action = as_string(action); + + for (let row in rows) + if (row.enabled && row.action == action) + count++; + + return count; +} + +function action_rule_count(action, source) { + print(action_rule_count_from_rows(rows_for_source(source), action), "\n"); +} + +function action_rule_index_from_rows(rows, action, target_section) { + let index = 0; + action = as_string(action); + target_section = as_string(target_section); + + for (let row in rows) { + if (!row.enabled || row.action != action) + continue; + + index++; + if (row.section == target_section) + return index; + } + + return 0; +} + +function action_rule_index(action, target_section, source) { + print(action_rule_index_from_rows(rows_for_source(source), action, target_section), "\n"); +} + +function arithmetic_number(base_text, index_text, offset) { + let base = parse_number(base_text); + let index = parse_number(index_text); + + if (base == null || index == null) + exit(1); + + return base + index + offset; +} + +function mark_value(base_text, index_text) { + print(arithmetic_number(base_text, index_text, 0), "\n"); +} + +function mark_hex(base_text, index_text) { + print(sprintf("0x%08x", arithmetic_number(base_text, index_text, 0)), "\n"); +} + +function offset_number(base_text, index_text) { + print(arithmetic_number(base_text, index_text, -1), "\n"); +} + +let mode = ARGV[0] || ""; + +if (mode == "count") + action_rule_count(ARGV[1]); +else if (mode == "count-uci") + action_rule_count(ARGV[1], "uci"); +else if (mode == "index") + action_rule_index(ARGV[1], ARGV[2]); +else if (mode == "index-uci") + action_rule_index(ARGV[1], ARGV[2], "uci"); +else if (mode == "has-enabled-uci") + exit(action_rule_count_from_rows(uci_rule_rows(), ARGV[1]) > 0 ? 0 : 1); +else if (mode == "mark-value") + mark_value(ARGV[1], ARGV[2]); +else if (mode == "mark-hex") + mark_hex(ARGV[1], ARGV[2]); +else if (mode == "queue-number" || mode == "port-number") + offset_number(ARGV[1], ARGV[2]); +else { + warn("Usage: providers/rules.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/provider_status.uc b/podkop/files/usr/lib/providers/status.uc similarity index 99% rename from podkop/files/usr/lib/provider_status.uc rename to podkop/files/usr/lib/providers/status.uc index e99e4390..e5b8f95b 100644 --- a/podkop/files/usr/lib/provider_status.uc +++ b/podkop/files/usr/lib/providers/status.uc @@ -338,6 +338,6 @@ else if (mode == "zapret2-status") else if (mode == "zapret2-check") zapret2_check(ARGV[1], ARGV[2], ARGV[3]); else { - warn("Usage: provider_status.uc ...\n"); + warn("Usage: providers/status.uc ...\n"); exit(1); } diff --git a/podkop/files/usr/lib/providers/zapret/check.uc b/podkop/files/usr/lib/providers/zapret/check.uc new file mode 100644 index 00000000..d1745e88 --- /dev/null +++ b/podkop/files/usr/lib/providers/zapret/check.uc @@ -0,0 +1,5 @@ +#!/usr/bin/env ucode + +let check = require("providers.nfqueue.check"); + +check.run(ARGV, "providers/zapret/check.uc"); diff --git a/podkop/files/usr/lib/providers/zapret/common.uc b/podkop/files/usr/lib/providers/zapret/common.uc new file mode 100644 index 00000000..7d007cdb --- /dev/null +++ b/podkop/files/usr/lib/providers/zapret/common.uc @@ -0,0 +1,61 @@ +#!/usr/bin/env ucode + +let constants = require("core.constants"); +let validator_module = null; + +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; + +function validator() { + if (validator_module == null) + validator_module = require("providers.zapret.validator"); + return validator_module; +} + +function config(ctx) { + let runtime_constants = (ctx && ctx.constants) || constants; + let lib_dir = (ctx && ctx.lib_dir) || LIB_DIR; + + return { + kind: "zapret", + action: "zapret", + binary_name: "nfqws", + binary: getenv("ZAPRET_NFQWS_BIN") || runtime_constants.ZAPRET_NFQWS_BIN, + provider_bin: getenv("ZAPRET_PROVIDER_NFQWS_BIN") || runtime_constants.ZAPRET_PROVIDER_NFQWS_BIN, + provider_files_dir: getenv("ZAPRET_PROVIDER_FILES_DIR") || runtime_constants.ZAPRET_PROVIDER_FILES_DIR, + provider_ipset_dir: getenv("ZAPRET_PROVIDER_IPSET_DIR") || runtime_constants.ZAPRET_PROVIDER_IPSET_DIR, + state_dir: getenv("ZAPRET_STATE_DIR") || runtime_constants.ZAPRET_STATE_DIR, + pid_dir: getenv("ZAPRET_PID_DIR") || runtime_constants.ZAPRET_PID_DIR, + child_pid_dir: getenv("ZAPRET_CHILD_PID_DIR") || runtime_constants.ZAPRET_CHILD_PID_DIR, + log_dir: getenv("ZAPRET_LOG_DIR") || runtime_constants.ZAPRET_LOG_DIR, + route_mark_base: getenv("ZAPRET_ROUTE_MARK_BASE") || runtime_constants.ZAPRET_ROUTE_MARK_BASE, + queue_base: getenv("ZAPRET_QUEUE_BASE") || runtime_constants.ZAPRET_QUEUE_BASE, + queue_range_size: getenv("ZAPRET_QUEUE_RANGE_SIZE") || runtime_constants.ZAPRET_QUEUE_RANGE_SIZE, + respawn_delay: getenv("ZAPRET_NFQWS_RESPAWN_DELAY") || runtime_constants.ZAPRET_NFQWS_RESPAWN_DELAY, + desync_mark: getenv("ZAPRET_DESYNC_MARK") || runtime_constants.ZAPRET_DESYNC_MARK, + desync_mark_postnat: getenv("ZAPRET_DESYNC_MARK_POSTNAT") || runtime_constants.ZAPRET_DESYNC_MARK_POSTNAT, + default_strategy: getenv("ZAPRET_DEFAULT_NFQWS_OPT") || runtime_constants.ZAPRET_DEFAULT_NFQWS_OPT, + legacy_default_strategy: getenv("ZAPRET_LEGACY_DEFAULT_NFQWS_OPT") || runtime_constants.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT, + strategy_option: "nfqws_opt", + validator_kind: "nfqws", + validator, + package_name: "zapret", + runtime_path: lib_dir + "/providers/zapret/runtime.uc", + check_path: lib_dir + "/providers/zapret/check.uc", + luci_package: "luci-app-zapret", + luci_menu: "/usr/share/luci/menu.d/luci-app-zapret.json", + luci_acl: "/usr/share/rpcd/acl.d/luci-app-zapret.json", + service_init: "/etc/init.d/zapret", + config_name: "zapret", + legacy_runtime_base: getenv("ZAPRET_LEGACY_RUNTIME_BASE_DIR") || runtime_constants.ZAPRET_LEGACY_RUNTIME_BASE_DIR, + provider_base_dir: runtime_constants.ZAPRET_PROVIDER_BASE_DIR, + hostlist_dir: getenv("ZAPRET_HOSTLIST_DIR") || runtime_constants.ZAPRET_HOSTLIST_DIR, + status_label: "zapret", + check_prefix: "zapret", + base_args: [ "--dpi-desync-fwmark=" + (getenv("ZAPRET_DESYNC_MARK") || runtime_constants.ZAPRET_DESYNC_MARK) ] + }; +} + +return { + config, + validator +}; diff --git a/podkop/files/usr/lib/providers/zapret/runtime.uc b/podkop/files/usr/lib/providers/zapret/runtime.uc new file mode 100644 index 00000000..7d3e655d --- /dev/null +++ b/podkop/files/usr/lib/providers/zapret/runtime.uc @@ -0,0 +1,6 @@ +#!/usr/bin/env ucode + +let runtime = require("providers.nfqueue.runtime"); +let provider = require("providers.zapret.common"); + +runtime.run(provider, ARGV); diff --git a/podkop/files/usr/lib/providers/zapret/validator.uc b/podkop/files/usr/lib/providers/zapret/validator.uc new file mode 100644 index 00000000..780cdd8c --- /dev/null +++ b/podkop/files/usr/lib/providers/zapret/validator.uc @@ -0,0 +1,23 @@ +#!/usr/bin/env ucode + +let validator = require("providers.nfqueue.validator"); + +const KIND = "nfqws"; +const USAGE = "providers/zapret/validator.uc [legacy-default]"; + +function validate_strategy(kind, raw_opt, legacy_default) { + return validator.validate_expected_strategy(KIND, kind, raw_opt, legacy_default); +} + +function module_exports() { + return { + normalize_strategy_whitespace: validator.normalize_strategy_whitespace, + strategy_or_default: validator.strategy_or_default, + validate_strategy + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +validator.run_expected(KIND, USAGE, ARGV); diff --git a/podkop/files/usr/lib/providers/zapret2/check.uc b/podkop/files/usr/lib/providers/zapret2/check.uc new file mode 100644 index 00000000..f0f80e23 --- /dev/null +++ b/podkop/files/usr/lib/providers/zapret2/check.uc @@ -0,0 +1,5 @@ +#!/usr/bin/env ucode + +let check = require("providers.nfqueue.check"); + +check.run(ARGV, "providers/zapret2/check.uc"); diff --git a/podkop/files/usr/lib/providers/zapret2/common.uc b/podkop/files/usr/lib/providers/zapret2/common.uc new file mode 100644 index 00000000..f08902d9 --- /dev/null +++ b/podkop/files/usr/lib/providers/zapret2/common.uc @@ -0,0 +1,68 @@ +#!/usr/bin/env ucode + +let constants = require("core.constants"); +let validator_module = null; + +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; + +function validator() { + if (validator_module == null) + validator_module = require("providers.zapret2.validator"); + return validator_module; +} + +function config(ctx) { + let runtime_constants = (ctx && ctx.constants) || constants; + let lib_dir = (ctx && ctx.lib_dir) || LIB_DIR; + let desync_mark = getenv("ZAPRET2_DESYNC_MARK") || runtime_constants.ZAPRET2_DESYNC_MARK; + let provider_lua_dir = getenv("ZAPRET2_PROVIDER_LUA_DIR") || runtime_constants.ZAPRET2_PROVIDER_LUA_DIR; + + return { + kind: "zapret2", + action: "zapret2", + binary_name: "nfqws2", + binary: getenv("ZAPRET2_NFQWS2_BIN") || runtime_constants.ZAPRET2_NFQWS2_BIN, + provider_bin: getenv("ZAPRET2_PROVIDER_NFQWS2_BIN") || runtime_constants.ZAPRET2_PROVIDER_NFQWS2_BIN, + provider_files_dir: getenv("ZAPRET2_PROVIDER_FILES_DIR") || runtime_constants.ZAPRET2_PROVIDER_FILES_DIR, + provider_ipset_dir: getenv("ZAPRET2_PROVIDER_IPSET_DIR") || runtime_constants.ZAPRET2_PROVIDER_IPSET_DIR, + provider_lua_dir, + state_dir: getenv("ZAPRET2_STATE_DIR") || runtime_constants.ZAPRET2_STATE_DIR, + pid_dir: getenv("ZAPRET2_PID_DIR") || runtime_constants.ZAPRET2_PID_DIR, + child_pid_dir: getenv("ZAPRET2_CHILD_PID_DIR") || runtime_constants.ZAPRET2_CHILD_PID_DIR, + log_dir: getenv("ZAPRET2_LOG_DIR") || runtime_constants.ZAPRET2_LOG_DIR, + route_mark_base: getenv("ZAPRET2_ROUTE_MARK_BASE") || runtime_constants.ZAPRET2_ROUTE_MARK_BASE, + queue_base: getenv("ZAPRET2_QUEUE_BASE") || runtime_constants.ZAPRET2_QUEUE_BASE, + queue_range_size: getenv("ZAPRET2_QUEUE_RANGE_SIZE") || runtime_constants.ZAPRET2_QUEUE_RANGE_SIZE, + respawn_delay: getenv("ZAPRET2_NFQWS2_RESPAWN_DELAY") || runtime_constants.ZAPRET2_NFQWS2_RESPAWN_DELAY, + desync_mark, + desync_mark_postnat: getenv("ZAPRET2_DESYNC_MARK_POSTNAT") || runtime_constants.ZAPRET2_DESYNC_MARK_POSTNAT, + default_strategy: getenv("ZAPRET2_DEFAULT_NFQWS2_OPT") || runtime_constants.ZAPRET2_DEFAULT_NFQWS2_OPT, + legacy_default_strategy: "", + strategy_option: "nfqws2_opt", + validator_kind: "nfqws2", + validator, + package_name: "zapret2", + runtime_path: lib_dir + "/providers/zapret2/runtime.uc", + check_path: lib_dir + "/providers/zapret2/check.uc", + luci_package: "luci-app-zapret2", + luci_menu: "/usr/share/luci/menu.d/luci-app-zapret2.json", + luci_acl: "/usr/share/rpcd/acl.d/luci-app-zapret2.json", + service_init: "/etc/init.d/zapret2", + config_name: "zapret2", + legacy_runtime_base: "", + hostlist_dir: "", + status_label: "zapret2", + check_prefix: "zapret2", + base_args: [ + "--fwmark=" + desync_mark, + "--lua-init=@" + provider_lua_dir + "/zapret-lib.lua", + "--lua-init=@" + provider_lua_dir + "/zapret-antidpi.lua", + "--lua-init=@" + provider_lua_dir + "/zapret-auto.lua" + ] + }; +} + +return { + config, + validator +}; diff --git a/podkop/files/usr/lib/providers/zapret2/runtime.uc b/podkop/files/usr/lib/providers/zapret2/runtime.uc new file mode 100644 index 00000000..e9a30f53 --- /dev/null +++ b/podkop/files/usr/lib/providers/zapret2/runtime.uc @@ -0,0 +1,6 @@ +#!/usr/bin/env ucode + +let runtime = require("providers.nfqueue.runtime"); +let provider = require("providers.zapret2.common"); + +runtime.run(provider, ARGV); diff --git a/podkop/files/usr/lib/providers/zapret2/validator.uc b/podkop/files/usr/lib/providers/zapret2/validator.uc new file mode 100644 index 00000000..d947beb3 --- /dev/null +++ b/podkop/files/usr/lib/providers/zapret2/validator.uc @@ -0,0 +1,23 @@ +#!/usr/bin/env ucode + +let validator = require("providers.nfqueue.validator"); + +const KIND = "nfqws2"; +const USAGE = "providers/zapret2/validator.uc "; + +function validate_strategy(kind, raw_opt, legacy_default) { + return validator.validate_expected_strategy(KIND, kind, raw_opt, legacy_default); +} + +function module_exports() { + return { + normalize_strategy_whitespace: validator.normalize_strategy_whitespace, + strategy_or_default: validator.strategy_or_default, + validate_strategy + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + +validator.run_expected(KIND, USAGE, ARGV); diff --git a/podkop/files/usr/lib/rulesets.uc b/podkop/files/usr/lib/routing/rulesets.uc similarity index 94% rename from podkop/files/usr/lib/rulesets.uc rename to podkop/files/usr/lib/routing/rulesets.uc index c74532c0..9a77e807 100644 --- a/podkop/files/usr/lib/rulesets.uc +++ b/podkop/files/usr/lib/routing/rulesets.uc @@ -101,6 +101,13 @@ function create_source(path) { exit(1); } +function ruleset_tag(section, name, kind) { + kind = as_string(kind); + return kind == "" + ? as_string(section) + "-" + as_string(name) + "-ruleset" + : as_string(section) + "-" + as_string(name) + "-" + kind + "-ruleset"; +} + function patch_source_values(path, key, values) { let ruleset = object_or_empty(read_json_file(path)); values = array_or_empty(values); @@ -563,10 +570,31 @@ function has_rules(path) { return length(array_or_empty(ruleset.rules)) > 0; } +function module_exports() { + return { + create_source, + patch_source, + patch_source_values, + import_plain_list, + extract_ip_cidr, + extract_ip_cidr_nft_elements, + has_domain_matchers, + has_rules, + ruleset_tag, + read_json_file, + write_json_file + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + let mode = ARGV[0] || ""; if (mode == "create-source") create_source(ARGV[1]); +else if (mode == "tag") + print(ruleset_tag(ARGV[1], ARGV[2], ARGV[3]), "\n"); else if (mode == "patch-source") patch_source(ARGV[1], ARGV[2], ARGV[3]); else if (mode == "import-plain-list") @@ -580,6 +608,6 @@ else if (mode == "has-domain-matchers") else if (mode == "has-rules") exit(has_rules(ARGV[1]) ? 0 : 1); else { - warn("Usage: rulesets.uc ...\n"); + warn("Usage: routing/rulesets.uc ...\n"); exit(1); } diff --git a/podkop/files/usr/lib/rules_nft_runtime.sh b/podkop/files/usr/lib/rules_nft_runtime.sh deleted file mode 100644 index 5818bfdb..00000000 --- a/podkop/files/usr/lib/rules_nft_runtime.sh +++ /dev/null @@ -1,700 +0,0 @@ -# shellcheck shell=ash - -rules_nft_runtime_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rules_nft_runtime.uc" "$@" -} - -rules_nft_cache_enabled() { - case "${PODKOP_RULE_CONDITION_CACHE_ENABLED:-0}" in - 1 | true | yes) - return 0 - ;; - esac - - return 1 -} - -rules_nft_cache_init() { - if [ -z "${PODKOP_RULE_CONDITION_CACHE_DIR:-}" ]; then - if [ -z "${PODKOP_RULE_CONDITION_CACHE_CLEANED:-}" ]; then - rm -rf /tmp/podkop-plus-rule-cache.* 2>/dev/null || true - PODKOP_RULE_CONDITION_CACHE_CLEANED=1 - fi - PODKOP_RULE_CONDITION_CACHE_DIR="/tmp/podkop-plus-rule-cache.$$" - mkdir -p "$PODKOP_RULE_CONDITION_CACHE_DIR" 2>/dev/null || return 1 - fi -} - -rules_nft_cache_dir() { - rules_nft_cache_init || return 1 - - printf '%s\n' "$PODKOP_RULE_CONDITION_CACHE_DIR" -} - -rule_condition_cache_clear() { - [ -z "${PODKOP_RULE_CONDITION_CACHE_DIR:-}" ] || rm -rf "$PODKOP_RULE_CONDITION_CACHE_DIR" 2>/dev/null || true - rm -rf /tmp/podkop-plus-rule-cache.* 2>/dev/null || true - PODKOP_RULE_CONDITION_CACHE_DIR="" - PODKOP_RULE_CONDITION_CACHE_CLEANED="" -} - -rules_nft_cache_key_is_safe() { - case "$1" in - "" | *[!A-Za-z0-9_]*) - return 1 - ;; - esac - - return 0 -} - -rules_nft_cache_path() { - local namespace="$1" - local section="$2" - local key="$3" - local kind="${4:-value}" - local cache_dir - - rules_nft_cache_enabled || return 1 - rules_nft_cache_key_is_safe "$namespace" || return 1 - rules_nft_cache_key_is_safe "$section" || return 1 - rules_nft_cache_key_is_safe "$key" || return 1 - rules_nft_cache_key_is_safe "$kind" || return 1 - - rules_nft_cache_init || return 1 - cache_dir="$PODKOP_RULE_CONDITION_CACHE_DIR" - printf '%s/%s_%s_%s_%s\n' "$cache_dir" "$namespace" "$section" "$key" "$kind" -} - -rules_nft_cache_get() { - local path="$1" - local value - - [ -f "$path" ] || return 1 - value="" - IFS= read -r value < "$path" || true - printf '%s\n' "$value" -} - -rules_nft_cache_put() { - local path="$1" - local value="$2" - - printf '%s\n' "$value" > "$path" 2>/dev/null || return 1 -} - -normalize_port_condition_for_nft() { - rules_nft_runtime_ucode normalize-port-condition-for-nft "$1" -} - -is_port_condition() { - normalize_port_condition_for_nft "$1" >/dev/null 2>&1 -} - -br_netfilter_disable() { - if lsmod | rules_nft_runtime_ucode stdin-contains br_netfilter >/dev/null 2>&1 && - [ "$(sysctl -n net.bridge.bridge-nf-call-iptables 2> /dev/null)" = "1" ]; then - log "br_netfilter enabled detected. Disabling" - sysctl -w net.bridge.bridge-nf-call-iptables=0 - sysctl -w net.bridge.bridge-nf-call-ip6tables=0 - fi -} - -parse_generic_string_to_commas_string() { - local string="$1" - - [ -n "$string" ] || return 0 - rules_nft_runtime_ucode text-list-to-csv "$string" comma -} - -parse_domain_or_subnet_string_to_commas_string() { - local string="$1" - local type="$2" - - [ -n "$string" ] || return 0 - rules_nft_runtime_ucode domain-subnet-text-csv "$string" "$type" -} - -parse_domain_or_subnet_file_to_comma_string() { - local filepath="$1" - local type="$2" - - rules_nft_runtime_ucode domain-subnet-file-csv "$filepath" "$type" -} - -parse_combined_domain_string_to_commas_string() { - local string="$1" - local type="$2" - - [ -n "$string" ] || return 0 - rules_nft_runtime_ucode combined-domain-text-csv "$string" "$type" -} - -parse_combined_domain_list_to_commas_string() { - local list_value="$1" - local type="$2" - - [ -n "$list_value" ] || return 0 - rules_nft_runtime_ucode combined-domain-csv "$(rule_list_value_to_commas_string "$list_value")" "$type" -} - -split_domain_or_subnet_file() { - local filepath="$1" - local domains_output="$2" - local subnets_output="$3" - - rules_nft_runtime_ucode split-domain-subnet-file "$filepath" "$domains_output" "$subnets_output" -} - -rule_list_value_to_commas_string() { - printf '%s\n' "$1" | tr ' ' ',' -} - -get_legacy_domain_condition_commas_string() { - local section="$1" - local key="$2" - local kind="$3" - - local text_mode conditions_text_mode text_value list_value - config_get_bool text_mode "$section" "${key}_text_mode" 0 - config_get_bool conditions_text_mode "$section" "conditions_text_mode" 0 - config_get text_value "$section" "${key}_text" - config_get list_value "$section" "$key" - - if [ "$text_mode" -eq 1 ] || [ "$conditions_text_mode" -eq 1 ]; then - case "$kind" in - domains) parse_domain_or_subnet_string_to_commas_string "$text_value" "domains" ;; - *) - parse_generic_string_to_commas_string "$text_value" - ;; - esac - return 0 - fi - - if [ -n "$list_value" ]; then - rule_list_value_to_commas_string "$list_value" - return 0 - fi - - if [ -n "$text_value" ]; then - case "$kind" in - domains) parse_domain_or_subnet_string_to_commas_string "$text_value" "domains" ;; - *) - parse_generic_string_to_commas_string "$text_value" - ;; - esac - return 0 - fi - - rule_list_value_to_commas_string "$list_value" -} - -get_combined_domain_condition_commas_string() { - local section="$1" - local requested_key="$2" - local legacy_kind="$3" - - local combined_text_mode conditions_text_mode combined_text_value combined_list_value - local combined_text_values combined_list_values combined_values legacy_values - config_get_bool combined_text_mode "$section" "domain_suffix_text_mode" 0 - config_get_bool conditions_text_mode "$section" "conditions_text_mode" 0 - config_get combined_text_value "$section" "domain_suffix_text" - config_get combined_list_value "$section" "domain_suffix" - - combined_text_values="$(parse_combined_domain_string_to_commas_string "$combined_text_value" "$requested_key")" - combined_list_values="$(parse_combined_domain_list_to_commas_string "$combined_list_value" "$requested_key")" - if [ -n "$combined_text_values" ] && [ -n "$combined_list_values" ]; then - combined_values="${combined_text_values},${combined_list_values}" - else - combined_values="${combined_text_values}${combined_list_values}" - fi - - if [ "$requested_key" = "domain_suffix" ]; then - printf '%s\n' "$combined_values" - return 0 - fi - - legacy_values="$(get_legacy_domain_condition_commas_string "$section" "$requested_key" "$legacy_kind")" - if [ -n "$combined_values" ] && [ -n "$legacy_values" ]; then - printf '%s,%s\n' "$combined_values" "$legacy_values" - else - printf '%s%s\n' "$combined_values" "$legacy_values" - fi -} - -get_rule_condition_commas_string_uncached() { - local section="$1" - local key="$2" - local kind="$3" - - local text_mode conditions_text_mode text_value list_value - - case "$key" in - domain) - get_combined_domain_condition_commas_string "$section" "domain" "domains" - return 0 - ;; - domain_suffix) - get_combined_domain_condition_commas_string "$section" "domain_suffix" "domains" - return 0 - ;; - domain_keyword) - get_combined_domain_condition_commas_string "$section" "domain_keyword" "generic" - return 0 - ;; - domain_regex) - get_combined_domain_condition_commas_string "$section" "domain_regex" "generic" - return 0 - ;; - esac - - config_get_bool text_mode "$section" "${key}_text_mode" 0 - config_get_bool conditions_text_mode "$section" "conditions_text_mode" 0 - config_get text_value "$section" "${key}_text" - config_get list_value "$section" "$key" - - if [ "$text_mode" -eq 1 ] || [ "$conditions_text_mode" -eq 1 ]; then - case "$kind" in - domains) parse_domain_or_subnet_string_to_commas_string "$text_value" "domains" ;; - subnets) parse_domain_or_subnet_string_to_commas_string "$text_value" "subnets" ;; - *) - parse_generic_string_to_commas_string "$text_value" - ;; - esac - return 0 - fi - - if [ -n "$list_value" ]; then - rule_list_value_to_commas_string "$list_value" - return 0 - fi - - if [ -n "$text_value" ]; then - case "$kind" in - domains) parse_domain_or_subnet_string_to_commas_string "$text_value" "domains" ;; - subnets) parse_domain_or_subnet_string_to_commas_string "$text_value" "subnets" ;; - *) - parse_generic_string_to_commas_string "$text_value" - ;; - esac - return 0 - fi - - rule_list_value_to_commas_string "$list_value" -} - -get_rule_condition_commas_string() { - local section="$1" - local key="$2" - local kind="$3" - local cache_path value - - cache_path="$(rules_nft_cache_path condition "$section" "$key" "$kind" 2>/dev/null || true)" - if [ -n "$cache_path" ]; then - if value="$(rules_nft_cache_get "$cache_path")"; then - printf '%s\n' "$value" - return 0 - fi - fi - - value="$(get_rule_condition_commas_string_uncached "$section" "$key" "$kind")" - [ -z "$cache_path" ] || rules_nft_cache_put "$cache_path" "$value" || true - printf '%s\n' "$value" -} - -get_rule_condition_json_array() { - local section="$1" - local key="$2" - local kind="$3" - local values cache_path value - - cache_path="$(rules_nft_cache_path condition_json "$section" "$key" "$kind" 2>/dev/null || true)" - if [ -n "$cache_path" ]; then - if value="$(rules_nft_cache_get "$cache_path")"; then - printf '%s\n' "$value" - return 0 - fi - fi - - values="$(get_rule_condition_commas_string "$section" "$key" "$kind")" - if [ -n "$values" ]; then - value="$(rules_nft_runtime_ucode csv-to-json-array "$values")" - else - value="[]" - fi - [ -z "$cache_path" ] || rules_nft_cache_put "$cache_path" "$value" || true - printf '%s\n' "$value" -} - -get_rule_ports_commas_string_uncached() { - local section="$1" - local values text_value - - config_get values "$section" "ports" - config_get text_value "$section" "ports_text" - - [ -n "$values$text_value" ] || return 0 - rules_nft_runtime_ucode rule-ports-csv "$values" "$text_value" -} - -get_rule_ports_commas_string() { - local section="$1" - local cache_path value - - cache_path="$(rules_nft_cache_path ports "$section" ports value 2>/dev/null || true)" - if [ -n "$cache_path" ]; then - if value="$(rules_nft_cache_get "$cache_path")"; then - printf '%s\n' "$value" - return 0 - fi - fi - - value="$(get_rule_ports_commas_string_uncached "$section")" - [ -z "$cache_path" ] || rules_nft_cache_put "$cache_path" "$value" || true - printf '%s\n' "$value" -} - -get_rule_port_values_json_array() { - local section="$1" - local ports cache_path value - - cache_path="$(rules_nft_cache_path ports_json "$section" values value 2>/dev/null || true)" - if [ -n "$cache_path" ]; then - if value="$(rules_nft_cache_get "$cache_path")"; then - printf '%s\n' "$value" - return 0 - fi - fi - - ports="$(get_rule_ports_commas_string "$section")" - if [ -n "$ports" ]; then - value="$(rules_nft_runtime_ucode rule-port-values-json "$ports")" - else - value="[]" - fi - [ -z "$cache_path" ] || rules_nft_cache_put "$cache_path" "$value" || true - printf '%s\n' "$value" -} - -get_rule_port_ranges_json_array() { - local section="$1" - local ports cache_path value - - cache_path="$(rules_nft_cache_path ports_json "$section" ranges value 2>/dev/null || true)" - if [ -n "$cache_path" ]; then - if value="$(rules_nft_cache_get "$cache_path")"; then - printf '%s\n' "$value" - return 0 - fi - fi - - ports="$(get_rule_ports_commas_string "$section")" - if [ -n "$ports" ]; then - value="$(rules_nft_runtime_ucode rule-port-ranges-json "$ports")" - else - value="[]" - fi - [ -z "$cache_path" ] || rules_nft_cache_put "$cache_path" "$value" || true - printf '%s\n' "$value" -} - -section_has_destination_matchers() { - local section="$1" - local domain domain_suffix domain_keyword domain_regex ip_cidr community_lists rule_set rule_set_with_subnets domain_ip_lists - - domain="$(get_rule_condition_commas_string "$section" "domain" "domains")" - domain_suffix="$(get_rule_condition_commas_string "$section" "domain_suffix" "domains")" - domain_keyword="$(get_rule_condition_commas_string "$section" "domain_keyword" "generic")" - domain_regex="$(get_rule_condition_commas_string "$section" "domain_regex" "generic")" - ip_cidr="$(get_rule_condition_commas_string "$section" "ip_cidr" "subnets")" - config_get community_lists "$section" "community_lists" - config_get rule_set "$section" "rule_set" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - - [ -n "$domain" ] || - [ -n "$domain_suffix" ] || - [ -n "$domain_keyword" ] || - [ -n "$domain_regex" ] || - [ -n "$ip_cidr" ] || - [ -n "$community_lists" ] || - [ -n "$rule_set" ] || - [ -n "$rule_set_with_subnets" ] || - [ -n "$domain_ip_lists" ] -} - -add_section_ip_cidr_to_nft_sets() { - local section="$1" - local ip_values="$2" - local ports - - [ -n "$ip_values" ] || return 0 - - ports="$(get_rule_ports_commas_string "$section")" - if [ -n "$ports" ]; then - local tmpfile status - tmpfile="$(mktemp)" - rules_nft_runtime_ucode csv-to-lines-file "$ip_values" "$tmpfile" - nft_add_ip_port_set_elements_from_ip_file_chunked "$tmpfile" "$NFT_TABLE_NAME" "$NFT_IP_PORT_SET_NAME" "$ports" - status=$? - rm -f "$tmpfile" - return "$status" - else - nft_add_set_elements "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME" "$ip_values" - fi -} - -add_section_ports_to_nft_set_if_needed() { - local section="$1" - local ports - - ports="$(get_rule_ports_commas_string "$section")" - [ -n "$ports" ] || return 0 - - if section_has_destination_matchers "$section"; then - return 0 - fi - - nft_add_set_elements "$NFT_TABLE_NAME" "$NFT_PORT_SET_NAME" "$ports" -} - -ruleset_registered() { - local tag="$1" - rules_nft_runtime_ucode csv-list-contains "$configured_rulesets" "$tag" >/dev/null 2>&1 -} - -register_ruleset_tag() { - local tag="$1" - if ! ruleset_registered "$tag"; then - if [ -z "$configured_rulesets" ]; then - configured_rulesets="$tag" - else - configured_rulesets="$configured_rulesets,$tag" - fi - fi -} - -ENSURED_RULESET_TAG="" -ENSURED_RULESET_KIND="unknown" - -get_community_ruleset_url() { - local name="$1" - - case "$name" in - ads_hagezi_pro) - printf '%s\n' "$SRS_ADS_HAGEZI_PRO_URL" - ;; - supercell) - printf '%s\n' "$SRS_SUPERCELL_URL" - ;; - *) - printf '%s\n' "$SRS_MAIN_URL/$name.srs" - ;; - esac -} - -ensure_builtin_ruleset() { - local name="$1" - local tag="builtin-$name-ruleset" - - ENSURED_RULESET_TAG="$tag" - ENSURED_RULESET_KIND="domains" - - if ! ruleset_registered "$tag"; then - local detour update_interval url - detour="$(get_download_detour_tag)" - update_interval="$(get_remote_ruleset_update_interval)" - url="$(get_community_ruleset_url "$name")" - config=$(sing_box_cm_add_remote_ruleset "$config" "$tag" "binary" "$url" "$detour" "$update_interval") - register_ruleset_tag "$tag" - fi -} - -ensure_inline_custom_ruleset() { - local reference="$1" - local tag hash detour update_interval extension format - - hash="$(printf '%s' "$reference" | md5sum | helpers_ucode md5sum-hex-prefix 12)" - tag="inline-custom-$hash-ruleset" - ENSURED_RULESET_TAG="$tag" - ENSURED_RULESET_KIND="$(detect_inline_ruleset_reference_kind "$reference")" - - if ! ruleset_registered "$tag"; then - update_interval="$(get_remote_ruleset_update_interval)" - - case "$reference" in - /*.srs) - config=$(sing_box_cm_add_local_ruleset "$config" "$tag" "binary" "$reference") - ;; - /*.json) - config=$(sing_box_cm_add_local_ruleset "$config" "$tag" "source" "$reference") - ;; - http://* | https://*) - extension="$(url_get_file_extension "$reference")" - detour="$(get_download_detour_tag)" - case "$extension" in - srs) - config=$(sing_box_cm_add_remote_ruleset "$config" "$tag" "binary" "$reference" "$detour" "$update_interval") - ;; - json) - config=$(sing_box_cm_add_remote_ruleset "$config" "$tag" "source" "$reference" "$detour" "$update_interval") - ;; - *) - format="$(get_inline_remote_ruleset_format "$reference")" - config=$(sing_box_cm_add_remote_ruleset "$config" "$tag" "$format" "$reference" "$detour" "$update_interval") - ;; - esac - ;; - *) - return 1 - ;; - esac - - register_ruleset_tag "$tag" - fi -} - -resolve_ruleset_reference() { - local reference="$1" - ENSURED_RULESET_TAG="" - ENSURED_RULESET_KIND="unknown" - - if list_has_item "$COMMUNITY_SERVICES" "$reference"; then - ensure_builtin_ruleset "$reference" - return 0 - fi - - ensure_inline_custom_ruleset "$reference" -} - -# Main funcs - -route_table_rule_mark() { - rules_nft_runtime_ucode file-regex-matches /etc/iproute2/rt_tables "105 $RT_TABLE_NAME" >/dev/null 2>&1 || - echo "105 $RT_TABLE_NAME" >> /etc/iproute2/rt_tables - - if ! has_tproxy_route; then - log "Added route for tproxy" "debug" - ip route add local 0.0.0.0/0 dev lo table "$RT_TABLE_NAME" 2> /dev/null || { - if has_tproxy_route; then - log "Route for tproxy exists" "debug" - else - log "Failed to add route for tproxy. Aborted." "fatal" - exit 1 - fi - } - else - log "Route for tproxy exists" "debug" - fi - - if ! has_tproxy_marking_rule; then - log "Create marking rule" "debug" - ip -4 rule add fwmark "$NFT_FAKEIP_MARK"/"$NFT_FAKEIP_MARK" table "$RT_TABLE_NAME" priority 105 2> /dev/null || { - if has_tproxy_marking_rule; then - log "Marking rule exist" "debug" - else - log "Failed to create marking rule. Aborted." "fatal" - exit 1 - fi - } - else - log "Marking rule exist" "debug" - fi -} - -has_tproxy_route() { - ip route list table "$RT_TABLE_NAME" 2>/dev/null | - rules_nft_runtime_ucode has-local-default-route >/dev/null 2>&1 -} - -has_tproxy_marking_rule() { - ip -4 rule list 2>/dev/null | - rules_nft_runtime_ucode has-tproxy-marking-rule "$RT_TABLE_NAME" "$NFT_FAKEIP_MARK" >/dev/null 2>&1 -} - -nft_init_interfaces_set() { - nft_create_ifname_set "$NFT_TABLE_NAME" "$NFT_INTERFACE_SET_NAME" - - local source_network_interfaces - config_get source_network_interfaces "settings" "source_network_interfaces" "br-lan" - - for interface in $source_network_interfaces; do - nft add element inet "$NFT_TABLE_NAME" "$NFT_INTERFACE_SET_NAME" "{ $interface }" - done -} - -create_nft_rules() { - log "Create nft table" - nft_create_table "$NFT_TABLE_NAME" - - log "Create localv4 set" - nft_create_ipv4_set "$NFT_TABLE_NAME" "$NFT_LOCALV4_SET_NAME" - nft add element inet "$NFT_TABLE_NAME" localv4 '{ - 0.0.0.0/8, - 10.0.0.0/8, - 127.0.0.0/8, - 169.254.0.0/16, - 172.16.0.0/12, - 192.0.0.0/24, - 192.0.2.0/24, - 192.88.99.0/24, - 192.168.0.0/16, - 198.51.100.0/24, - 203.0.113.0/24, - 224.0.0.0/4, - 240.0.0.0-255.255.255.255 - }' - - log "Create common set" - nft_create_ipv4_set "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME" - - log "Create port set" - nft_create_inet_service_set "$NFT_TABLE_NAME" "$NFT_PORT_SET_NAME" - - log "Create IP and port set" - nft_create_ipv4_port_set "$NFT_TABLE_NAME" "$NFT_IP_PORT_SET_NAME" - - log "Create interface set" - nft_init_interfaces_set - - log "Create nft rules" - nft add chain inet "$NFT_TABLE_NAME" mangle '{ type filter hook prerouting priority -150; policy accept; }' - nft add chain inet "$NFT_TABLE_NAME" mangle_output '{ type route hook output priority -150; policy accept; }' - nft add chain inet "$NFT_TABLE_NAME" proxy '{ type filter hook prerouting priority -100; policy accept; }' - - nft add rule inet "$NFT_TABLE_NAME" mangle ct status dnat return - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "@$NFT_LOCALV4_SET_NAME" return - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "@$NFT_COMMON_SET_NAME" meta l4proto tcp meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "@$NFT_COMMON_SET_NAME" meta l4proto udp meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr . tcp dport "@$NFT_IP_PORT_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr . udp dport "@$NFT_IP_PORT_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr != "@$NFT_LOCALV4_SET_NAME" tcp dport "@$NFT_PORT_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr != "@$NFT_LOCALV4_SET_NAME" udp dport "@$NFT_PORT_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "$SB_FAKEIP_INET4_RANGE" meta l4proto tcp meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "$SB_FAKEIP_INET4_RANGE" meta l4proto udp meta mark set "$NFT_FAKEIP_MARK" counter - - nft add rule inet "$NFT_TABLE_NAME" proxy meta mark \& "$NFT_FAKEIP_MARK" == "$NFT_FAKEIP_MARK" meta l4proto tcp tproxy ip to :"$SB_TPROXY_INBOUND_PORT" counter - nft add rule inet "$NFT_TABLE_NAME" proxy meta mark \& "$NFT_FAKEIP_MARK" == "$NFT_FAKEIP_MARK" meta l4proto udp tproxy ip to :"$SB_TPROXY_INBOUND_PORT" counter - - nft add rule inet "$NFT_TABLE_NAME" mangle_output ip daddr "@$NFT_LOCALV4_SET_NAME" return - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark "$NFT_OUTBOUND_MARK" counter return - create_zapret_nft_rules - create_zapret2_nft_rules - nft add rule inet "$NFT_TABLE_NAME" mangle_output ip daddr "@$NFT_COMMON_SET_NAME" meta l4proto tcp meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle_output ip daddr "@$NFT_COMMON_SET_NAME" meta l4proto udp meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle_output ip daddr . tcp dport "@$NFT_IP_PORT_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle_output ip daddr . udp dport "@$NFT_IP_PORT_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle_output tcp dport "@$NFT_PORT_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle_output udp dport "@$NFT_PORT_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle_output ip daddr "$SB_FAKEIP_INET4_RANGE" meta l4proto tcp meta mark set "$NFT_FAKEIP_MARK" counter - nft add rule inet "$NFT_TABLE_NAME" mangle_output ip daddr "$SB_FAKEIP_INET4_RANGE" meta l4proto udp meta mark set "$NFT_FAKEIP_MARK" counter - - local exclude_ntp - config_get_bool exclude_ntp "settings" "exclude_ntp" "0" - if [ "$exclude_ntp" -eq 1 ]; then - log "NTP traffic exclude for proxy" - nft insert rule inet "$NFT_TABLE_NAME" mangle udp dport 123 return - fi -} - -. "$PODKOP_LIB/dnsmasq_runtime.sh" diff --git a/podkop/files/usr/lib/rules_nft_runtime.uc b/podkop/files/usr/lib/rules_nft_runtime.uc deleted file mode 100644 index 212da4f5..00000000 --- a/podkop/files/usr/lib/rules_nft_runtime.uc +++ /dev/null @@ -1,751 +0,0 @@ -#!/usr/bin/env ucode - -let fs = require("fs"); - -function as_string(value) { - return value == null ? "" : "" + value; -} - -function ascii_lower(value) { - let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - let lower = "abcdefghijklmnopqrstuvwxyz"; - return replace(as_string(value), /[A-Z]/g, function(ch) { - return substr(lower, index(upper, ch), 1); - }); -} - -function write_json(value) { - print(sprintf("%J", value), "\n"); -} - -function write_compact_string_array(values) { - print("["); - for (let i = 0; i < length(values); i++) { - if (i > 0) - print(","); - print(sprintf("%J", as_string(values[i]))); - } - print("]\n"); -} - -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 write_text_file(path, text) { - let result = fs.writefile(path, as_string(text)); - if (result == null) - return false; - if (type(result) == "boolean" && !result) - return false; - return true; -} - -function strip_list_comment(line) { - line = replace(as_string(line), /[[:space:]]*\/\/.*$/, ""); - return replace(line, /[[:space:]]*#.*$/, ""); -} - -function print_csv(values) { - for (let i = 0; i < length(values); i++) { - if (i > 0) - print(","); - print(as_string(values[i])); - } - if (length(values) > 0) - print("\n"); -} - -function text_list_values(value, separator_mode) { - let result = []; - separator_mode = as_string(separator_mode); - - for (let line in split(as_string(value), "\n")) { - line = strip_list_comment(line); - line = separator_mode == "comma-space" - ? replace(line, /[ ,]/g, "\n") - : replace(line, /,/g, "\n"); - - for (let item in split(line, "\n")) { - item = trim(replace(item, /\r/g, "")); - if (item != "") - push(result, item); - } - } - - return result; -} - -function text_list_to_csv(value, separator_mode) { - print_csv(text_list_values(value, separator_mode)); -} - -function csv_to_json_array(value) { - value = as_string(value); - if (value == "") { - print("[]\n"); - return; - } - - write_compact_string_array(split(value, ",")); -} - -function csv_list_contains(value, needle) { - needle = as_string(needle); - if (needle == "") - return false; - - for (let item in split(as_string(value), ",")) { - if (item == needle) - return true; - } - - return false; -} - -function stdin_contains(needle) { - return index(read_stdin(), as_string(needle)) >= 0; -} - -function valid_ipv4(value) { - value = as_string(value); - if (match(value, /^([0-9]{1,3}\.){3}[0-9]{1,3}$/) == null) - return false; - - let parts = split(value, "."); - if (length(parts) != 4) - return false; - - for (let part in parts) { - if (part == "" || match(part, /^[0-9]+$/) == null) - return false; - let octet = int(part); - if (octet < 0 || octet > 255) - return false; - } - - return true; -} - -function valid_ipv4_cidr(value) { - value = as_string(value); - let slash = index(value, "/"); - if (slash < 0 || index(substr(value, slash + 1), "/") >= 0) - return false; - - let address = substr(value, 0, slash); - let prefix = substr(value, slash + 1); - if (prefix == "" || match(prefix, /^[0-9]+$/) == null) - return false; - - prefix = int(prefix); - return valid_ipv4(address) && prefix >= 0 && prefix <= 32; -} - -function valid_domain_suffix(value) { - value = ascii_lower(value); - if (substr(value, 0, 1) == ".") - value = substr(value, 1); - - return match(value, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/) != null; -} - -function decimal_without_leading_zero(value) { - value = as_string(value); - return value != "" && match(value, /^[0-9]+$/) != null && (length(value) == 1 || substr(value, 0, 1) != "0"); -} - -function nft_ipv4(value, allow_trailing_dot) { - value = as_string(value); - if (allow_trailing_dot && length(value) > 0 && substr(value, length(value) - 1, 1) == ".") - value = substr(value, 0, length(value) - 1); - - let parts = split(value, "."); - if (length(parts) != 4) - return false; - - for (let part in parts) { - if (!decimal_without_leading_zero(part)) - return false; - - let octet = int(part); - if (octet < 0 || octet > 255) - return false; - } - - return true; -} - -function nft_ipv4_cidr(value) { - value = as_string(value); - let slash = index(value, "/"); - if (slash <= 0 || index(substr(value, slash + 1), "/") >= 0) - return false; - - let prefix = substr(value, slash + 1); - if (!decimal_without_leading_zero(prefix)) - return false; - - let prefix_number = int(prefix); - return nft_ipv4(substr(value, 0, slash), false) && prefix_number >= 0 && prefix_number <= 32; -} - -function nft_ip_or_cidr(value) { - return nft_ipv4(value, true) || nft_ipv4_cidr(value); -} - -function domain_subnet_line_values(data) { - let result = []; - - for (let line in split(as_string(data), "\n")) { - line = trim(replace(strip_list_comment(line), /\r/g, "")); - if (line != "") - push(result, line); - } - - return result; -} - -function domain_subnet_value_valid(value, kind) { - kind = as_string(kind); - if (kind == "domains") - return valid_domain_suffix(value); - if (kind == "subnets") - return valid_ipv4(value) || valid_ipv4_cidr(value); - - exit(1); -} - -function prefixed_domain_value(value, requested_kind) { - value = trim(as_string(value)); - requested_kind = as_string(requested_kind); - - if (value == "") - return null; - - let colon = index(value, ":"); - let prefix = ""; - let body = value; - if (colon > 0) { - let candidate = ascii_lower(substr(value, 0, colon)); - if (candidate == "full" || candidate == "keyword" || candidate == "regex") { - prefix = candidate; - body = substr(value, colon + 1); - } - else { - return null; - } - } - - body = trim(body); - if (body == "") - return null; - - if (prefix == "") { - if (requested_kind != "domain_suffix") - return null; - let normalized = ascii_lower(body); - return valid_domain_suffix(normalized) ? normalized : null; - } - - if (prefix == "full") { - if (requested_kind != "domain") - return null; - let normalized = ascii_lower(body); - return valid_domain_suffix(normalized) ? normalized : null; - } - - if (prefix == "keyword") - return requested_kind == "domain_keyword" ? body : null; - - return requested_kind == "domain_regex" ? body : null; -} - -function normalize_domain_subnet_value(value, kind) { - kind = as_string(kind); - if (kind == "domains") { - let normalized = ascii_lower(value); - return valid_domain_suffix(normalized) ? normalized : null; - } - if (kind == "subnets") - return valid_ipv4(value) || valid_ipv4_cidr(value) ? value : null; - - exit(1); -} - -function filter_domain_subnet_values(values, kind) { - let result = []; - kind = as_string(kind); - - if (kind != "domains" && kind != "subnets") - exit(1); - - for (let value in values) { - let normalized = normalize_domain_subnet_value(value, kind); - if (normalized != null) - push(result, normalized); - } - - return result; -} - -function combined_domain_text_csv(value, requested_kind) { - let result = []; - - for (let item in text_list_values(value, "comma-space")) { - let normalized = prefixed_domain_value(item, requested_kind); - if (normalized != null) - push(result, normalized); - } - - print_csv(result); -} - -function combined_domain_csv(value, requested_kind) { - let result = []; - - for (let item in split(as_string(value), ",")) { - let normalized = prefixed_domain_value(item, requested_kind); - if (normalized != null) - push(result, normalized); - } - - print_csv(result); -} - -function domain_subnet_text_csv(value, kind) { - print_csv(filter_domain_subnet_values(text_list_values(value, "comma-space"), kind)); -} - -function domain_subnet_file_csv(path, kind) { - let data = fs.readfile(path); - if (data == null) - exit(1); - - print_csv(filter_domain_subnet_values(domain_subnet_line_values(data), kind)); -} - -function split_domain_subnet_file(path, domains_path, subnets_path) { - let data = fs.readfile(path); - if (data == null) - exit(1); - - let domains = []; - let subnets = []; - - for (let value in domain_subnet_line_values(data)) { - let domain = normalize_domain_subnet_value(value, "domains"); - if (domain != null) - push(domains, domain); - else if (valid_ipv4(value) || valid_ipv4_cidr(value)) - push(subnets, value); - } - - if (!write_text_file(domains_path, length(domains) > 0 ? join("\n", domains) + "\n" : "")) - exit(1); - if (!write_text_file(subnets_path, length(subnets) > 0 ? join("\n", subnets) + "\n" : "")) - exit(1); -} - -function normalize_port_number_value(value) { - value = trim(as_string(value)); - if (value == "" || match(value, /^[0-9]+$/) == null) - return null; - - let number = int(value); - return number >= 1 && number <= 65535 ? number : null; -} - -function normalize_port_condition_value(value) { - value = trim(as_string(value)); - if (value == "") - return null; - - let dash = index(value, "-"); - if (dash < 0) { - let number = normalize_port_number_value(value); - return number == null ? null : as_string(number); - } - - let start = normalize_port_number_value(substr(value, 0, dash)); - let end = normalize_port_number_value(substr(value, dash + 1)); - if (start == null || end == null || start > end) - return null; - - return start == end ? as_string(start) : sprintf("%d-%d", start, end); -} - -function normalize_port_condition_for_nft(value) { - let normalized = normalize_port_condition_value(value); - if (normalized == null) - exit(1); - print(normalized, "\n"); -} - -function normalize_port_range_value(value) { - value = trim(as_string(value)); - let dash = index(value, "-"); - if (dash < 0) - return null; - - let start = normalize_port_number_value(substr(value, 0, dash)); - let end = normalize_port_number_value(substr(value, dash + 1)); - if (start == null || end == null || start > end) - return null; - - return sprintf("%d:%d", start, end); -} - -function append_unique_port(result, seen, item) { - let normalized = normalize_port_condition_value(item); - if (normalized == null || seen[normalized]) - return; - - seen[normalized] = true; - push(result, normalized); -} - -function rule_ports_csv(list_values, text_value) { - let result = []; - let seen = {}; - let normalized_list = replace(as_string(list_values), /[[:space:]]+/g, "\n"); - - for (let item in split(normalized_list, "\n")) - append_unique_port(result, seen, item); - - for (let item in text_list_values(text_value, "comma-space")) - append_unique_port(result, seen, item); - - print_csv(result); -} - -function rule_port_values_json(csv) { - let result = []; - - for (let item in split(as_string(csv), ",")) { - if (index(item, "-") >= 0) - continue; - - let port = normalize_port_number_value(item); - if (port != null) - push(result, port); - } - - write_json(result); -} - -function rule_port_ranges_json(csv) { - let result = []; - - for (let item in split(as_string(csv), ",")) { - if (index(item, "-") < 0) - continue; - - let range = normalize_port_range_value(item); - if (range != null) - push(result, range); - } - - write_json(result); -} - -function csv_to_lines_file(csv, path) { - if (!fs.writefile(path, replace(as_string(csv) + "\n", /,/g, "\n"))) - exit(1); -} - -function nft_write_chunk(chunks, chunk) { - if (length(chunk) > 0) - push(chunks, "" + length(chunk) + "\t" + join(",", chunk)); -} - -function nft_push_chunk_value(chunks, chunk, value, chunk_size) { - push(chunk, value); - if (length(chunk) < chunk_size) - return chunk; - - nft_write_chunk(chunks, chunk); - return []; -} - -function nft_invalid(invalid, value, message) { - push(invalid, as_string(value) + "\t" + message); -} - -function str_last_index(value, needle) { - value = as_string(value); - needle = as_string(needle); - let result = -1; - let start = 0; - - while (true) { - let offset = index(substr(value, start), needle); - if (offset < 0) - return result; - - result = start + offset; - start = result + length(needle); - } -} - -function nft_trimmed_lines(path) { - let data = fs.readfile(path); - if (data == null) - exit(1); - - let result = []; - for (let line in split(as_string(data), "\n")) { - line = trim(replace(as_string(line), /\r/g, "")); - if (line != "") - push(result, line); - } - - return result; -} - -function nft_chunk_size(value) { - value = int(value || 5000); - return value > 0 ? value : 5000; -} - -function nft_prepare_chunks(path, kind, ports_csv, chunk_size_text, chunks_path, invalid_path) { - let chunk_size = nft_chunk_size(chunk_size_text); - let chunks = []; - let invalid = []; - let chunk = []; - let ports = split(as_string(ports_csv), ","); - - for (let line in nft_trimmed_lines(path)) { - if (kind == "ports") { - let port = normalize_port_condition_value(line); - if (port == null) { - nft_invalid(invalid, line, "is not a valid port or port range"); - continue; - } - chunk = nft_push_chunk_value(chunks, chunk, port, chunk_size); - continue; - } - - if (kind == "ip-ports") { - let separator = index(line, " . "); - let last_separator = str_last_index(line, " . "); - if (separator < 0 || last_separator < 0) { - nft_invalid(invalid, line, "is not an IPv4/CIDR and port nft tuple"); - continue; - } - - let ip = substr(line, 0, separator); - let port = substr(line, last_separator + 3); - let original_port = port; - if (!nft_ip_or_cidr(ip)) { - nft_invalid(invalid, ip, "is not IPv4 or IPv4 CIDR"); - continue; - } - - port = normalize_port_condition_value(port); - if (port == null) { - nft_invalid(invalid, original_port, "is not a valid port or port range"); - continue; - } - - chunk = nft_push_chunk_value(chunks, chunk, ip + " . " + port, chunk_size); - continue; - } - - if (!nft_ip_or_cidr(line)) { - nft_invalid(invalid, line, "is not IPv4 or IPv4 CIDR"); - continue; - } - - if (kind == "ip-port-from-ip") { - for (let port in ports) { - if (port == "") - continue; - - let normalized = normalize_port_condition_value(port); - if (normalized == null) { - nft_invalid(invalid, port, "is not a valid port or port range"); - continue; - } - - chunk = nft_push_chunk_value(chunks, chunk, line + " . " + normalized, chunk_size); - } - } - else if (kind == "ips") { - chunk = nft_push_chunk_value(chunks, chunk, line, chunk_size); - } - else { - exit(1); - } - } - - nft_write_chunk(chunks, chunk); - - if (!write_text_file(chunks_path, length(chunks) > 0 ? join("\n", chunks) + "\n" : "")) - exit(1); - if (!write_text_file(invalid_path, length(invalid) > 0 ? join("\n", invalid) + "\n" : "")) - exit(1); -} - -function hex_digit_value(value) { - let pos = index("0123456789abcdef", lc(as_string(value))); - return pos >= 0 ? pos : null; -} - -function parse_mark_number(value) { - value = lc(trim(as_string(value))); - if (value == "") - return null; - - if (substr(value, 0, 2) == "0x") { - value = substr(value, 2); - if (value == "") - return null; - - let result = 0; - for (let i = 0; i < length(value); i++) { - let digit = hex_digit_value(substr(value, i, 1)); - if (digit == null) - return null; - result = result * 16 + digit; - } - return result; - } - - return match(value, /^[0-9]+$/) == null ? null : int(value); -} - -function normalized_fields(line) { - line = trim(replace(as_string(line), /\r/g, "")); - line = replace(line, /[[:space:]]+/g, " "); - return line == "" ? [] : split(line, " "); -} - -function rule_line_has_lookup_table(fields, table) { - table = as_string(table); - - for (let i = 0; i + 1 < length(fields); i++) - if (fields[i] == "lookup" && fields[i + 1] == table) - return true; - - return false; -} - -function rule_line_has_fwmark(fields, expected_mark) { - for (let i = 0; i + 1 < length(fields); i++) { - if (fields[i] != "fwmark") - continue; - - let parts = split(fields[i + 1], "/"); - if (length(parts) != 2) - continue; - - if (parse_mark_number(parts[0]) == expected_mark && parse_mark_number(parts[1]) == expected_mark) - return true; - } - - return false; -} - -function has_tproxy_marking_rule(table, mark) { - let rule_list = read_stdin(); - let expected_mark = parse_mark_number(mark); - let has_lookup = false; - let has_fwmark = false; - - if (expected_mark == null) - exit(1); - - for (let line in split(rule_list, "\n")) { - let fields = normalized_fields(line); - if (length(fields) == 0) - continue; - - if (!has_lookup && rule_line_has_lookup_table(fields, table)) - has_lookup = true; - if (!has_fwmark && rule_line_has_fwmark(fields, expected_mark)) - has_fwmark = true; - - if (has_lookup && has_fwmark) - return true; - } - - return false; -} - -function has_local_default_route() { - for (let line in split(read_stdin(), "\n")) { - line = trim(replace(as_string(line), /\r/g, "")); - line = replace(line, /[[:space:]]+/g, " "); - if (index(line, "local default dev lo scope host") >= 0) - return true; - } - - return false; -} - -function file_regex_matches(path, pattern) { - let data = fs.readfile(as_string(path)); - if (data == null) - return null; - - try { - return match(data, regexp(as_string(pattern))) != null; - } - catch (e) { - return false; - } -} - -function file_regex_matches_exit(path, pattern) { - let result = file_regex_matches(path, pattern); - exit(result == null ? 2 : (result ? 0 : 1)); -} - -let mode = ARGV[0] || ""; - -if (mode == "text-list-to-csv") - text_list_to_csv(ARGV[1], ARGV[2]); -else if (mode == "csv-to-json-array") - csv_to_json_array(ARGV[1]); -else if (mode == "csv-list-contains") - exit(csv_list_contains(ARGV[1], ARGV[2]) ? 0 : 1); -else if (mode == "stdin-contains") - exit(stdin_contains(ARGV[1]) ? 0 : 1); -else if (mode == "domain-subnet-text-csv") - domain_subnet_text_csv(ARGV[1], ARGV[2]); -else if (mode == "combined-domain-text-csv") - combined_domain_text_csv(ARGV[1], ARGV[2]); -else if (mode == "combined-domain-csv") - combined_domain_csv(ARGV[1], ARGV[2]); -else if (mode == "domain-subnet-file-csv") - domain_subnet_file_csv(ARGV[1], ARGV[2]); -else if (mode == "split-domain-subnet-file") - split_domain_subnet_file(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "normalize-port-condition-for-nft") - normalize_port_condition_for_nft(ARGV[1]); -else if (mode == "rule-ports-csv") - rule_ports_csv(ARGV[1], ARGV[2]); -else if (mode == "rule-port-values-json") - rule_port_values_json(ARGV[1]); -else if (mode == "rule-port-ranges-json") - rule_port_ranges_json(ARGV[1]); -else if (mode == "csv-to-lines-file") - csv_to_lines_file(ARGV[1], ARGV[2]); -else if (mode == "nft-prepare-chunks") - nft_prepare_chunks(ARGV[1], ARGV[2], ARGV[3] || "", ARGV[4], ARGV[5], ARGV[6]); -else if (mode == "has-tproxy-marking-rule") - exit(has_tproxy_marking_rule(ARGV[1], ARGV[2]) ? 0 : 1); -else if (mode == "has-local-default-route") - exit(has_local_default_route() ? 0 : 1); -else if (mode == "file-regex-matches") - file_regex_matches_exit(ARGV[1], ARGV[2]); -else { - warn("Usage: rules_nft_runtime.uc ...\n"); - exit(1); -} diff --git a/podkop/files/usr/lib/rulesets.sh b/podkop/files/usr/lib/rulesets.sh deleted file mode 100644 index ae6f33a6..00000000 --- a/podkop/files/usr/lib/rulesets.sh +++ /dev/null @@ -1,166 +0,0 @@ -# Constructs and returns a ruleset tag using section, name, optional type, and a fixed postfix -get_ruleset_tag() { - local section="$1" - local name="$2" - local type="$3" - local postfix="ruleset" - - if [ -n "$type" ]; then - echo "$section-$name-$type-$postfix" - else - echo "$section-$name-$postfix" - fi -} - -# Creates a new ruleset JSON file if it doesn't already exist -create_source_rule_set() { - local ruleset_filepath="$1" - - if file_exists "$ruleset_filepath"; then - return 3 - fi - - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" create-source "$ruleset_filepath" -} - -####################################### -# Patch a source ruleset JSON file for sing-box by appending a new ruleset object containing the provided key -# and value. -# Arguments: -# filepath: path to the JSON file to patch -# key: the ruleset key to insert (e.g., "ip_cidr") -# value: a JSON array of values to assign to the key -# Example: -# patch_source_ruleset_rules "/tmp/sing-box/ruleset.json" "ip_cidr" '["1.1.1.1","2.2.2.2"]' -####################################### -patch_source_ruleset_rules() { - local filepath="$1" - local key="$2" - local value="$3" - - local tmpfile - tmpfile="$(mktemp)" || return 1 - - cp "$filepath" "$tmpfile" || { - rm -f "$tmpfile" - return 1 - } - - if ! ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" patch-source "$tmpfile" "$key" "$value"; then - rm -f "$tmpfile" - return 1 - fi - - mv "$tmpfile" "$filepath" -} - -log_plain_ruleset_import_counts() { - local counts_filepath="$1" - local ruleset_filepath="$2" - local noun="$3" - - [ -s "$counts_filepath" ] || return 0 - - while IFS= read -r count; do - [ -z "$count" ] && continue - log "Adding $count elements to $noun at $ruleset_filepath" "debug" - done < "$counts_filepath" -} - -log_plain_ruleset_import_invalid() { - local invalid_filepath="$1" - local message="$2" - - [ -s "$invalid_filepath" ] || return 0 - - while IFS= read -r line; do - [ -z "$line" ] && continue - log "'$line' $message" "debug" - done < "$invalid_filepath" -} - -# Imports a plain domain list into a ruleset in chunks, validating domains and appending them as domain_suffix rules -import_plain_domain_list_to_local_source_ruleset_chunked() { - local plain_list_filepath="$1" - local ruleset_filepath="$2" - local chunk_size="${3:-5000}" - - local invalid_filepath counts_filepath - invalid_filepath="$(mktemp)" || return 1 - counts_filepath="$(mktemp)" || { - rm -f "$invalid_filepath" - return 1 - } - - if ! ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" import-plain-list \ - "$plain_list_filepath" "$ruleset_filepath" "domain_suffix" "domains" "$chunk_size" \ - "$invalid_filepath" "$counts_filepath"; then - rm -f "$invalid_filepath" "$counts_filepath" - return 1 - fi - - log_plain_ruleset_import_counts "$counts_filepath" "$ruleset_filepath" "rule set" - log_plain_ruleset_import_invalid "$invalid_filepath" "is not a valid domain" - - rm -f "$invalid_filepath" "$counts_filepath" -} - -# Imports a plain IPv4/CIDR list into a ruleset in chunks, validating entries and appending them as ip_cidr rules -import_plain_subnet_list_to_local_source_ruleset_chunked() { - local plain_list_filepath="$1" - local ruleset_filepath="$2" - local chunk_size="${3:-5000}" - - local invalid_filepath counts_filepath - invalid_filepath="$(mktemp)" || return 1 - counts_filepath="$(mktemp)" || { - rm -f "$invalid_filepath" - return 1 - } - - if ! ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" import-plain-list \ - "$plain_list_filepath" "$ruleset_filepath" "ip_cidr" "subnets" "$chunk_size" \ - "$invalid_filepath" "$counts_filepath"; then - rm -f "$invalid_filepath" "$counts_filepath" - return 1 - fi - - log_plain_ruleset_import_counts "$counts_filepath" "$ruleset_filepath" "ruleset" - log_plain_ruleset_import_invalid "$invalid_filepath" "is not IPv4 or IPv4 CIDR" - - rm -f "$invalid_filepath" "$counts_filepath" -} - -# Decompiles a sing-box SRS binary file into a JSON ruleset file -decompile_binary_ruleset() { - local binary_filepath="$1" - local output_filepath="$2" - - log "Decompiling $binary_filepath to $output_filepath" "debug" - sing-box rule-set decompile "$binary_filepath" -o "$output_filepath" - if [ $? -ne 0 ]; then - log "Decompilation command failed for $binary_filepath" "error" - return 1 - fi -} - -# Extracts all ip_cidr entries from a JSON ruleset file and writes them to an output file. -extract_ip_cidr_from_json_ruleset_to_file() { - local json_file="$1" - local output_file="$2" - - log "Extracting ip_cidr entries from $json_file to $output_file" "debug" - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" extract-ip-cidr "$json_file" "$output_file" -} - -extract_ip_cidr_nft_elements_from_json_ruleset_to_files() { - local json_file="$1" - local unscoped_output_file="$2" - local scoped_output_file="$3" - local ports_json="${4:-[]}" - local port_ranges_json="${5:-[]}" - - log "Extracting ip_cidr nft elements from $json_file to $unscoped_output_file and $scoped_output_file" "debug" - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" extract-ip-cidr-nft \ - "$json_file" "$unscoped_output_file" "$scoped_output_file" "$ports_json" "$port_ranges_json" -} diff --git a/podkop/files/usr/lib/runtime_state.sh b/podkop/files/usr/lib/runtime_state.sh deleted file mode 100644 index 9a98bb9f..00000000 --- a/podkop/files/usr/lib/runtime_state.sh +++ /dev/null @@ -1,1394 +0,0 @@ -# shellcheck shell=ash - -close_inherited_service_lock_fd() { - # OpenWrt init scripts may keep their service lock on fd 1000. - # shellcheck disable=SC3023 - exec 1000>&- 2>/dev/null || true -} - -acquire_runtime_dir_lock() { - local lock_dir="$1" - local owner_pid - - if mkdir "$lock_dir" 2>/dev/null; then - printf '%s\n' "$$" > "$lock_dir/pid" - return 0 - fi - - owner_pid="$(runtime_state_ucode file-first-line "$lock_dir/pid" 2>/dev/null)" - if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null; then - return 1 - fi - - rm -f "$lock_dir/pid" 2>/dev/null - rmdir "$lock_dir" 2>/dev/null || return 1 - mkdir "$lock_dir" 2>/dev/null || return 1 - printf '%s\n' "$$" > "$lock_dir/pid" -} - -acquire_runtime_dir_lock_wait() { - local lock_dir="$1" - local timeout="${2:-300}" - local start now elapsed - - start="$(date +%s 2>/dev/null)" - case "$start" in - '' | *[!0-9]*) start=0 ;; - esac - - while ! acquire_runtime_dir_lock "$lock_dir"; do - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) now="$start" ;; - esac - elapsed=$((now - start)) - [ "$elapsed" -lt "$timeout" ] || return 1 - sleep 2 - done - - return 0 -} - -release_runtime_dir_lock() { - local lock_dir="$1" - - rm -f "$lock_dir/pid" 2>/dev/null - rmdir "$lock_dir" 2>/dev/null -} - -runtime_state_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/runtime_state.uc" "$@" -} - -mark_pending_reload() { - local reason="${1:-pending}" - local now - - ensure_runtime_dirs - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) now=0 ;; - esac - - { - printf 'reason=%s\n' "$reason" - printf 'updated_at=%s\n' "$now" - } > "$PODKOP_PENDING_RELOAD_FILE" -} - -consume_pending_reload() { - [ -f "$PODKOP_PENDING_RELOAD_FILE" ] || return 1 - rm -f "$PODKOP_PENDING_RELOAD_FILE" 2>/dev/null -} - -run_pending_reload_if_requested() { - consume_pending_reload || return 0 - echolog "Applying pending Podkop Plus reload" - "$PODKOP_SERVICE_INIT" reload pending >/dev/null 2>&1 & -} - -sync_time_if_needed() { - local current_year - - current_year="$(date +%Y 2>/dev/null)" - case "$current_year" in - '' | *[!0-9]*) - return 0 - ;; - esac - - [ "$current_year" -lt 2024 ] || return 0 - - /usr/sbin/ntpd -q -p 194.190.168.1 -p 216.239.35.0 -p 216.239.35.4 -p 162.159.200.1 -p 162.159.200.123 >/dev/null 2>&1 || true -} - -process_age_seconds() { - local pid="$1" - local stat stat_rest start_ticks uptime_seconds - - [ -r "/proc/$pid/stat" ] || return 1 - - stat="$(cat "/proc/$pid/stat" 2>/dev/null)" || return 1 - stat_rest="${stat##*) }" - - set -- $stat_rest - [ "$#" -ge 20 ] || return 1 - shift 19 - start_ticks="$1" - - case "$start_ticks" in - '' | *[!0-9]*) return 1 ;; - esac - - read -r uptime_seconds _ < /proc/uptime || return 1 - uptime_seconds="${uptime_seconds%%.*}" - case "$uptime_seconds" in - '' | *[!0-9]*) return 1 ;; - esac - - echo $((uptime_seconds - (start_ticks / 100))) -} - -sing_box_service_pid() { - local service_json='{"name":"sing-box"}' - - ubus call service list "$service_json" 2>/dev/null | - runtime_state_ucode sing-box-service-pid 2>/dev/null -} - -pid_is_sing_box() { - local pid="$1" - local exe - - case "$pid" in - '' | *[!0-9]*) return 1 ;; - esac - - exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || return 1 - [ "${exe##*/}" = "sing-box" ] -} - -sing_box_service_is_running() { - local pid - - pid="$(sing_box_service_pid)" - pid_is_sing_box "$pid" -} - -sing_box_service_is_stable() { - local min_age="${1:-$PODKOP_RUNTIME_STABLE_MIN_AGE}" - local pid age - - pid="$(sing_box_service_pid)" - pid_is_sing_box "$pid" || return 1 - - age="$(process_age_seconds "$pid")" || return 1 - [ "$age" -ge "$min_age" ] -} - -podkop_runtime_network_is_configured() { - nft list table inet "$NFT_TABLE_NAME" >/dev/null 2>&1 && - has_tproxy_marking_rule && - has_tproxy_route -} - -podkop_is_running() { - sing_box_service_is_running && - podkop_runtime_network_is_configured -} - -podkop_is_stably_running() { - sing_box_service_is_stable && - podkop_runtime_network_is_configured -} - -wait_for_podkop_stable_start() { - local timeout="${1:-8}" - - while [ "$timeout" -gt 0 ]; do - podkop_is_stably_running && return 0 - sleep 1 - timeout=$((timeout - 1)) - done - - return 1 -} - -clear_reload_state() { - rm -f "$PODKOP_RELOAD_STATE_FILE" -} - -read_reload_state_value() { - local key="$1" - - [ -f "$PODKOP_RELOAD_STATE_FILE" ] || return 1 - - runtime_state_ucode get "$PODKOP_RELOAD_STATE_FILE" "$key" -} - -reload_state_has_key() { - local key="$1" - - [ -f "$PODKOP_RELOAD_STATE_FILE" ] || return 1 - - runtime_state_ucode has-key "$PODKOP_RELOAD_STATE_FILE" "$key" -} - -reload_state_is_compatible() { - [ "$(read_reload_state_value "format")" = "$PODKOP_RELOAD_STATE_FORMAT" ] -} - -load_previous_reload_state() { - local line key value - - PREVIOUS_RELOAD_STATE_FORMAT="" - PREVIOUS_RELOAD_STATE_SERVICE_TRIGGER_SIGNATURE="" - PREVIOUS_RELOAD_STATE_RESTART_SIGNATURE="" - PREVIOUS_RELOAD_STATE_DNSMASQ_SIGNATURE="" - PREVIOUS_RELOAD_STATE_SING_BOX_SIGNATURE="" - PREVIOUS_RELOAD_STATE_NFT_SIGNATURE="" - PREVIOUS_RELOAD_STATE_ZAPRET_QUEUE_SIGNATURE="" - PREVIOUS_RELOAD_STATE_ZAPRET_RUNTIME_SIGNATURE="" - PREVIOUS_RELOAD_STATE_ZAPRET2_QUEUE_SIGNATURE="" - PREVIOUS_RELOAD_STATE_ZAPRET2_RUNTIME_SIGNATURE="" - PREVIOUS_RELOAD_STATE_BYEDPI_RUNTIME_SIGNATURE="" - PREVIOUS_RELOAD_STATE_LIST_SIGNATURE="" - PREVIOUS_RELOAD_STATE_CRON_SIGNATURE="" - PREVIOUS_RELOAD_STATE_URLTEST_ENABLED_SECTIONS="" - PREVIOUS_RELOAD_STATE_URLTEST_ENABLED_SECTIONS_KNOWN=0 - - [ -f "$PODKOP_RELOAD_STATE_FILE" ] || return 1 - - while IFS= read -r line || [ -n "$line" ]; do - key="${line%%=*}" - [ "$line" != "$key" ] && value="${line#*=}" || value="" - - case "$key" in - format) PREVIOUS_RELOAD_STATE_FORMAT="$value" ;; - service_trigger_signature) PREVIOUS_RELOAD_STATE_SERVICE_TRIGGER_SIGNATURE="$value" ;; - restart_signature) PREVIOUS_RELOAD_STATE_RESTART_SIGNATURE="$value" ;; - dnsmasq_signature) PREVIOUS_RELOAD_STATE_DNSMASQ_SIGNATURE="$value" ;; - sing_box_signature) PREVIOUS_RELOAD_STATE_SING_BOX_SIGNATURE="$value" ;; - nft_signature) PREVIOUS_RELOAD_STATE_NFT_SIGNATURE="$value" ;; - zapret_queue_signature) PREVIOUS_RELOAD_STATE_ZAPRET_QUEUE_SIGNATURE="$value" ;; - zapret_runtime_signature) PREVIOUS_RELOAD_STATE_ZAPRET_RUNTIME_SIGNATURE="$value" ;; - zapret2_queue_signature) PREVIOUS_RELOAD_STATE_ZAPRET2_QUEUE_SIGNATURE="$value" ;; - zapret2_runtime_signature) PREVIOUS_RELOAD_STATE_ZAPRET2_RUNTIME_SIGNATURE="$value" ;; - byedpi_runtime_signature) PREVIOUS_RELOAD_STATE_BYEDPI_RUNTIME_SIGNATURE="$value" ;; - list_signature) PREVIOUS_RELOAD_STATE_LIST_SIGNATURE="$value" ;; - cron_signature) PREVIOUS_RELOAD_STATE_CRON_SIGNATURE="$value" ;; - urltest_enabled_sections) - PREVIOUS_RELOAD_STATE_URLTEST_ENABLED_SECTIONS="$value" - PREVIOUS_RELOAD_STATE_URLTEST_ENABLED_SECTIONS_KNOWN=1 - ;; - esac - done < "$PODKOP_RELOAD_STATE_FILE" - - [ "$PREVIOUS_RELOAD_STATE_FORMAT" = "$PODKOP_RELOAD_STATE_FORMAT" ] -} - -signature_begin() { - RELOAD_SIGNATURE_TMPFILE="$(mktemp)" -} - -signature_add() { - local key="$1" - local value="$2" - - printf '[%s]\n%s\n' "$key" "$value" >> "$RELOAD_SIGNATURE_TMPFILE" -} - -signature_finish() { - local hash_line - - hash_line="$(md5sum "$RELOAD_SIGNATURE_TMPFILE")" - rm -f "$RELOAD_SIGNATURE_TMPFILE" - set -- $hash_line - echo "$1" -} - -build_service_trigger_signature() { - # These settings are consumed by init.d/procd triggers, not by sing-box/nft - # runtime. Track them separately so config reloads can refresh procd without - # forcing an in-process runtime restart. - local enable_badwan_interface_monitoring badwan_monitored_interfaces badwan_reload_delay - - signature_begin - config_get_bool enable_badwan_interface_monitoring "settings" "enable_badwan_interface_monitoring" 0 - signature_add "settings.enable_badwan_interface_monitoring" "$enable_badwan_interface_monitoring" - - if [ "$enable_badwan_interface_monitoring" -eq 1 ]; then - config_get badwan_monitored_interfaces "settings" "badwan_monitored_interfaces" - config_get badwan_reload_delay "settings" "badwan_reload_delay" "2000" - signature_add "settings.badwan_monitored_interfaces" "$badwan_monitored_interfaces" - signature_add "settings.badwan_reload_delay" "$badwan_reload_delay" - fi - - signature_finish -} - -build_dnsmasq_signature() { - local dont_touch_dhcp legacy_dnsmasq_present - - signature_begin - config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0 - signature_add "settings.dont_touch_dhcp" "$dont_touch_dhcp" - signature_add "dhcp.@dnsmasq[0].server" "$(uci_get "dhcp" "@dnsmasq[0]" "server")" - signature_add "dhcp.@dnsmasq[0].noresolv" "$(uci_get "dhcp" "@dnsmasq[0]" "noresolv")" - signature_add "dhcp.@dnsmasq[0].cachesize" "$(uci_get "dhcp" "@dnsmasq[0]" "cachesize")" - signature_add "dhcp.@dnsmasq[0].podkop_server" "$(uci_get "dhcp" "@dnsmasq[0]" "podkop_server")" - signature_add "dhcp.@dnsmasq[0].podkop_noresolv" "$(uci_get "dhcp" "@dnsmasq[0]" "podkop_noresolv")" - signature_add "dhcp.@dnsmasq[0].podkop_cachesize" "$(uci_get "dhcp" "@dnsmasq[0]" "podkop_cachesize")" - legacy_dnsmasq_present=0 - uci -q show "dhcp.podkop_plus" >/dev/null 2>&1 && legacy_dnsmasq_present=1 - signature_add "dhcp.podkop_plus.present" "$legacy_dnsmasq_present" - signature_finish -} - -append_sing_box_rule_signature() { - local section="$1" - local action domain domain_suffix domain_keyword domain_regex ip_cidr source_ip_cidr fully_routed_ips \ - ports community_lists rule_set rule_set_with_subnets domain_ip_lists mixed_proxy_enabled mixed_proxy_port \ - mixed_proxy_auth_enabled mixed_proxy_username mixed_proxy_password resolve_real_ip_for_routing - - rule_is_enabled "$section" || return 0 - - action="$(get_rule_action "$section")" - [ -n "$action" ] || return 0 - - signature_add "rule.$section.action" "$action" - - case "$action" in - proxy) - local selector_proxy_links subscription_urls udp_over_tcp urltest_enabled \ - urltest_check_interval urltest_tolerance urltest_testing_url subscription_update_enabled \ - subscription_update_interval detect_server_country urltest_filter_mode urltest_exclude_countries urltest_include_countries \ - urltest_exclude_outbounds urltest_exclude_regex urltest_include_outbounds urltest_include_regex \ - outbound_detour_enabled outbound_detour_section - config_get selector_proxy_links "$section" "selector_proxy_links" - config_get subscription_urls "$section" "subscription_urls" - config_get_bool udp_over_tcp "$section" "enable_udp_over_tcp" 0 - config_get_bool urltest_enabled "$section" "urltest_enabled" 0 - config_get detect_server_country "$section" "detect_server_country" "$SERVER_COUNTRY_METHOD_FLAG_EMOJI" - detect_server_country="$(normalize_detect_server_country_method "$detect_server_country")" - urltest_check_interval="$(get_urltest_check_interval_for_rule "$section")" - config_get urltest_tolerance "$section" "urltest_tolerance" 50 - config_get urltest_testing_url "$section" "urltest_testing_url" "https://www.gstatic.com/generate_204" - config_get_bool subscription_update_enabled "$section" "subscription_update_enabled" 1 - subscription_update_interval="$(get_subscription_update_interval_for_rule "$section")" - config_get urltest_filter_mode "$section" "urltest_filter_mode" "disabled" - config_get urltest_exclude_countries "$section" "urltest_exclude_countries" - config_get urltest_include_countries "$section" "urltest_include_countries" - config_get urltest_exclude_outbounds "$section" "urltest_exclude_outbounds" - config_get urltest_exclude_regex "$section" "urltest_exclude_regex" - config_get urltest_include_outbounds "$section" "urltest_include_outbounds" - config_get urltest_include_regex "$section" "urltest_include_regex" - - signature_add "rule.$section.selector_proxy_links" "$selector_proxy_links" - signature_add "rule.$section.subscription_urls" "$subscription_urls" - signature_add "rule.$section.enable_udp_over_tcp" "$udp_over_tcp" - signature_add "rule.$section.urltest_enabled" "$urltest_enabled" - signature_add "rule.$section.detect_server_country" "$detect_server_country" - signature_add "rule.$section.urltest_check_interval" "$urltest_check_interval" - signature_add "rule.$section.urltest_tolerance" "$urltest_tolerance" - signature_add "rule.$section.urltest_testing_url" "$urltest_testing_url" - signature_add "rule.$section.urltest_filter_mode" "$urltest_filter_mode" - signature_add "rule.$section.urltest_exclude_countries" "$urltest_exclude_countries" - signature_add "rule.$section.urltest_include_countries" "$urltest_include_countries" - signature_add "rule.$section.urltest_exclude_outbounds" "$urltest_exclude_outbounds" - signature_add "rule.$section.urltest_exclude_regex" "$urltest_exclude_regex" - signature_add "rule.$section.urltest_include_outbounds" "$urltest_include_outbounds" - signature_add "rule.$section.urltest_include_regex" "$urltest_include_regex" - signature_add "rule.$section.subscription_update_enabled" "$subscription_update_enabled" - signature_add "rule.$section.subscription_update_interval" "$subscription_update_interval" - - config_get_bool outbound_detour_enabled "$section" "outbound_detour_enabled" 0 - signature_add "rule.$section.outbound_detour_enabled" "$outbound_detour_enabled" - if [ "$outbound_detour_enabled" -eq 1 ]; then - config_get outbound_detour_section "$section" "outbound_detour_section" - signature_add "rule.$section.outbound_detour_section" "$outbound_detour_section" - fi - - config_get_bool mixed_proxy_enabled "$section" "mixed_proxy_enabled" 0 - signature_add "rule.$section.mixed_proxy_enabled" "$mixed_proxy_enabled" - if [ "$mixed_proxy_enabled" -eq 1 ]; then - config_get mixed_proxy_port "$section" "mixed_proxy_port" - signature_add "rule.$section.mixed_proxy_port" "$mixed_proxy_port" - config_get_bool mixed_proxy_auth_enabled "$section" "mixed_proxy_auth_enabled" 0 - signature_add "rule.$section.mixed_proxy_auth_enabled" "$mixed_proxy_auth_enabled" - if [ "$mixed_proxy_auth_enabled" -eq 1 ]; then - config_get mixed_proxy_username "$section" "mixed_proxy_username" - config_get mixed_proxy_password "$section" "mixed_proxy_password" - signature_add "rule.$section.mixed_proxy_username" "$mixed_proxy_username" - signature_add "rule.$section.mixed_proxy_password" "$mixed_proxy_password" - fi - fi - - config_get_bool resolve_real_ip_for_routing "$section" "resolve_real_ip_for_routing" 0 - signature_add "rule.$section.resolve_real_ip_for_routing" "$resolve_real_ip_for_routing" - ;; - outbound) - local outbound_json outbound_detour_enabled outbound_detour_section - config_get outbound_json "$section" "outbound_json" - signature_add "rule.$section.outbound_json" "$outbound_json" - - config_get_bool outbound_detour_enabled "$section" "outbound_detour_enabled" 0 - signature_add "rule.$section.outbound_detour_enabled" "$outbound_detour_enabled" - if [ "$outbound_detour_enabled" -eq 1 ]; then - config_get outbound_detour_section "$section" "outbound_detour_section" - signature_add "rule.$section.outbound_detour_section" "$outbound_detour_section" - fi - - config_get_bool mixed_proxy_enabled "$section" "mixed_proxy_enabled" 0 - signature_add "rule.$section.mixed_proxy_enabled" "$mixed_proxy_enabled" - if [ "$mixed_proxy_enabled" -eq 1 ]; then - config_get mixed_proxy_port "$section" "mixed_proxy_port" - signature_add "rule.$section.mixed_proxy_port" "$mixed_proxy_port" - config_get_bool mixed_proxy_auth_enabled "$section" "mixed_proxy_auth_enabled" 0 - signature_add "rule.$section.mixed_proxy_auth_enabled" "$mixed_proxy_auth_enabled" - if [ "$mixed_proxy_auth_enabled" -eq 1 ]; then - config_get mixed_proxy_username "$section" "mixed_proxy_username" - config_get mixed_proxy_password "$section" "mixed_proxy_password" - signature_add "rule.$section.mixed_proxy_username" "$mixed_proxy_username" - signature_add "rule.$section.mixed_proxy_password" "$mixed_proxy_password" - fi - fi - - config_get_bool resolve_real_ip_for_routing "$section" "resolve_real_ip_for_routing" 0 - signature_add "rule.$section.resolve_real_ip_for_routing" "$resolve_real_ip_for_routing" - ;; - byedpi) - local byedpi_index - byedpi_index="$(get_byedpi_rule_index "$section")" - signature_add "rule.$section.byedpi_index" "$byedpi_index" - - config_get_bool mixed_proxy_enabled "$section" "mixed_proxy_enabled" 0 - signature_add "rule.$section.mixed_proxy_enabled" "$mixed_proxy_enabled" - if [ "$mixed_proxy_enabled" -eq 1 ]; then - config_get mixed_proxy_port "$section" "mixed_proxy_port" - signature_add "rule.$section.mixed_proxy_port" "$mixed_proxy_port" - config_get_bool mixed_proxy_auth_enabled "$section" "mixed_proxy_auth_enabled" 0 - signature_add "rule.$section.mixed_proxy_auth_enabled" "$mixed_proxy_auth_enabled" - if [ "$mixed_proxy_auth_enabled" -eq 1 ]; then - config_get mixed_proxy_username "$section" "mixed_proxy_username" - config_get mixed_proxy_password "$section" "mixed_proxy_password" - signature_add "rule.$section.mixed_proxy_username" "$mixed_proxy_username" - signature_add "rule.$section.mixed_proxy_password" "$mixed_proxy_password" - fi - fi - - signature_add "rule.$section.resolve_real_ip_for_routing" "1" - ;; - zapret | zapret2) - config_get_bool mixed_proxy_enabled "$section" "mixed_proxy_enabled" 0 - signature_add "rule.$section.mixed_proxy_enabled" "$mixed_proxy_enabled" - if [ "$mixed_proxy_enabled" -eq 1 ]; then - config_get mixed_proxy_port "$section" "mixed_proxy_port" - signature_add "rule.$section.mixed_proxy_port" "$mixed_proxy_port" - config_get_bool mixed_proxy_auth_enabled "$section" "mixed_proxy_auth_enabled" 0 - signature_add "rule.$section.mixed_proxy_auth_enabled" "$mixed_proxy_auth_enabled" - if [ "$mixed_proxy_auth_enabled" -eq 1 ]; then - config_get mixed_proxy_username "$section" "mixed_proxy_username" - config_get mixed_proxy_password "$section" "mixed_proxy_password" - signature_add "rule.$section.mixed_proxy_username" "$mixed_proxy_username" - signature_add "rule.$section.mixed_proxy_password" "$mixed_proxy_password" - fi - fi - ;; - vpn) - local interface_name domain_resolver_enabled domain_resolver_dns_type domain_resolver_dns_server - config_get interface_name "$section" "interface" - config_get_bool domain_resolver_enabled "$section" "domain_resolver_enabled" 0 - signature_add "rule.$section.interface" "$interface_name" - signature_add "rule.$section.domain_resolver_enabled" "$domain_resolver_enabled" - if [ "$domain_resolver_enabled" -eq 1 ]; then - config_get domain_resolver_dns_type "$section" "domain_resolver_dns_type" - config_get domain_resolver_dns_server "$section" "domain_resolver_dns_server" - signature_add "rule.$section.domain_resolver_dns_type" "$domain_resolver_dns_type" - signature_add "rule.$section.domain_resolver_dns_server" "$domain_resolver_dns_server" - fi - - config_get_bool mixed_proxy_enabled "$section" "mixed_proxy_enabled" 0 - signature_add "rule.$section.mixed_proxy_enabled" "$mixed_proxy_enabled" - if [ "$mixed_proxy_enabled" -eq 1 ]; then - config_get mixed_proxy_port "$section" "mixed_proxy_port" - signature_add "rule.$section.mixed_proxy_port" "$mixed_proxy_port" - config_get_bool mixed_proxy_auth_enabled "$section" "mixed_proxy_auth_enabled" 0 - signature_add "rule.$section.mixed_proxy_auth_enabled" "$mixed_proxy_auth_enabled" - if [ "$mixed_proxy_auth_enabled" -eq 1 ]; then - config_get mixed_proxy_username "$section" "mixed_proxy_username" - config_get mixed_proxy_password "$section" "mixed_proxy_password" - signature_add "rule.$section.mixed_proxy_username" "$mixed_proxy_username" - signature_add "rule.$section.mixed_proxy_password" "$mixed_proxy_password" - fi - fi - - config_get_bool resolve_real_ip_for_routing "$section" "resolve_real_ip_for_routing" 0 - signature_add "rule.$section.resolve_real_ip_for_routing" "$resolve_real_ip_for_routing" - ;; - esac - - domain="$(get_rule_condition_commas_string "$section" "domain" "domains")" - domain_suffix="$(get_rule_condition_commas_string "$section" "domain_suffix" "domains")" - domain_keyword="$(get_rule_condition_commas_string "$section" "domain_keyword" "generic")" - domain_regex="$(get_rule_condition_commas_string "$section" "domain_regex" "generic")" - ip_cidr="$(get_rule_condition_commas_string "$section" "ip_cidr" "subnets")" - source_ip_cidr="$(get_rule_condition_commas_string "$section" "source_ip_cidr" "subnets")" - ports="$(get_rule_ports_commas_string "$section")" - config_get fully_routed_ips "$section" "fully_routed_ips" - config_get community_lists "$section" "community_lists" - config_get rule_set "$section" "rule_set" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - - signature_add "rule.$section.domain" "$domain" - signature_add "rule.$section.domain_suffix" "$domain_suffix" - signature_add "rule.$section.domain_keyword" "$domain_keyword" - signature_add "rule.$section.domain_regex" "$domain_regex" - signature_add "rule.$section.ip_cidr" "$ip_cidr" - signature_add "rule.$section.source_ip_cidr" "$source_ip_cidr" - signature_add "rule.$section.ports" "$ports" - signature_add "rule.$section.fully_routed_ips" "$fully_routed_ips" - signature_add "rule.$section.community_lists" "$community_lists" - signature_add "rule.$section.rule_set" "$rule_set" - signature_add "rule.$section.rule_set_with_subnets" "$rule_set_with_subnets" - signature_add "rule.$section.domain_ip_lists" "$domain_ip_lists" -} - -append_sing_box_server_signature() { - local section="$1" - local enabled label protocol listen listen_port public_host routing_mode routing_section security \ - inbound_json \ - users tls_server_name tls_alpn tls_certificate_path tls_key_path reality_handshake_server \ - reality_handshake_server_port reality_private_key reality_public_key reality_short_id \ - reality_max_time_difference transport transport_path transport_host transport_service_name transport_hosts transport_xhttp_mode \ - client_fingerprint server_uuid server_username server_password vless_flow vmess_alter_id \ - shadowsocks_method hysteria2_up_mbps hysteria2_down_mbps hysteria2_obfs_type hysteria2_obfs_password \ - mtproto_secret mtproto_faketls mtproto_padding mtproto_concurrency mtproto_domain_fronting_port \ - mtproto_domain_fronting_ip mtproto_domain_fronting_proxy_protocol mtproto_prefer_ip mtproto_auto_update \ - mtproto_allow_fallback_on_unknown_dc mtproto_tolerate_time_skewness mtproto_idle_timeout \ - mtproto_handshake_timeout \ - tailscale_auth_key tailscale_control_url tailscale_hostname \ - tailscale_accept_routes tailscale_advertise_routes \ - tailscale_advertise_exit_node - - config_get_bool enabled "$section" "enabled" 0 - signature_add "server.$section.enabled" "$enabled" - [ "$enabled" -eq 1 ] || return 0 - - config_get label "$section" "label" "$section" - config_get protocol "$section" "protocol" "vless" - config_get listen "$section" "listen" "0.0.0.0" - config_get listen_port "$section" "listen_port" - config_get public_host "$section" "public_host" - config_get inbound_json "$section" "inbound_json" - config_get routing_mode "$section" "routing_mode" "rules" - config_get routing_section "$section" "routing_section" - config_get security "$section" "security" "reality" - config_get users "$section" "server_users" - config_get tls_server_name "$section" "tls_server_name" - config_get tls_alpn "$section" "tls_alpn" - config_get tls_certificate_path "$section" "tls_certificate_path" - config_get tls_key_path "$section" "tls_key_path" - config_get reality_handshake_server "$section" "reality_handshake_server" - config_get reality_handshake_server_port "$section" "reality_handshake_server_port" - config_get reality_private_key "$section" "reality_private_key" - config_get reality_public_key "$section" "reality_public_key" - config_get reality_short_id "$section" "reality_short_id" - config_get reality_max_time_difference "$section" "reality_max_time_difference" - config_get transport "$section" "transport" "tcp" - config_get transport_path "$section" "transport_path" - config_get transport_host "$section" "transport_host" - config_get transport_service_name "$section" "transport_service_name" - config_get transport_hosts "$section" "transport_hosts" - config_get transport_xhttp_mode "$section" "transport_xhttp_mode" - config_get client_fingerprint "$section" "client_fingerprint" - config_get server_uuid "$section" "server_uuid" - config_get server_username "$section" "server_username" - config_get server_password "$section" "server_password" - config_get vless_flow "$section" "vless_flow" - config_get vmess_alter_id "$section" "vmess_alter_id" - config_get shadowsocks_method "$section" "shadowsocks_method" - config_get hysteria2_up_mbps "$section" "hysteria2_up_mbps" - config_get hysteria2_down_mbps "$section" "hysteria2_down_mbps" - config_get hysteria2_obfs_type "$section" "hysteria2_obfs_type" - config_get hysteria2_obfs_password "$section" "hysteria2_obfs_password" - config_get mtproto_secret "$section" "mtproto_secret" - config_get mtproto_faketls "$section" "mtproto_faketls" - config_get mtproto_padding "$section" "mtproto_padding" - config_get mtproto_concurrency "$section" "mtproto_concurrency" - config_get mtproto_domain_fronting_port "$section" "mtproto_domain_fronting_port" - config_get mtproto_domain_fronting_ip "$section" "mtproto_domain_fronting_ip" - config_get mtproto_domain_fronting_proxy_protocol "$section" "mtproto_domain_fronting_proxy_protocol" - config_get mtproto_prefer_ip "$section" "mtproto_prefer_ip" - config_get mtproto_auto_update "$section" "mtproto_auto_update" - config_get mtproto_allow_fallback_on_unknown_dc "$section" "mtproto_allow_fallback_on_unknown_dc" - config_get mtproto_tolerate_time_skewness "$section" "mtproto_tolerate_time_skewness" - config_get mtproto_idle_timeout "$section" "mtproto_idle_timeout" - config_get mtproto_handshake_timeout "$section" "mtproto_handshake_timeout" - config_get tailscale_auth_key "$section" "tailscale_auth_key" - config_get tailscale_control_url "$section" "tailscale_control_url" - config_get tailscale_hostname "$section" "tailscale_hostname" - config_get tailscale_accept_routes "$section" "tailscale_accept_routes" - config_get tailscale_advertise_routes "$section" "tailscale_advertise_routes" - config_get tailscale_advertise_exit_node "$section" "tailscale_advertise_exit_node" - - signature_add "server.$section.label" "$label" - signature_add "server.$section.protocol" "$protocol" - signature_add "server.$section.listen" "$listen" - signature_add "server.$section.listen_port" "$listen_port" - signature_add "server.$section.public_host" "$public_host" - signature_add "server.$section.inbound_json" "$inbound_json" - signature_add "server.$section.routing_mode" "$routing_mode" - signature_add "server.$section.routing_section" "$routing_section" - signature_add "server.$section.security" "$security" - signature_add "server.$section.server_users" "$users" - signature_add "server.$section.tls_server_name" "$tls_server_name" - signature_add "server.$section.tls_alpn" "$tls_alpn" - signature_add "server.$section.tls_certificate_path" "$tls_certificate_path" - signature_add "server.$section.tls_key_path" "$tls_key_path" - signature_add "server.$section.reality_handshake_server" "$reality_handshake_server" - signature_add "server.$section.reality_handshake_server_port" "$reality_handshake_server_port" - signature_add "server.$section.reality_private_key" "$reality_private_key" - signature_add "server.$section.reality_public_key" "$reality_public_key" - signature_add "server.$section.reality_short_id" "$reality_short_id" - signature_add "server.$section.reality_max_time_difference" "$reality_max_time_difference" - signature_add "server.$section.transport" "$transport" - signature_add "server.$section.transport_path" "$transport_path" - signature_add "server.$section.transport_host" "$transport_host" - signature_add "server.$section.transport_service_name" "$transport_service_name" - signature_add "server.$section.transport_hosts" "$transport_hosts" - signature_add "server.$section.transport_xhttp_mode" "$transport_xhttp_mode" - signature_add "server.$section.client_fingerprint" "$client_fingerprint" - signature_add "server.$section.server_uuid" "$server_uuid" - signature_add "server.$section.server_username" "$server_username" - signature_add "server.$section.server_password" "$server_password" - signature_add "server.$section.vless_flow" "$vless_flow" - signature_add "server.$section.vmess_alter_id" "$vmess_alter_id" - signature_add "server.$section.shadowsocks_method" "$shadowsocks_method" - signature_add "server.$section.hysteria2_up_mbps" "$hysteria2_up_mbps" - signature_add "server.$section.hysteria2_down_mbps" "$hysteria2_down_mbps" - signature_add "server.$section.hysteria2_obfs_type" "$hysteria2_obfs_type" - signature_add "server.$section.hysteria2_obfs_password" "$hysteria2_obfs_password" - signature_add "server.$section.mtproto_secret" "$mtproto_secret" - signature_add "server.$section.mtproto_faketls" "$mtproto_faketls" - signature_add "server.$section.mtproto_padding" "$mtproto_padding" - signature_add "server.$section.mtproto_concurrency" "$mtproto_concurrency" - signature_add "server.$section.mtproto_domain_fronting_port" "$mtproto_domain_fronting_port" - signature_add "server.$section.mtproto_domain_fronting_ip" "$mtproto_domain_fronting_ip" - signature_add "server.$section.mtproto_domain_fronting_proxy_protocol" "$mtproto_domain_fronting_proxy_protocol" - signature_add "server.$section.mtproto_prefer_ip" "$mtproto_prefer_ip" - signature_add "server.$section.mtproto_auto_update" "$mtproto_auto_update" - signature_add "server.$section.mtproto_allow_fallback_on_unknown_dc" "$mtproto_allow_fallback_on_unknown_dc" - signature_add "server.$section.mtproto_tolerate_time_skewness" "$mtproto_tolerate_time_skewness" - signature_add "server.$section.mtproto_idle_timeout" "$mtproto_idle_timeout" - signature_add "server.$section.mtproto_handshake_timeout" "$mtproto_handshake_timeout" - signature_add "server.$section.tailscale_auth_key" "$tailscale_auth_key" - signature_add "server.$section.tailscale_control_url" "$tailscale_control_url" - signature_add "server.$section.tailscale_hostname" "$tailscale_hostname" - signature_add "server.$section.tailscale_accept_routes" "$tailscale_accept_routes" - signature_add "server.$section.tailscale_advertise_routes" "$tailscale_advertise_routes" - signature_add "server.$section.tailscale_advertise_exit_node" "$tailscale_advertise_exit_node" -} - -build_sing_box_signature() { - local dns_type dns_server bootstrap_dns_server rewrite_ttl output_network_interface disable_quic routing_excluded_ips \ - update_interval cache_path config_path log_level enable_yacd enable_yacd_wan_access yacd_secret_key \ - download_lists_via_proxy download_subscriptions_via_proxy download_components_via_proxy \ - download_lists_via_proxy_section service_listen_address mwan3_active - - signature_begin - - config_get dns_type "settings" "dns_type" "doh" - config_get dns_server "settings" "dns_server" "1.1.1.1" - config_get bootstrap_dns_server "settings" "bootstrap_dns_server" "77.88.8.8" - config_get rewrite_ttl "settings" "dns_rewrite_ttl" "60" - config_get output_network_interface "settings" "output_network_interface" - config_get_bool disable_quic "settings" "disable_quic" 0 - config_get routing_excluded_ips "settings" "routing_excluded_ips" - update_interval="$(get_settings_update_interval)" - config_get cache_path "settings" "cache_path" "/tmp/sing-box/cache.db" - config_get config_path "settings" "config_path" - config_get log_level "settings" "log_level" "warn" - config_get service_listen_address "settings" "service_listen_address" - config_get_bool enable_yacd "settings" "enable_yacd" 0 - config_get_bool enable_yacd_wan_access "settings" "enable_yacd_wan_access" 0 - config_get_bool download_lists_via_proxy "settings" "download_lists_via_proxy" 0 - config_get_bool download_subscriptions_via_proxy "settings" "download_subscriptions_via_proxy" 0 - config_get_bool download_components_via_proxy "settings" "download_components_via_proxy" 0 - mwan3_active=0 - mwan3_is_active && mwan3_active=1 - - signature_add "settings.dns_type" "$dns_type" - signature_add "settings.dns_server" "$dns_server" - signature_add "settings.bootstrap_dns_server" "$bootstrap_dns_server" - signature_add "settings.dns_rewrite_ttl" "$rewrite_ttl" - signature_add "settings.output_network_interface" "$output_network_interface" - signature_add "settings.disable_quic" "$disable_quic" - signature_add "settings.routing_excluded_ips" "$routing_excluded_ips" - if has_remote_sing_box_ruleset_sources; then - signature_add "settings.update_interval" "$update_interval" - fi - signature_add "settings.cache_path" "$cache_path" - signature_add "settings.config_path" "$config_path" - signature_add "settings.log_level" "$log_level" - signature_add "settings.service_listen_address" "$service_listen_address" - signature_add "runtime.mwan3_active" "$mwan3_active" - signature_add "settings.enable_yacd" "$enable_yacd" - if [ "$enable_yacd" -eq 1 ]; then - signature_add "settings.enable_yacd_wan_access" "$enable_yacd_wan_access" - config_get yacd_secret_key "settings" "yacd_secret_key" - signature_add "settings.yacd_secret_key" "$yacd_secret_key" - fi - - signature_add "settings.download_lists_via_proxy" "$download_lists_via_proxy" - signature_add "settings.download_subscriptions_via_proxy" "$download_subscriptions_via_proxy" - signature_add "settings.download_components_via_proxy" "$download_components_via_proxy" - if download_via_proxy_any_enabled; then - config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section" - signature_add "settings.download_lists_via_proxy_section" "$download_lists_via_proxy_section" - fi - - config_foreach append_sing_box_rule_signature "section" - config_foreach append_sing_box_server_signature "server" - signature_finish -} - -append_nft_rule_signature() { - local section="$1" - local ip_cidr ports fully_routed_ips community_lists community_subnet_lists remote_subnet_lists rule_set_with_subnets domain_ip_lists - - rule_is_enabled "$section" || return 0 - - ip_cidr="$(get_rule_condition_commas_string "$section" "ip_cidr" "subnets")" - ports="$(get_rule_ports_commas_string "$section")" - config_get fully_routed_ips "$section" "fully_routed_ips" - config_get community_lists "$section" "community_lists" - community_subnet_lists="$(filter_community_subnet_lists "$community_lists")" - config_get remote_subnet_lists "$section" "remote_subnet_lists" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - - signature_add "rule.$section.ip_cidr" "$ip_cidr" - signature_add "rule.$section.ports" "$ports" - signature_add "rule.$section.fully_routed_ips" "$fully_routed_ips" - signature_add "rule.$section.community_subnet_lists" "$community_subnet_lists" - signature_add "rule.$section.remote_subnet_lists" "$remote_subnet_lists" - signature_add "rule.$section.rule_set_with_subnets" "$rule_set_with_subnets" - signature_add "rule.$section.domain_ip_lists" "$domain_ip_lists" -} - -build_nft_signature() { - local source_network_interfaces exclude_ntp - - signature_begin - config_get source_network_interfaces "settings" "source_network_interfaces" "br-lan" - config_get_bool exclude_ntp "settings" "exclude_ntp" 0 - signature_add "settings.source_network_interfaces" "$source_network_interfaces" - signature_add "settings.exclude_ntp" "$exclude_ntp" - config_foreach append_nft_rule_signature "section" - signature_finish -} - -append_zapret_queue_signature() { - local section="$1" - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret" ] || return 0 - - signature_add "zapret_queue.section" "$section" -} - -build_zapret_queue_signature() { - signature_begin - config_foreach append_zapret_queue_signature "section" - signature_finish -} - -append_zapret_runtime_signature() { - local section="$1" - local domain domain_suffix community_lists rule_set rule_set_with_subnets domain_ip_lists user_domain_list_type items - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret" ] || return 0 - - domain="$(get_rule_condition_commas_string "$section" "domain" "domains")" - domain_suffix="$(get_rule_condition_commas_string "$section" "domain_suffix" "domains")" - config_get community_lists "$section" "community_lists" - config_get rule_set "$section" "rule_set" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - config_get user_domain_list_type "$section" "user_domain_list_type" "disabled" - - signature_add "zapret.$section.nfqws_opt" "$(get_rule_nfqws_opt "$section")" - signature_add "zapret.$section.domain" "$domain" - signature_add "zapret.$section.domain_suffix" "$domain_suffix" - signature_add "zapret.$section.community_lists" "$community_lists" - signature_add "zapret.$section.rule_set" "$rule_set" - signature_add "zapret.$section.rule_set_with_subnets" "$rule_set_with_subnets" - signature_add "zapret.$section.domain_ip_lists" "$domain_ip_lists" - signature_add "zapret.$section.user_domain_list_type" "$user_domain_list_type" - config_get items "$section" "local_domain_lists" - signature_add "zapret.$section.local_domain_lists" "$items" - config_get items "$section" "remote_domain_lists" - signature_add "zapret.$section.remote_domain_lists" "$items" - - case "$user_domain_list_type" in - dynamic) config_get items "$section" "user_domains" ;; - text) config_get items "$section" "user_domains_text" ;; - *) items="" ;; - esac - signature_add "zapret.$section.user_domains" "$items" -} - -build_zapret_runtime_signature() { - signature_begin - config_foreach append_zapret_runtime_signature "section" - signature_finish -} - -append_zapret2_queue_signature() { - local section="$1" - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret2" ] || return 0 - - signature_add "zapret2_queue.section" "$section" -} - -build_zapret2_queue_signature() { - signature_begin - config_foreach append_zapret2_queue_signature "section" - signature_finish -} - -append_zapret2_runtime_signature() { - local section="$1" - local domain domain_suffix community_lists rule_set rule_set_with_subnets domain_ip_lists user_domain_list_type items - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret2" ] || return 0 - - domain="$(get_rule_condition_commas_string "$section" "domain" "domains")" - domain_suffix="$(get_rule_condition_commas_string "$section" "domain_suffix" "domains")" - config_get community_lists "$section" "community_lists" - config_get rule_set "$section" "rule_set" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - config_get user_domain_list_type "$section" "user_domain_list_type" "disabled" - - signature_add "zapret2.$section.nfqws2_opt" "$(get_rule_nfqws2_opt "$section")" - signature_add "zapret2.$section.domain" "$domain" - signature_add "zapret2.$section.domain_suffix" "$domain_suffix" - signature_add "zapret2.$section.community_lists" "$community_lists" - signature_add "zapret2.$section.rule_set" "$rule_set" - signature_add "zapret2.$section.rule_set_with_subnets" "$rule_set_with_subnets" - signature_add "zapret2.$section.domain_ip_lists" "$domain_ip_lists" - signature_add "zapret2.$section.user_domain_list_type" "$user_domain_list_type" - config_get items "$section" "local_domain_lists" - signature_add "zapret2.$section.local_domain_lists" "$items" - config_get items "$section" "remote_domain_lists" - signature_add "zapret2.$section.remote_domain_lists" "$items" - - case "$user_domain_list_type" in - dynamic) config_get items "$section" "user_domains" ;; - text) config_get items "$section" "user_domains_text" ;; - *) items="" ;; - esac - signature_add "zapret2.$section.user_domains" "$items" -} - -build_zapret2_runtime_signature() { - signature_begin - config_foreach append_zapret2_runtime_signature "section" - signature_finish -} - -append_byedpi_runtime_signature() { - local section="$1" - local byedpi_index - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "byedpi" ] || return 0 - - byedpi_index="$(get_byedpi_rule_index "$section")" - signature_add "byedpi.$section.index" "$byedpi_index" - signature_add "byedpi.$section.byedpi_cmd_opts" "$(get_rule_byedpi_cmd_opts "$section")" -} - -build_byedpi_runtime_signature() { - signature_begin - config_foreach append_byedpi_runtime_signature "section" - signature_finish -} - -append_list_update_signature() { - local section="$1" - local ports community_lists community_subnet_lists remote_domain_lists remote_subnet_lists rule_set_with_subnets domain_ip_lists - - rule_is_enabled "$section" || return 0 - - ports="$(get_rule_ports_commas_string "$section")" - config_get community_lists "$section" "community_lists" - community_subnet_lists="$(filter_community_subnet_lists "$community_lists")" - config_get remote_domain_lists "$section" "remote_domain_lists" - config_get remote_subnet_lists "$section" "remote_subnet_lists" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - - signature_add "lists.$section.ports" "$ports" - signature_add "lists.$section.community_subnet_lists" "$community_subnet_lists" - signature_add "lists.$section.remote_domain_lists" "$remote_domain_lists" - signature_add "lists.$section.remote_subnet_lists" "$remote_subnet_lists" - signature_add "lists.$section.rule_set_with_subnets" "$rule_set_with_subnets" - signature_add "lists.$section.domain_ip_lists" "$domain_ip_lists" -} - -build_list_update_signature() { - signature_begin - config_foreach append_list_update_signature "section" - signature_finish -} - -append_subscription_update_cron_signature() { - local section="$1" - local subscription_update_interval subscription_urls subscription_update_enabled - - rule_is_subscription_proxy "$section" || return 0 - - config_get subscription_urls "$section" "subscription_urls" - config_get_bool subscription_update_enabled "$section" "subscription_update_enabled" 1 - subscription_update_interval="$(get_subscription_update_interval_for_rule "$section")" - - signature_add "subscription.$section.subscription_urls" "$subscription_urls" - signature_add "subscription.$section.subscription_update_enabled" "$subscription_update_enabled" - signature_add "subscription.$section.subscription_update_interval" "$subscription_update_interval" -} - -build_cron_signature() { - local update_interval - - signature_begin - update_interval="$(get_settings_update_interval)" - signature_add "settings.update_interval" "$update_interval" - config_foreach append_list_update_signature "section" - config_foreach append_subscription_update_cron_signature "section" - signature_finish -} - -list_has_remote_references() { - local items="$1" - local item - - for item in $items; do - case "$item" in - http://* | https://*) - return 0 - ;; - esac - done - - return 1 -} - -community_service_has_subnet_list() { - case "$1" in - twitter | meta | telegram | cloudflare | hetzner | ovh | digitalocean | cloudfront | discord | roblox) - return 0 - ;; - esac - - return 1 -} - -filter_community_subnet_lists() { - local items="$1" - local item result - - for item in $items; do - community_service_has_subnet_list "$item" || continue - - if [ -z "$result" ]; then - result="$item" - else - result="$result $item" - fi - done - - printf '%s\n' "$result" -} - -reference_is_remote_sing_box_ruleset() { - case "$1" in - http://* | https://*) - return 0 - ;; - esac - - return 1 -} - -list_has_remote_sing_box_rulesets() { - local items="$1" - local item - - for item in $items; do - reference_is_remote_sing_box_ruleset "$item" && return 0 - done - - return 1 -} - -enabled_rule_has_remote_sing_box_ruleset_source() { - local section="$1" - local community_lists rule_set rule_set_with_subnets - - rule_is_enabled "$section" || return 0 - - config_get community_lists "$section" "community_lists" - if [ -n "$community_lists" ]; then - RELOAD_REMOTE_SING_BOX_RULESET_SOURCES_PRESENT=1 - return 0 - fi - - config_get rule_set "$section" "rule_set" - if list_has_remote_sing_box_rulesets "$rule_set"; then - RELOAD_REMOTE_SING_BOX_RULESET_SOURCES_PRESENT=1 - return 0 - fi - - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - if list_has_remote_sing_box_rulesets "$rule_set_with_subnets"; then - RELOAD_REMOTE_SING_BOX_RULESET_SOURCES_PRESENT=1 - fi -} - -has_remote_sing_box_ruleset_sources() { - RELOAD_REMOTE_SING_BOX_RULESET_SOURCES_PRESENT=0 - config_foreach enabled_rule_has_remote_sing_box_ruleset_source "section" - [ "$RELOAD_REMOTE_SING_BOX_RULESET_SOURCES_PRESENT" -eq 1 ] -} - -get_settings_update_interval() { - local enabled update_interval - - config_get_bool enabled "settings" "list_update_enabled" 1 - if [ "$enabled" -eq 0 ]; then - printf '\n' - return 0 - fi - - config_get update_interval "settings" "update_interval" "1d" - if [ -n "$update_interval" ]; then - printf '%s\n' "$update_interval" - else - printf '1d\n' - fi -} - -get_subscription_update_interval_for_rule() { - local section="$1" - local enabled value - - rule_has_subscription_urls "$section" || { - printf '\n' - return 0 - } - - config_get_bool enabled "$section" "subscription_update_enabled" 1 - if [ "$enabled" -eq 0 ]; then - printf '\n' - return 0 - fi - - config_get value "$section" "subscription_update_interval" - if [ -n "$value" ]; then - printf '%s\n' "$value" - else - printf '1h\n' - fi -} - -get_urltest_check_interval_for_rule() { - local section="$1" - local enabled value - - config_get_bool enabled "$section" "urltest_enabled" 0 - if [ "$enabled" -eq 0 ]; then - printf '\n' - return 0 - fi - - config_get value "$section" "urltest_check_interval" - if [ -n "$value" ]; then - printf '%s\n' "$value" - else - printf '3m\n' - fi -} - -get_remote_ruleset_update_interval() { - local update_interval - - update_interval="$(get_settings_update_interval)" - if [ -n "$update_interval" ]; then - printf '%s\n' "$update_interval" - else - printf '%s\n' "$SING_BOX_DISABLED_UPDATE_INTERVAL" - fi - -} - -append_urltest_enabled_section() { - local section="$1" - local action enabled - - rule_is_enabled "$section" || return 0 - - action="$(get_rule_action "$section")" - [ "$action" = "proxy" ] || return 0 - - config_get_bool enabled "$section" "urltest_enabled" 0 - [ "$enabled" -eq 1 ] || return 0 - - RELOAD_STATE_URLTEST_ENABLED_SECTIONS="${RELOAD_STATE_URLTEST_ENABLED_SECTIONS}${RELOAD_STATE_URLTEST_ENABLED_SECTIONS:+ }$section" -} - -build_urltest_enabled_sections() { - RELOAD_STATE_URLTEST_ENABLED_SECTIONS="" - config_foreach append_urltest_enabled_section "section" - printf '%s\n' "$RELOAD_STATE_URLTEST_ENABLED_SECTIONS" -} - -build_new_urltest_enabled_sections() { - local previous_sections="$1" - local current_sections="$2" - local section new_sections - - new_sections="" - for section in $current_sections; do - list_has_item "$previous_sections" "$section" && continue - new_sections="${new_sections}${new_sections:+ }$section" - done - - printf '%s\n' "$new_sections" -} - -capture_reload_state() { - config_load "$PODKOP_CONFIG_NAME" - RELOAD_STATE_SERVICE_TRIGGER_SIGNATURE="$(build_service_trigger_signature)" - RELOAD_STATE_DNSMASQ_SIGNATURE="$(build_dnsmasq_signature)" - RELOAD_STATE_SING_BOX_SIGNATURE="$(build_sing_box_signature)" - RELOAD_STATE_NFT_SIGNATURE="$(build_nft_signature)" - RELOAD_STATE_ZAPRET_QUEUE_SIGNATURE="$(build_zapret_queue_signature)" - RELOAD_STATE_ZAPRET_RUNTIME_SIGNATURE="$(build_zapret_runtime_signature)" - RELOAD_STATE_ZAPRET2_QUEUE_SIGNATURE="$(build_zapret2_queue_signature)" - RELOAD_STATE_ZAPRET2_RUNTIME_SIGNATURE="$(build_zapret2_runtime_signature)" - RELOAD_STATE_BYEDPI_RUNTIME_SIGNATURE="$(build_byedpi_runtime_signature)" - RELOAD_STATE_LIST_SIGNATURE="$(build_list_update_signature)" - RELOAD_STATE_CRON_SIGNATURE="$(build_cron_signature)" - RELOAD_STATE_URLTEST_ENABLED_SECTIONS="$(build_urltest_enabled_sections)" - config_get_bool RELOAD_STATE_DONT_TOUCH_DHCP "settings" "dont_touch_dhcp" 0 -} - -write_reload_state() { - local status - - mkdir -p "$PODKOP_RUNTIME_STATE_DIR" - runtime_state_ucode write-reload-state \ - "$PODKOP_RELOAD_STATE_FILE" \ - "$PODKOP_RELOAD_STATE_FORMAT" \ - "$RELOAD_STATE_SERVICE_TRIGGER_SIGNATURE" \ - "$RELOAD_STATE_DNSMASQ_SIGNATURE" \ - "$RELOAD_STATE_SING_BOX_SIGNATURE" \ - "$RELOAD_STATE_NFT_SIGNATURE" \ - "$RELOAD_STATE_ZAPRET_QUEUE_SIGNATURE" \ - "$RELOAD_STATE_ZAPRET_RUNTIME_SIGNATURE" \ - "$RELOAD_STATE_ZAPRET2_QUEUE_SIGNATURE" \ - "$RELOAD_STATE_ZAPRET2_RUNTIME_SIGNATURE" \ - "$RELOAD_STATE_BYEDPI_RUNTIME_SIGNATURE" \ - "$RELOAD_STATE_LIST_SIGNATURE" \ - "$RELOAD_STATE_CRON_SIGNATURE" \ - "$RELOAD_STATE_URLTEST_ENABLED_SECTIONS" \ - "$RELOAD_STATE_DONT_TOUCH_DHCP" - status="$?" - if [ -n "${PODKOP_RULE_CONDITION_CACHE_DIR:-}" ]; then - rm -rf "$PODKOP_RULE_CONDITION_CACHE_DIR" 2>/dev/null || true - PODKOP_RULE_CONDITION_CACHE_DIR="" - fi - rm -rf /tmp/podkop-plus-rule-cache.* 2>/dev/null || true - return "$status" -} - -enabled_rule_has_list_sources() { - local section="$1" - local community_lists community_subnet_lists remote_domain_lists remote_subnet_lists rule_set_with_subnets domain_ip_lists - - rule_is_enabled "$section" || return 0 - config_get community_lists "$section" "community_lists" - community_subnet_lists="$(filter_community_subnet_lists "$community_lists")" - config_get remote_domain_lists "$section" "remote_domain_lists" - config_get remote_subnet_lists "$section" "remote_subnet_lists" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - - if [ -n "$community_subnet_lists" ] || - [ -n "$remote_domain_lists" ] || - [ -n "$remote_subnet_lists" ] || - [ -n "$rule_set_with_subnets" ] || - list_has_remote_references "$domain_ip_lists"; then - RELOAD_LIST_SOURCES_PRESENT=1 - fi -} - -has_list_update_sources() { - RELOAD_LIST_SOURCES_PRESENT=0 - config_foreach enabled_rule_has_list_sources "section" - [ "$RELOAD_LIST_SOURCES_PRESENT" -eq 1 ] -} - -enabled_rule_has_subscription_update_source() { - local section="$1" - local subscription_update_interval - - rule_is_subscription_proxy "$section" || return 0 - - subscription_update_interval="$(get_subscription_update_interval_for_rule "$section")" - [ -n "$subscription_update_interval" ] || return 0 - - RELOAD_SUBSCRIPTION_SOURCES_PRESENT=1 -} - -has_subscription_update_sources() { - RELOAD_SUBSCRIPTION_SOURCES_PRESENT=0 - config_foreach enabled_rule_has_subscription_update_source "section" - [ "$RELOAD_SUBSCRIPTION_SOURCES_PRESENT" -eq 1 ] -} - -enabled_rule_has_nft_list_sources() { - local section="$1" - local community_lists community_subnet_lists remote_subnet_lists rule_set_with_subnets domain_ip_lists - - rule_is_enabled "$section" || return 0 - config_get community_lists "$section" "community_lists" - community_subnet_lists="$(filter_community_subnet_lists "$community_lists")" - config_get remote_subnet_lists "$section" "remote_subnet_lists" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - - if [ -n "$community_subnet_lists" ] || - [ -n "$remote_subnet_lists" ] || - [ -n "$rule_set_with_subnets" ] || - list_has_remote_references "$domain_ip_lists"; then - RELOAD_NFT_LIST_SOURCES_PRESENT=1 - fi -} - -has_nft_list_update_sources() { - RELOAD_NFT_LIST_SOURCES_PRESENT=0 - config_foreach enabled_rule_has_nft_list_sources "section" - [ "$RELOAD_NFT_LIST_SOURCES_PRESENT" -eq 1 ] -} - -populate_nft_runtime_sets_from_rule() { - local section="$1" - local ip_values - - rule_is_enabled "$section" || return 0 - subscription_section_is_deferred "$section" && return 0 - - ip_values="$(get_rule_condition_commas_string "$section" "ip_cidr" "subnets")" - add_section_ip_cidr_to_nft_sets "$section" "$ip_values" - add_section_ports_to_nft_set_if_needed "$section" - populate_fully_routed_ips_nft_from_rule "$section" -} - -populate_fully_routed_ips_nft_from_rule() { - local section="$1" - - rule_is_enabled "$section" || return 0 - subscription_section_is_deferred "$section" && return 0 - - config_list_foreach "$section" "fully_routed_ips" populate_fully_routed_ip_nft -} - -populate_fully_routed_ip_nft() { - local source_ip="$1" - - [ "$PODKOP_NFT_POPULATE_ENABLED" = "1" ] || return 0 - - nft_list_all_traffic_from_ip "$source_ip" -} - -populate_nft_runtime_sets() { - config_foreach populate_nft_runtime_sets_from_rule "section" -} - -rebuild_nft_runtime() { - log "Rebuilding nft runtime" - - if nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then - nft delete table inet "$NFT_TABLE_NAME" - fi - - route_table_rule_mark - create_nft_rules -} - -refresh_list_update_cron() { - local cron_job update_interval - - remove_cron_job - - if has_list_update_sources; then - update_interval="$(get_settings_update_interval)" - if [ -z "$update_interval" ]; then - log "Remote list auto-update is disabled" - else - cron_job="$(build_list_update_cron_job)" || return 1 - crontab -l 2> /dev/null | { - cat - echo "$cron_job" - } | crontab - - log "The cron job has been created: $cron_job" - fi - fi - - if has_subscription_update_sources; then - cron_job="$(build_subscription_update_cron_job)" || return 1 - crontab -l 2> /dev/null | { - cat - echo "$cron_job" - } | crontab - - log "The subscription cron job has been created: $cron_job" - fi -} - -reload_sing_box_runtime() { - log "Reloading sing-box runtime" - - if ! /etc/init.d/sing-box reload; then - log "Failed to reload sing-box. Aborted." "fatal" - exit 1 - fi -} - -schedule_urltest_selector_switch() { - local selector_tag="$1" - local urltest_tag="$2" - - PODKOP_URLTEST_SELECTOR_SWITCHES="${PODKOP_URLTEST_SELECTOR_SWITCHES}${selector_tag} ${urltest_tag} -" -} - -apply_urltest_selector_switch() { - local selector_tag="$1" - local urltest_tag="$2" - local attempt response - - 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" | runtime_state_ucode response-success >/dev/null 2>&1; then - log "Selected URLTest outbound '$urltest_tag' for selector '$selector_tag'" - return 0 - fi - - sleep 1 - attempt=$((attempt + 1)) - done - - log "Failed to select URLTest outbound '$urltest_tag' for selector '$selector_tag'" "warn" -} - -apply_pending_urltest_selector_switches() { - local selector_tag urltest_tag - - [ -n "$PODKOP_URLTEST_SELECTOR_SWITCHES" ] || return 0 - - printf '%s' "$PODKOP_URLTEST_SELECTOR_SWITCHES" | while read -r selector_tag urltest_tag; do - [ -n "$selector_tag" ] || continue - [ -n "$urltest_tag" ] || continue - apply_urltest_selector_switch "$selector_tag" "$urltest_tag" - done -} diff --git a/podkop/files/usr/lib/runtime_state.uc b/podkop/files/usr/lib/runtime_state.uc deleted file mode 100644 index d0a22f4f..00000000 --- a/podkop/files/usr/lib/runtime_state.uc +++ /dev/null @@ -1,142 +0,0 @@ -#!/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_stdin_json() { - let data = read_stdin(); - try { - return json(data); - } - catch (e) { - return null; - } -} - -function file_first_line(path) { - let data = fs.readfile(path); - if (data == null) - exit(1); - - let newline = index(data, "\n"); - print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); -} - -function read_state_value(path, needle) { - let data = fs.readfile(path); - if (data == null) - exit(1); - - needle = as_string(needle); - for (let line in split(data, "\n")) { - let equals = index(line, "="); - let key = equals >= 0 ? substr(line, 0, equals) : line; - if (key == needle) { - print(equals >= 0 ? substr(line, equals + 1) : line, "\n"); - return; - } - } -} - -function state_has_key(path, needle) { - let data = fs.readfile(path); - if (data == null) - return false; - - needle = as_string(needle); - for (let line in split(data, "\n")) { - let equals = index(line, "="); - let key = equals >= 0 ? substr(line, 0, equals) : line; - if (key == needle) - return true; - } - - return false; -} - -function write_reload_state(path) { - let fields = [ - "format", - "service_trigger_signature", - "dnsmasq_signature", - "sing_box_signature", - "nft_signature", - "zapret_queue_signature", - "zapret_runtime_signature", - "zapret2_queue_signature", - "zapret2_runtime_signature", - "byedpi_runtime_signature", - "list_signature", - "cron_signature", - "urltest_enabled_sections", - "dont_touch_dhcp" - ]; - - let output = ""; - for (let i = 0; i < length(fields); i++) - output += fields[i] + "=" + as_string(ARGV[i + 2]) + "\n"; - - if (!fs.writefile(path, output)) - exit(1); -} - -function response_success() { - let value = read_stdin_json(); - return type(value) == "object" && value.success === true; -} - -function stdin_first_field() { - let data = read_stdin(); - let newline = index(data, "\n"); - let line = newline >= 0 ? substr(data, 0, newline) : data; - let fields = split(trim(as_string(line)), /[ \t\r\n]+/); - - if (length(fields) > 0 && fields[0] != "") - print(fields[0], "\n"); -} - -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; - } - } -} - -let mode = ARGV[0]; - -if (mode == "file-first-line") - file_first_line(ARGV[1]); -else if (mode == "get") - read_state_value(ARGV[1], ARGV[2]); -else if (mode == "has-key") - exit(state_has_key(ARGV[1], ARGV[2]) ? 0 : 1); -else if (mode == "write-reload-state") - write_reload_state(ARGV[1]); -else if (mode == "response-success") - exit(response_success() ? 0 : 1); -else if (mode == "stdin-first-field") - stdin_first_field(); -else if (mode == "sing-box-service-pid") - sing_box_service_pid(); -else { - warn("Usage: runtime_state.uc ...\n"); - exit(1); -} diff --git a/podkop/files/usr/lib/server/service.uc b/podkop/files/usr/lib/server/service.uc new file mode 100644 index 00000000..92594b9b --- /dev/null +++ b/podkop/files/usr/lib/server/service.uc @@ -0,0 +1,1104 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function write_json(value) { + print(sprintf("%J", value), "\n"); +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function run(command) { + return system(command) == 0; +} + +function output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return replace(as_string(data), /[\r\n]+$/g, ""); +} + +function log(message, level) { + level = as_string(level || "info"); + run("logger -t " + shell_quote("podkop-plus") + " " + shell_quote("[" + level + "] " + as_string(message))); +} + +function fatal(message) { + log(message, "fatal"); + exit(1); +} + +function uci_get(path) { + return uci.get(path); +} + +function uci_set(path, value) { + return uci.set(path, value); +} + +function uci_commit(package_name) { + return uci.commit(package_name); +} + +function words(value) { + value = trim(as_string(value)); + return value == "" ? [] : split(value, /[ \t\r\n]+/); +} + +function uci_sections(type_name) { + return uci.sections(CONFIG_NAME, type_name); +} + +function config_path(section, option) { + return CONFIG_NAME + "." + as_string(section) + "." + as_string(option); +} + +function config_get(section, option, fallback) { + let value = uci_get(config_path(section, option)); + return value == "" ? as_string(fallback || "") : value; +} + +let SERVER_DEFAULTS_CHANGED = false; + +function server_default_set_option(section, option, value) { + value = as_string(value); + if (value == "") + return; + if (uci_get(config_path(section, option)) != "") + return; + if (uci_set(config_path(section, option), value)) + SERVER_DEFAULTS_CHANGED = true; +} + +function server_set_option(section, option, value) { + value = as_string(value); + if (value == "") + return; + if (uci_get(config_path(section, option)) == value) + return; + if (uci_set(config_path(section, option), value)) + SERVER_DEFAULTS_CHANGED = true; +} + +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() { + try { + return json(read_stdin()); + } + catch (e) { + return null; + } +} + +function stdin_first_nonempty_line() { + for (let line in split(read_stdin(), "\n")) { + if (trim(as_string(line)) != "") { + print(line, "\n"); + return; + } + } +} + +function stdin_remove_newlines() { + print(replace(read_stdin(), /\n/g, "")); +} + +function shell_single_quote(value) { + value = as_string(value); + if (value == "") + return; + print("'", replace(value, /'/g, "'\\''"), "'\n"); +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function array_append_string(value) { + let result = array_or_empty(read_stdin_json()); + push(result, as_string(value)); + write_json(result); +} + +function regex_matches(value, pattern) { + pattern = as_string(pattern); + if (pattern == "") + return false; + + try { + return match(as_string(value), regexp(pattern)) != null; + } + catch (e) { + return false; + } +} + +function valid_dotted_ipv4(value) { + return regex_matches(value, "^([0-9]{1,3}\\.){3}[0-9]{1,3}$"); +} + +function valid_file_path(value) { + value = as_string(value); + return value != "" && + value != "/" && + substr(value, length(value) - 1) != "/" && + regex_matches(value, "^/[A-Za-z0-9_./-]+$"); +} + +function valid_duration(value) { + value = as_string(value); + return value != "" && regex_matches(value, "^([0-9]+(ns|us|ms|s|m|h))+$"); +} + +function valid_client_value(value) { + value = as_string(value); + return value != "" && !regex_matches(value, "[[:cntrl:]]"); +} + +function valid_port(value) { + value = as_string(value); + if (value == "" || regex_matches(value, "[^0-9]")) + return false; + + value = int(value); + return value >= 1 && value <= 65535; +} + +function normalize_host(value) { + value = as_string(value); + if (substr(value, 0, 1) == "[") + value = substr(value, 1); + if (substr(value, length(value) - 1) == "]") + value = substr(value, 0, length(value) - 1); + return lc(value); +} + +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 str_remove_suffix(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + return str_endswith(value, suffix) ? substr(value, 0, length(value) - length(suffix)) : value; +} + +function valid_ipv4(value) { + value = as_string(value); + if (!regex_matches(value, "^([0-9]{1,3}\\.){3}[0-9]{1,3}$")) + return false; + + let parts = split(value, "."); + if (length(parts) != 4) + return false; + + for (let part in parts) { + if (part == "" || regex_matches(part, "[^0-9]")) + return false; + let octet = int(part); + if (octet < 0 || octet > 255) + return false; + } + + return true; +} + +function valid_domain(value) { + return regex_matches(ascii_lower(value), "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"); +} + +function valid_host(value) { + value = normalize_host(value); + return value != "" && (valid_ipv4(value) || valid_domain(value)); +} + +function valid_absolute_path(value) { + return substr(as_string(value), 0, 1) == "/"; +} + +function valid_http_url(value) { + value = as_string(value); + let scheme = index(value, "://"); + if (scheme < 0) + return false; + + let prefix = substr(value, 0, scheme + 3); + if (prefix != "http://" && prefix != "https://") + return false; + + let rest = substr(value, scheme + 3); + let slash = index(rest, "/"); + let host_port = slash >= 0 ? substr(rest, 0, slash) : rest; + let colon = index(host_port, ":"); + let host = colon >= 0 ? substr(host_port, 0, colon) : host_port; + + return valid_host(host); +} + +function valid_uuid(value) { + return regex_matches(value, "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); +} + +function valid_nonnegative_integer(value) { + return regex_matches(value, "^[0-9]+$"); +} + +function valid_reality_short_id(value) { + return regex_matches(value, "^[0-9a-fA-F]{1,8}$"); +} + +function valid_transport_service_name(value) { + return regex_matches(value, "^[A-Za-z0-9_.-]+$"); +} + +function mtproto_base_secret_from_value(value) { + value = lc(as_string(value)); + if (regex_matches(value, "^[0-9a-f]{32}$")) { + print(value, "\n"); + return; + } + + if (regex_matches(value, "^ee[0-9a-f]{32}([0-9a-f]{2})+$")) { + print(substr(value, 2, 32), "\n"); + return; + } + + exit(1); +} + +function hex_digit_value(value) { + value = ord(lc(as_string(value))); + if (value >= 48 && value <= 57) + return value - 48; + if (value >= 97 && value <= 102) + return value - 87; + return -1; +} + +function hex_to_string(value) { + value = as_string(value); + if (length(value) == 0 || length(value) % 2 != 0 || !regex_matches(value, "^([0-9a-fA-F]{2})+$")) + exit(1); + + for (let i = 0; i < length(value); i += 2) { + let high = hex_digit_value(substr(value, i, 1)); + let low = hex_digit_value(substr(value, i + 1, 1)); + if (high < 0 || low < 0) + exit(1); + print(chr(high * 16 + low)); + } +} + +function string_to_hex(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) + print(sprintf("%02x", ord(substr(value, i, 1)))); +} + +function mtproto_faketls_from_secret(value) { + value = lc(as_string(value)); + if (!regex_matches(value, "^ee[0-9a-f]{32}([0-9a-f]{2})+$")) + exit(1); + hex_to_string(substr(value, 34)); +} + +function mtproto_build_secret(base, faketls, padding) { + if (as_string(padding) != "1") + exit(1); + + print("ee", as_string(base)); + string_to_hex(faketls); + print("\n"); +} + +function valid_mtproto_base_secret(value) { + value = lc(as_string(value)); + return regex_matches(value, "^[0-9a-f]{32}$") && value != "00000000000000000000000000000000"; +} + +function default_security_for_protocol(protocol) { + protocol = as_string(protocol); + if (protocol == "vless") + print("reality\n"); + else if (protocol == "trojan" || protocol == "hysteria2") + print("tls\n"); + else + print("none\n"); +} + +function safe_filename(value) { + value = as_string(value); + for (let i = 0; i < length(value); i++) { + let chr = substr(value, i, 1); + print(regex_matches(chr, "^[A-Za-z0-9_.-]$") ? chr : "_"); + } +} + +function valid_public_ipv4(value) { + value = as_string(value); + if (!valid_ipv4(value)) + return false; + + let parts = split(value, "."); + let a = int(parts[0]); + let b = int(parts[1]); + + if (a == 0 || a == 10 || a == 127 || a >= 224) + return false; + if (a == 169 && b == 254) + return false; + if (a == 192 && (b == 168 || b == 0 || b == 2)) + return false; + if (a == 198 && (b == 18 || b == 19 || b == 51)) + return false; + if (a == 203 && b == 0) + return false; + if (a == 100 && b >= 64 && b <= 127) + return false; + if (a == 172 && b >= 16 && b <= 31) + return false; + + return true; +} + +function valid_ipv6_literal(value) { + value = as_string(value); + return index(value, ":") >= 0 && regex_matches(value, "^[0-9A-Fa-f:.]+$"); +} + +function valid_cidr(value) { + value = as_string(value); + let slash = index(value, "/"); + if (slash < 0 || index(substr(value, slash + 1), "/") >= 0) + return false; + + let address = substr(value, 0, slash); + let prefix = substr(value, slash + 1); + if (prefix == "" || regex_matches(prefix, "[^0-9]")) + return false; + + prefix = int(prefix); + if (valid_ipv4(address)) + return prefix >= 0 && prefix <= 32; + if (valid_ipv6_literal(address)) + return prefix >= 0 && prefix <= 128; + + return false; +} + +function server_users_from_tsv(protocol, path) { + let result = []; + let data = fs.readfile(path); + + if (data != null) { + for (let line in split(as_string(data), "\n")) { + if (line == "") + continue; + + let parts = split(line, "\t"); + let name = as_string(parts[0] || ""); + let credential = as_string(parts[1] || ""); + let flow = as_string(parts[2] || ""); + + if (credential == "") + continue; + + if (protocol == "vless") { + let user = { uuid: credential }; + if (name != "") + user.name = name; + if (flow != "") + user.flow = flow; + push(result, user); + } + else if (protocol == "vmess") { + let user = { uuid: credential, alterId: int(flow || "0", 10) || 0 }; + if (name != "") + user.name = name; + push(result, user); + } + else if (protocol == "trojan") { + let user = { password: credential }; + if (name != "") + user.name = name; + push(result, user); + } + else if (protocol == "hysteria2") { + let user = { password: credential }; + if (name != "") + user.name = name; + push(result, user); + } + else if (protocol == "mtproto") { + let user = { secret: credential }; + if (name != "") + user.name = name; + push(result, user); + } + else if (protocol == "socks") { + let user = { + username: name != "" ? name : "user", + password: credential + }; + push(result, user); + } + } + } + + write_json(result); +} + +function json_length(path) { + let value = read_json_file(path); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function error_response(message) { + write_json({ + success: false, + message: as_string(message) + }); +} + +function reality_keypair_response(private_key, public_key) { + write_json({ + success: true, + private_key: as_string(private_key), + public_key: as_string(public_key) + }); +} + +function tls_fingerprint_response(fingerprint) { + write_json({ + success: true, + sha256: as_string(fingerprint) + }); +} + +function first_key_value_line_value(data, prefix) { + prefix = as_string(prefix); + for (let line in split(as_string(data), "\n")) { + line = as_string(line); + if (!regex_matches(line, "^" + prefix + ":")) + continue; + + let value = substr(line, length(prefix) + 1); + return replace(value, /^[ \t\r]*/, ""); + } + + return null; +} + +function reality_keypair_tsv() { + let data = read_stdin(); + let private_key = first_key_value_line_value(data, "PrivateKey"); + let public_key = first_key_value_line_value(data, "PublicKey"); + + if (private_key == null || public_key == null || private_key == "" || public_key == "") + exit(1); + + print(private_key, "\t", public_key, "\n"); +} + +function pem_block(data, begin_marker, end_marker) { + let lines = []; + let in_block = false; + + for (let line in split(as_string(data), "\n")) { + line = str_remove_suffix(as_string(line), "\r"); + if (!in_block && line != begin_marker) + continue; + + in_block = true; + push(lines, line); + + if (line == end_marker) + return join("\n", lines); + } + + return ""; +} + +function write_tls_keypair_files(key_path, certificate_path) { + let data = read_stdin(); + let key = pem_block(data, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"); + let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + + if (key == "" || cert == "") + exit(1); + + if (!fs.writefile(key_path, key + "\n")) + exit(1); + if (!fs.writefile(certificate_path, cert + "\n")) + exit(1); +} + +function certificate_base64(path) { + let data = fs.readfile(path); + let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + let lines = []; + + if (cert == "") + exit(1); + + for (let line in split(cert, "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (line == "-----BEGIN CERTIFICATE-----" || line == "-----END CERTIFICATE-----" || line == "") + continue; + + push(lines, line); + } + + if (length(lines) == 0) + exit(1); + + print(join("\n", lines), "\n"); +} + +function valid_sha256_hex(value) { + return regex_matches(value, "^[0-9a-f]{64}$"); +} + +function command_first_nonempty_line(command) { + for (let line in split(output(command), "\n")) + if (trim(as_string(line)) != "") + return line; + return ""; +} + +function server_generate_uuid() { + return command_first_nonempty_line("sing-box generate uuid 2>/dev/null"); +} + +function server_generate_password() { + return replace(output("sing-box generate rand --base64 18 2>/dev/null"), /\n/g, ""); +} + +function server_generate_short_id() { + return replace(output("sing-box generate rand --hex 4 2>/dev/null"), /\n/g, ""); +} + +function server_generate_mtproto_secret() { + let random_hex = replace(output("sing-box generate rand --hex 16 2>/dev/null"), /\n/g, ""); + if (random_hex == "") + random_hex = replace(output("head -c 16 /dev/urandom | hexdump -ve '1/1 \"%02x\"' 2>/dev/null"), /\n/g, ""); + if (random_hex == "00000000000000000000000000000000") + random_hex = "11111111111111111111111111111111"; + return random_hex; +} + +function default_security_value(protocol) { + protocol = as_string(protocol); + if (protocol == "vless") + return "reality"; + if (protocol == "trojan" || protocol == "hysteria2") + return "tls"; + return "none"; +} + +function server_effective_security_value(section, protocol) { + protocol = as_string(protocol); + let security = config_get(section, "security", ""); + if (security == "") + security = default_security_value(protocol); + + if (protocol == "shadowsocks" || protocol == "socks" || protocol == "mtproto" || protocol == "tailscale" || protocol == "json_inbound") + security = "none"; + else if (protocol == "hysteria2") + security = "tls"; + else if ((protocol == "vmess" || protocol == "trojan") && security == "reality") + security = default_security_value(protocol); + + return security; +} + +function print_effective_security(section, protocol) { + print(server_effective_security_value(section, protocol), "\n"); +} + +function generate_reality_keypair_values() { + let data = output("sing-box generate reality-keypair 2>/dev/null"); + let private_key = first_key_value_line_value(data, "PrivateKey"); + let public_key = first_key_value_line_value(data, "PublicKey"); + + if (private_key == null || public_key == null || private_key == "" || public_key == "") + return null; + + return { + private_key: private_key, + public_key: public_key + }; +} + +function generate_reality_keypair_cli() { + let pair = generate_reality_keypair_values(); + if (pair == null) { + error_response("Failed to generate Reality key pair"); + exit(1); + } + + reality_keypair_response(pair.private_key, pair.public_key); +} + +function write_tls_keypair_data(data, key_path, certificate_path) { + let key = pem_block(data, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"); + let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + + if (key == "" || cert == "") + return false; + + return fs.writefile(key_path, key + "\n") && fs.writefile(certificate_path, cert + "\n"); +} + +function server_generate_tls_keypair_files(server_name, certificate_path, key_path) { + let data = output("sing-box generate tls-keypair " + shell_quote(server_name) + " 2>/dev/null"); + if (data == "") + return false; + + let key_dir = replace(as_string(key_path), /\/[^\/]*$/, ""); + let cert_dir = replace(as_string(certificate_path), /\/[^\/]*$/, ""); + if (!run("mkdir -p " + shell_quote(key_dir) + " " + shell_quote(cert_dir))) + return false; + + if (!write_tls_keypair_data(data, key_path, certificate_path)) + return false; + + run("chmod 600 " + shell_quote(key_path) + " >/dev/null 2>&1"); + return true; +} + +function certificate_base64_text(path) { + let data = fs.readfile(path); + let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + let lines = []; + + if (cert == "") + return ""; + + for (let line in split(cert, "\n")) { + line = replace(as_string(line), /\r/g, ""); + if (line == "-----BEGIN CERTIFICATE-----" || line == "-----END CERTIFICATE-----" || line == "") + continue; + push(lines, line); + } + + return length(lines) == 0 ? "" : join("\n", lines) + "\n"; +} + +function server_tls_certificate_sha256_from_file(certificate_path) { + if (!valid_file_path(certificate_path)) + return ""; + let base64_text = certificate_base64_text(certificate_path); + if (base64_text == "") + return ""; + + let tmp_path = output("mktemp 2>/dev/null"); + if (tmp_path == "") + return ""; + + if (!fs.writefile(tmp_path, base64_text)) { + fs.unlink(tmp_path); + return ""; + } + + let fingerprint = output("base64 -d " + shell_quote(tmp_path) + " 2>/dev/null | sha256sum 2>/dev/null"); + fs.unlink(tmp_path); + fingerprint = substr(fingerprint, 0, index(fingerprint, " ") >= 0 ? index(fingerprint, " ") : length(fingerprint)); + return valid_sha256_hex(fingerprint) ? fingerprint : ""; +} + +function get_tls_certificate_sha256_cli(section) { + if (as_string(section) == "") { + error_response("Missing server section"); + exit(1); + } + + let protocol = config_get(section, "protocol", "vless"); + let security = server_effective_security_value(section, protocol); + if (security != "tls") { + error_response("Server does not use TLS"); + exit(1); + } + + let certificate_path = config_get(section, "tls_certificate_path", ""); + if (certificate_path == "") { + error_response("TLS certificate path is empty"); + exit(1); + } + + let fingerprint = server_tls_certificate_sha256_from_file(certificate_path); + if (fingerprint == "") { + error_response("Failed to read TLS certificate fingerprint"); + exit(1); + } + + tls_fingerprint_response(fingerprint); +} + +function network_status_ipv4_address(data) { + try { + data = json(as_string(data)); + } + catch (e) { + return ""; + } + + let addresses = data["ipv4-address"]; + if (type(addresses) != "array") + return ""; + + for (let item in addresses) + if (type(item) == "object" && as_string(item.address) != "") + return as_string(item.address); + + return ""; +} + +function server_detect_default_public_host() { + for (let interface in [ "wan", "wwan" ]) { + let ip = network_status_ipv4_address(output("ubus -S call " + shell_quote("network.interface." + interface) + " status 2>/dev/null")); + if (valid_public_ipv4(ip)) + return ip; + } + + let lan_ip = uci_get("network.lan.ipaddr"); + return valid_dotted_ipv4(lan_ip) ? lan_ip : ""; +} + +function first_server_user(section) { + let users = words(uci_get(config_path(section, "server_users"))); + return length(users) > 0 ? users[0] : ""; +} + +function server_prepare_legacy_user_defaults(section, protocol) { + let entry = first_server_user(section); + if (entry == "") + return; + + let parts = split(entry, "|"); + let name = "client"; + let credential = entry; + let extra = ""; + if (length(parts) > 1) { + name = as_string(parts[0]); + credential = as_string(parts[1]); + extra = as_string(parts[2] || ""); + } + + if (protocol == "vless" || protocol == "vmess") + server_default_set_option(section, "server_uuid", credential); + else if (protocol == "shadowsocks" || protocol == "socks" || protocol == "trojan" || protocol == "hysteria2") + server_default_set_option(section, "server_password", credential); + else if (protocol == "mtproto") { + let mtproto_faketls = ""; + try { + let capture = output("ucode " + shell_quote(getenv("PODKOP_SERVER_RUNTIME_UC") || "/usr/lib/podkop-plus/server/service.uc") + " mtproto-faketls-from-secret " + shell_quote(credential) + " 2>/dev/null"); + mtproto_faketls = capture; + } + catch (e) { + mtproto_faketls = ""; + } + let base_secret = ""; + try { + base_secret = output("ucode " + shell_quote(getenv("PODKOP_SERVER_RUNTIME_UC") || "/usr/lib/podkop-plus/server/service.uc") + " mtproto-base-secret " + shell_quote(credential) + " 2>/dev/null"); + } + catch (e) { + base_secret = ""; + } + server_default_set_option(section, "mtproto_secret", base_secret != "" ? base_secret : credential); + server_default_set_option(section, "mtproto_faketls", mtproto_faketls != "" ? mtproto_faketls : "google.com"); + server_default_set_option(section, "mtproto_padding", "1"); + } + + if (protocol == "socks" && name != "") + server_default_set_option(section, "server_username", name); + if (protocol == "vless" && extra != "") + server_default_set_option(section, "vless_flow", extra); +} + +function server_prepare_tls_defaults(section, protocol, security) { + if (security != "tls") + return; + if (config_get(section, "enabled", "1") == "0") + return; + + let tls_server_name = config_get(section, "tls_server_name", ""); + if (tls_server_name == "") + tls_server_name = "www.microsoft.com"; + server_default_set_option(section, "tls_server_name", tls_server_name); + + let certificate_path = config_get(section, "tls_certificate_path", ""); + let key_path = config_get(section, "tls_key_path", ""); + if (certificate_path == "" || key_path == "") { + let safe_name = safe_filename_string(section); + let default_dir = "/etc/podkop-plus/server-certs"; + if (certificate_path == "") + certificate_path = default_dir + "/" + safe_name + ".crt"; + if (key_path == "") + key_path = default_dir + "/" + safe_name + ".key"; + server_set_option(section, "tls_certificate_path", certificate_path); + server_set_option(section, "tls_key_path", key_path); + } + + if (!valid_file_path(certificate_path)) + fatal("Server '" + section + "' has invalid TLS certificate path. Specify a file path. Aborted."); + if (!valid_file_path(key_path)) + fatal("Server '" + section + "' has invalid TLS key path. Specify a file path. Aborted."); + if (certificate_path == key_path) + fatal("Server '" + section + "' has the same TLS certificate and key path. Specify different files. Aborted."); + + if (!run("[ -s " + shell_quote(certificate_path) + " ] && [ -s " + shell_quote(key_path) + " ]")) { + log("Generating self-signed TLS certificate for server '" + section + "'", "info"); + if (!server_generate_tls_keypair_files(tls_server_name, certificate_path, key_path)) + fatal("Failed to generate TLS certificate for server '" + section + "'. Aborted."); + } +} + +function safe_filename_string(value) { + value = as_string(value); + let result = []; + for (let i = 0; i < length(value); i++) { + let chr = substr(value, i, 1); + push(result, regex_matches(chr, "^[A-Za-z0-9_.-]$") ? chr : "_"); + } + return join("", result); +} + +function prepare_server_defaults(section) { + let protocol = config_get(section, "protocol", "vless"); + if (!(protocol == "shadowsocks" || protocol == "socks" || protocol == "vmess" || protocol == "vless" || + protocol == "trojan" || protocol == "hysteria2" || protocol == "mtproto" || protocol == "tailscale" || + protocol == "json_inbound")) + fatal("Server '" + section + "' has unsupported protocol '" + protocol + "'. Aborted."); + + server_default_set_option(section, "protocol", protocol); + server_default_set_option(section, "label", section); + server_default_set_option(section, "enabled", "1"); + server_default_set_option(section, "routing_mode", "rules"); + + if (protocol == "json_inbound") { + server_set_option(section, "security", "none"); + return; + } + + server_default_set_option(section, "listen", "0.0.0.0"); + server_default_set_option(section, "listen_port", "443"); + server_default_set_option(section, "public_host", server_detect_default_public_host()); + + let security = server_effective_security_value(section, protocol); + server_set_option(section, "security", security); + server_prepare_legacy_user_defaults(section, protocol); + + if (protocol == "vless" || protocol == "vmess") { + if (uci_get(config_path(section, "server_uuid")) == "") { + let uuid = server_generate_uuid(); + server_default_set_option(section, "server_uuid", uuid); + } + } + else if (protocol == "shadowsocks" || protocol == "socks" || protocol == "trojan" || protocol == "hysteria2") { + if (uci_get(config_path(section, "server_password")) == "") { + let password = server_generate_password(); + server_default_set_option(section, "server_password", password); + } + } + + if (protocol == "mtproto") { + let mtproto_secret = uci_get(config_path(section, "mtproto_secret")); + let mtproto_faketls = ""; + let mtproto_base_secret = ""; + if (mtproto_secret != "") { + mtproto_faketls = output("ucode " + shell_quote(getenv("PODKOP_SERVER_RUNTIME_UC") || "/usr/lib/podkop-plus/server/service.uc") + " mtproto-faketls-from-secret " + shell_quote(mtproto_secret) + " 2>/dev/null"); + mtproto_base_secret = output("ucode " + shell_quote(getenv("PODKOP_SERVER_RUNTIME_UC") || "/usr/lib/podkop-plus/server/service.uc") + " mtproto-base-secret " + shell_quote(mtproto_secret) + " 2>/dev/null"); + } + if (mtproto_base_secret == "") + mtproto_base_secret = server_generate_mtproto_secret(); + if (mtproto_secret != mtproto_base_secret) + server_set_option(section, "mtproto_secret", mtproto_base_secret); + server_default_set_option(section, "mtproto_faketls", mtproto_faketls != "" ? mtproto_faketls : "google.com"); + server_default_set_option(section, "mtproto_padding", "1"); + server_default_set_option(section, "mtproto_domain_fronting_port", "443"); + server_default_set_option(section, "mtproto_prefer_ip", "prefer-ipv4"); + server_default_set_option(section, "mtproto_tolerate_time_skewness", "3s"); + server_default_set_option(section, "mtproto_idle_timeout", "5m"); + server_default_set_option(section, "mtproto_handshake_timeout", "10s"); + } + + if (protocol == "socks" && uci_get(config_path(section, "server_username")) == "") + server_default_set_option(section, "server_username", config_get(section, "label", section)); + if (protocol == "vless") + server_default_set_option(section, "vless_flow", "none"); + if (protocol == "vmess") + server_default_set_option(section, "vmess_alter_id", "0"); + if (protocol == "shadowsocks") + server_default_set_option(section, "shadowsocks_method", "aes-128-gcm"); + if (protocol == "tailscale") { + let safe_name = safe_filename_string(section); + server_default_set_option(section, "tailscale_control_url", "https://controlplane.tailscale.com"); + server_default_set_option(section, "tailscale_hostname", "podkop-" + safe_name); + server_default_set_option(section, "tailscale_advertise_exit_node", "1"); + } + + if (security == "reality") { + server_default_set_option(section, "tls_server_name", "www.microsoft.com"); + server_default_set_option(section, "client_fingerprint", "chrome"); + server_default_set_option(section, "reality_handshake_server", "www.microsoft.com"); + server_default_set_option(section, "reality_handshake_server_port", "443"); + if (uci_get(config_path(section, "reality_short_id")) == "") + server_default_set_option(section, "reality_short_id", server_generate_short_id()); + server_default_set_option(section, "reality_max_time_difference", "1m"); + + let reality_private_key = uci_get(config_path(section, "reality_private_key")); + let reality_public_key = uci_get(config_path(section, "reality_public_key")); + if (reality_private_key == "" || reality_public_key == "") { + log("Generating Reality key pair for server '" + section + "'", "info"); + let pair = generate_reality_keypair_values(); + if (pair == null) + fatal("Failed to generate Reality key pair for server '" + section + "'. Aborted."); + server_set_option(section, "reality_private_key", pair.private_key); + server_set_option(section, "reality_public_key", pair.public_key); + } + } + + server_prepare_tls_defaults(section, protocol, security); +} + +function prepare_all_server_defaults() { + SERVER_DEFAULTS_CHANGED = false; + for (let section in uci_sections("server")) + prepare_server_defaults(section); + if (SERVER_DEFAULTS_CHANGED) + uci_commit(CONFIG_NAME); +} + +let mode = ARGV[0] || ""; + +if (mode == "valid-file-path") + exit(valid_file_path(ARGV[1]) ? 0 : 1); +else if (mode == "valid-dotted-ipv4") + exit(valid_dotted_ipv4(ARGV[1]) ? 0 : 1); +else if (mode == "stdin-first-nonempty-line") + stdin_first_nonempty_line(); +else if (mode == "stdin-remove-newlines") + stdin_remove_newlines(); +else if (mode == "shell-single-quote") + shell_single_quote(ARGV[1]); +else if (mode == "valid-port") + exit(valid_port(ARGV[1]) ? 0 : 1); +else if (mode == "normalize-host") + print(normalize_host(ARGV[1]), "\n"); +else if (mode == "valid-host") + exit(valid_host(ARGV[1]) ? 0 : 1); +else if (mode == "valid-absolute-path") + exit(valid_absolute_path(ARGV[1]) ? 0 : 1); +else if (mode == "valid-http-url") + exit(valid_http_url(ARGV[1]) ? 0 : 1); +else if (mode == "valid-duration") + exit(valid_duration(ARGV[1]) ? 0 : 1); +else if (mode == "valid-client-value") + exit(valid_client_value(ARGV[1]) ? 0 : 1); +else if (mode == "valid-uuid") + exit(valid_uuid(ARGV[1]) ? 0 : 1); +else if (mode == "valid-nonnegative-integer") + exit(valid_nonnegative_integer(ARGV[1]) ? 0 : 1); +else if (mode == "valid-reality-short-id") + exit(valid_reality_short_id(ARGV[1]) ? 0 : 1); +else if (mode == "valid-transport-service-name") + exit(valid_transport_service_name(ARGV[1]) ? 0 : 1); +else if (mode == "mtproto-base-secret") + mtproto_base_secret_from_value(ARGV[1]); +else if (mode == "mtproto-faketls-from-secret") + mtproto_faketls_from_secret(ARGV[1]); +else if (mode == "mtproto-build-secret") + mtproto_build_secret(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "valid-mtproto-base-secret") + exit(valid_mtproto_base_secret(ARGV[1]) ? 0 : 1); +else if (mode == "default-security-for-protocol") + default_security_for_protocol(ARGV[1]); +else if (mode == "effective-security") + print_effective_security(ARGV[1], ARGV[2]); +else if (mode == "safe-filename") + safe_filename(ARGV[1]); +else if (mode == "valid-public-ipv4") + exit(valid_public_ipv4(ARGV[1]) ? 0 : 1); +else if (mode == "valid-cidr") + exit(valid_cidr(ARGV[1]) ? 0 : 1); +else if (mode == "server-users-from-tsv") + server_users_from_tsv(ARGV[1], ARGV[2]); +else if (mode == "json-length") + json_length(ARGV[1]); +else if (mode == "array-append-string") + array_append_string(ARGV[1]); +else if (mode == "error-response") + error_response(ARGV[1]); +else if (mode == "prepare-all-defaults") + prepare_all_server_defaults(); +else if (mode == "generate-reality-keypair") + generate_reality_keypair_cli(); +else if (mode == "tls-certificate-sha256") + get_tls_certificate_sha256_cli(ARGV[1]); +else if (mode == "reality-keypair-response") + reality_keypair_response(ARGV[1], ARGV[2]); +else if (mode == "reality-keypair-tsv") + reality_keypair_tsv(); +else if (mode == "write-tls-keypair-files") + write_tls_keypair_files(ARGV[1], ARGV[2]); +else if (mode == "certificate-base64") + certificate_base64(ARGV[1]); +else if (mode == "valid-sha256-hex") + exit(valid_sha256_hex(ARGV[1]) ? 0 : 1); +else if (mode == "tls-fingerprint-response") + tls_fingerprint_response(ARGV[1]); +else { + warn("Usage: server/service.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/server_runtime.sh b/podkop/files/usr/lib/server_runtime.sh deleted file mode 100644 index edcc8feb..00000000 --- a/podkop/files/usr/lib/server_runtime.sh +++ /dev/null @@ -1,1200 +0,0 @@ -# shellcheck shell=ash - -server_runtime_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/server_runtime.uc" "$@" -} - -configure_server_inbound() { - local section="$1" - local protocol listen listen_port public_host inbound_tag required_proto port_conflict_owners users_tmp status \ - inbound_json inbound_json_tmp \ - shadowsocks_method server_password hysteria2_up_mbps hysteria2_down_mbps \ - hysteria2_obfs_type hysteria2_obfs_password mtproto_faketls mtproto_padding mtproto_concurrency \ - mtproto_domain_fronting_port mtproto_domain_fronting_ip \ - mtproto_domain_fronting_proxy_protocol mtproto_prefer_ip mtproto_auto_update \ - mtproto_allow_fallback_on_unknown_dc mtproto_tolerate_time_skewness \ - mtproto_idle_timeout mtproto_handshake_timeout - - server_is_enabled "$section" || return 0 - - config_get protocol "$section" "protocol" "vless" - case "$protocol" in - shadowsocks | socks | vmess | vless | trojan | hysteria2 | mtproto | tailscale | json_inbound) ;; - *) - log "Server '$section' has unsupported protocol '$protocol'. Aborted." "fatal" - exit 1 - ;; - esac - - inbound_tag="$(get_server_inbound_tag_by_section "$section")" - - if [ "$protocol" = "tailscale" ]; then - configure_tailscale_server_endpoint "$section" - return 0 - fi - - if [ "$protocol" = "json_inbound" ]; then - config_get inbound_json "$section" "inbound_json" - if [ -z "$inbound_json" ]; then - log "JSON inbound server '$section' has empty inbound_json. Aborted." "fatal" - exit 1 - fi - if ! printf '%s' "$inbound_json" | config_validation_ucode valid-inbound >/dev/null 2>&1; then - log "JSON inbound server '$section' must contain a valid sing-box inbound JSON object with a type field. Aborted." "fatal" - exit 1 - fi - inbound_json_tmp="$(mktemp)" || { - log "Failed to create temporary JSON inbound file for server '$section'. Aborted." "fatal" - exit 1 - } - printf '%s' "$inbound_json" > "$inbound_json_tmp" || { - rm -f "$inbound_json_tmp" - log "Failed to write temporary JSON inbound file for server '$section'. Aborted." "fatal" - exit 1 - } - config=$(sing_box_cm_add_raw_inbound_file "$config" "$inbound_tag" "$inbound_json_tmp") - status=$? - rm -f "$inbound_json_tmp" - [ "$status" -eq 0 ] || { - log "Failed to configure JSON inbound server '$section'. Aborted." "fatal" - exit 1 - } - return 0 - fi - - config_get listen "$section" "listen" "0.0.0.0" - config_get listen_port "$section" "listen_port" - config_get public_host "$section" "public_host" - - if ! is_ipv4 "$listen"; then - log "Server '$section' has invalid listen address '$listen'. Use an IPv4 address. Aborted." "fatal" - exit 1 - fi - if ! server_port_is_valid "$listen_port"; then - log "Server '$section' has invalid listen port '$listen_port'. Aborted." "fatal" - exit 1 - fi - if [ -n "$public_host" ] && ! server_host_is_valid "$public_host"; then - log "Server '$section' has invalid public host '$public_host'. Use a domain name or IPv4 address. Aborted." "fatal" - exit 1 - fi - - required_proto="$(server_required_inbound_proto "$protocol")" - port_conflict_owners="$(server_required_port_conflict_owners "$listen" "$listen_port" "$required_proto")" - if [ -n "$port_conflict_owners" ]; then - log "Server '$section' cannot listen on $listen:$listen_port [$required_proto]: port is already used by $port_conflict_owners. Aborted." "fatal" - exit 1 - fi - - case "$protocol" in - shadowsocks) - config_get shadowsocks_method "$section" "shadowsocks_method" "aes-128-gcm" - config_get server_password "$section" "server_password" - case "$shadowsocks_method" in - aes-128-gcm | aes-256-gcm | chacha20-ietf-poly1305) ;; - *) - log "Server '$section' has unsupported Shadowsocks method '$shadowsocks_method'. Aborted." "fatal" - exit 1 - ;; - esac - if [ -z "$server_password" ]; then - log "Server '$section' has no client password. Aborted." "fatal" - exit 1 - fi - if ! server_client_value_is_valid "$server_password"; then - log "Server '$section' has invalid Shadowsocks password. It must not contain control characters. Aborted." "fatal" - exit 1 - fi - config=$(sing_box_cm_add_shadowsocks_inbound "$config" "$inbound_tag" "$listen" "$listen_port" "$shadowsocks_method" "$server_password") - status=$? - ;; - socks | vmess | vless | trojan | hysteria2 | mtproto) - users_tmp="$(server_write_users_json "$section" "$protocol")" || { - log "Failed to prepare users for server '$section'. Aborted." "fatal" - exit 1 - } - - case "$protocol" in - socks) - config=$(sing_box_cm_add_socks_inbound "$config" "$inbound_tag" "$listen" "$listen_port" "$users_tmp") - ;; - vmess) - config=$(sing_box_cm_add_vmess_inbound "$config" "$inbound_tag" "$listen" "$listen_port" "$users_tmp") - ;; - vless) - config=$(sing_box_cm_add_vless_inbound "$config" "$inbound_tag" "$listen" "$listen_port" "$users_tmp") - ;; - trojan) - config=$(sing_box_cm_add_trojan_inbound "$config" "$inbound_tag" "$listen" "$listen_port" "$users_tmp") - ;; - hysteria2) - config_get hysteria2_up_mbps "$section" "hysteria2_up_mbps" - config_get hysteria2_down_mbps "$section" "hysteria2_down_mbps" - config_get hysteria2_obfs_type "$section" "hysteria2_obfs_type" - config_get hysteria2_obfs_password "$section" "hysteria2_obfs_password" - case "$hysteria2_obfs_type" in - '' | salamander) ;; - *) - log "Server '$section' has unsupported Hysteria2 obfuscation '$hysteria2_obfs_type'. Aborted." "fatal" - exit 1 - ;; - esac - if [ "$hysteria2_obfs_type" = "salamander" ] && [ -z "$hysteria2_obfs_password" ]; then - log "Server '$section' uses Hysteria2 salamander obfuscation but password is not set. Aborted." "fatal" - exit 1 - fi - config=$( - sing_box_cm_add_hysteria2_inbound \ - "$config" "$inbound_tag" "$listen" "$listen_port" "$users_tmp" \ - "$hysteria2_up_mbps" "$hysteria2_down_mbps" "$hysteria2_obfs_type" "$hysteria2_obfs_password" - ) - ;; - mtproto) - config_get mtproto_faketls "$section" "mtproto_faketls" "google.com" - config_get_bool mtproto_padding "$section" "mtproto_padding" 1 - config_get mtproto_concurrency "$section" "mtproto_concurrency" - config_get mtproto_domain_fronting_port "$section" "mtproto_domain_fronting_port" "443" - config_get mtproto_domain_fronting_ip "$section" "mtproto_domain_fronting_ip" - config_get_bool mtproto_domain_fronting_proxy_protocol "$section" "mtproto_domain_fronting_proxy_protocol" 0 - config_get mtproto_prefer_ip "$section" "mtproto_prefer_ip" "prefer-ipv4" - config_get_bool mtproto_auto_update "$section" "mtproto_auto_update" 0 - config_get_bool mtproto_allow_fallback_on_unknown_dc "$section" "mtproto_allow_fallback_on_unknown_dc" 0 - config_get mtproto_tolerate_time_skewness "$section" "mtproto_tolerate_time_skewness" "3s" - config_get mtproto_idle_timeout "$section" "mtproto_idle_timeout" "5m" - config_get mtproto_handshake_timeout "$section" "mtproto_handshake_timeout" "10s" - - if ! server_host_is_valid "$mtproto_faketls"; then - log "Server '$section' has invalid FakeTLS host '$mtproto_faketls'. Use a domain name or IPv4 address. Aborted." "fatal" - exit 1 - fi - if [ "$mtproto_padding" -ne 1 ]; then - log "Server '$section' has padding disabled, but sing-box-extended MTProxy requires padded FakeTLS secrets. Aborted." "fatal" - exit 1 - fi - if [ -n "$mtproto_concurrency" ]; then - case "$mtproto_concurrency" in - '' | *[!0-9]*) - log "Server '$section' has invalid concurrency '$mtproto_concurrency'. Use a non-negative integer. Aborted." "fatal" - exit 1 - ;; - esac - fi - if ! server_port_is_valid "$mtproto_domain_fronting_port"; then - log "Server '$section' has invalid fronting port '$mtproto_domain_fronting_port'. Aborted." "fatal" - exit 1 - fi - if [ -n "$mtproto_domain_fronting_ip" ] && ! is_ipv4 "$mtproto_domain_fronting_ip"; then - log "Server '$section' has invalid fronting IP '$mtproto_domain_fronting_ip'. Use an IPv4 address. Aborted." "fatal" - exit 1 - fi - case "$mtproto_prefer_ip" in - prefer-ipv4 | prefer-ipv6 | only-ipv4 | only-ipv6) ;; - *) - log "Server '$section' has unsupported preferred IP mode '$mtproto_prefer_ip'. Aborted." "fatal" - exit 1 - ;; - esac - if ! server_duration_is_valid "$mtproto_tolerate_time_skewness"; then - log "Server '$section' has invalid time skew tolerance '$mtproto_tolerate_time_skewness'. Aborted." "fatal" - exit 1 - fi - if ! server_duration_is_valid "$mtproto_idle_timeout"; then - log "Server '$section' has invalid idle timeout '$mtproto_idle_timeout'. Aborted." "fatal" - exit 1 - fi - if ! server_duration_is_valid "$mtproto_handshake_timeout"; then - log "Server '$section' has invalid handshake timeout '$mtproto_handshake_timeout'. Aborted." "fatal" - exit 1 - fi - - config=$( - sing_box_cm_add_mtproxy_inbound \ - "$config" "$inbound_tag" "$listen" "$listen_port" "$users_tmp" \ - "$mtproto_concurrency" "$mtproto_domain_fronting_port" "$mtproto_domain_fronting_ip" \ - "$mtproto_domain_fronting_proxy_protocol" "$mtproto_prefer_ip" "$mtproto_auto_update" \ - "$mtproto_allow_fallback_on_unknown_dc" "$mtproto_tolerate_time_skewness" \ - "$mtproto_idle_timeout" "$mtproto_handshake_timeout" - ) - ;; - esac - status=$? - rm -f "$users_tmp" - ;; - esac - [ "$status" -eq 0 ] || { - log "Failed to configure server inbound '$section'. Aborted." "fatal" - exit 1 - } - - server_apply_tls "$section" "$inbound_tag" - server_apply_transport "$section" "$inbound_tag" -} - -configure_tailscale_server_endpoint() { - local section="$1" - local endpoint_tag safe_name state_directory auth_key control_url hostname accept_routes \ - advertise_routes advertise_exit_node status - - endpoint_tag="$(get_server_inbound_tag_by_section "$section")" - safe_name="$(server_safe_filename "$section")" - - state_directory="/etc/podkop-plus/tailscale/$safe_name" - config_get auth_key "$section" "tailscale_auth_key" - config_get control_url "$section" "tailscale_control_url" "https://controlplane.tailscale.com" - config_get hostname "$section" "tailscale_hostname" "podkop-$safe_name" - config_get_bool accept_routes "$section" "tailscale_accept_routes" 0 - config_get_bool advertise_exit_node "$section" "tailscale_advertise_exit_node" 0 - tailscale_collect_advertise_routes "$section" - advertise_routes="$TAILSCALE_ADVERTISE_ROUTES_JSON" - - if ! server_absolute_path_is_valid "$state_directory"; then - log "Server '$section' has invalid Tailscale state directory '$state_directory'. Use an absolute path. Aborted." "fatal" - exit 1 - fi - if [ -z "$auth_key" ]; then - log "Server '$section' uses Tailscale but auth key is empty. Aborted." "fatal" - exit 1 - fi - if ! server_http_url_is_valid "$control_url"; then - log "Server '$section' has invalid Tailscale control URL '$control_url'. Use an HTTP or HTTPS URL. Aborted." "fatal" - exit 1 - fi - if ! server_host_is_valid "$hostname"; then - log "Server '$section' has invalid Tailscale hostname '$hostname'. Aborted." "fatal" - exit 1 - fi - - mkdir -p "$state_directory" || { - log "Failed to create Tailscale state directory for server '$section'. Aborted." "fatal" - exit 1 - } - - config=$( - sing_box_cm_add_tailscale_endpoint \ - "$config" \ - "$endpoint_tag" \ - "$state_directory" \ - "$auth_key" \ - "$control_url" \ - "0" \ - "$hostname" \ - "$accept_routes" \ - "" \ - "0" \ - "$advertise_routes" \ - "$advertise_exit_node" \ - "" - ) - status=$? - [ "$status" -eq 0 ] || { - log "Failed to configure Tailscale endpoint '$section'. Aborted." "fatal" - exit 1 - } -} - -tailscale_normalize_advertise_route() { - local value="$1" - - case "$value" in - */*) - printf '%s\n' "$value" - return 0 - ;; - esac - - if is_ipv4 "$value"; then - printf '%s/32\n' "$value" - return 0 - fi - - if is_ipv6_literal "$value"; then - printf '%s/128\n' "$value" - return 0 - fi - - printf '%s\n' "$value" -} - -tailscale_collect_advertise_route_handler() { - local value="$1" normalized - local section="$2" - - [ -n "$value" ] || return 0 - TAILSCALE_ADVERTISE_ROUTE_SEEN=1 - normalized="$(tailscale_normalize_advertise_route "$value")" - - if ! server_cidr_is_valid "$normalized"; then - log "Server '$section' has invalid Tailscale advertised route '$value'. Use CIDR prefixes or IP addresses like 192.168.1.0/24 or 192.168.1.10. Aborted." "fatal" - exit 1 - fi - - TAILSCALE_ADVERTISE_ROUTES_JSON="$(printf '%s' "$TAILSCALE_ADVERTISE_ROUTES_JSON" | - server_runtime_ucode array-append-string "$normalized" 2>/dev/null)" || { - log "Failed to prepare Tailscale advertised routes for server '$section'. Aborted." "fatal" - exit 1 - } -} - -tailscale_collect_advertise_routes() { - local section="$1" - local values value - - TAILSCALE_ADVERTISE_ROUTES_JSON="[]" - TAILSCALE_ADVERTISE_ROUTE_SEEN=0 - - config_list_foreach "$section" "tailscale_advertise_routes" tailscale_collect_advertise_route_handler "$section" - [ "$TAILSCALE_ADVERTISE_ROUTE_SEEN" -eq 1 ] && return 0 - - config_get values "$section" "tailscale_advertise_routes" - [ -n "$values" ] || return 0 - - for value in $values; do - tailscale_collect_advertise_route_handler "$value" "$section" - done -} - -server_port_is_valid() { - server_runtime_ucode valid-port "$1" >/dev/null 2>&1 -} - -server_normalize_host() { - server_runtime_ucode normalize-host "$1" -} - -server_host_is_valid() { - server_runtime_ucode valid-host "$1" >/dev/null 2>&1 -} - -server_absolute_path_is_valid() { - server_runtime_ucode valid-absolute-path "$1" >/dev/null 2>&1 -} - -server_file_path_is_valid() { - server_runtime_ucode valid-file-path "$1" >/dev/null 2>&1 -} - -server_client_value_is_valid() { - server_runtime_ucode valid-client-value "$1" >/dev/null 2>&1 -} - -server_http_url_is_valid() { - server_runtime_ucode valid-http-url "$1" >/dev/null 2>&1 -} - -server_duration_is_valid() { - server_runtime_ucode valid-duration "$1" >/dev/null 2>&1 -} - -server_default_security_for_protocol() { - server_runtime_ucode default-security-for-protocol "$1" -} - -server_effective_security() { - local section="$1" - local protocol="$2" - local security - - config_get security "$section" "security" - [ -n "$security" ] || security="$(server_default_security_for_protocol "$protocol")" - - case "$protocol" in - shadowsocks | socks | mtproto | tailscale) - security="none" - ;; - json_inbound) - security="none" - ;; - hysteria2) - security="tls" - ;; - vmess | trojan) - [ "$security" != "reality" ] || security="$(server_default_security_for_protocol "$protocol")" - ;; - esac - - printf '%s\n' "$security" -} - -server_default_set_option() { - local section="$1" - local option="$2" - local value="$3" - local current - - [ -n "$value" ] || return 0 - current="$(uci -q get "$PODKOP_CONFIG_NAME.$section.$option" 2>/dev/null)" - [ -n "$current" ] && return 0 - - uci set "$PODKOP_CONFIG_NAME.$section.$option=$value" - SERVER_DEFAULTS_CHANGED=1 -} - -server_set_option() { - local section="$1" - local option="$2" - local value="$3" - local current - - [ -n "$value" ] || return 0 - current="$(uci -q get "$PODKOP_CONFIG_NAME.$section.$option" 2>/dev/null)" - [ "$current" = "$value" ] && return 0 - - uci set "$PODKOP_CONFIG_NAME.$section.$option=$value" - SERVER_DEFAULTS_CHANGED=1 -} - -server_default_add_list() { - local section="$1" - local option="$2" - local value="$3" - local current quoted_value - - [ -n "$value" ] || return 0 - current="$(uci -q get "$PODKOP_CONFIG_NAME.$section.$option" 2>/dev/null)" - [ -n "$current" ] && return 0 - - quoted_value="$(server_runtime_ucode shell-single-quote "$value")" - printf 'add_list %s.%s.%s=%s\n' "$PODKOP_CONFIG_NAME" "$section" "$option" "$quoted_value" | uci -q batch - SERVER_DEFAULTS_CHANGED=1 -} - -server_generate_uuid() { - sing-box generate uuid 2>/dev/null | server_runtime_ucode stdin-first-nonempty-line -} - -server_generate_password() { - sing-box generate rand --base64 18 2>/dev/null | server_runtime_ucode stdin-remove-newlines -} - -server_generate_mtproto_secret() { - local random_hex - - random_hex="$(sing-box generate rand --hex 16 2>/dev/null | server_runtime_ucode stdin-remove-newlines)" - if [ -z "$random_hex" ]; then - random_hex="$(head -c 16 /dev/urandom | hexdump -ve '1/1 "%02x"')" - fi - - case "$random_hex" in - 00000000000000000000000000000000) random_hex="11111111111111111111111111111111" ;; - esac - - printf '%s\n' "$random_hex" -} - -server_mtproto_base_secret_from_value() { - server_runtime_ucode mtproto-base-secret "$1" -} - -server_mtproto_faketls_from_secret() { - server_runtime_ucode mtproto-faketls-from-secret "$1" -} - -server_mtproto_base_secret_is_valid() { - server_runtime_ucode valid-mtproto-base-secret "$1" >/dev/null 2>&1 -} - -server_build_mtproto_secret() { - local base="$1" - local faketls="$2" - local padding="$3" - - server_runtime_ucode mtproto-build-secret "$base" "$faketls" "$padding" -} - -server_generate_short_id() { - sing-box generate rand --hex 4 2>/dev/null | server_runtime_ucode stdin-remove-newlines -} - -server_generate_reality_keypair() { - local output parsed old_ifs - - SERVER_REALITY_PRIVATE_KEY="" - SERVER_REALITY_PUBLIC_KEY="" - output="$(sing-box generate reality-keypair 2>/dev/null)" || return 1 - parsed="$(printf '%s\n' "$output" | server_runtime_ucode reality-keypair-tsv)" || return 1 - - old_ifs="$IFS" - IFS="$(printf '\t')" - read -r SERVER_REALITY_PRIVATE_KEY SERVER_REALITY_PUBLIC_KEY </dev/null)" || return 1 - - key_dir="${key_path%/*}" - cert_dir="${certificate_path%/*}" - mkdir -p "$key_dir" "$cert_dir" || return 1 - - printf '%s\n' "$output" | server_runtime_ucode write-tls-keypair-files "$key_path" "$certificate_path" || return 1 - chmod 600 "$key_path" 2>/dev/null || true -} - -server_tls_certificate_sha256_from_file() { - local certificate_path="$1" - local b64_tmp der_tmp fingerprint - - server_file_path_is_valid "$certificate_path" || return 1 - [ -s "$certificate_path" ] || return 1 - - b64_tmp="$(mktemp)" || return 1 - der_tmp="$(mktemp)" || { - rm -f "$b64_tmp" - return 1 - } - - if ! server_runtime_ucode certificate-base64 "$certificate_path" > "$b64_tmp" || [ ! -s "$b64_tmp" ]; then - rm -f "$b64_tmp" "$der_tmp" - return 1 - fi - - if ! base64 -d "$b64_tmp" > "$der_tmp" 2>/dev/null || [ ! -s "$der_tmp" ]; then - rm -f "$b64_tmp" "$der_tmp" - return 1 - fi - - fingerprint="$(sha256sum "$der_tmp" 2>/dev/null)" || { - rm -f "$b64_tmp" "$der_tmp" - return 1 - } - fingerprint="${fingerprint%% *}" - rm -f "$b64_tmp" "$der_tmp" - - server_runtime_ucode valid-sha256-hex "$fingerprint" >/dev/null 2>&1 || return 1 - printf '%s\n' "$fingerprint" -} - -get_tls_certificate_sha256() { - local section="$1" - local protocol security certificate_path fingerprint - - if [ -z "$section" ]; then - server_runtime_ucode error-response "Missing server section" - return 1 - fi - - config_get protocol "$section" "protocol" "vless" - security="$(server_effective_security "$section" "$protocol")" - if [ "$security" != "tls" ]; then - server_runtime_ucode error-response "Server does not use TLS" - return 1 - fi - - config_get certificate_path "$section" "tls_certificate_path" - if [ -z "$certificate_path" ]; then - server_runtime_ucode error-response "TLS certificate path is empty" - return 1 - fi - - fingerprint="$(server_tls_certificate_sha256_from_file "$certificate_path")" || { - server_runtime_ucode error-response "Failed to read TLS certificate fingerprint" - return 1 - } - - server_runtime_ucode tls-fingerprint-response "$fingerprint" -} - -server_is_public_ipv4() { - server_runtime_ucode valid-public-ipv4 "$1" >/dev/null 2>&1 -} - -server_cidr_is_valid() { - server_runtime_ucode valid-cidr "$1" >/dev/null 2>&1 -} - -server_detect_default_public_host() { - local interface ip lan_ip - - for interface in wan wwan; do - ip="$(ubus -S call "network.interface.$interface" status 2>/dev/null | helpers_ucode network-status-ipv4-address 2>/dev/null)" - if server_is_public_ipv4 "$ip"; then - printf '%s\n' "$ip" - return 0 - fi - done - - lan_ip="$(uci -q get network.lan.ipaddr 2>/dev/null)" - if server_runtime_ucode valid-dotted-ipv4 "$lan_ip" >/dev/null 2>&1; then - printf '%s\n' "$lan_ip" - fi -} - -server_first_user_entry_handler() { - local entry="$1" - - [ -n "$SERVER_FIRST_USER_ENTRY" ] && return 0 - SERVER_FIRST_USER_ENTRY="$entry" -} - -server_prepare_legacy_user_defaults() { - local section="$1" - local protocol="$2" - local entry name rest credential extra mtproto_faketls - - SERVER_FIRST_USER_ENTRY="" - config_list_foreach "$section" "server_users" server_first_user_entry_handler - entry="$SERVER_FIRST_USER_ENTRY" - SERVER_FIRST_USER_ENTRY="" - [ -n "$entry" ] || return 0 - - name="${entry%%|*}" - if [ "$entry" = "$name" ]; then - credential="$entry" - name="client" - extra="" - else - rest="${entry#*|}" - credential="${rest%%|*}" - if [ "$rest" = "$credential" ]; then - extra="" - else - extra="${rest#*|}" - fi - fi - - case "$protocol" in - vless | vmess) - server_default_set_option "$section" "server_uuid" "$credential" - ;; - shadowsocks | socks | trojan | hysteria2) - server_default_set_option "$section" "server_password" "$credential" - ;; - mtproto) - mtproto_faketls="$(server_mtproto_faketls_from_secret "$credential" 2>/dev/null || true)" - credential="$(server_mtproto_base_secret_from_value "$credential" 2>/dev/null || printf '%s' "$credential")" - server_default_set_option "$section" "mtproto_secret" "$credential" - server_default_set_option "$section" "mtproto_faketls" "${mtproto_faketls:-google.com}" - server_default_set_option "$section" "mtproto_padding" "1" - ;; - esac - if [ "$protocol" = "socks" ] && [ -n "$name" ]; then - server_default_set_option "$section" "server_username" "$name" - fi - if [ "$protocol" = "vless" ] && [ -n "$extra" ]; then - server_default_set_option "$section" "vless_flow" "$extra" - fi -} - -server_prepare_tls_defaults() { - local section="$1" - local protocol="$2" - local security="$3" - local enabled tls_server_name certificate_path key_path safe_name default_dir - - [ "$security" = "tls" ] || return 0 - config_get_bool enabled "$section" "enabled" 1 - [ "$enabled" -eq 1 ] || return 0 - - config_get tls_server_name "$section" "tls_server_name" - [ -n "$tls_server_name" ] || tls_server_name="www.microsoft.com" - server_default_set_option "$section" "tls_server_name" "$tls_server_name" - - config_get certificate_path "$section" "tls_certificate_path" - config_get key_path "$section" "tls_key_path" - if [ -z "$certificate_path" ] || [ -z "$key_path" ]; then - safe_name="$(server_safe_filename "$section")" - default_dir="/etc/podkop-plus/server-certs" - certificate_path="${certificate_path:-$default_dir/$safe_name.crt}" - key_path="${key_path:-$default_dir/$safe_name.key}" - server_set_option "$section" "tls_certificate_path" "$certificate_path" - server_set_option "$section" "tls_key_path" "$key_path" - fi - - if ! server_file_path_is_valid "$certificate_path"; then - log "Server '$section' has invalid TLS certificate path. Specify a file path. Aborted." "fatal" - exit 1 - fi - if ! server_file_path_is_valid "$key_path"; then - log "Server '$section' has invalid TLS key path. Specify a file path. Aborted." "fatal" - exit 1 - fi - if [ "$certificate_path" = "$key_path" ]; then - log "Server '$section' has the same TLS certificate and key path. Specify different files. Aborted." "fatal" - exit 1 - fi - - if [ ! -s "$certificate_path" ] || [ ! -s "$key_path" ]; then - log "Generating self-signed TLS certificate for server '$section'" "info" - if ! server_generate_tls_keypair_files "$tls_server_name" "$certificate_path" "$key_path"; then - log "Failed to generate TLS certificate for server '$section'. Aborted." "fatal" - exit 1 - fi - fi -} - -prepare_server_defaults() { - local section="$1" - local protocol security uuid password server_username label short_id reality_private_key reality_public_key safe_name mtproto_secret mtproto_base_secret mtproto_faketls - - config_get protocol "$section" "protocol" "vless" - case "$protocol" in - shadowsocks | socks | vmess | vless | trojan | hysteria2 | mtproto | tailscale | json_inbound) ;; - *) - log "Server '$section' has unsupported protocol '$protocol'. Aborted." "fatal" - exit 1 - ;; - esac - - server_default_set_option "$section" "protocol" "$protocol" - server_default_set_option "$section" "label" "$section" - server_default_set_option "$section" "enabled" "1" - server_default_set_option "$section" "routing_mode" "rules" - - if [ "$protocol" = "json_inbound" ]; then - server_set_option "$section" "security" "none" - return 0 - fi - - server_default_set_option "$section" "listen" "0.0.0.0" - server_default_set_option "$section" "listen_port" "443" - server_default_set_option "$section" "public_host" "$(server_detect_default_public_host)" - - security="$(server_effective_security "$section" "$protocol")" - server_set_option "$section" "security" "$security" - - server_prepare_legacy_user_defaults "$section" "$protocol" - - case "$protocol" in - vless | vmess) - uuid="$(uci -q get "$PODKOP_CONFIG_NAME.$section.server_uuid" 2>/dev/null)" - if [ -z "$uuid" ]; then - uuid="$(server_generate_uuid)" - server_default_set_option "$section" "server_uuid" "$uuid" - fi - ;; - shadowsocks | socks | trojan | hysteria2) - password="$(uci -q get "$PODKOP_CONFIG_NAME.$section.server_password" 2>/dev/null)" - if [ -z "$password" ]; then - password="$(server_generate_password)" - server_default_set_option "$section" "server_password" "$password" - fi - ;; - esac - - if [ "$protocol" = "mtproto" ]; then - mtproto_secret="$(uci -q get "$PODKOP_CONFIG_NAME.$section.mtproto_secret" 2>/dev/null)" - mtproto_faketls="$(server_mtproto_faketls_from_secret "$mtproto_secret" 2>/dev/null || true)" - mtproto_base_secret="$(server_mtproto_base_secret_from_value "$mtproto_secret" 2>/dev/null || true)" - if [ -z "$mtproto_base_secret" ]; then - mtproto_base_secret="$(server_generate_mtproto_secret)" - fi - if [ "$mtproto_secret" != "$mtproto_base_secret" ]; then - server_set_option "$section" "mtproto_secret" "$mtproto_base_secret" - fi - server_default_set_option "$section" "mtproto_faketls" "${mtproto_faketls:-google.com}" - server_default_set_option "$section" "mtproto_padding" "1" - server_default_set_option "$section" "mtproto_domain_fronting_port" "443" - server_default_set_option "$section" "mtproto_prefer_ip" "prefer-ipv4" - server_default_set_option "$section" "mtproto_tolerate_time_skewness" "3s" - server_default_set_option "$section" "mtproto_idle_timeout" "5m" - server_default_set_option "$section" "mtproto_handshake_timeout" "10s" - fi - - if [ "$protocol" = "socks" ]; then - server_username="$(uci -q get "$PODKOP_CONFIG_NAME.$section.server_username" 2>/dev/null)" - if [ -z "$server_username" ]; then - config_get label "$section" "label" "$section" - server_default_set_option "$section" "server_username" "$label" - fi - fi - - if [ "$protocol" = "vless" ]; then - server_default_set_option "$section" "vless_flow" "none" - fi - if [ "$protocol" = "vmess" ]; then - server_default_set_option "$section" "vmess_alter_id" "0" - fi - if [ "$protocol" = "shadowsocks" ]; then - server_default_set_option "$section" "shadowsocks_method" "aes-128-gcm" - fi - if [ "$protocol" = "tailscale" ]; then - safe_name="$(server_safe_filename "$section")" - server_default_set_option "$section" "tailscale_control_url" "https://controlplane.tailscale.com" - server_default_set_option "$section" "tailscale_hostname" "podkop-$safe_name" - server_default_set_option "$section" "tailscale_advertise_exit_node" "1" - fi - - if [ "$security" = "reality" ]; then - server_default_set_option "$section" "tls_server_name" "www.microsoft.com" - server_default_set_option "$section" "client_fingerprint" "chrome" - server_default_set_option "$section" "reality_handshake_server" "www.microsoft.com" - server_default_set_option "$section" "reality_handshake_server_port" "443" - short_id="$(uci -q get "$PODKOP_CONFIG_NAME.$section.reality_short_id" 2>/dev/null)" - if [ -z "$short_id" ]; then - server_default_set_option "$section" "reality_short_id" "$(server_generate_short_id)" - fi - server_default_set_option "$section" "reality_max_time_difference" "1m" - - reality_private_key="$(uci -q get "$PODKOP_CONFIG_NAME.$section.reality_private_key" 2>/dev/null)" - reality_public_key="$(uci -q get "$PODKOP_CONFIG_NAME.$section.reality_public_key" 2>/dev/null)" - if [ -z "$reality_private_key" ] || [ -z "$reality_public_key" ]; then - log "Generating Reality key pair for server '$section'" "info" - if ! server_generate_reality_keypair; then - log "Failed to generate Reality key pair for server '$section'. Aborted." "fatal" - exit 1 - fi - server_set_option "$section" "reality_private_key" "$SERVER_REALITY_PRIVATE_KEY" - server_set_option "$section" "reality_public_key" "$SERVER_REALITY_PUBLIC_KEY" - fi - fi - - server_prepare_tls_defaults "$section" "$protocol" "$security" -} - -prepare_server_defaults_handler() { - prepare_server_defaults "$1" -} - -prepare_all_server_defaults() { - SERVER_DEFAULTS_CHANGED=0 - config_foreach prepare_server_defaults_handler "server" - if [ "$SERVER_DEFAULTS_CHANGED" -eq 1 ]; then - commit_podkop_config - fi -} - -server_write_users_json() { - local section="$1" - local protocol="$2" - local tsv_tmp users_tmp user_count server_user_name server_uuid server_password vless_flow vmess_alter_id credential extra \ - mtproto_base_secret mtproto_faketls mtproto_padding - - tsv_tmp="$(mktemp)" || return 1 - users_tmp="$(mktemp)" || { - rm -f "$tsv_tmp" - return 1 - } - - config_get server_user_name "$section" "label" "$section" - [ -n "$server_user_name" ] || server_user_name="$section" - - case "$protocol" in - vless | vmess) - config_get server_uuid "$section" "server_uuid" - credential="$server_uuid" - if ! server_runtime_ucode valid-uuid "$credential" >/dev/null 2>&1; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has an invalid client UUID. Aborted." "fatal" - exit 1 - fi - if [ "$protocol" = "vless" ]; then - config_get vless_flow "$section" "vless_flow" - [ "$vless_flow" != "none" ] || vless_flow="" - if [ -n "$vless_flow" ] && [ "$vless_flow" != "xtls-rprx-vision" ]; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has unsupported VLESS flow '$vless_flow'. Aborted." "fatal" - exit 1 - fi - extra="$vless_flow" - else - config_get vmess_alter_id "$section" "vmess_alter_id" "0" - if ! server_runtime_ucode valid-nonnegative-integer "$vmess_alter_id" >/dev/null 2>&1; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has invalid VMess alter_id '$vmess_alter_id'. Use a non-negative integer. Aborted." "fatal" - exit 1 - fi - extra="$vmess_alter_id" - fi - ;; - socks) - config_get server_user_name "$section" "server_username" - if [ -z "$server_user_name" ]; then - config_get server_user_name "$section" "label" "$section" - fi - [ -n "$server_user_name" ] || server_user_name="$section" - config_get server_password "$section" "server_password" - credential="$server_password" - extra="" - ;; - trojan | hysteria2) - config_get server_password "$section" "server_password" - credential="$server_password" - extra="" - ;; - mtproto) - config_get server_password "$section" "mtproto_secret" - config_get mtproto_faketls "$section" "mtproto_faketls" "google.com" - config_get_bool mtproto_padding "$section" "mtproto_padding" 1 - mtproto_base_secret="$(server_mtproto_base_secret_from_value "$server_password" 2>/dev/null || true)" - if ! server_mtproto_base_secret_is_valid "$mtproto_base_secret"; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has invalid base secret. Use 32 non-zero hex characters. Aborted." "fatal" - exit 1 - fi - if ! server_host_is_valid "$mtproto_faketls"; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has invalid FakeTLS host '$mtproto_faketls'. Use a domain name or IPv4 address. Aborted." "fatal" - exit 1 - fi - if [ "$mtproto_padding" -ne 1 ]; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has padding disabled, but sing-box-extended MTProxy requires padded FakeTLS secrets. Aborted." "fatal" - exit 1 - fi - credential="$(server_build_mtproto_secret "$mtproto_base_secret" "$mtproto_faketls" "$mtproto_padding")" - extra="" - ;; - *) - rm -f "$tsv_tmp" "$users_tmp" - return 1 - ;; - esac - - if ! server_client_value_is_valid "$server_user_name"; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has invalid client name. It must not contain control characters. Aborted." "fatal" - exit 1 - fi - - if [ -z "$credential" ]; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has no client credential. Aborted." "fatal" - exit 1 - fi - if ! server_client_value_is_valid "$credential"; then - rm -f "$tsv_tmp" "$users_tmp" - log "Server '$section' has invalid client credential. It must not contain control characters. Aborted." "fatal" - exit 1 - fi - - printf '%s\t%s\t%s\n' "$server_user_name" "$credential" "$extra" > "$tsv_tmp" - - if ! server_runtime_ucode server-users-from-tsv "$protocol" "$tsv_tmp" > "$users_tmp"; then - rm -f "$tsv_tmp" "$users_tmp" - return 1 - fi - - rm -f "$tsv_tmp" - - user_count="$(server_runtime_ucode json-length "$users_tmp" 2>/dev/null)" - case "$user_count" in - '' | *[!0-9]*) user_count=0 ;; - esac - if [ "$user_count" -eq 0 ]; then - rm -f "$users_tmp" - log "Server '$section' has no valid users configured. Aborted." "fatal" - exit 1 - fi - - printf '%s\n' "$users_tmp" -} - -validate_server_reality_short_id() { - local value="$1" - local section="$2" - - if ! server_runtime_ucode valid-reality-short-id "$value" >/dev/null 2>&1; then - log "Server '$section' has invalid Reality short_id '$value'. Use 1-8 hex digits. Aborted." "fatal" - exit 1 - fi -} - -validate_server_reality_short_id_counted() { - SERVER_REALITY_SHORT_ID_COUNT=$((SERVER_REALITY_SHORT_ID_COUNT + 1)) - validate_server_reality_short_id "$@" -} - -validate_server_reality_short_ids() { - local section="$1" - local value - - SERVER_REALITY_SHORT_ID_COUNT=0 - config_list_foreach "$section" "reality_short_id" validate_server_reality_short_id_counted "$section" - if [ "$SERVER_REALITY_SHORT_ID_COUNT" -eq 0 ]; then - config_get value "$section" "reality_short_id" - if [ -n "$value" ]; then - SERVER_REALITY_SHORT_ID_COUNT=1 - validate_server_reality_short_id "$value" "$section" - fi - fi -} - -validate_server_transport_host_handler() { - local value="$1" - local section="$2" - - [ -n "$value" ] || return 0 - if ! server_host_is_valid "$value"; then - log "Server '$section' has invalid HTTP transport host '$value'. Use a domain name or IPv4 address. Aborted." "fatal" - exit 1 - fi -} - -server_apply_tls() { - local section="$1" - local inbound_tag="$2" - local protocol security tls_server_name tls_alpn tls_certificate_path tls_key_path \ - reality_handshake_server reality_handshake_server_port reality_private_key \ - reality_short_id reality_max_time_difference - - config_get protocol "$section" "protocol" "vless" - security="$(server_effective_security "$section" "$protocol")" - case "$security" in - none | tls | reality) ;; - *) - log "Server '$section' has unsupported security '$security'. Aborted." "fatal" - exit 1 - ;; - esac - - [ "$security" != "none" ] || return 0 - - config_get tls_server_name "$section" "tls_server_name" - tls_alpn="$(config_list_to_json "$section" "tls_alpn")" - config_get tls_certificate_path "$section" "tls_certificate_path" - config_get tls_key_path "$section" "tls_key_path" - config_get reality_handshake_server "$section" "reality_handshake_server" "www.microsoft.com" - config_get reality_handshake_server_port "$section" "reality_handshake_server_port" "443" - config_get reality_private_key "$section" "reality_private_key" - reality_short_id="$(config_list_to_json "$section" "reality_short_id")" - config_get reality_max_time_difference "$section" "reality_max_time_difference" "1m" - - if ! server_host_is_valid "$tls_server_name"; then - log "Server '$section' has invalid TLS server name/SNI '$tls_server_name'. Use a domain name or IPv4 address. Aborted." "fatal" - exit 1 - fi - - if [ "$security" = "tls" ]; then - if [ -z "$tls_certificate_path" ] || [ -z "$tls_key_path" ]; then - log "Server '$section' uses TLS but certificate/key paths are empty. Aborted." "fatal" - exit 1 - fi - if ! server_file_path_is_valid "$tls_certificate_path"; then - log "Server '$section' has invalid TLS certificate path. Specify a file path. Aborted." "fatal" - exit 1 - fi - if ! server_file_path_is_valid "$tls_key_path"; then - log "Server '$section' has invalid TLS key path. Specify a file path. Aborted." "fatal" - exit 1 - fi - if [ "$tls_certificate_path" = "$tls_key_path" ]; then - log "Server '$section' has the same TLS certificate and key path. Specify different files. Aborted." "fatal" - exit 1 - fi - fi - - if [ "$security" = "reality" ]; then - if [ -z "$reality_handshake_server" ] || ! server_host_is_valid "$reality_handshake_server" || ! server_port_is_valid "$reality_handshake_server_port"; then - log "Server '$section' has invalid Reality handshake target. Aborted." "fatal" - exit 1 - fi - if [ -z "$reality_private_key" ]; then - log "Server '$section' uses Reality but private key is not set. Aborted." "fatal" - exit 1 - fi - if ! server_duration_is_valid "$reality_max_time_difference"; then - log "Server '$section' has invalid Reality max time difference '$reality_max_time_difference'. Use a duration like 30s, 5m, or 1h30m. Aborted." "fatal" - exit 1 - fi - validate_server_reality_short_ids "$section" - if [ "$reality_short_id" = "[]" ]; then - log "Server '$section' uses Reality but short_id is not set. Aborted." "fatal" - exit 1 - fi - fi - - config=$( - sing_box_cm_set_tls_for_inbound \ - "$config" \ - "$inbound_tag" \ - "$security" \ - "$tls_server_name" \ - "$tls_alpn" \ - "$tls_certificate_path" \ - "$tls_key_path" \ - "$reality_handshake_server" \ - "$reality_handshake_server_port" \ - "$reality_private_key" \ - "$reality_short_id" \ - "$reality_max_time_difference" - ) -} - -server_apply_transport() { - local section="$1" - local inbound_tag="$2" - local protocol transport transport_path transport_host transport_service_name transport_hosts transport_xhttp_mode - - config_get protocol "$section" "protocol" "vless" - case "$protocol" in - vless | vmess | trojan) ;; - *) return 0 ;; - esac - - config_get transport "$section" "transport" "tcp" - if ! config_validation_ucode enum-valid "$transport" tcp raw ws grpc http httpupgrade xhttp >/dev/null 2>&1; then - log "Server '$section' has unsupported transport '$transport'. Aborted." "fatal" - exit 1 - fi - - [ "$transport" != "tcp" ] && [ "$transport" != "raw" ] || return 0 - - config_get transport_path "$section" "transport_path" - config_get transport_host "$section" "transport_host" - config_get transport_service_name "$section" "transport_service_name" - config_get transport_xhttp_mode "$section" "transport_xhttp_mode" "auto" - transport_hosts="$(config_list_to_json "$section" "transport_hosts")" - - case "$transport" in - ws | http | httpupgrade | xhttp) - if [ -n "$transport_path" ]; then - case "$transport_path" in - /*) ;; - *) - log "Server '$section' has invalid transport path '$transport_path'. It must start with /. Aborted." "fatal" - exit 1 - ;; - esac - fi - ;; - esac - if [ -n "$transport_host" ] && ! server_host_is_valid "$transport_host"; then - log "Server '$section' has invalid transport host '$transport_host'. Use a domain name or IPv4 address. Aborted." "fatal" - exit 1 - fi - config_list_foreach "$section" "transport_hosts" validate_server_transport_host_handler "$section" - if [ -n "$transport_service_name" ] && ! server_runtime_ucode valid-transport-service-name "$transport_service_name" >/dev/null 2>&1; then - log "Server '$section' has invalid gRPC service name '$transport_service_name'. Aborted." "fatal" - exit 1 - fi - if [ "$transport" = "xhttp" ]; then - if ! config_validation_ucode enum-valid "$transport_xhttp_mode" auto packet-up stream-up stream-one >/dev/null 2>&1; then - log "Server '$section' has unsupported XHTTP mode '$transport_xhttp_mode'. Aborted." "fatal" - exit 1 - fi - fi - - config=$( - sing_box_cm_set_transport_for_inbound \ - "$config" \ - "$inbound_tag" \ - "$transport" \ - "$transport_path" \ - "$transport_host" \ - "$transport_service_name" \ - "$transport_hosts" \ - "$transport_xhttp_mode" - ) -} diff --git a/podkop/files/usr/lib/server_runtime.uc b/podkop/files/usr/lib/server_runtime.uc deleted file mode 100644 index c68ea758..00000000 --- a/podkop/files/usr/lib/server_runtime.uc +++ /dev/null @@ -1,599 +0,0 @@ -#!/usr/bin/env ucode - -let fs = require("fs"); - -function as_string(value) { - return value == null ? "" : "" + value; -} - -function ascii_lower(value) { - let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - let lower = "abcdefghijklmnopqrstuvwxyz"; - return replace(as_string(value), /[A-Z]/g, function(ch) { - return substr(lower, index(upper, ch), 1); - }); -} - -function write_json(value) { - print(sprintf("%J", value), "\n"); -} - -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() { - try { - return json(read_stdin()); - } - catch (e) { - return null; - } -} - -function stdin_first_nonempty_line() { - for (let line in split(read_stdin(), "\n")) { - if (trim(as_string(line)) != "") { - print(line, "\n"); - return; - } - } -} - -function stdin_remove_newlines() { - print(replace(read_stdin(), /\n/g, "")); -} - -function shell_single_quote(value) { - value = as_string(value); - if (value == "") - return; - print("'", replace(value, /'/g, "'\\''"), "'\n"); -} - -function array_or_empty(value) { - return type(value) == "array" ? value : []; -} - -function array_append_string(value) { - let result = array_or_empty(read_stdin_json()); - push(result, as_string(value)); - write_json(result); -} - -function regex_matches(value, pattern) { - pattern = as_string(pattern); - if (pattern == "") - return false; - - try { - return match(as_string(value), regexp(pattern)) != null; - } - catch (e) { - return false; - } -} - -function valid_dotted_ipv4(value) { - return regex_matches(value, "^([0-9]{1,3}\\.){3}[0-9]{1,3}$"); -} - -function valid_file_path(value) { - value = as_string(value); - return value != "" && - value != "/" && - substr(value, length(value) - 1) != "/" && - regex_matches(value, "^/[A-Za-z0-9_./-]+$"); -} - -function valid_duration(value) { - value = as_string(value); - return value != "" && regex_matches(value, "^([0-9]+(ns|us|ms|s|m|h))+$"); -} - -function valid_client_value(value) { - value = as_string(value); - return value != "" && !regex_matches(value, "[[:cntrl:]]"); -} - -function valid_port(value) { - value = as_string(value); - if (value == "" || regex_matches(value, "[^0-9]")) - return false; - - value = int(value); - return value >= 1 && value <= 65535; -} - -function normalize_host(value) { - value = as_string(value); - if (substr(value, 0, 1) == "[") - value = substr(value, 1); - if (substr(value, length(value) - 1) == "]") - value = substr(value, 0, length(value) - 1); - return lc(value); -} - -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 str_remove_suffix(value, suffix) { - value = as_string(value); - suffix = as_string(suffix); - return str_endswith(value, suffix) ? substr(value, 0, length(value) - length(suffix)) : value; -} - -function valid_ipv4(value) { - value = as_string(value); - if (!regex_matches(value, "^([0-9]{1,3}\\.){3}[0-9]{1,3}$")) - return false; - - let parts = split(value, "."); - if (length(parts) != 4) - return false; - - for (let part in parts) { - if (part == "" || regex_matches(part, "[^0-9]")) - return false; - let octet = int(part); - if (octet < 0 || octet > 255) - return false; - } - - return true; -} - -function valid_domain(value) { - return regex_matches(ascii_lower(value), "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"); -} - -function valid_host(value) { - value = normalize_host(value); - return value != "" && (valid_ipv4(value) || valid_domain(value)); -} - -function valid_absolute_path(value) { - return substr(as_string(value), 0, 1) == "/"; -} - -function valid_http_url(value) { - value = as_string(value); - let scheme = index(value, "://"); - if (scheme < 0) - return false; - - let prefix = substr(value, 0, scheme + 3); - if (prefix != "http://" && prefix != "https://") - return false; - - let rest = substr(value, scheme + 3); - let slash = index(rest, "/"); - let host_port = slash >= 0 ? substr(rest, 0, slash) : rest; - let colon = index(host_port, ":"); - let host = colon >= 0 ? substr(host_port, 0, colon) : host_port; - - return valid_host(host); -} - -function valid_uuid(value) { - return regex_matches(value, "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); -} - -function valid_nonnegative_integer(value) { - return regex_matches(value, "^[0-9]+$"); -} - -function valid_reality_short_id(value) { - return regex_matches(value, "^[0-9a-fA-F]{1,8}$"); -} - -function valid_transport_service_name(value) { - return regex_matches(value, "^[A-Za-z0-9_.-]+$"); -} - -function mtproto_base_secret_from_value(value) { - value = lc(as_string(value)); - if (regex_matches(value, "^[0-9a-f]{32}$")) { - print(value, "\n"); - return; - } - - if (regex_matches(value, "^ee[0-9a-f]{32}([0-9a-f]{2})+$")) { - print(substr(value, 2, 32), "\n"); - return; - } - - exit(1); -} - -function hex_digit_value(value) { - value = ord(lc(as_string(value))); - if (value >= 48 && value <= 57) - return value - 48; - if (value >= 97 && value <= 102) - return value - 87; - return -1; -} - -function hex_to_string(value) { - value = as_string(value); - if (length(value) == 0 || length(value) % 2 != 0 || !regex_matches(value, "^([0-9a-fA-F]{2})+$")) - exit(1); - - for (let i = 0; i < length(value); i += 2) { - let high = hex_digit_value(substr(value, i, 1)); - let low = hex_digit_value(substr(value, i + 1, 1)); - if (high < 0 || low < 0) - exit(1); - print(chr(high * 16 + low)); - } -} - -function string_to_hex(value) { - value = as_string(value); - for (let i = 0; i < length(value); i++) - print(sprintf("%02x", ord(substr(value, i, 1)))); -} - -function mtproto_faketls_from_secret(value) { - value = lc(as_string(value)); - if (!regex_matches(value, "^ee[0-9a-f]{32}([0-9a-f]{2})+$")) - exit(1); - hex_to_string(substr(value, 34)); -} - -function mtproto_build_secret(base, faketls, padding) { - if (as_string(padding) != "1") - exit(1); - - print("ee", as_string(base)); - string_to_hex(faketls); - print("\n"); -} - -function valid_mtproto_base_secret(value) { - value = lc(as_string(value)); - return regex_matches(value, "^[0-9a-f]{32}$") && value != "00000000000000000000000000000000"; -} - -function default_security_for_protocol(protocol) { - protocol = as_string(protocol); - if (protocol == "vless") - print("reality\n"); - else if (protocol == "trojan" || protocol == "hysteria2") - print("tls\n"); - else - print("none\n"); -} - -function safe_filename(value) { - value = as_string(value); - for (let i = 0; i < length(value); i++) { - let chr = substr(value, i, 1); - print(regex_matches(chr, "^[A-Za-z0-9_.-]$") ? chr : "_"); - } -} - -function valid_public_ipv4(value) { - value = as_string(value); - if (!valid_ipv4(value)) - return false; - - let parts = split(value, "."); - let a = int(parts[0]); - let b = int(parts[1]); - - if (a == 0 || a == 10 || a == 127 || a >= 224) - return false; - if (a == 169 && b == 254) - return false; - if (a == 192 && (b == 168 || b == 0 || b == 2)) - return false; - if (a == 198 && (b == 18 || b == 19 || b == 51)) - return false; - if (a == 203 && b == 0) - return false; - if (a == 100 && b >= 64 && b <= 127) - return false; - if (a == 172 && b >= 16 && b <= 31) - return false; - - return true; -} - -function valid_ipv6_literal(value) { - value = as_string(value); - return index(value, ":") >= 0 && regex_matches(value, "^[0-9A-Fa-f:.]+$"); -} - -function valid_cidr(value) { - value = as_string(value); - let slash = index(value, "/"); - if (slash < 0 || index(substr(value, slash + 1), "/") >= 0) - return false; - - let address = substr(value, 0, slash); - let prefix = substr(value, slash + 1); - if (prefix == "" || regex_matches(prefix, "[^0-9]")) - return false; - - prefix = int(prefix); - if (valid_ipv4(address)) - return prefix >= 0 && prefix <= 32; - if (valid_ipv6_literal(address)) - return prefix >= 0 && prefix <= 128; - - return false; -} - -function server_users_from_tsv(protocol, path) { - let result = []; - let data = fs.readfile(path); - - if (data != null) { - for (let line in split(as_string(data), "\n")) { - if (line == "") - continue; - - let parts = split(line, "\t"); - let name = as_string(parts[0] || ""); - let credential = as_string(parts[1] || ""); - let flow = as_string(parts[2] || ""); - - if (credential == "") - continue; - - if (protocol == "vless") { - let user = { uuid: credential }; - if (name != "") - user.name = name; - if (flow != "") - user.flow = flow; - push(result, user); - } - else if (protocol == "vmess") { - let user = { uuid: credential, alterId: int(flow || "0", 10) || 0 }; - if (name != "") - user.name = name; - push(result, user); - } - else if (protocol == "trojan") { - let user = { password: credential }; - if (name != "") - user.name = name; - push(result, user); - } - else if (protocol == "hysteria2") { - let user = { password: credential }; - if (name != "") - user.name = name; - push(result, user); - } - else if (protocol == "mtproto") { - let user = { secret: credential }; - if (name != "") - user.name = name; - push(result, user); - } - else if (protocol == "socks") { - let user = { - username: name != "" ? name : "user", - password: credential - }; - push(result, user); - } - } - } - - write_json(result); -} - -function json_length(path) { - let value = read_json_file(path); - if (type(value) == "array" || type(value) == "object") - print(length(value), "\n"); - else - print("0\n"); -} - -function error_response(message) { - write_json({ - success: false, - message: as_string(message) - }); -} - -function reality_keypair_response(private_key, public_key) { - write_json({ - success: true, - private_key: as_string(private_key), - public_key: as_string(public_key) - }); -} - -function tls_fingerprint_response(fingerprint) { - write_json({ - success: true, - sha256: as_string(fingerprint) - }); -} - -function first_key_value_line_value(data, prefix) { - prefix = as_string(prefix); - for (let line in split(as_string(data), "\n")) { - line = as_string(line); - if (!regex_matches(line, "^" + prefix + ":")) - continue; - - let value = substr(line, length(prefix) + 1); - return replace(value, /^[ \t\r]*/, ""); - } - - return null; -} - -function reality_keypair_tsv() { - let data = read_stdin(); - let private_key = first_key_value_line_value(data, "PrivateKey"); - let public_key = first_key_value_line_value(data, "PublicKey"); - - if (private_key == null || public_key == null || private_key == "" || public_key == "") - exit(1); - - print(private_key, "\t", public_key, "\n"); -} - -function pem_block(data, begin_marker, end_marker) { - let lines = []; - let in_block = false; - - for (let line in split(as_string(data), "\n")) { - line = str_remove_suffix(as_string(line), "\r"); - if (!in_block && line != begin_marker) - continue; - - in_block = true; - push(lines, line); - - if (line == end_marker) - return join("\n", lines); - } - - return ""; -} - -function write_tls_keypair_files(key_path, certificate_path) { - let data = read_stdin(); - let key = pem_block(data, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"); - let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); - - if (key == "" || cert == "") - exit(1); - - if (!fs.writefile(key_path, key + "\n")) - exit(1); - if (!fs.writefile(certificate_path, cert + "\n")) - exit(1); -} - -function certificate_base64(path) { - let data = fs.readfile(path); - let cert = pem_block(data, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); - let lines = []; - - if (cert == "") - exit(1); - - for (let line in split(cert, "\n")) { - line = replace(as_string(line), /\r/g, ""); - if (line == "-----BEGIN CERTIFICATE-----" || line == "-----END CERTIFICATE-----" || line == "") - continue; - - push(lines, line); - } - - if (length(lines) == 0) - exit(1); - - print(join("\n", lines), "\n"); -} - -function valid_sha256_hex(value) { - return regex_matches(value, "^[0-9a-f]{64}$"); -} - -let mode = ARGV[0] || ""; - -if (mode == "valid-file-path") - exit(valid_file_path(ARGV[1]) ? 0 : 1); -else if (mode == "valid-dotted-ipv4") - exit(valid_dotted_ipv4(ARGV[1]) ? 0 : 1); -else if (mode == "stdin-first-nonempty-line") - stdin_first_nonempty_line(); -else if (mode == "stdin-remove-newlines") - stdin_remove_newlines(); -else if (mode == "shell-single-quote") - shell_single_quote(ARGV[1]); -else if (mode == "valid-port") - exit(valid_port(ARGV[1]) ? 0 : 1); -else if (mode == "normalize-host") - print(normalize_host(ARGV[1]), "\n"); -else if (mode == "valid-host") - exit(valid_host(ARGV[1]) ? 0 : 1); -else if (mode == "valid-absolute-path") - exit(valid_absolute_path(ARGV[1]) ? 0 : 1); -else if (mode == "valid-http-url") - exit(valid_http_url(ARGV[1]) ? 0 : 1); -else if (mode == "valid-duration") - exit(valid_duration(ARGV[1]) ? 0 : 1); -else if (mode == "valid-client-value") - exit(valid_client_value(ARGV[1]) ? 0 : 1); -else if (mode == "valid-uuid") - exit(valid_uuid(ARGV[1]) ? 0 : 1); -else if (mode == "valid-nonnegative-integer") - exit(valid_nonnegative_integer(ARGV[1]) ? 0 : 1); -else if (mode == "valid-reality-short-id") - exit(valid_reality_short_id(ARGV[1]) ? 0 : 1); -else if (mode == "valid-transport-service-name") - exit(valid_transport_service_name(ARGV[1]) ? 0 : 1); -else if (mode == "mtproto-base-secret") - mtproto_base_secret_from_value(ARGV[1]); -else if (mode == "mtproto-faketls-from-secret") - mtproto_faketls_from_secret(ARGV[1]); -else if (mode == "mtproto-build-secret") - mtproto_build_secret(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "valid-mtproto-base-secret") - exit(valid_mtproto_base_secret(ARGV[1]) ? 0 : 1); -else if (mode == "default-security-for-protocol") - default_security_for_protocol(ARGV[1]); -else if (mode == "safe-filename") - safe_filename(ARGV[1]); -else if (mode == "valid-public-ipv4") - exit(valid_public_ipv4(ARGV[1]) ? 0 : 1); -else if (mode == "valid-cidr") - exit(valid_cidr(ARGV[1]) ? 0 : 1); -else if (mode == "server-users-from-tsv") - server_users_from_tsv(ARGV[1], ARGV[2]); -else if (mode == "json-length") - json_length(ARGV[1]); -else if (mode == "array-append-string") - array_append_string(ARGV[1]); -else if (mode == "error-response") - error_response(ARGV[1]); -else if (mode == "reality-keypair-response") - reality_keypair_response(ARGV[1], ARGV[2]); -else if (mode == "reality-keypair-tsv") - reality_keypair_tsv(); -else if (mode == "write-tls-keypair-files") - write_tls_keypair_files(ARGV[1], ARGV[2]); -else if (mode == "certificate-base64") - certificate_base64(ARGV[1]); -else if (mode == "valid-sha256-hex") - exit(valid_sha256_hex(ARGV[1]) ? 0 : 1); -else if (mode == "tls-fingerprint-response") - tls_fingerprint_response(ARGV[1]); -else { - warn("Usage: server_runtime.uc ...\n"); - exit(1); -} diff --git a/podkop/files/usr/lib/service/initd.uc b/podkop/files/usr/lib/service/initd.uc new file mode 100644 index 00000000..0c99a4e6 --- /dev/null +++ b/podkop/files/usr/lib/service/initd.uc @@ -0,0 +1,632 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function constant_value(name, fallback) { + let value = constants[name]; + return value == null ? as_string(fallback) : as_string(value); +} + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || constant_value("PODKOP_CONFIG_NAME", "podkop-plus"); +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const BIN_PATH = getenv("PODKOP_BIN") || constant_value("PODKOP_BIN", "/usr/bin/podkop-plus"); +const SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || constant_value("PODKOP_SERVICE_INIT", "/etc/init.d/podkop-plus"); +const SERVICE_NAME = getenv("PODKOP_SERVICE_NAME") || constant_value("PODKOP_SERVICE_NAME", "podkop-plus"); +const CONFIG_FILE = getenv("PODKOP_CONFIG_FILE") || "/etc/config/" + CONFIG_NAME; +const RELOAD_LOCK_DIR = getenv("PODKOP_RELOAD_LOCK_DIR") || "/var/run/podkop-plus.reload.lock"; +const RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const PENDING_RELOAD_FILE = getenv("PODKOP_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending"; +const SERVICE_TRIGGER_SYNC_FILE = getenv("PODKOP_SERVICE_TRIGGER_SYNC_FILE") || RUNTIME_STATE_DIR + "/service-triggers.sync"; +const INTERNAL_CONFIG_TRIGGER_GUARD = getenv("PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD") || "/var/run/podkop-plus.internal-config-change"; +const CONFIG_CHANGE_REASON = getenv("PODKOP_CONFIG_CHANGE_REASON") || "on_config_change"; + +const DNS_APPLY_UC = LIB_DIR + "/dns/apply.uc"; +const UI_UC = LIB_DIR + "/service/ui.uc"; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function shell_assignment(name, value) { + print(as_string(name), "=", shell_quote(value), "\n"); +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function command_status(command) { + return normalize_status(system(command)); +} + +function command_capture(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return { status: 1, output: "" }; + + let data = pipe.read("all"); + let status = normalize_status(pipe.close()); + return { status, output: data == null ? "" : as_string(data) }; +} + +function command_output(command) { + let result = command_capture(command); + return result.status == 0 ? result.output : ""; +} + +function command_output_from_args(args) { + return command_output(command_from_args(args) + " 2>/dev/null"); +} + +function command_success_from_args(args) { + return command_status(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_status_from_args(args) { + return command_status(command_from_args(args)); +} + +function module_args(module_path, args) { + let result = [ "ucode", "-L", LIB_DIR, module_path ]; + for (let arg in (type(args) == "array" ? args : [])) + push(result, arg); + return result; +} + +function module_command(module_path, args) { + return command_from_args(module_args(module_path, args)); +} + +function module_status(module_path, args) { + return command_status(module_command(module_path, args)); +} + +function module_output(module_path, args) { + let result = command_capture(module_command(module_path, args)); + return result.status == 0 ? result.output : ""; +} + +function trim(value) { + return replace(as_string(value), /^[ \t\r\n]+|[ \t\r\n]+$/g, ""); +} + +function numeric_text(value) { + return match(as_string(value), /^[0-9]+$/) != null; +} + +function current_epoch() { + return as_string(int(clock()[0])); +} + +function bool_text(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + let value = object_or_empty(section)[key]; + if (value == null) + return as_string(fallback); + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function file_executable(path) { + return command_success_from_args([ "test", "-x", as_string(path) ]); +} + +function unlink_file(path) { + fs.unlink(as_string(path)); +} + +function ensure_parent_dir(path) { + let dir = replace(as_string(path), /\/[^\/]*$/, ""); + if (dir == "" || dir == path) + return true; + return fs.mkdir(dir, 0755) || fs.stat(dir) != null; +} + +function write_text_file(path, text) { + return fs.writefile(as_string(path), as_string(text)); +} + +function first_line_value(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function config_file_hash(path) { + let fields = split(trim(command_output_from_args([ "md5sum", as_string(path) ])), " "); + return length(fields) > 0 ? as_string(fields[0]) : ""; +} + +function pid_alive(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function lock_dir_write_owner(lock_dir, owner_pid) { + return write_text_file(as_string(lock_dir) + "/pid", as_string(owner_pid) + "\n"); +} + +function acquire_runtime_dir_lock(lock_dir, owner_pid) { + lock_dir = as_string(lock_dir); + owner_pid = as_string(owner_pid); + if (lock_dir == "" || owner_pid == "") + return false; + + if (command_success_from_args([ "mkdir", lock_dir ])) { + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + release_runtime_dir_lock(lock_dir); + return false; + } + + if (pid_alive(first_line_value(lock_dir + "/pid"))) + return false; + + command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); + if (!command_success_from_args([ "rmdir", lock_dir ])) + return false; + if (!command_success_from_args([ "mkdir", lock_dir ])) + return false; + + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + + release_runtime_dir_lock(lock_dir); + return false; +} + +function release_runtime_dir_lock(lock_dir) { + lock_dir = as_string(lock_dir); + if (lock_dir == "") + return; + + command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); + command_success_from_args([ "rmdir", lock_dir ]); +} + +function mark_pending_reload(path, reason) { + path = as_string(path || PENDING_RELOAD_FILE); + reason = as_string(reason || "pending"); + + if (!ensure_parent_dir(path)) + return false; + + return write_text_file(path, "reason=" + reason + "\nupdated_at=" + current_epoch() + "\n"); +} + +function consume_pending_reload(path) { + path = as_string(path || PENDING_RELOAD_FILE); + if (!file_exists(path)) + return false; + + unlink_file(path); + return true; +} + +function run_pending_reload_if_requested(path, init_script) { + path = as_string(path || PENDING_RELOAD_FILE); + init_script = as_string(init_script || SERVICE_INIT); + + if (!consume_pending_reload(path)) + return; + + command_success_from_args([ "logger", "-t", SERVICE_NAME, "[info] Applying pending Podkop Plus reload" ]); + system(shell_quote(init_script) + " reload pending >/dev/null 2>&1 &"); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function settings_from_fixture(path) { + let data = fs.readfile(path); + if (data == null) + return {}; + try { + data = json(data); + } + catch (e) { + return {}; + } + return object_or_empty(object_or_empty(data).settings); +} + +function initd_service_trigger_sync_requested(path) { + path = as_string(path); + if (!file_exists(path)) + return false; + + let value = first_line_value(path); + unlink_file(path); + return value == "1"; +} + +function initd_guard_matches_current_config(guard_path, config_path, now_value) { + let guard_timestamp = first_line_value(guard_path); + if (!numeric_text(guard_timestamp)) + return false; + + now_value = now_value == null ? current_epoch() : as_string(now_value); + if (!numeric_text(now_value)) + return true; + + if (int(now_value, 10) - int(guard_timestamp, 10) > 30) + return false; + + let data = fs.readfile(guard_path); + if (data == null) + return false; + + let lines = split(data, "\n"); + let guard_hash = length(lines) > 1 ? as_string(lines[1]) : ""; + if (guard_hash == "") + return false; + + return guard_hash == config_file_hash(config_path); +} + +function initd_should_skip_internal_config_reload(reason, guard_path, config_path, expected_reason, now_value) { + if (as_string(reason) != as_string(expected_reason || CONFIG_CHANGE_REASON)) + return false; + if (!file_exists(as_string(guard_path))) + return false; + + let matches = initd_guard_matches_current_config(guard_path, config_path, now_value); + unlink_file(guard_path); + return matches; +} + +function initd_should_restore_dnsmasq_on_start_from_value(reason, shutdown_correctly) { + if (as_string(reason) == "triggered") + return false; + + shutdown_correctly = as_string(shutdown_correctly); + if (shutdown_correctly == "") + shutdown_correctly = "1"; + return shutdown_correctly == "0"; +} + +function initd_should_ignore_config_change_reload(reason, expected_reason, runtime_running, service_enabled) { + return as_string(reason) == as_string(expected_reason || CONFIG_CHANGE_REASON) && + !bool_text(runtime_running) && + !bool_text(service_enabled); +} + +function initd_should_sync_service_triggers(reason, expected_reason, sync_file) { + if (as_string(reason) != as_string(expected_reason || CONFIG_CHANGE_REASON)) + return false; + return initd_service_trigger_sync_requested(sync_file); +} + +function restore_dnsmasq_failsafe() { + if (!file_exists(DNS_APPLY_UC)) + return 0; + return module_status(DNS_APPLY_UC, [ "failsafe-restore" ]); +} + +function begin_external_service_action(action, source, owner_pid) { + if (as_string(getenv("PODKOP_UI_ACTION_TRACKED") || "0") == "1") + return ""; + if (!file_exists(UI_UC)) + return ""; + + let job_id = trim(module_output(UI_UC, [ "service-action-begin-if-idle", action, source || "initd" ])); + if (job_id != "") + module_status(UI_UC, [ "service-action-update-pid", job_id, owner_pid || owner_pid_value() ]); + + return job_id; +} + +function finish_external_service_action(action, job_id, status) { + if (as_string(job_id) == "" || !file_exists(UI_UC)) + return 0; + return module_status(UI_UC, [ "service-action-finish-after-command", action, job_id, as_string(status) ]); +} + +function owner_pid_value() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function runtime_status_object() { + let data = command_output_from_args([ BIN_PATH, "get_status" ]); + try { + data = json(data); + } + catch (e) { + return {}; + } + return object_or_empty(data); +} + +function runtime_is_running() { + return bool_text(runtime_status_object().running); +} + +function status_service() { + if (runtime_is_running()) { + print("running\n"); + return 0; + } + + print("not running\n"); + return 1; +} + +function retry_start_on_wan_up(owner_pid) { + if (runtime_is_running()) + return 0; + + return command_status_from_args([ SERVICE_INIT, "restart", "triggered" ]); +} + +function service_is_enabled() { + return file_exists("/etc/rc.d/S99" + SERVICE_NAME); +} + +function start_plan_value(reason, owner_pid, settings, bin_ok) { + settings = object_or_empty(settings); + let job_id = begin_external_service_action("start", "initd", owner_pid); + bin_ok = bin_ok == null ? file_executable(BIN_PATH) : bool_text(bin_ok); + + if (!bin_ok) { + restore_dnsmasq_failsafe(); + finish_external_service_action("start", job_id, 1); + } + else if (initd_should_restore_dnsmasq_on_start_from_value(reason, option(settings, "shutdown_correctly", "1"))) { + restore_dnsmasq_failsafe(); + } + + let badwan_enabled = option(settings, "enable_badwan_interface_monitoring", "") == "1"; + let badwan_interfaces = option(settings, "badwan_monitored_interfaces", ""); + + return { + job_id, + bin_ok, + badwan_netdev: badwan_enabled ? badwan_interfaces : "" + }; +} + +function start_plan(reason, owner_pid, settings, bin_ok) { + let plan = start_plan_value(reason, owner_pid, settings, bin_ok); + shell_assignment("INITD_UI_JOB_ID", plan.job_id); + shell_assignment("INITD_BIN_OK", plan.bin_ok ? "1" : "0"); + shell_assignment("INITD_BADWAN_NETDEV", plan.badwan_netdev); +} + +function start_service(reason, owner_pid) { + print("Start Podkop Plus\n"); + let plan = start_plan_value(reason, owner_pid, uci_settings(), null); + if (!plan.bin_ok) + return 1; + + let status = command_status_from_args([ BIN_PATH, "start" ]); + finish_external_service_action("start", plan.job_id, status); + return status; +} + +function stop_plan(owner_pid, bin_ok) { + let job_id = begin_external_service_action("stop", "initd", owner_pid); + bin_ok = bin_ok == null ? file_executable(BIN_PATH) : bool_text(bin_ok); + + if (!bin_ok) { + restore_dnsmasq_failsafe(); + finish_external_service_action("stop", job_id, 1); + } + + shell_assignment("INITD_UI_JOB_ID", job_id); + shell_assignment("INITD_BIN_OK", bin_ok ? "1" : "0"); +} + +function stop_finish(job_id, status) { + status = int(status || 0); + if (status != 0) + restore_dnsmasq_failsafe(); + finish_external_service_action("stop", job_id, status); + return status; +} + +function stop_service(owner_pid) { + let job_id = begin_external_service_action("stop", "initd", owner_pid); + if (!file_executable(BIN_PATH)) { + restore_dnsmasq_failsafe(); + finish_external_service_action("stop", job_id, 1); + return 1; + } + + let status = command_status_from_args([ BIN_PATH, "stop" ]); + return stop_finish(job_id, status); +} + +function reload_begin_value(reason, owner_pid, runtime_running_value, service_enabled_value) { + reason = as_string(reason); + + if (initd_should_skip_internal_config_reload( + reason, + INTERNAL_CONFIG_TRIGGER_GUARD, + CONFIG_FILE, + CONFIG_CHANGE_REASON, + null + )) { + return { action: "skip", job_id: "" }; + } + + let running = runtime_running_value == null ? runtime_is_running() : bool_text(runtime_running_value); + let enabled = service_enabled_value == null ? service_is_enabled() : bool_text(service_enabled_value); + if (initd_should_ignore_config_change_reload(reason, CONFIG_CHANGE_REASON, running, enabled)) { + return { action: "skip", job_id: "" }; + } + + if (!acquire_runtime_dir_lock(RELOAD_LOCK_DIR, owner_pid || owner_pid_value())) { + mark_pending_reload(PENDING_RELOAD_FILE, reason || "reload_busy"); + return { action: "skip", job_id: "" }; + } + + unlink_file(SERVICE_TRIGGER_SYNC_FILE); + let job_id = begin_external_service_action("reload", "initd", owner_pid); + return { action: "run", job_id }; +} + +function reload_begin(reason, owner_pid, runtime_running_value, service_enabled_value) { + let plan = reload_begin_value(reason, owner_pid, runtime_running_value, service_enabled_value); + shell_assignment("INITD_RELOAD_ACTION", plan.action); + if (plan.action == "run") + shell_assignment("INITD_UI_JOB_ID", plan.job_id); + return 0; +} + +function reload_finish_value(reason, job_id, status) { + status = int(status || 0); + finish_external_service_action("reload", job_id, status); + let sync = status == 0 && initd_should_sync_service_triggers(reason, CONFIG_CHANGE_REASON, SERVICE_TRIGGER_SYNC_FILE); + release_runtime_dir_lock(RELOAD_LOCK_DIR); + run_pending_reload_if_requested(PENDING_RELOAD_FILE, SERVICE_INIT); + return { status, sync }; +} + +function reload_finish(reason, job_id, status) { + let plan = reload_finish_value(reason, job_id, status); + shell_assignment("INITD_SYNC_SERVICE_TRIGGERS", plan.sync ? "1" : "0"); + return plan.status; +} + +function reload_service(reason, owner_pid) { + let plan = reload_begin_value(reason, owner_pid, null, null); + if (plan.action != "run") + return 0; + + let status = command_status(command_from_args([ "env", "PODKOP_UI_ACTION_TRACKED=1", BIN_PATH, "reload", reason ]) + " >/dev/null 2>&1"); + let finish = reload_finish_value(reason, plan.job_id, status); + if (finish.sync) + print("sync\n"); + return finish.status; +} + +function reload_release() { + release_runtime_dir_lock(RELOAD_LOCK_DIR); + return 0; +} + +function trigger_plan(settings) { + settings = object_or_empty(settings); + let badwan_enabled = option(settings, "enable_badwan_interface_monitoring", "") == "1"; + let badwan_interfaces = split(replace(trim(option(settings, "badwan_monitored_interfaces", "")), /[ \t\r\n]+/g, " "), " "); + let delay = option(settings, "badwan_reload_delay", "2000"); + if (delay == "") + delay = "2000"; + + print("delay\t", delay, "\n"); + print("config\tconfig.change\t", CONFIG_NAME, "\t", SERVICE_INIT, "\treload\t", CONFIG_CHANGE_REASON, "\n"); + print("interface\tinterface.*.up\twan\t", SERVICE_INIT, "\tretry_start_on_wan_up\t\n"); + + if (badwan_enabled) { + for (let iface in badwan_interfaces) { + iface = trim(iface); + if (iface == "" || iface == "wan") + continue; + print("interface\tinterface.*.up\t", iface, "\t", SERVICE_INIT, "\treload\t\n"); + } + } +} + +let mode = ARGV[0] || ""; + +if (mode == "restore-dnsmasq-failsafe") + exit(restore_dnsmasq_failsafe()); +else if (mode == "runtime-running") + exit(runtime_is_running() ? 0 : 1); +else if (mode == "status-service") + exit(status_service()); +else if (mode == "retry-start-on-wan-up") + exit(retry_start_on_wan_up(ARGV[1])); +else if (mode == "service-enabled") + exit(service_is_enabled() ? 0 : 1); +else if (mode == "begin-action") { + let job_id = begin_external_service_action(ARGV[1], ARGV[2] || "initd", ARGV[3]); + if (job_id != "") + print(job_id, "\n"); +} +else if (mode == "finish-action") + exit(finish_external_service_action(ARGV[1], ARGV[2], ARGV[3])); +else if (mode == "start-plan") + start_plan(ARGV[1], ARGV[2], uci_settings(), null); +else if (mode == "start-service") + exit(start_service(ARGV[1], ARGV[2])); +else if (mode == "start-plan-fixture") { + let settings = { + shutdown_correctly: ARGV[2], + enable_badwan_interface_monitoring: ARGV[3], + badwan_monitored_interfaces: ARGV[4] + }; + start_plan(ARGV[1], ARGV[5] || "0", settings, ARGV[6] == null ? "1" : ARGV[6]); +} +else if (mode == "stop-plan") + stop_plan(ARGV[1], null); +else if (mode == "stop-plan-fixture") + stop_plan(ARGV[1] || "0", ARGV[2] == null ? "1" : ARGV[2]); +else if (mode == "stop-finish") + exit(stop_finish(ARGV[1], ARGV[2])); +else if (mode == "stop-service") + exit(stop_service(ARGV[1])); +else if (mode == "reload-begin") + exit(reload_begin(ARGV[1], ARGV[2], null, null)); +else if (mode == "reload-begin-fixture") + exit(reload_begin(ARGV[1], ARGV[2] || "0", ARGV[3], ARGV[4])); +else if (mode == "reload-finish") + exit(reload_finish(ARGV[1], ARGV[2], ARGV[3])); +else if (mode == "reload-service") + exit(reload_service(ARGV[1], ARGV[2])); +else if (mode == "reload-release") + exit(reload_release()); +else if (mode == "trigger-plan") + trigger_plan(uci_settings()); +else if (mode == "trigger-plan-fixture") + trigger_plan(settings_from_fixture(ARGV[1])); +else if (mode == "initd-service-trigger-sync-requested") + exit(initd_service_trigger_sync_requested(ARGV[1]) ? 0 : 1); +else if (mode == "initd-guard-matches-current-config") + exit(initd_guard_matches_current_config(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "initd-should-skip-internal-config-reload") + exit(initd_should_skip_internal_config_reload(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +else if (mode == "initd-should-restore-dnsmasq-on-start-fixture") + exit(initd_should_restore_dnsmasq_on_start_from_value(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "initd-should-ignore-config-change-reload") + exit(initd_should_ignore_config_change_reload(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +else if (mode == "initd-should-sync-service-triggers") + exit(initd_should_sync_service_triggers(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else { + warn("Usage: service/initd.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/service/lifecycle.uc b/podkop/files/usr/lib/service/lifecycle.uc new file mode 100644 index 00000000..0f0953ea --- /dev/null +++ b/podkop/files/usr/lib/service/lifecycle.uc @@ -0,0 +1,1045 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let constants = require("core.constants"); +let uci_core = require("core.uci"); +let migration = require("config.migration"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function constant_value(name, fallback) { + let value = constants[name]; + return value == null ? as_string(fallback) : as_string(value); +} + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || constant_value("PODKOP_CONFIG_NAME", "podkop-plus"); +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const BIN_PATH = getenv("PODKOP_BIN") || constant_value("PODKOP_BIN", "/usr/bin/podkop-plus"); +const SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || constant_value("PODKOP_SERVICE_INIT", "/etc/init.d/podkop-plus"); +const SERVICE_NAME = getenv("PODKOP_SERVICE_NAME") || constant_value("PODKOP_SERVICE_NAME", "podkop-plus"); +const LUCI_VIEW_DIR = getenv("PODKOP_LUCI_VIEW_DIR") || constant_value("PODKOP_LUCI_VIEW_DIR", "/www/luci-static/resources/view/podkop_plus"); +const LUCI_I18N_DOMAIN = getenv("PODKOP_LUCI_I18N_DOMAIN") || constant_value("PODKOP_LUCI_I18N_DOMAIN", "podkop_plus"); + +const RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const SYSTEM_INFO_CACHE_FILE = getenv("PODKOP_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json"; +const RELOAD_STATE_FILE = getenv("PODKOP_RELOAD_STATE_FILE") || RUNTIME_STATE_DIR + "/reload-state"; +const RELOAD_STATE_SNAPSHOT_FILE = getenv("PODKOP_RELOAD_STATE_SNAPSHOT_FILE") || RUNTIME_STATE_DIR + "/reload-state.snapshot." + clock()[0] + "." + clock()[1]; +const PENDING_RELOAD_FILE = getenv("PODKOP_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending"; +const SERVICE_TRIGGER_SYNC_FILE = getenv("PODKOP_SERVICE_TRIGGER_SYNC_FILE") || RUNTIME_STATE_DIR + "/service-triggers.sync"; +const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update"; +const SUBSCRIPTION_UPDATE_JOB_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR") || RUNTIME_STATE_DIR + "/subscription-update-jobs"; +const SUBSCRIPTION_LINKS_DIR = getenv("PODKOP_SUBSCRIPTION_LINKS_DIR") || RUNTIME_STATE_DIR + "/subscription-links"; +const SUBSCRIPTION_METADATA_DIR = getenv("PODKOP_SUBSCRIPTION_METADATA_DIR") || RUNTIME_STATE_DIR + "/subscription-metadata"; +const OUTBOUND_METADATA_DIR = getenv("PODKOP_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata"; +const SECTION_CACHE_DIR = getenv("PODKOP_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const RULE_CONDITION_CACHE_DIR = getenv("PODKOP_RULE_CONDITION_CACHE_DIR") || RUNTIME_STATE_DIR + "/rule-condition-cache"; +const RUNTIME_CACHE_FORMAT_FILE = getenv("PODKOP_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format"; +const PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/podkop-plus/subscription-cache"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE = getenv("PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE") || RUNTIME_STATE_DIR + "/subscription-bootstrap-retry.pid"; +const SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR") || RUNTIME_STATE_DIR + "/subscription-update.lock"; +const RELOAD_LOCK_DIR = getenv("PODKOP_RELOAD_LOCK_DIR") || "/var/run/podkop-plus.reload.lock"; + +const LIST_UPDATE_CRON_MARKER = getenv("PODKOP_LIST_UPDATE_CRON_MARKER") || "# podkop-plus-list-update"; +const SUBSCRIPTION_UPDATE_CRON_MARKER = getenv("PODKOP_SUBSCRIPTION_UPDATE_CRON_MARKER") || "# podkop-plus-subscription-update"; +const RELOAD_STATE_FORMAT = int(getenv("PODKOP_RELOAD_STATE_FORMAT") || "1"); +const RUNTIME_CACHE_FORMAT = int(getenv("PODKOP_RUNTIME_CACHE_FORMAT") || "7"); +const RUNTIME_STABLE_MIN_AGE = int(getenv("PODKOP_RUNTIME_STABLE_MIN_AGE") || "2"); +const NFT_POPULATE_ENABLED_DEFAULT = int(getenv("PODKOP_NFT_POPULATE_ENABLED") || "1"); + +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || constant_value("TMP_SING_BOX_FOLDER", "/tmp/sing-box"); +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || constant_value("TMP_RULESET_FOLDER", TMP_SING_BOX_FOLDER + "/rulesets"); +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || constant_value("TMP_SUBSCRIPTION_FOLDER", TMP_SING_BOX_FOLDER + "/subscriptions"); + +const RT_TABLE_NAME = constant_value("RT_TABLE_NAME", "podkop"); +const NFT_TABLE_NAME = constant_value("NFT_TABLE_NAME", "PodkopPlusTable"); +const NFT_LOCALV4_SET_NAME = constant_value("NFT_LOCALV4_SET_NAME", "localv4"); +const NFT_COMMON_SET_NAME = constant_value("NFT_COMMON_SET_NAME", "podkop_plus_subnets"); +const NFT_PORT_SET_NAME = constant_value("NFT_PORT_SET_NAME", "podkop_plus_ports"); +const NFT_IP_PORT_SET_NAME = constant_value("NFT_IP_PORT_SET_NAME", "podkop_plus_ip_ports"); +const NFT_INTERFACE_SET_NAME = constant_value("NFT_INTERFACE_SET_NAME", "podkop_plus_interfaces"); +const NFT_FAKEIP_MARK = constant_value("NFT_FAKEIP_MARK", "0x00100000"); +const NFT_OUTBOUND_MARK = constant_value("NFT_OUTBOUND_MARK", "0x00200000"); + +const SB_FAKEIP_INET4_RANGE = constant_value("SB_FAKEIP_INET4_RANGE", "198.18.0.0/15"); +const SB_TPROXY_INBOUND_PORT = constant_value("SB_TPROXY_INBOUND_PORT", "1602"); +const SB_SERVICE_MIXED_INBOUND_ADDRESS = constant_value("SB_SERVICE_MIXED_INBOUND_ADDRESS", "127.0.0.1"); +const SB_SERVICE_MIXED_INBOUND_PORT = constant_value("SB_SERVICE_MIXED_INBOUND_PORT", "4534"); +const SB_VARIANT_STATE_FILE = constant_value("SB_VARIANT_STATE_FILE", "/etc/podkop-plus/sing-box-variant"); +const SB_VERSION_STATE_FILE = constant_value("SB_VERSION_STATE_FILE", "/etc/podkop-plus/sing-box-version"); + +const ZAPRET_PROVIDER_NFQWS_BIN = constant_value("ZAPRET_PROVIDER_NFQWS_BIN", "/opt/zapret/nfq/nfqws"); +const ZAPRET_ROUTE_MARK_BASE = constant_value("ZAPRET_ROUTE_MARK_BASE", "0x01000000"); +const ZAPRET_QUEUE_BASE = constant_value("ZAPRET_QUEUE_BASE", "4000"); +const ZAPRET_DESYNC_MARK = constant_value("ZAPRET_DESYNC_MARK", "0x40000000"); +const ZAPRET_DESYNC_MARK_POSTNAT = constant_value("ZAPRET_DESYNC_MARK_POSTNAT", "0x20000000"); +const ZAPRET2_PROVIDER_NFQWS2_BIN = constant_value("ZAPRET2_PROVIDER_NFQWS2_BIN", "/opt/zapret2/nfq2/nfqws2"); +const ZAPRET2_ROUTE_MARK_BASE = constant_value("ZAPRET2_ROUTE_MARK_BASE", "0x01010000"); +const ZAPRET2_QUEUE_BASE = constant_value("ZAPRET2_QUEUE_BASE", "4300"); +const ZAPRET2_DESYNC_MARK = constant_value("ZAPRET2_DESYNC_MARK", "0x40000000"); +const ZAPRET2_DESYNC_MARK_POSTNAT = constant_value("ZAPRET2_DESYNC_MARK_POSTNAT", "0x20000000"); +const BYEDPI_BIN = constant_value("BYEDPI_BIN", "/usr/bin/ciadpi"); + +const DNS_APPLY_UC = LIB_DIR + "/dns/apply.uc"; +const MIGRATION_UC = LIB_DIR + "/config/migration.uc"; +const VALIDATOR_UC = LIB_DIR + "/config/validator.uc"; +const SERVER_UC = LIB_DIR + "/server/service.uc"; +const NFT_UC = LIB_DIR + "/nft/apply.uc"; +const SINGBOX_UC = LIB_DIR + "/singbox/runtime.uc"; +const SUBSCRIPTION_CACHE_UC = LIB_DIR + "/subscription/cache.uc"; +const UPDATES_UC = LIB_DIR + "/components/updates.uc"; +const STATE_UC = LIB_DIR + "/service/state.uc"; +const RELOAD_UC = LIB_DIR + "/service/reload.uc"; +const UI_UC = LIB_DIR + "/service/ui.uc"; +const DIAGNOSTICS_UC = LIB_DIR + "/diagnostics/runtime.uc"; +const ZAPRET_UC = LIB_DIR + "/providers/zapret/runtime.uc"; +const ZAPRET2_UC = LIB_DIR + "/providers/zapret2/runtime.uc"; +const BYEDPI_UC = LIB_DIR + "/providers/byedpi/runtime.uc"; +const PACKAGES_UC = LIB_DIR + "/core/packages.uc"; + +let start_subscription_update_lock_held = false; +let subscription_caches_prepared = getenv("PODKOP_SUBSCRIPTION_CACHES_PREPARED") || "0"; +let subscription_runtime_no_refresh = getenv("PODKOP_SUBSCRIPTION_RUNTIME_NO_REFRESH") || "0"; +let subscription_deferred_sections = ""; +let nft_populate_enabled = NFT_POPULATE_ENABLED_DEFAULT; +let rule_condition_cache_enabled = 0; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function command_status(command) { + return normalize_status(system(command)); +} + +function command_capture(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return { status: 1, output: "" }; + + let data = pipe.read("all"); + let status = normalize_status(pipe.close()); + return { status, output: data == null ? "" : as_string(data) }; +} + +function command_output(command) { + let result = command_capture(command); + return result.status == 0 ? result.output : ""; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_status_from_args(args) { + return command_status(command_from_args(args)); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args) + " 2>/dev/null"); +} + +function command_success_from_args(args) { + return command_status(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function trim(value) { + return replace(as_string(value), /^[ \t\r\n]+|[ \t\r\n]+$/g, ""); +} + +function owner_pid() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function now_seconds() { + return int(clock()[0]); +} + +function bool_text(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)); +} + +function remove_file(path) { + return fs.unlink(as_string(path)) || true; +} + +function ensure_dir(path) { + path = as_string(path); + return path == "" || fs.mkdir(path, 0755) || fs.stat(path) != null; +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", SERVICE_NAME, "[" + level + "] " + as_string(message) ]); +} + +function lifecycle_env() { + return { + PODKOP_CONFIG_NAME: CONFIG_NAME, + PODKOP_LIB: LIB_DIR, + PODKOP_BIN: BIN_PATH, + PODKOP_SERVICE_INIT: SERVICE_INIT, + PODKOP_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + PODKOP_SYSTEM_INFO_CACHE_FILE: SYSTEM_INFO_CACHE_FILE, + PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + PODKOP_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + PODKOP_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + PODKOP_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + PODKOP_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + PODKOP_RULE_CONDITION_CACHE_DIR: RULE_CONDITION_CACHE_DIR, + PODKOP_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + PODKOP_RUNTIME_CACHE_FORMAT: as_string(RUNTIME_CACHE_FORMAT), + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE: SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE, + PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR: SUBSCRIPTION_UPDATE_LOCK_DIR, + PODKOP_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE, + PODKOP_RELOAD_LOCK_DIR: RELOAD_LOCK_DIR, + TMP_SING_BOX_FOLDER: TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER: TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER: TMP_SUBSCRIPTION_FOLDER, + SB_SERVICE_MIXED_INBOUND_ADDRESS: SB_SERVICE_MIXED_INBOUND_ADDRESS, + SB_SERVICE_MIXED_INBOUND_PORT: SB_SERVICE_MIXED_INBOUND_PORT, + SB_VARIANT_STATE_FILE: SB_VARIANT_STATE_FILE, + SB_VERSION_STATE_FILE: SB_VERSION_STATE_FILE, + ZAPRET_PROVIDER_NFQWS_BIN: ZAPRET_PROVIDER_NFQWS_BIN, + ZAPRET2_PROVIDER_NFQWS2_BIN: ZAPRET2_PROVIDER_NFQWS2_BIN, + BYEDPI_BIN: BYEDPI_BIN, + PODKOP_RULE_CONDITION_CACHE_ENABLED: as_string(rule_condition_cache_enabled) + }; +} + +function command_env(assignments) { + let parts = []; + for (let name, value in assignments) + push(parts, as_string(name) + "=" + shell_quote(value)); + return join(" ", parts); +} + +function module_args(module_path, args) { + let result = [ "ucode", "-L", LIB_DIR, module_path ]; + for (let arg in (type(args) == "array" ? args : [])) + push(result, arg); + return result; +} + +function module_command(module_path, args) { + return command_env(lifecycle_env()) + " " + command_from_args(module_args(module_path, args)); +} + +function module_capture(module_path, args) { + return command_capture(module_command(module_path, args)); +} + +function module_status(module_path, args) { + return command_status(module_command(module_path, args)); +} + +function module_success(module_path, args) { + return module_status(module_path, args) == 0; +} + +function module_output(module_path, args) { + let result = module_capture(module_path, args); + return result.status == 0 ? result.output : ""; +} + +function module_background(module_path, args) { + return command_status(module_command(module_path, args) + " >/dev/null 2>&1 &") == 0; +} + +function config_get(path, fallback) { + path = as_string(path); + if (!uci_core.exists(path)) + return as_string(fallback); + return trim(uci_core.get(path)); +} + +function config_set(path, value) { + return uci_core.set(path, value); +} + +function config_commit() { + if (!uci_core.commit(CONFIG_NAME)) + return 1; + migration.mark_internal_config_guard(); + return 0; +} + +function setting_bool(name, fallback) { + let value = config_get(CONFIG_NAME + ".settings." + as_string(name), fallback ? "1" : "0"); + return bool_text(value); +} + +function dns_apply_status(args) { + return module_status(DNS_APPLY_UC, args); +} + +function dns_apply_success(args) { + return dns_apply_status(args) == 0; +} + +function dnsmasq_configure(force) { + let args = [ "configure" ]; + if (force) + push(args, "force"); + return dns_apply_status(args); +} + +function dnsmasq_restore(force) { + let args = [ "restore" ]; + if (force) + push(args, "force"); + return dns_apply_status(args); +} + +function dnsmasq_restore_fail_safe() { + return dns_apply_status([ "failsafe-restore" ]); +} + +function dnsmasq_has_podkop_managed_state() { + return dns_apply_success([ "has-managed-state" ]); +} + +function validate_start_config() { + let status = module_status(MIGRATION_UC, [ "migrate" ]); + if (status != 0) { + log_message("Config migration failed. Aborted.", "fatal"); + return status; + } + + status = module_status(VALIDATOR_UC, [ "check-requirements" ]); + if (status != 0) + return status; + + status = module_status(SERVER_UC, [ "prepare-all-defaults" ]); + if (status != 0) + return status; + + status = module_status(VALIDATOR_UC, [ "validate-runtime" ]); + if (status != 0) { + log_message("Runtime config validation failed. Aborted.", "fatal"); + return status; + } + + return 0; +} + +function acquire_start_subscription_update_lock() { + if (module_success(STATE_UC, [ "acquire-runtime-dir-lock-wait", SUBSCRIPTION_UPDATE_LOCK_DIR, owner_pid(), "300" ])) { + start_subscription_update_lock_held = true; + return true; + } + + log_message("Subscription update is already running during startup. Aborted.", "fatal"); + return false; +} + +function release_start_subscription_update_lock() { + if (!start_subscription_update_lock_held) + return; + + module_success(STATE_UC, [ "release-runtime-dir-lock", SUBSCRIPTION_UPDATE_LOCK_DIR ]); + start_subscription_update_lock_held = false; +} + +function nft_rebuild_runtime() { + return module_status(NFT_UC, [ + "nft-rebuild-runtime-from-uci", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_LOCALV4_SET_NAME, + NFT_COMMON_SET_NAME, + NFT_PORT_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_FAKEIP_MARK, + NFT_OUTBOUND_MARK, + SB_FAKEIP_INET4_RANGE, + SB_TPROXY_INBOUND_PORT, + ZAPRET_PROVIDER_NFQWS_BIN, + ZAPRET_ROUTE_MARK_BASE, + ZAPRET_QUEUE_BASE, + ZAPRET_DESYNC_MARK, + ZAPRET_DESYNC_MARK_POSTNAT, + ZAPRET2_PROVIDER_NFQWS2_BIN, + ZAPRET2_ROUTE_MARK_BASE, + ZAPRET2_QUEUE_BASE, + ZAPRET2_DESYNC_MARK, + ZAPRET2_DESYNC_MARK_POSTNAT + ]); +} + +function nft_populate_runtime_sets() { + return module_status(NFT_UC, [ + "nft-populate-runtime-sets-from-uci", + as_string(nft_populate_enabled), + subscription_deferred_sections, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_PORT_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_LOCALV4_SET_NAME, + NFT_FAKEIP_MARK + ]); +} + +function singbox_init_config() { + let result = module_capture(SINGBOX_UC, [ + "init-config", + as_string(nft_populate_enabled), + subscription_caches_prepared, + subscription_runtime_no_refresh + ]); + if (result.status == 0) { + subscription_deferred_sections = trim(result.output); + subscription_caches_prepared = "1"; + } + return result.status; +} + +function refresh_cron() { + return module_status(UPDATES_UC, [ + "refresh-cron-from-uci", + BIN_PATH, + LIST_UPDATE_CRON_MARKER, + SUBSCRIPTION_UPDATE_CRON_MARKER + ]); +} + +function remove_cron_jobs() { + return module_status(UPDATES_UC, [ + "remove-cron-jobs", + LIST_UPDATE_CRON_MARKER, + SUBSCRIPTION_UPDATE_CRON_MARKER + ]); +} + +function prepare_subscription_caches(mode) { + let result = module_capture(SUBSCRIPTION_CACHE_UC, [ + "prepare-caches", + mode, + subscription_caches_prepared, + subscription_runtime_no_refresh + ]); + if (result.status == 0) { + subscription_deferred_sections = trim(result.output); + subscription_caches_prepared = "1"; + } + return result.status; +} + +function start_main() { + let status; + + log_message("Starting Podkop Plus", "info"); + + status = validate_start_config(); + if (status != 0) + return status; + + status = module_status(NFT_UC, [ "ensure-bridge-netfilter-disabled" ]); + if (status != 0) + return status; + + module_success(STATE_UC, [ "sync-time-if-needed" ]); + + status = module_status(SUBSCRIPTION_CACHE_UC, [ "ensure-runtime-dirs" ]); + if (status != 0) + return status; + + if (!acquire_start_subscription_update_lock()) + return 1; + + status = prepare_subscription_caches("startup"); + if (status != 0) { + log_message("Subscription caches are not ready. Aborted.", "fatal"); + return status; + } + + status = nft_rebuild_runtime(); + if (status != 0) + return status; + + status = module_status(SINGBOX_UC, [ "configure-service" ]); + if (status != 0) + return status; + + status = singbox_init_config(); + if (status != 0) + return status; + + status = refresh_cron(); + if (status != 0) + return status; + + module_success(BYEDPI_UC, [ "start-runtime" ]); + + if (!command_success_from_args([ "/etc/init.d/sing-box", "start" ])) { + log_message("Failed to start sing-box. Aborted.", "fatal"); + return 1; + } + + status = module_status(SUBSCRIPTION_CACHE_UC, [ "run-deferred-bootstrap", subscription_deferred_sections ]); + if (status != 0) + return status; + + release_start_subscription_update_lock(); + module_success(ZAPRET_UC, [ "start-runtime" ]); + module_success(ZAPRET2_UC, [ "start-runtime" ]); + + module_background(UPDATES_UC, [ "list-update" ]); + return 0; +} + +function start_impl() { + let status = start_main(); + if (status != 0) + return status; + + if (!setting_bool("dont_touch_dhcp", false)) { + status = dnsmasq_configure(false); + if (status != 0) + return status; + } + else if (dnsmasq_has_podkop_managed_state()) { + log_message("dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes", "info"); + status = dnsmasq_restore(true); + if (status != 0) + return status; + } + + if (!config_set(CONFIG_NAME + ".settings.shutdown_correctly", "0")) + return 1; + + status = config_commit(); + if (status != 0) + return status; + + status = module_status(STATE_UC, [ + "write-current-reload-state-clean", + RELOAD_STATE_FILE, + as_string(RELOAD_STATE_FORMAT), + RULE_CONDITION_CACHE_DIR + ]); + if (status != 0) + return status; + + module_background(DIAGNOSTICS_UC, [ "get-system-info" ]); + return 0; +} + +function start() { + let status = start_impl(); + release_start_subscription_update_lock(); + + if (status != 0) { + dnsmasq_restore_fail_safe(); + return status; + } + + status = module_status(STATE_UC, [ + "wait-podkop-stable-start", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_FAKEIP_MARK, + as_string(RUNTIME_STABLE_MIN_AGE), + "8" + ]); + if (status != 0) { + log_message("Podkop Plus did not reach a stable running state after start. Restoring dnsmasq.", "fatal"); + stop_main(); + dnsmasq_restore_fail_safe(); + return status; + } + + return module_status(STATE_UC, [ "run-pending-reload-if-requested", PENDING_RELOAD_FILE, SERVICE_INIT ]); +} + +function stop_main() { + let status = 0; + + log_message("Stopping Podkop Plus", "info"); + module_success(SUBSCRIPTION_CACHE_UC, [ "stop-deferred-bootstrap-worker" ]); + module_success(UPDATES_UC, [ "stop-list-update" ]); + remove_cron_jobs(); + command_success_from_args([ "find", TMP_RULESET_FOLDER, "-mindepth", "1", "-maxdepth", "1", "-type", "f", "-delete" ]); + + module_success(ZAPRET_UC, [ "stop-runtime" ]); + module_success(ZAPRET2_UC, [ "stop-runtime" ]); + module_success(BYEDPI_UC, [ "stop-runtime" ]); + + log_message("Flush nft", "info"); + if (command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) + command_success_from_args([ "nft", "delete", "table", "inet", NFT_TABLE_NAME ]); + + log_message("Flush ip rule", "info"); + if (module_success(NFT_UC, [ "tproxy-marking-rule-present", RT_TABLE_NAME, NFT_FAKEIP_MARK ])) + command_success_from_args([ "ip", "rule", "del", "fwmark", NFT_FAKEIP_MARK + "/" + NFT_FAKEIP_MARK, "table", RT_TABLE_NAME, "priority", "105" ]); + + log_message("Flush ip route", "info"); + if (module_success(NFT_UC, [ "tproxy-route-present", RT_TABLE_NAME ])) + command_success_from_args([ "ip", "route", "flush", "table", RT_TABLE_NAME ]); + + log_message("Stop sing-box", "info"); + let sing_box_status = command_status_from_args([ "/etc/init.d/sing-box", "stop" ]); + if (sing_box_status != 0) + status = sing_box_status; + + return status; +} + +function stop_impl() { + let status = 0; + + if (!setting_bool("dont_touch_dhcp", false)) { + let dns_status = dnsmasq_restore(false); + if (dns_status != 0) + status = dns_status; + } + else if (dnsmasq_has_podkop_managed_state()) { + log_message("dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes", "info"); + let dns_status = dnsmasq_restore(true); + if (dns_status != 0) + status = dns_status; + } + + let runtime_status = stop_main(); + if (runtime_status != 0) + status = runtime_status; + + if (!config_set(CONFIG_NAME + ".settings.shutdown_correctly", "1")) + status = 1; + else { + let commit_status = config_commit(); + if (commit_status != 0) + status = commit_status; + } + + module_success(STATE_UC, [ "clear-reload-state", RELOAD_STATE_FILE, RELOAD_STATE_SNAPSHOT_FILE ]); + + if (status != 0) + dnsmasq_restore_fail_safe(); + + return status; +} + +function stop() { + return stop_impl(); +} + +function restart_runtime_for_reload() { + let status; + + log_message("Reload fallback: restart Podkop Plus runtime in-process", "info"); + + status = stop_main(); + if (status != 0) + return status; + + status = start_main(); + if (status != 0) + return status; + + if (!setting_bool("dont_touch_dhcp", false)) + dnsmasq_configure(true); + else if (dnsmasq_has_podkop_managed_state()) { + log_message("dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes", "info"); + dnsmasq_restore(true); + } + + config_set(CONFIG_NAME + ".settings.shutdown_correctly", "0"); + if (config_commit() != 0) + return 1; + + return module_status(STATE_UC, [ + "write-current-reload-state-clean", + RELOAD_STATE_FILE, + as_string(RELOAD_STATE_FORMAT), + RULE_CONDITION_CACHE_DIR + ]); +} + +function write_service_trigger_sync_state(changed) { + ensure_dir(RUNTIME_STATE_DIR); + write_file(SERVICE_TRIGGER_SYNC_FILE, as_string(changed) + "\n"); +} + +function parse_reload_plan(output) { + let result = { + changed_service_triggers: 0, + changed_dnsmasq: 0, + changed_sing_box: 0, + changed_nft: 0, + changed_zapret_queue: 0, + changed_zapret_runtime: 0, + changed_zapret2_queue: 0, + changed_zapret2_runtime: 0, + changed_byedpi_runtime: 0, + changed_cron: 0, + changed_list: 0, + needs_sing_box_reload: 0, + needs_nft_rebuild: 0, + needs_zapret_restart: 0, + needs_zapret2_restart: 0, + needs_byedpi_restart: 0, + needs_dnsmasq_configure: 0, + needs_dnsmasq_restore: 0, + needs_cron_refresh: 0, + needs_list_update: 0, + has_work: 0 + }; + + for (let line in split(as_string(output), "\n")) { + line = as_string(line); + if (line == "") + continue; + let fields = split(line, "\t"); + if (length(fields) < 2) + continue; + let key = fields[0]; + if (result[key] != null) + result[key] = int(fields[1] || 0); + } + + return result; +} + +function reload(reason) { + let status; + let force_runtime_reload = as_string(reason || "") == "on_config_change" ? 0 : 1; + rule_condition_cache_enabled = force_runtime_reload; + + log_message("Podkop Plus reload", "info"); + + status = validate_start_config(); + if (status != 0) + return status; + + status = module_status(SUBSCRIPTION_CACHE_UC, [ "ensure-runtime-dirs" ]); + if (status != 0) + return status; + + if (!module_success(STATE_UC, [ "podkop-running", RT_TABLE_NAME, NFT_TABLE_NAME, NFT_FAKEIP_MARK ])) { + log_message("Podkop Plus runtime is incomplete, falling back to in-process restart", "info"); + return restart_runtime_for_reload(); + } + + remove_file(RELOAD_STATE_SNAPSHOT_FILE); + status = module_status(STATE_UC, [ + "capture-reload-state", + RELOAD_STATE_SNAPSHOT_FILE, + as_string(RELOAD_STATE_FORMAT) + ]); + if (status != 0) + return status; + + let current_reload_state_file = trim(command_output_from_args([ "mktemp" ])); + if (current_reload_state_file == "") + return 1; + + status = module_status(STATE_UC, [ + "write-captured-reload-state", + current_reload_state_file, + RELOAD_STATE_SNAPSHOT_FILE, + as_string(RELOAD_STATE_FORMAT), + "", + "0", + "0" + ]); + if (status != 0) { + remove_file(current_reload_state_file); + return status; + } + + let dnsmasq_managed_state = dnsmasq_has_podkop_managed_state() ? 1 : 0; + let list_update_sources = module_success(STATE_UC, [ "has-list-update-sources" ]) ? 1 : 0; + let nft_list_update_sources = module_success(STATE_UC, [ "has-nft-list-update-sources" ]) ? 1 : 0; + let runtime_cache_needs_rebuild = 0; + if (as_string(getenv("PODKOP_RUNTIME_CACHE_INVALIDATED") || "0") == "1" || + module_success(SUBSCRIPTION_CACHE_UC, [ "runtime-cache-needs-rebuild", SECTION_CACHE_DIR ])) + runtime_cache_needs_rebuild = 1; + + let plan_result = module_capture(RELOAD_UC, [ + "plan-state-files", + RELOAD_STATE_FILE, + current_reload_state_file, + as_string(force_runtime_reload), + as_string(dnsmasq_managed_state), + as_string(list_update_sources), + as_string(nft_list_update_sources), + as_string(runtime_cache_needs_rebuild) + ]); + remove_file(current_reload_state_file); + + if (plan_result.status != 0) { + if (plan_result.status == 2) { + log_message("Reload state is missing or incompatible, falling back to in-process restart", "info"); + return restart_runtime_for_reload(); + } + return plan_result.status; + } + + let plan = parse_reload_plan(plan_result.output); + write_service_trigger_sync_state(plan.changed_service_triggers); + + log_message("Reload signature changes: service_triggers=" + plan.changed_service_triggers + + " sing-box=" + plan.changed_sing_box + + " nft=" + plan.changed_nft + + " zapret_queue=" + plan.changed_zapret_queue + + " zapret_runtime=" + plan.changed_zapret_runtime + + " zapret2_queue=" + plan.changed_zapret2_queue + + " zapret2_runtime=" + plan.changed_zapret2_runtime + + " byedpi_runtime=" + plan.changed_byedpi_runtime + + " dnsmasq=" + plan.changed_dnsmasq + + " cron=" + plan.changed_cron + + " list=" + plan.changed_list + + " forced=" + force_runtime_reload, "debug"); + log_message("Reload plan: sing-box=" + plan.needs_sing_box_reload + + " nft=" + plan.needs_nft_rebuild + + " zapret=" + plan.needs_zapret_restart + + " zapret2=" + plan.needs_zapret2_restart + + " byedpi=" + plan.needs_byedpi_restart + + " dnsmasq_configure=" + plan.needs_dnsmasq_configure + + " dnsmasq_restore=" + plan.needs_dnsmasq_restore + + " cron=" + plan.needs_cron_refresh + + " list_update=" + plan.needs_list_update, "debug"); + + if (plan.has_work == 0) { + status = module_status(STATE_UC, [ + "write-captured-reload-state", + RELOAD_STATE_FILE, + RELOAD_STATE_SNAPSHOT_FILE, + as_string(RELOAD_STATE_FORMAT), + RULE_CONDITION_CACHE_DIR, + "1", + "1" + ]); + if (status == 0) + log_message("Reload skipped: runtime-relevant configuration is unchanged", "info"); + return status; + } + + if (plan.needs_zapret_restart == 1) + module_success(ZAPRET_UC, [ "stop-runtime" ]); + if (plan.needs_zapret2_restart == 1) + module_success(ZAPRET2_UC, [ "stop-runtime" ]); + if (plan.needs_byedpi_restart == 1) + module_success(BYEDPI_UC, [ "stop-runtime" ]); + + if (plan.needs_nft_rebuild == 1) { + log_message("Rebuilding nft runtime", "info"); + status = nft_rebuild_runtime(); + if (status != 0) + return status; + } + + if (plan.needs_sing_box_reload == 1) { + status = module_status(SINGBOX_UC, [ "configure-service" ]); + if (status != 0) + return status; + nft_populate_enabled = plan.needs_nft_rebuild == 1 ? 1 : 0; + status = singbox_init_config(); + if (status != 0) + return status; + nft_populate_enabled = NFT_POPULATE_ENABLED_DEFAULT; + status = module_status(STATE_UC, [ "reload-sing-box-runtime" ]); + if (status != 0) + return status; + } + else if (plan.needs_nft_rebuild == 1 && nft_populate_enabled == 1) { + status = nft_populate_runtime_sets(); + if (status != 0) + return status; + } + + if (plan.needs_zapret_restart == 1) + module_success(ZAPRET_UC, [ "start-runtime" ]); + if (plan.needs_zapret2_restart == 1) + module_success(ZAPRET2_UC, [ "start-runtime" ]); + if (plan.needs_byedpi_restart == 1) + module_success(BYEDPI_UC, [ "start-runtime" ]); + + if (plan.needs_dnsmasq_configure == 1) { + status = dnsmasq_configure(true); + if (status != 0) + return status; + module_success(STATE_UC, [ "capture-reload-state", RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT) ]); + } + else if (plan.needs_dnsmasq_restore == 1) { + log_message("dont_touch_dhcp is enabled, restoring previous Podkop Plus dnsmasq changes", "info"); + status = dnsmasq_restore(true); + if (status != 0) + return status; + module_success(STATE_UC, [ "capture-reload-state", RELOAD_STATE_SNAPSHOT_FILE, as_string(RELOAD_STATE_FORMAT) ]); + } + + if (plan.needs_cron_refresh == 1) { + status = refresh_cron(); + if (status != 0) + return status; + } + + if (plan.needs_list_update == 1) + module_background(UPDATES_UC, [ "list-update" ]); + + return module_status(STATE_UC, [ + "write-captured-reload-state", + RELOAD_STATE_FILE, + RELOAD_STATE_SNAPSHOT_FILE, + as_string(RELOAD_STATE_FORMAT), + RULE_CONDITION_CACHE_DIR, + "1", + "1" + ]); +} + +function reload_tracked(reason) { + if (as_string(getenv("PODKOP_UI_ACTION_TRACKED") || "0") == "1") + return reload(reason); + + let job_id = trim(module_output(UI_UC, [ "service-action-begin-if-idle", "reload", "runtime_reload" ])); + if (job_id != "") + module_success(UI_UC, [ "service-action-update-pid", job_id, owner_pid() ]); + + let status = reload(reason); + if (job_id != "") + module_success(UI_UC, [ "service-action-finish-after-command", "reload", job_id, as_string(status) ]); + + return status; +} + +function restart() { + log_message("Podkop Plus restart", "info"); + + let status = stop_impl(); + if (status != 0) + return status; + + status = start_impl(); + if (status != 0) + return status; + + if (module_success(STATE_UC, [ + "podkop-stably-running", + RT_TABLE_NAME, + NFT_TABLE_NAME, + NFT_FAKEIP_MARK, + as_string(RUNTIME_STABLE_MIN_AGE) + ])) + return module_status(STATE_UC, [ "run-pending-reload-if-requested", PENDING_RELOAD_FILE, SERVICE_INIT ]); + + return 1; +} + +function package_manager_remove_if_installed(package_name) { + package_name = as_string(package_name); + if (command_success_from_args([ "sh", "-c", "command -v apk" ])) { + if (command_success_from_args([ "apk", "info", "-e", package_name ])) + command_success_from_args([ "apk", "del", package_name ]); + return; + } + + if (module_success(PACKAGES_UC, [ "opkg-installed", package_name ])) + command_success_from_args([ "opkg", "remove", "--force-depends", package_name ]); +} + +function uninstall() { + log_message("Uninstalling Podkop Plus", "info"); + + if (fs.stat(SERVICE_INIT) != null) { + stop(); + command_success_from_args([ SERVICE_INIT, "disable" ]); + } + + dnsmasq_restore_fail_safe(); + + if (fs.stat("/etc/init.d/podkop") != null) { + command_success_from_args([ "/etc/init.d/podkop", "stop" ]); + command_success_from_args([ "/etc/init.d/podkop", "disable" ]); + } + + package_manager_remove_if_installed("luci-i18n-podkop-plus-ru"); + package_manager_remove_if_installed("luci-app-podkop-plus"); + + if (module_success(SINGBOX_UC, [ "managed-service-installed" ])) { + module_success(SINGBOX_UC, [ "remove-managed-service-script" ]); + remove_file("/usr/bin/sing-box"); + remove_file("/usr/lib/libcronet.so"); + } + + command_success_from_args([ "rm", "-rf", "/usr/lib/podkop-plus" ]); + command_success_from_args([ "rm", "-rf", LUCI_VIEW_DIR ]); + remove_file(SERVICE_INIT); + remove_file(BIN_PATH); + remove_file("/usr/share/luci/menu.d/luci-app-podkop-plus.json"); + remove_file("/usr/share/rpcd/acl.d/luci-app-podkop-plus.json"); + remove_file("/etc/uci-defaults/50_luci-podkop-plus"); + command_success_from_args([ "find", "/usr/lib/lua/luci/i18n", "-maxdepth", "1", "-type", "f", "-name", LUCI_I18N_DOMAIN + ".*.lmo", "-delete" ]); + remove_file("/usr/lib/lua/luci/i18n/" + LUCI_I18N_DOMAIN + ".ru.lua"); + remove_file("/usr/lib/lua/luci/i18n/" + LUCI_I18N_DOMAIN + ".en.lua"); + command_success("rm -f /var/luci-indexcache* /tmp/luci-indexcache* 2>/dev/null"); + if (fs.stat("/etc/init.d/rpcd") != null) + command_success_from_args([ "/etc/init.d/rpcd", "reload" ]); + + print("{\"removed\":true}\n"); + return 0; +} + +function enable_service() { + return command_status_from_args([ SERVICE_INIT, "enable" ]); +} + +function disable_service() { + return command_status_from_args([ SERVICE_INIT, "disable" ]); +} + +let mode = ARGV[0] || ""; +let status = 1; + +if (mode == "main") + status = start_main(); +else if (mode == "start") + status = start(); +else if (mode == "stop") + status = stop(); +else if (mode == "reload") + status = reload_tracked(ARGV[1] || ""); +else if (mode == "restart") + status = restart(); +else if (mode == "enable") + status = enable_service(); +else if (mode == "disable") + status = disable_service(); +else if (mode == "dnsmasq-restore" || mode == "restore-dnsmasq") + status = dnsmasq_restore_fail_safe(); +else if (mode == "uninstall") + status = uninstall(); +else { + warn("Usage: service/lifecycle.uc ...\n"); + status = 1; +} + +release_start_subscription_update_lock(); +exit(status); diff --git a/podkop/files/usr/lib/service/package.uc b/podkop/files/usr/lib/service/package.uc new file mode 100644 index 00000000..b3a4b0e1 --- /dev/null +++ b/podkop/files/usr/lib/service/package.uc @@ -0,0 +1,181 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function env(name, fallback) { + let value = getenv(name); + return value == null ? as_string(fallback) : as_string(value); +} + +const CONFIG_NAME = env("PODKOP_CONFIG_NAME", "podkop-plus"); +const CONFIG_PATH = env("PODKOP_CONFIG", "/etc/config/" + CONFIG_NAME); +const LEGACY_CONFIG_PATH = env("PODKOP_LEGACY_CONFIG", "/etc/config/podkop_plus"); +const RT_TABLES_PATH = env("PODKOP_RT_TABLES", "/etc/iproute2/rt_tables"); +const BIN_PATH = env("PODKOP_BIN", "/usr/bin/podkop-plus"); +const DNS_APPLY_UC = env("PODKOP_DNS_APPLY_UC", "/usr/lib/podkop-plus/dns/apply.uc"); +const SING_BOX_INIT = env("PODKOP_SING_BOX_INIT", "/etc/init.d/sing-box"); +const SING_BOX_BIN = env("PODKOP_SING_BOX_BIN", "/usr/bin/sing-box"); +const SING_BOX_CRONET = env("PODKOP_SING_BOX_CRONET", "/usr/lib/libcronet.so"); +const SING_BOX_MANAGED_MARKER = env("SB_MANAGED_SERVICE_MARKER", "Podkop Plus managed sing-box service for binary variants"); +const PACKAGE_TEST_MODE = env("PODKOP_PACKAGE_TEST_MODE", "") != ""; + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function normalize_status(status) { + status = int(status); + return status > 255 ? int(status / 256) : status; +} + +function command_success_from_args(args) { + return normalize_status(system(command_from_args(args) + " >/dev/null 2>&1")) == 0; +} + +function path_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function unlink_if_exists(path) { + if (path_exists(path)) + fs.unlink(as_string(path)); +} + +function copy_legacy_config_if_needed() { + if (env("IPKG_INSTROOT", "") != "") + return true; + if (path_exists(CONFIG_PATH) || !path_exists(LEGACY_CONFIG_PATH)) + return true; + + let data = fs.readfile(LEGACY_CONFIG_PATH); + if (data == null || fs.writefile(CONFIG_PATH, data) == null) + return false; + + command_success_from_args([ "chmod", "0644", CONFIG_PATH ]); + return true; +} + +function remove_rt_tables_entry() { + let data = fs.readfile(RT_TABLES_PATH); + if (data == null) + return true; + + let changed = false; + let lines = []; + for (let line in split(data, "\n")) { + if (index(line, "105 podkopplus") >= 0) { + changed = true; + continue; + } + push(lines, line); + } + + return !changed || fs.writefile(RT_TABLES_PATH, join("\n", lines)) != null; +} + +function ascii_lower(value) { + let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let lower = "abcdefghijklmnopqrstuvwxyz"; + return replace(as_string(value), /[A-Z]/g, function(ch) { + return substr(lower, index(upper, ch), 1); + }); +} + +function truthy(value) { + value = ascii_lower(trim(as_string(value))); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function dont_touch_dhcp_enabled() { + return truthy(uci_core.get(CONFIG_NAME + ".settings.dont_touch_dhcp")); +} + +function restore_dnsmasq_if_needed() { + if (dont_touch_dhcp_enabled()) + return; + + command_success_from_args([ BIN_PATH, "restore_dnsmasq" ]); + if (path_exists(DNS_APPLY_UC)) + command_success_from_args([ "ucode", DNS_APPLY_UC, "failsafe-restore" ]); +} + +function remove_managed_sing_box() { + let data = fs.readfile(SING_BOX_INIT); + if (data == null || index(data, SING_BOX_MANAGED_MARKER) < 0) + return; + + command_success_from_args([ SING_BOX_INIT, "stop" ]); + command_success_from_args([ SING_BOX_INIT, "disable" ]); + unlink_if_exists(SING_BOX_INIT); + unlink_if_exists(SING_BOX_BIN); + unlink_if_exists(SING_BOX_CRONET); +} + +function prerm_cleanup() { + if (env("IPKG_INSTROOT", "") != "") + return true; + + remove_rt_tables_entry(); + if (!PACKAGE_TEST_MODE) { + command_success_from_args([ "/etc/init.d/podkop-plus", "stop" ]); + restore_dnsmasq_if_needed(); + remove_managed_sing_box(); + } + return true; +} + +function luci_cache_globs() { + let configured = env("PODKOP_LUCI_CACHE_GLOBS", ""); + if (configured != "") + return split(configured, /[ \t\r\n]+/); + + return [ "/var/luci-indexcache*", "/tmp/luci-indexcache*" ]; +} + +function remove_luci_index_cache() { + for (let pattern in luci_cache_globs()) { + pattern = as_string(pattern); + if (pattern == "") + continue; + + for (let path in fs.glob(pattern)) + unlink_if_exists(path); + } +} + +function luci_postinst() { + remove_luci_index_cache(); + if (!PACKAGE_TEST_MODE) { + if (path_exists("/etc/init.d/rpcd")) + command_success_from_args([ "/etc/init.d/rpcd", "reload" ]); + command_success_from_args([ "logger", "-t", "podkop-plus", "uci-defaults script executed" ]); + } + return true; +} + +let mode = ARGV[0] || ""; + +if (mode == "preinst") + exit(copy_legacy_config_if_needed() ? 0 : 1); +else if (mode == "prerm") + exit(prerm_cleanup() ? 0 : 1); +else if (mode == "remove-rt-tables-entry") + exit(remove_rt_tables_entry() ? 0 : 1); +else if (mode == "luci-postinst") + exit(luci_postinst() ? 0 : 1); +else { + warn("Usage: service/package.uc \n"); + exit(1); +} diff --git a/podkop/files/usr/lib/service/reload.uc b/podkop/files/usr/lib/service/reload.uc new file mode 100644 index 00000000..cea56bbf --- /dev/null +++ b/podkop/files/usr/lib/service/reload.uc @@ -0,0 +1,302 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function arg_bool(value) { + return value === true || value == "true" || value == "1" || value == 1; +} + +function whitespace_fields(value) { + value = as_string(value); + let result = []; + + for (let item in split(trim(value), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + + return result; +} + +function list_has_item(items, needle) { + needle = as_string(needle); + if (needle == "") + return false; + + for (let item in whitespace_fields(items)) + if (item == needle) + return true; + + return false; +} + +function new_urltest_enabled_sections(previous_sections, current_sections, previous_known) { + if (!arg_bool(previous_known)) + return ""; + + let result = []; + for (let section in whitespace_fields(current_sections)) + if (!list_has_item(previous_sections, section)) + push(result, section); + + return join(" ", result); +} + +function emit(name, value) { + print(name, "\t", as_string(value), "\n"); +} + +function emit_bool(name, value) { + emit(name, value ? "1" : "0"); +} + +function emit_reload_plan(previous, current, context) { + let changed = { + service_triggers: current.service_trigger != previous.service_trigger, + dnsmasq: current.dnsmasq != previous.dnsmasq, + sing_box: current.sing_box != previous.sing_box, + nft: current.nft != previous.nft, + zapret_queue: current.zapret_queue != previous.zapret_queue, + zapret_runtime: current.zapret_runtime != previous.zapret_runtime, + zapret2_queue: current.zapret2_queue != previous.zapret2_queue, + zapret2_runtime: current.zapret2_runtime != previous.zapret2_runtime, + byedpi_runtime: current.byedpi_runtime != previous.byedpi_runtime, + list: current.list != previous.list, + cron: current.cron != previous.cron + }; + + let needs = { + sing_box_reload: false, + nft_rebuild: false, + zapret_restart: false, + zapret2_restart: false, + byedpi_restart: false, + dnsmasq_configure: false, + dnsmasq_restore: false, + cron_refresh: false, + list_update: false + }; + + if (changed.sing_box) + needs.sing_box_reload = true; + + if (changed.nft) + needs.nft_rebuild = true; + + if (changed.zapret_queue) { + needs.zapret_restart = true; + needs.nft_rebuild = true; + needs.sing_box_reload = true; + } + + if (changed.zapret_runtime) + needs.zapret_restart = true; + + if (changed.zapret2_queue) { + needs.zapret2_restart = true; + needs.nft_rebuild = true; + needs.sing_box_reload = true; + } + + if (changed.zapret2_runtime) + needs.zapret2_restart = true; + + if (changed.byedpi_runtime) + needs.byedpi_restart = true; + + if (changed.dnsmasq) { + if (!current.dont_touch_dhcp) + needs.dnsmasq_configure = true; + else if (context.dnsmasq_managed_state) + needs.dnsmasq_restore = true; + } + + if (changed.cron) + needs.cron_refresh = true; + + if (changed.list && context.has_list_update_sources) + needs.list_update = true; + + if (needs.nft_rebuild && context.has_nft_list_update_sources) + needs.list_update = true; + + if (context.runtime_cache_needs_rebuild) { + changed.sing_box = true; + needs.sing_box_reload = true; + } + + if (context.force_runtime_reload && + !needs.sing_box_reload && + !needs.nft_rebuild && + !needs.zapret_restart && + !needs.zapret2_restart && + !needs.byedpi_restart && + !needs.dnsmasq_configure && + !needs.dnsmasq_restore) + needs.sing_box_reload = true; + + let has_work = + needs.sing_box_reload || + needs.nft_rebuild || + needs.zapret_restart || + needs.zapret2_restart || + needs.byedpi_restart || + needs.dnsmasq_configure || + needs.dnsmasq_restore || + needs.cron_refresh || + needs.list_update; + + emit("urltest_new_enabled_sections", new_urltest_enabled_sections( + previous.urltest_sections, + current.urltest_sections, + previous.urltest_sections_known + )); + + emit_bool("changed_service_triggers", changed.service_triggers); + emit_bool("changed_dnsmasq", changed.dnsmasq); + emit_bool("changed_sing_box", changed.sing_box); + emit_bool("changed_nft", changed.nft); + emit_bool("changed_zapret_queue", changed.zapret_queue); + emit_bool("changed_zapret_runtime", changed.zapret_runtime); + emit_bool("changed_zapret2_queue", changed.zapret2_queue); + emit_bool("changed_zapret2_runtime", changed.zapret2_runtime); + emit_bool("changed_byedpi_runtime", changed.byedpi_runtime); + emit_bool("changed_cron", changed.cron); + emit_bool("changed_list", changed.list); + + emit_bool("needs_sing_box_reload", needs.sing_box_reload); + emit_bool("needs_nft_rebuild", needs.nft_rebuild); + emit_bool("needs_zapret_restart", needs.zapret_restart); + emit_bool("needs_zapret2_restart", needs.zapret2_restart); + emit_bool("needs_byedpi_restart", needs.byedpi_restart); + emit_bool("needs_dnsmasq_configure", needs.dnsmasq_configure); + emit_bool("needs_dnsmasq_restore", needs.dnsmasq_restore); + emit_bool("needs_cron_refresh", needs.cron_refresh); + emit_bool("needs_list_update", needs.list_update); + emit_bool("has_work", has_work); +} + +function reload_plan() { + let previous = { + service_trigger: as_string(ARGV[1]), + dnsmasq: as_string(ARGV[2]), + sing_box: as_string(ARGV[3]), + nft: as_string(ARGV[4]), + zapret_queue: as_string(ARGV[5]), + zapret_runtime: as_string(ARGV[6]), + zapret2_queue: as_string(ARGV[7]), + zapret2_runtime: as_string(ARGV[8]), + byedpi_runtime: as_string(ARGV[9]), + list: as_string(ARGV[10]), + cron: as_string(ARGV[11]), + urltest_sections_known: arg_bool(ARGV[12]), + urltest_sections: as_string(ARGV[13]) + }; + + let current = { + service_trigger: as_string(ARGV[14]), + dnsmasq: as_string(ARGV[15]), + sing_box: as_string(ARGV[16]), + nft: as_string(ARGV[17]), + zapret_queue: as_string(ARGV[18]), + zapret_runtime: as_string(ARGV[19]), + zapret2_queue: as_string(ARGV[20]), + zapret2_runtime: as_string(ARGV[21]), + byedpi_runtime: as_string(ARGV[22]), + list: as_string(ARGV[23]), + cron: as_string(ARGV[24]), + urltest_sections: as_string(ARGV[25]), + dont_touch_dhcp: arg_bool(ARGV[26]) + }; + + let context = { + force_runtime_reload: arg_bool(ARGV[27]), + dnsmasq_managed_state: arg_bool(ARGV[28]), + has_list_update_sources: arg_bool(ARGV[29]), + has_nft_list_update_sources: arg_bool(ARGV[30]), + runtime_cache_needs_rebuild: arg_bool(ARGV[31]) + }; + + emit_reload_plan(previous, current, context); +} + +function read_state_file(path) { + let data = fs.readfile(path); + if (data == null) + return null; + + let state = {}; + let has = {}; + + for (let line in split(data, "\n")) { + if (line == "") + continue; + + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == "") + continue; + + state[key] = equals >= 0 ? substr(line, equals + 1) : ""; + has[key] = true; + } + + state.__has = has; + return state; +} + +function plan_state_from_file(path) { + let state = read_state_file(path); + if (state == null) + return null; + + return { + format: as_string(state.format), + service_trigger: as_string(state.service_trigger_signature || state.restart_signature), + dnsmasq: as_string(state.dnsmasq_signature), + sing_box: as_string(state.sing_box_signature), + nft: as_string(state.nft_signature), + zapret_queue: as_string(state.zapret_queue_signature), + zapret_runtime: as_string(state.zapret_runtime_signature), + zapret2_queue: as_string(state.zapret2_queue_signature), + zapret2_runtime: as_string(state.zapret2_runtime_signature), + byedpi_runtime: as_string(state.byedpi_runtime_signature), + list: as_string(state.list_signature), + cron: as_string(state.cron_signature), + urltest_sections_known: state.__has.urltest_enabled_sections === true, + urltest_sections: as_string(state.urltest_enabled_sections), + dont_touch_dhcp: arg_bool(state.dont_touch_dhcp) + }; +} + +function plan_state_files() { + let previous = plan_state_from_file(ARGV[1]); + let current = plan_state_from_file(ARGV[2]); + if (previous == null || current == null || previous.format == "" || previous.format != current.format) + exit(2); + + let context = { + force_runtime_reload: arg_bool(ARGV[3]), + dnsmasq_managed_state: arg_bool(ARGV[4]), + has_list_update_sources: arg_bool(ARGV[5]), + has_nft_list_update_sources: arg_bool(ARGV[6]), + runtime_cache_needs_rebuild: arg_bool(ARGV[7]) + }; + + emit_reload_plan(previous, current, context); +} + +let mode = ARGV[0] || ""; + +if (mode == "plan") + reload_plan(); +else if (mode == "plan-state-files") + plan_state_files(); +else { + warn("Usage: service/reload.uc plan ...\n"); + warn(" service/reload.uc plan-state-files \n"); + exit(1); +} diff --git a/podkop/files/usr/lib/service/state.uc b/podkop/files/usr/lib/service/state.uc new file mode 100644 index 00000000..2d054810 --- /dev/null +++ b/podkop/files/usr/lib/service/state.uc @@ -0,0 +1,1637 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); +let rule_config = require("config.rule"); +let zapret_validator = require("providers.zapret.validator"); +let zapret2_validator = require("providers.zapret2.validator"); +let byedpi_validator = require("providers.byedpi.validator"); +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const DEFAULT_PENDING_RELOAD_FILE = getenv("PODKOP_PENDING_RELOAD_FILE") || "/var/run/podkop-plus/reload.pending"; +const DEFAULT_SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || "/etc/init.d/podkop-plus"; +const ZAPRET_DEFAULT_NFQWS_OPT = getenv("ZAPRET_DEFAULT_NFQWS_OPT") || ""; +const ZAPRET2_DEFAULT_NFQWS2_OPT = getenv("ZAPRET2_DEFAULT_NFQWS2_OPT") || ""; +const BYEDPI_DEFAULT_CMD_OPTS = getenv("BYEDPI_DEFAULT_CMD_OPTS") || ""; + +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_stdin_json() { + let data = read_stdin(); + try { + return json(data); + } + catch (e) { + return null; + } +} + +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_text_file(path, text) { + let result = fs.writefile(path, as_string(text)); + if (result == null) + return false; + if (type(result) == "boolean" && !result) + return false; + return true; +} + +function unlink_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function command_output_from_args(args) { + let pipe = fs.popen(command_from_args(args), "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_trimmed_output_from_args(args) { + return replace(command_output_from_args(args), /[\r\n]+$/g, ""); +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function uci_get(path) { + return uci_core.get(path); +} + +function uci_exists(path) { + return uci_core.exists(path); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let newline = index(data, "\n"); + print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); +} + +function read_state_value(path, needle) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + needle = as_string(needle); + for (let line in split(data, "\n")) { + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == needle) { + print(equals >= 0 ? substr(line, equals + 1) : line, "\n"); + return; + } + } +} + +function state_has_key(path, needle) { + let data = fs.readfile(path); + if (data == null) + return false; + + needle = as_string(needle); + for (let line in split(data, "\n")) { + let equals = index(line, "="); + let key = equals >= 0 ? substr(line, 0, equals) : line; + if (key == needle) + return true; + } + + return false; +} + +const RELOAD_STATE_FIELDS = [ + "format", + "service_trigger_signature", + "dnsmasq_signature", + "sing_box_signature", + "nft_signature", + "zapret_queue_signature", + "zapret_runtime_signature", + "zapret2_queue_signature", + "zapret2_runtime_signature", + "byedpi_runtime_signature", + "list_signature", + "cron_signature", + "urltest_enabled_sections", + "dont_touch_dhcp" +]; + +function reload_state_text(values) { + let output = ""; + values = object_or_empty(values); + + for (let field in RELOAD_STATE_FIELDS) + output += field + "=" + as_string(values[field]) + "\n"; + + return output; +} + +function parent_dir(path) { + path = as_string(path); + let slash = rindex(path, "/"); + if (slash < 0) + return ""; + return substr(path, 0, slash); +} + +function ensure_parent_dir(path) { + let dir = parent_dir(path); + if (dir == "" || dir == ".") + return true; + return command_success_from_args([ "mkdir", "-p", dir ]); +} + +function numeric_text(value) { + return match(as_string(value), /^[0-9]+$/) != null; +} + +function current_epoch() { + let value = command_trimmed_output_from_args([ "date", "+%s" ]); + return numeric_text(value) ? value : "0"; +} + +function config_file_hash(path) { + let hash_line = trim(command_output_from_args([ "md5sum", as_string(path) ])); + return length(hash_line) >= 32 ? substr(hash_line, 0, 32) : ""; +} + +function current_year() { + let value = command_trimmed_output_from_args([ "date", "+%Y" ]); + return numeric_text(value) ? int(value) : null; +} + +function time_sync_needed(year) { + if (year == null) + return false; + return int(year) < 2024; +} + +function sync_time_if_needed() { + if (!time_sync_needed(current_year())) + return; + + command_success_from_args([ + "/usr/sbin/ntpd", + "-q", + "-p", "194.190.168.1", + "-p", "216.239.35.0", + "-p", "216.239.35.4", + "-p", "162.159.200.1", + "-p", "162.159.200.123" + ]); +} + +function mark_pending_reload(path, reason) { + path = as_string(path || DEFAULT_PENDING_RELOAD_FILE); + reason = as_string(reason || "pending"); + + if (!ensure_parent_dir(path)) + exit(1); + + if (!write_text_file(path, "reason=" + reason + "\nupdated_at=" + current_epoch() + "\n")) + exit(1); +} + +function consume_pending_reload(path) { + path = as_string(path || DEFAULT_PENDING_RELOAD_FILE); + if (fs.stat(path) == null) + return false; + + unlink_file(path); + return true; +} + +function run_pending_reload_if_requested(path, init_script) { + path = as_string(path || DEFAULT_PENDING_RELOAD_FILE); + init_script = as_string(init_script || DEFAULT_SERVICE_INIT); + + if (!consume_pending_reload(path)) + return; + + command_success_from_args([ "logger", "-t", "podkop-plus", "[info] Applying pending Podkop Plus reload" ]); + system(shell_quote(init_script) + " reload pending >/dev/null 2>&1 &"); +} + +function first_line_value(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function pid_alive(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function lock_dir_write_owner(lock_dir, owner_pid) { + return write_text_file(as_string(lock_dir) + "/pid", as_string(owner_pid) + "\n"); +} + +function acquire_runtime_dir_lock(lock_dir, owner_pid) { + lock_dir = as_string(lock_dir); + owner_pid = as_string(owner_pid); + if (lock_dir == "" || owner_pid == "") + return false; + + if (command_success_from_args([ "mkdir", lock_dir ])) { + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + release_runtime_dir_lock(lock_dir); + return false; + } + + if (pid_alive(first_line_value(lock_dir + "/pid"))) + return false; + + command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); + if (!command_success_from_args([ "rmdir", lock_dir ])) + return false; + if (!command_success_from_args([ "mkdir", lock_dir ])) + return false; + + if (lock_dir_write_owner(lock_dir, owner_pid)) + return true; + + release_runtime_dir_lock(lock_dir); + return false; +} + +function acquire_runtime_dir_lock_wait(lock_dir, owner_pid, timeout) { + let timeout_text = as_string(timeout == null ? "300" : timeout); + timeout = numeric_text(timeout_text) ? int(timeout_text, 10) : 300; + let start = int(current_epoch(), 10) || 0; + + while (!acquire_runtime_dir_lock(lock_dir, owner_pid)) { + let now = int(current_epoch(), 10) || start; + if (now - start >= timeout) + return false; + command_success_from_args([ "sleep", "2" ]); + } + + return true; +} + +function release_runtime_dir_lock(lock_dir) { + lock_dir = as_string(lock_dir); + if (lock_dir == "") + return; + + command_success_from_args([ "rm", "-f", lock_dir + "/pid" ]); + command_success_from_args([ "rmdir", lock_dir ]); +} + +function reload_sing_box_runtime() { + command_success_from_args([ "logger", "-t", "podkop-plus", "[info] Reloading sing-box runtime" ]); + if (command_success_from_args([ "/etc/init.d/sing-box", "reload" ])) + return; + + command_success_from_args([ "logger", "-t", "podkop-plus", "[fatal] Failed to reload sing-box. Aborted." ]); + exit(1); +} + +function write_reload_state(path, values) { + if (!ensure_parent_dir(path)) + exit(1); + + if (!fs.writefile(path, reload_state_text(values))) + exit(1); +} + +function copy_file(source, target) { + let data = fs.readfile(source); + if (data == null) + return false; + if (!ensure_parent_dir(target)) + return false; + return write_text_file(target, data); +} + +function cleanup_rule_condition_cache(path) { + path = as_string(path); + if (path != "") + command_success_from_args([ "rm", "-rf", path ]); + system("rm -rf /tmp/podkop-plus-rule-cache.* >/dev/null 2>&1"); +} + +function clear_reload_state(path, snapshot_path) { + unlink_file(path); + if (as_string(snapshot_path) != "") + unlink_file(snapshot_path); +} + +function remove_file(path) { + unlink_file(path); +} + +function reload_state_values_from_args(offset) { + let values = {}; + offset = int(offset || 0); + + for (let i = 0; i < length(RELOAD_STATE_FIELDS); i++) + values[RELOAD_STATE_FIELDS[i]] = ARGV[i + offset]; + + return values; +} + +function response_success() { + let value = read_stdin_json(); + return type(value) == "object" && value.success === true; +} + +function stdin_first_field() { + let data = read_stdin(); + let newline = index(data, "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + + if (length(fields) > 0 && fields[0] != "") + print(fields[0], "\n"); +} + +function sing_box_service_pid_from_value(value) { + 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) + return int(instance.pid); + } + + return 0; +} + +function sing_box_service_pid() { + let pid = sing_box_service_pid_from_value(read_stdin_json()); + if (pid > 0) + print(pid, "\n"); +} + +function sing_box_service_pid_runtime() { + let data = command_output_from_args([ "ubus", "call", "service", "list", "{\"name\":\"sing-box\"}" ]); + try { + return sing_box_service_pid_from_value(json(data)); + } + catch (e) { + return 0; + } +} + +function path_basename(path) { + path = as_string(path); + let slash = rindex(path, "/"); + return slash >= 0 ? substr(path, slash + 1) : path; +} + +function pid_is_sing_box(pid) { + pid = as_string(pid); + if (match(pid, /^[0-9]+$/) == null) + return false; + + return path_basename(command_trimmed_output_from_args([ "readlink", "/proc/" + pid + "/exe" ])) == "sing-box"; +} + +function process_age_seconds(pid) { + pid = as_string(pid); + if (match(pid, /^[0-9]+$/) == null) + return null; + + let stat = fs.readfile("/proc/" + pid + "/stat"); + if (stat == null) + return null; + + let marker = index(stat, ") "); + if (marker < 0) + return null; + + let fields = split(trim(substr(stat, marker + 2)), /[ \t\r\n]+/); + if (length(fields) < 20) + return null; + + let start_ticks = fields[19]; + if (match(start_ticks, /^[0-9]+$/) == null) + return null; + + let uptime = fs.readfile("/proc/uptime"); + if (uptime == null) + return null; + + let uptime_seconds = split(trim(as_string(uptime)), /[ \t\r\n.]+/)[0]; + if (match(uptime_seconds, /^[0-9]+$/) == null) + return null; + + return int(uptime_seconds) - int(int(start_ticks) / 100); +} + +function sing_box_service_running() { + let pid = sing_box_service_pid_runtime(); + return pid > 0 && pid_is_sing_box(pid); +} + +function sing_box_service_stable(min_age) { + min_age = int(min_age || 2); + + let pid = sing_box_service_pid_runtime(); + if (pid <= 0 || !pid_is_sing_box(pid)) + return false; + + let age = process_age_seconds(pid); + return age != null && age >= min_age; +} + +function podkop_runtime_network_configured(rt_table, nft_table, mark) { + return command_success_from_args([ "nft", "list", "table", "inet", nft_table ]) && + command_success_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/nft/apply.uc", "tproxy-route-rule-present", rt_table, mark ]); +} + +function podkop_running(rt_table, nft_table, mark) { + return sing_box_service_running() && podkop_runtime_network_configured(rt_table, nft_table, mark); +} + +function podkop_stably_running(rt_table, nft_table, mark, min_age) { + return sing_box_service_stable(min_age) && podkop_runtime_network_configured(rt_table, nft_table, mark); +} + +function wait_podkop_stable_start(rt_table, nft_table, mark, min_age, timeout) { + timeout = int(timeout || 8); + while (timeout > 0) { + if (podkop_stably_running(rt_table, nft_table, mark, min_age)) + return true; + + command_success_from_args([ "sleep", "1" ]); + timeout--; + } + + return false; +} + +function whitespace_fields(value) { + value = as_string(value); + let result = []; + for (let item in split(trim(value), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_option(section, key, fallback) { + return arg_bool(option(section, key, fallback ? "1" : "0")); +} + +function bool_value(value) { + return arg_bool(value) ? "1" : "0"; +} + +function bool_option_value(section, key, fallback) { + return bool_option(section, key, fallback) ? "1" : "0"; +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function str_startswith(value, prefix) { + value = as_string(value); + prefix = as_string(prefix); + return substr(value, 0, length(prefix)) == prefix; +} + +function reference_is_remote(value) { + return str_startswith(value, "http://") || str_startswith(value, "https://"); +} + +function list_has_remote_references(value) { + for (let item in whitespace_fields(value)) + if (reference_is_remote(item)) + return true; + return false; +} + +function community_service_has_subnet_list(value) { + return rule_config.community_service_has_subnet_list(value); +} + +function filter_community_subnet_lists(value) { + print(rule_config.filter_community_subnet_lists_value(value), "\n"); +} + +function has_community_subnet_list(value) { + return rule_config.has_community_subnet_list(value); +} + +function rule_has_list_update_source(enabled, community_lists, remote_domain_lists, remote_subnet_lists, rule_set_with_subnets, domain_ip_lists) { + return arg_bool(enabled) && ( + has_community_subnet_list(community_lists) || + as_string(remote_domain_lists) != "" || + as_string(remote_subnet_lists) != "" || + as_string(rule_set_with_subnets) != "" || + list_has_remote_references(domain_ip_lists) + ); +} + +function rule_has_nft_list_update_source(enabled, community_lists, remote_subnet_lists, rule_set_with_subnets, domain_ip_lists) { + return arg_bool(enabled) && ( + has_community_subnet_list(community_lists) || + as_string(remote_subnet_lists) != "" || + as_string(rule_set_with_subnets) != "" || + list_has_remote_references(domain_ip_lists) + ); +} + +function rule_has_subscription_update_source(is_subscription_proxy, subscription_update_interval) { + return arg_bool(is_subscription_proxy) && as_string(subscription_update_interval) != ""; +} + +function download_via_proxy_any_enabled(settings) { + return bool_option(settings, "download_lists_via_proxy", false) || + bool_option(settings, "download_subscriptions_via_proxy", false) || + bool_option(settings, "download_components_via_proxy", false); +} + +function signature_add_value(body, key, value) { + return body + "[" + as_string(key) + "]\n" + as_string(value) + "\n"; +} + +function signature_hash(body) { + let path = trim(command_output_from_args([ "mktemp" ])); + if (path == "") + return ""; + + if (!write_text_file(path, body)) { + unlink_file(path); + return ""; + } + + let hash_line = command_output_from_args([ "md5sum", path ]); + unlink_file(path); + hash_line = trim(hash_line); + + return length(hash_line) >= 32 ? substr(hash_line, 0, 32) : ""; +} + +function print_signature_hash(body) { + let hash = signature_hash(body); + if (hash == "") + return false; + + print(hash, "\n"); + return true; +} + +function dont_touch_dhcp_value(settings) { + return bool_option(settings, "dont_touch_dhcp", false) ? "1" : "0"; +} + +function service_trigger_signature_body(settings) { + let enabled = bool_option(settings, "enable_badwan_interface_monitoring", false); + let body = signature_add_value("", "settings.enable_badwan_interface_monitoring", enabled ? "1" : "0"); + + if (enabled) { + body = signature_add_value(body, "settings.badwan_monitored_interfaces", option(settings, "badwan_monitored_interfaces", "")); + body = signature_add_value(body, "settings.badwan_reload_delay", option(settings, "badwan_reload_delay", "2000")); + } + + return body; +} + +function normalize_detect_server_country_method(value) { + value = as_string(value); + if (value == "country_is") + return "country_is"; + return "flag_emoji"; +} + +function dnsmasq_signature_body(settings, dnsmasq, legacy_dnsmasq_present) { + let body = ""; + + body = signature_add_value(body, "settings.dont_touch_dhcp", dont_touch_dhcp_value(settings)); + body = signature_add_value(body, "dhcp.@dnsmasq[0].server", option(dnsmasq, "server", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].noresolv", option(dnsmasq, "noresolv", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].cachesize", option(dnsmasq, "cachesize", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].podkop_server", option(dnsmasq, "podkop_server", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].podkop_noresolv", option(dnsmasq, "podkop_noresolv", "")); + body = signature_add_value(body, "dhcp.@dnsmasq[0].podkop_cachesize", option(dnsmasq, "podkop_cachesize", "")); + body = signature_add_value(body, "dhcp.podkop_plus.present", arg_bool(legacy_dnsmasq_present) ? "1" : "0"); + + return body; +} + +function settings_update_interval(settings) { + settings = object_or_empty(settings); + + if (!bool_option(settings, "list_update_enabled", true)) + return ""; + + let value = option(settings, "update_interval", "1d"); + return value != "" ? value : "1d"; +} + +function section_rule_ports_csv(section) { + return rule_config.rule_ports_csv_value(option(section, "ports", ""), option(section, "ports_text", "")); +} + +function section_rule_condition_csv(section, key, kind) { + return rule_config.rule_condition_csv_value( + key, + kind, + option(section, key + "_text_mode", "0"), + option(section, "conditions_text_mode", "0"), + option(section, key + "_text", ""), + option(section, key, ""), + option(section, "domain_suffix_text", ""), + option(section, "domain_suffix", "") + ); +} + +function nft_runtime_signature_body(settings, sections) { + let body = ""; + + body = signature_add_value(body, "settings.source_network_interfaces", option(settings, "source_network_interfaces", "br-lan")); + body = signature_add_value(body, "settings.exclude_ntp", bool_option(settings, "exclude_ntp", false) ? "1" : "0"); + + for (let section in sections) { + section = object_or_empty(section); + let name = section_name(section); + if (name == "" || !bool_option(section, "enabled", true)) + continue; + + body = signature_add_value(body, "rule." + name + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets")); + body = signature_add_value(body, "rule." + name + ".ports", section_rule_ports_csv(section)); + body = signature_add_value(body, "rule." + name + ".fully_routed_ips", option(section, "fully_routed_ips", "")); + body = signature_add_value(body, "rule." + name + ".community_subnet_lists", rule_config.filter_community_subnet_lists_value(option(section, "community_lists", ""))); + body = signature_add_value(body, "rule." + name + ".remote_subnet_lists", option(section, "remote_subnet_lists", "")); + body = signature_add_value(body, "rule." + name + ".rule_set_with_subnets", option(section, "rule_set_with_subnets", "")); + body = signature_add_value(body, "rule." + name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + } + + return body; +} + +function section_subscription_update_interval(section) { + if (option(section, "subscription_urls", "") == "") + return ""; + + if (!bool_option(section, "subscription_update_enabled", true)) + return ""; + + let value = option(section, "subscription_update_interval", ""); + return value != "" ? value : "1h"; +} + +function section_is_subscription_proxy(section) { + return bool_option(section, "enabled", true) && + option(section, "action", "") == "proxy" && + option(section, "subscription_urls", "") != ""; +} + +function section_urltest_check_interval(section) { + if (!bool_option(section, "urltest_enabled", false)) + return ""; + + let value = option(section, "urltest_check_interval", ""); + return value != "" ? value : "3m"; +} + +function append_list_update_signature_body(body, section) { + let name = section_name(section); + if (name == "" || !bool_option(section, "enabled", true)) + return body; + + body = signature_add_value(body, "lists." + name + ".ports", section_rule_ports_csv(section)); + body = signature_add_value(body, "lists." + name + ".community_subnet_lists", rule_config.filter_community_subnet_lists_value(option(section, "community_lists", ""))); + body = signature_add_value(body, "lists." + name + ".remote_domain_lists", option(section, "remote_domain_lists", "")); + body = signature_add_value(body, "lists." + name + ".remote_subnet_lists", option(section, "remote_subnet_lists", "")); + body = signature_add_value(body, "lists." + name + ".rule_set_with_subnets", option(section, "rule_set_with_subnets", "")); + body = signature_add_value(body, "lists." + name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + + return body; +} + +function list_update_signature_body(sections) { + let body = ""; + + for (let section in sections) + body = append_list_update_signature_body(body, object_or_empty(section)); + + return body; +} + +function cron_signature_body(settings, sections) { + let body = signature_add_value("", "settings.update_interval", settings_update_interval(settings)); + + for (let section in sections) + body = append_list_update_signature_body(body, object_or_empty(section)); + + for (let section in sections) { + section = object_or_empty(section); + if (!section_is_subscription_proxy(section)) + continue; + + let name = section_name(section); + body = signature_add_value(body, "subscription." + name + ".subscription_urls", option(section, "subscription_urls", "")); + body = signature_add_value(body, "subscription." + name + ".subscription_update_enabled", bool_option(section, "subscription_update_enabled", true) ? "1" : "0"); + body = signature_add_value(body, "subscription." + name + ".subscription_update_interval", section_subscription_update_interval(section)); + } + + return body; +} + +function urltest_enabled_sections_value(sections) { + let result = []; + + for (let section in sections) { + section = object_or_empty(section); + if (bool_option(section, "enabled", true) && + option(section, "action", "") == "proxy" && + bool_option(section, "urltest_enabled", false)) + push(result, section_name(section)); + } + + return join(" ", result); +} + +function print_urltest_enabled_sections(sections) { + print(urltest_enabled_sections_value(sections), "\n"); +} + +function sing_box_signature_has_remote_ruleset_sources(sections) { + for (let section in sections) { + section = object_or_empty(section); + if (!bool_option(section, "enabled", true)) + continue; + + if (option(section, "community_lists", "") != "" || + list_has_remote_references(option(section, "rule_set", "")) || + list_has_remote_references(option(section, "rule_set_with_subnets", ""))) + return true; + } + + return false; +} + +function sing_box_signature_enabled_action_index(sections, target_section, action) { + let result = 0; + target_section = as_string(target_section); + action = as_string(action); + + for (let section in sections) { + section = object_or_empty(section); + if (section_name(section) == "" || + !bool_option(section, "enabled", true) || + option(section, "action", "") != action) + continue; + + result++; + if (section_name(section) == target_section) + return result; + } + + return 0; +} + +function signature_add_mixed_proxy_body(body, section, prefix) { + let mixed_proxy_enabled = bool_option_value(section, "mixed_proxy_enabled", false); + body = signature_add_value(body, prefix + ".mixed_proxy_enabled", mixed_proxy_enabled); + + if (mixed_proxy_enabled == "1") { + body = signature_add_value(body, prefix + ".mixed_proxy_port", option(section, "mixed_proxy_port", "")); + let auth_enabled = bool_option_value(section, "mixed_proxy_auth_enabled", false); + body = signature_add_value(body, prefix + ".mixed_proxy_auth_enabled", auth_enabled); + if (auth_enabled == "1") { + body = signature_add_value(body, prefix + ".mixed_proxy_username", option(section, "mixed_proxy_username", "")); + body = signature_add_value(body, prefix + ".mixed_proxy_password", option(section, "mixed_proxy_password", "")); + } + } + + return body; +} + +function signature_add_outbound_detour_body(body, section, prefix) { + let outbound_detour_enabled = bool_option_value(section, "outbound_detour_enabled", false); + body = signature_add_value(body, prefix + ".outbound_detour_enabled", outbound_detour_enabled); + + if (outbound_detour_enabled == "1") + body = signature_add_value(body, prefix + ".outbound_detour_section", option(section, "outbound_detour_section", "")); + + return body; +} + +function append_sing_box_rule_signature_body(body, section, sections) { + section = object_or_empty(section); + let name = section_name(section); + if (name == "" || !bool_option(section, "enabled", true)) + return body; + + let action = option(section, "action", ""); + if (action == "") + return body; + + let prefix = "rule." + name; + body = signature_add_value(body, prefix + ".action", action); + + if (action == "proxy") { + body = signature_add_value(body, prefix + ".selector_proxy_links", option(section, "selector_proxy_links", "")); + body = signature_add_value(body, prefix + ".subscription_urls", option(section, "subscription_urls", "")); + body = signature_add_value(body, prefix + ".enable_udp_over_tcp", bool_option_value(section, "enable_udp_over_tcp", false)); + body = signature_add_value(body, prefix + ".urltest_enabled", bool_option_value(section, "urltest_enabled", false)); + body = signature_add_value(body, prefix + ".detect_server_country", normalize_detect_server_country_method(option(section, "detect_server_country", "flag_emoji"))); + body = signature_add_value(body, prefix + ".urltest_check_interval", section_urltest_check_interval(section)); + body = signature_add_value(body, prefix + ".urltest_tolerance", option(section, "urltest_tolerance", "50")); + body = signature_add_value(body, prefix + ".urltest_testing_url", option(section, "urltest_testing_url", "https://www.gstatic.com/generate_204")); + body = signature_add_value(body, prefix + ".urltest_filter_mode", option(section, "urltest_filter_mode", "disabled")); + body = signature_add_value(body, prefix + ".urltest_exclude_countries", option(section, "urltest_exclude_countries", "")); + body = signature_add_value(body, prefix + ".urltest_include_countries", option(section, "urltest_include_countries", "")); + body = signature_add_value(body, prefix + ".urltest_exclude_outbounds", option(section, "urltest_exclude_outbounds", "")); + body = signature_add_value(body, prefix + ".urltest_exclude_regex", option(section, "urltest_exclude_regex", "")); + body = signature_add_value(body, prefix + ".urltest_include_outbounds", option(section, "urltest_include_outbounds", "")); + body = signature_add_value(body, prefix + ".urltest_include_regex", option(section, "urltest_include_regex", "")); + body = signature_add_value(body, prefix + ".subscription_update_enabled", bool_option_value(section, "subscription_update_enabled", true)); + body = signature_add_value(body, prefix + ".subscription_update_interval", section_subscription_update_interval(section)); + body = signature_add_outbound_detour_body(body, section, prefix); + body = signature_add_mixed_proxy_body(body, section, prefix); + body = signature_add_value(body, prefix + ".resolve_real_ip_for_routing", bool_option_value(section, "resolve_real_ip_for_routing", false)); + } + else if (action == "outbound") { + body = signature_add_value(body, prefix + ".outbound_json", option(section, "outbound_json", "")); + body = signature_add_outbound_detour_body(body, section, prefix); + body = signature_add_mixed_proxy_body(body, section, prefix); + body = signature_add_value(body, prefix + ".resolve_real_ip_for_routing", bool_option_value(section, "resolve_real_ip_for_routing", false)); + } + else if (action == "byedpi") { + body = signature_add_value(body, prefix + ".byedpi_index", sing_box_signature_enabled_action_index(sections, name, "byedpi")); + body = signature_add_mixed_proxy_body(body, section, prefix); + body = signature_add_value(body, prefix + ".resolve_real_ip_for_routing", "1"); + } + else if (action == "zapret" || action == "zapret2") { + body = signature_add_mixed_proxy_body(body, section, prefix); + } + else if (action == "vpn") { + body = signature_add_value(body, prefix + ".interface", option(section, "interface", "")); + let resolver_enabled = bool_option_value(section, "domain_resolver_enabled", false); + body = signature_add_value(body, prefix + ".domain_resolver_enabled", resolver_enabled); + if (resolver_enabled == "1") { + body = signature_add_value(body, prefix + ".domain_resolver_dns_type", option(section, "domain_resolver_dns_type", "")); + body = signature_add_value(body, prefix + ".domain_resolver_dns_server", option(section, "domain_resolver_dns_server", "")); + } + body = signature_add_mixed_proxy_body(body, section, prefix); + body = signature_add_value(body, prefix + ".resolve_real_ip_for_routing", bool_option_value(section, "resolve_real_ip_for_routing", false)); + } + + body = signature_add_value(body, prefix + ".domain", section_rule_condition_csv(section, "domain", "domains")); + body = signature_add_value(body, prefix + ".domain_suffix", section_rule_condition_csv(section, "domain_suffix", "domains")); + body = signature_add_value(body, prefix + ".domain_keyword", section_rule_condition_csv(section, "domain_keyword", "generic")); + body = signature_add_value(body, prefix + ".domain_regex", section_rule_condition_csv(section, "domain_regex", "generic")); + body = signature_add_value(body, prefix + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets")); + body = signature_add_value(body, prefix + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets")); + body = signature_add_value(body, prefix + ".ports", section_rule_ports_csv(section)); + body = signature_add_value(body, prefix + ".fully_routed_ips", option(section, "fully_routed_ips", "")); + body = signature_add_value(body, prefix + ".community_lists", option(section, "community_lists", "")); + body = signature_add_value(body, prefix + ".rule_set", option(section, "rule_set", "")); + body = signature_add_value(body, prefix + ".rule_set_with_subnets", option(section, "rule_set_with_subnets", "")); + body = signature_add_value(body, prefix + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + + return body; +} + +function append_sing_box_server_signature_body(body, server) { + server = object_or_empty(server); + let name = section_name(server); + if (name == "") + return body; + + let prefix = "server." + name; + let enabled = bool_option_value(server, "enabled", false); + body = signature_add_value(body, prefix + ".enabled", enabled); + if (enabled != "1") + return body; + + let fields = [ + [ "label", name ], + [ "protocol", "vless" ], + [ "listen", "0.0.0.0" ], + [ "listen_port", "" ], + [ "public_host", "" ], + [ "inbound_json", "" ], + [ "routing_mode", "rules" ], + [ "routing_section", "" ], + [ "security", "reality" ], + [ "server_users", "" ], + [ "tls_server_name", "" ], + [ "tls_alpn", "" ], + [ "tls_certificate_path", "" ], + [ "tls_key_path", "" ], + [ "reality_handshake_server", "" ], + [ "reality_handshake_server_port", "" ], + [ "reality_private_key", "" ], + [ "reality_public_key", "" ], + [ "reality_short_id", "" ], + [ "reality_max_time_difference", "" ], + [ "transport", "tcp" ], + [ "transport_path", "" ], + [ "transport_host", "" ], + [ "transport_service_name", "" ], + [ "transport_hosts", "" ], + [ "transport_xhttp_mode", "" ], + [ "client_fingerprint", "" ], + [ "server_uuid", "" ], + [ "server_username", "" ], + [ "server_password", "" ], + [ "vless_flow", "" ], + [ "vmess_alter_id", "" ], + [ "shadowsocks_method", "" ], + [ "hysteria2_up_mbps", "" ], + [ "hysteria2_down_mbps", "" ], + [ "hysteria2_obfs_type", "" ], + [ "hysteria2_obfs_password", "" ], + [ "mtproto_secret", "" ], + [ "mtproto_faketls", "" ], + [ "mtproto_padding", "" ], + [ "mtproto_concurrency", "" ], + [ "mtproto_domain_fronting_port", "" ], + [ "mtproto_domain_fronting_ip", "" ], + [ "mtproto_domain_fronting_proxy_protocol", "" ], + [ "mtproto_prefer_ip", "" ], + [ "mtproto_auto_update", "" ], + [ "mtproto_allow_fallback_on_unknown_dc", "" ], + [ "mtproto_tolerate_time_skewness", "" ], + [ "mtproto_idle_timeout", "" ], + [ "mtproto_handshake_timeout", "" ], + [ "tailscale_auth_key", "" ], + [ "tailscale_control_url", "" ], + [ "tailscale_hostname", "" ], + [ "tailscale_accept_routes", "" ], + [ "tailscale_advertise_routes", "" ], + [ "tailscale_advertise_exit_node", "" ] + ]; + + for (let field in fields) + body = signature_add_value(body, prefix + "." + field[0], option(server, field[0], field[1])); + + return body; +} + +function sing_box_signature_body(settings, sections, servers, mwan3_active) { + settings = object_or_empty(settings); + let body = ""; + + body = signature_add_value(body, "settings.dns_type", option(settings, "dns_type", "doh")); + body = signature_add_value(body, "settings.dns_server", option(settings, "dns_server", "1.1.1.1")); + body = signature_add_value(body, "settings.bootstrap_dns_server", option(settings, "bootstrap_dns_server", "77.88.8.8")); + body = signature_add_value(body, "settings.dns_rewrite_ttl", option(settings, "dns_rewrite_ttl", "60")); + body = signature_add_value(body, "settings.output_network_interface", option(settings, "output_network_interface", "")); + body = signature_add_value(body, "settings.disable_quic", bool_option_value(settings, "disable_quic", false)); + body = signature_add_value(body, "settings.routing_excluded_ips", option(settings, "routing_excluded_ips", "")); + if (sing_box_signature_has_remote_ruleset_sources(sections)) + body = signature_add_value(body, "settings.update_interval", settings_update_interval(settings)); + body = signature_add_value(body, "settings.cache_path", option(settings, "cache_path", "/tmp/sing-box/cache.db")); + body = signature_add_value(body, "settings.config_path", option(settings, "config_path", "")); + body = signature_add_value(body, "settings.log_level", option(settings, "log_level", "warn")); + body = signature_add_value(body, "settings.service_listen_address", option(settings, "service_listen_address", "")); + body = signature_add_value(body, "runtime.mwan3_active", bool_value(mwan3_active)); + + let enable_yacd = bool_option_value(settings, "enable_yacd", false); + body = signature_add_value(body, "settings.enable_yacd", enable_yacd); + if (enable_yacd == "1") { + body = signature_add_value(body, "settings.enable_yacd_wan_access", bool_option_value(settings, "enable_yacd_wan_access", false)); + body = signature_add_value(body, "settings.yacd_secret_key", option(settings, "yacd_secret_key", "")); + } + + body = signature_add_value(body, "settings.download_lists_via_proxy", bool_option_value(settings, "download_lists_via_proxy", false)); + body = signature_add_value(body, "settings.download_subscriptions_via_proxy", bool_option_value(settings, "download_subscriptions_via_proxy", false)); + body = signature_add_value(body, "settings.download_components_via_proxy", bool_option_value(settings, "download_components_via_proxy", false)); + if (download_via_proxy_any_enabled(settings)) + body = signature_add_value(body, "settings.download_lists_via_proxy_section", option(settings, "download_lists_via_proxy_section", "")); + + for (let section in sections) + body = append_sing_box_rule_signature_body(body, object_or_empty(section), sections); + + for (let server in servers) + body = append_sing_box_server_signature_body(body, object_or_empty(server)); + + return body; +} + +function section_action_is_enabled(section, action) { + return section_name(section) != "" && + bool_option(section, "enabled", true) && + option(section, "action", "") == as_string(action); +} + +function action_queue_signature_body(sections, action, signature_key) { + let body = ""; + + for (let section in sections) { + section = object_or_empty(section); + if (section_action_is_enabled(section, action)) + body = signature_add_value(body, signature_key, section_name(section)); + } + + return body; +} + +function section_user_domains_for_list_type(section) { + let list_type = option(section, "user_domain_list_type", "disabled"); + + if (list_type == "dynamic") + return option(section, "user_domains", ""); + if (list_type == "text") + return option(section, "user_domains_text", ""); + return ""; +} + +function append_zapret_runtime_signature_body(body, section, signature_prefix, opt_key, normalized_opt) { + let name = section_name(section); + let user_domain_list_type = option(section, "user_domain_list_type", "disabled"); + + body = signature_add_value(body, signature_prefix + "." + name + "." + opt_key, normalized_opt); + body = signature_add_value(body, signature_prefix + "." + name + ".domain", section_rule_condition_csv(section, "domain", "domains")); + body = signature_add_value(body, signature_prefix + "." + name + ".domain_suffix", section_rule_condition_csv(section, "domain_suffix", "domains")); + body = signature_add_value(body, signature_prefix + "." + name + ".community_lists", option(section, "community_lists", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".rule_set", option(section, "rule_set", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".rule_set_with_subnets", option(section, "rule_set_with_subnets", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".domain_ip_lists", option(section, "domain_ip_lists", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".user_domain_list_type", user_domain_list_type); + body = signature_add_value(body, signature_prefix + "." + name + ".local_domain_lists", option(section, "local_domain_lists", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".remote_domain_lists", option(section, "remote_domain_lists", "")); + body = signature_add_value(body, signature_prefix + "." + name + ".user_domains", section_user_domains_for_list_type(section)); + + return body; +} + +function zapret_runtime_signature_body(sections) { + let body = ""; + + for (let section in sections) { + section = object_or_empty(section); + if (!section_action_is_enabled(section, "zapret")) + continue; + + body = append_zapret_runtime_signature_body( + body, + section, + "zapret", + "nfqws_opt", + zapret_validator.strategy_or_default(option(section, "nfqws_opt", ""), ZAPRET_DEFAULT_NFQWS_OPT) + ); + } + + return body; +} + +function zapret2_runtime_signature_body(sections) { + let body = ""; + + for (let section in sections) { + section = object_or_empty(section); + if (!section_action_is_enabled(section, "zapret2")) + continue; + + body = append_zapret_runtime_signature_body( + body, + section, + "zapret2", + "nfqws2_opt", + zapret2_validator.strategy_or_default(option(section, "nfqws2_opt", ""), ZAPRET2_DEFAULT_NFQWS2_OPT) + ); + } + + return body; +} + +function enabled_action_index(sections, target_section, action) { + let result = 0; + target_section = as_string(target_section); + action = as_string(action); + + for (let section in sections) { + section = object_or_empty(section); + if (!section_action_is_enabled(section, action)) + continue; + + result++; + if (section_name(section) == target_section) + return result; + } + + return 0; +} + +function byedpi_runtime_signature_body(sections) { + let body = ""; + + for (let section in sections) { + section = object_or_empty(section); + if (!section_action_is_enabled(section, "byedpi")) + continue; + + let name = section_name(section); + body = signature_add_value(body, "byedpi." + name + ".index", enabled_action_index(sections, name, "byedpi")); + body = signature_add_value(body, "byedpi." + name + ".byedpi_cmd_opts", byedpi_validator.strategy_or_default(option(section, "byedpi_cmd_opts", ""), BYEDPI_DEFAULT_CMD_OPTS)); + } + + return body; +} + +function reload_state_values_from_sources(format, settings, sections, servers, dnsmasq, legacy_dnsmasq_present, mwan3_active_value) { + return { + format: as_string(format), + service_trigger_signature: signature_hash(service_trigger_signature_body(settings)), + dnsmasq_signature: signature_hash(dnsmasq_signature_body(settings, dnsmasq, legacy_dnsmasq_present)), + sing_box_signature: signature_hash(sing_box_signature_body(settings, sections, servers, mwan3_active_value)), + nft_signature: signature_hash(nft_runtime_signature_body(settings, sections)), + zapret_queue_signature: signature_hash(action_queue_signature_body(sections, "zapret", "zapret_queue.section")), + zapret_runtime_signature: signature_hash(zapret_runtime_signature_body(sections)), + zapret2_queue_signature: signature_hash(action_queue_signature_body(sections, "zapret2", "zapret2_queue.section")), + zapret2_runtime_signature: signature_hash(zapret2_runtime_signature_body(sections)), + byedpi_runtime_signature: signature_hash(byedpi_runtime_signature_body(sections)), + list_signature: signature_hash(list_update_signature_body(sections)), + cron_signature: signature_hash(cron_signature_body(settings, sections)), + urltest_enabled_sections: urltest_enabled_sections_value(sections), + dont_touch_dhcp: dont_touch_dhcp_value(settings) + }; +} + +function fixture_section_list(data, type_name) { + type_name = as_string(type_name || "section"); + let value = object_or_empty(data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function uci_sections(type_name) { + return uci_core.section_objects(CONFIG_NAME, type_name); +} + +function uci_servers() { + return uci_sections("server"); +} + +function mwan3_has_enabled_interface() { + for (let section in uci_core.section_objects("mwan3", "interface")) + if (option(section, "enabled", "0") == "1") + return true; + + return false; +} + +function mwan3_active() { + if (!command_success_from_args([ "test", "-x", "/etc/init.d/mwan3" ])) + return false; + if (!command_success_from_args([ "test", "-s", "/etc/config/mwan3" ])) + return false; + if (!mwan3_has_enabled_interface()) + return false; + + return command_success_from_args([ "/etc/init.d/mwan3", "status" ]) || + command_success_from_args([ "/etc/init.d/mwan3", "enabled" ]); +} + +function has_remote_sing_box_ruleset_sources_from_sections(sections) { + for (let section in sections) { + if (!bool_option(section, "enabled", true)) + continue; + + if (option(section, "community_lists", "") != "" || + list_has_remote_references(option(section, "rule_set", "")) || + list_has_remote_references(option(section, "rule_set_with_subnets", ""))) + return true; + } + + return false; +} + +function has_list_update_sources_from_sections(sections) { + for (let section in sections) + if (rule_has_list_update_source( + bool_option(section, "enabled", true), + option(section, "community_lists", ""), + option(section, "remote_domain_lists", ""), + option(section, "remote_subnet_lists", ""), + option(section, "rule_set_with_subnets", ""), + option(section, "domain_ip_lists", "") + )) + return true; + + return false; +} + +function has_nft_list_update_sources_from_sections(sections) { + for (let section in sections) + if (rule_has_nft_list_update_source( + bool_option(section, "enabled", true), + option(section, "community_lists", ""), + option(section, "remote_subnet_lists", ""), + option(section, "rule_set_with_subnets", ""), + option(section, "domain_ip_lists", "") + )) + return true; + + return false; +} + +function has_subscription_update_sources_from_sections(sections) { + for (let section in sections) { + section = object_or_empty(section); + if (section_is_subscription_proxy(section) && section_subscription_update_interval(section) != "") + return true; + } + + return false; +} + +function fixture_sections(path) { + return fixture_section_list(object_or_empty(read_json_file(path))); +} + +function fixture_servers(data) { + return fixture_section_list(data, "server"); +} + +function fixture_data(path) { + return object_or_empty(read_json_file(path)); +} + +function fixture_settings(data) { + return object_or_empty(object_or_empty(data).settings); +} + +function fixture_dnsmasq(data) { + return object_or_empty(object_or_empty(data).dhcp_dnsmasq); +} + +function fixture_legacy_dnsmasq_present(data) { + return object_or_empty(data).legacy_dnsmasq_present; +} + +function fixture_mwan3_active(data) { + return arg_bool(object_or_empty(object_or_empty(data).runtime).mwan3_active); +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function uci_section(section_name) { + return object_or_empty(uci_core.get_all(CONFIG_NAME, section_name)); +} + +function uci_dnsmasq() { + return { + server: uci_get("dhcp.@dnsmasq[0].server"), + noresolv: uci_get("dhcp.@dnsmasq[0].noresolv"), + cachesize: uci_get("dhcp.@dnsmasq[0].cachesize"), + podkop_server: uci_get("dhcp.@dnsmasq[0].podkop_server"), + podkop_noresolv: uci_get("dhcp.@dnsmasq[0].podkop_noresolv"), + podkop_cachesize: uci_get("dhcp.@dnsmasq[0].podkop_cachesize") + }; +} + +function current_reload_state_values(format) { + let settings = uci_settings(); + let sections = uci_sections("section"); + + return reload_state_values_from_sources( + format, + settings, + sections, + uci_servers(), + uci_dnsmasq(), + uci_exists("dhcp.podkop_plus"), + mwan3_active() + ); +} + +function capture_reload_state(path, format) { + write_reload_state(path, current_reload_state_values(format || "1")); +} + +function write_current_reload_state_clean(path, format, cache_dir) { + write_reload_state(path, current_reload_state_values(format || "1")); + cleanup_rule_condition_cache(cache_dir); +} + +function write_captured_reload_state(path, snapshot_path, format, cache_dir, cleanup_cache, clear_snapshot) { + snapshot_path = as_string(snapshot_path); + if (snapshot_path == "") { + if (as_string(cleanup_cache) == "1") + write_current_reload_state_clean(path, format, cache_dir); + else + write_reload_state(path, current_reload_state_values(format || "1")); + return; + } + + if (fs.stat(snapshot_path) == null) + capture_reload_state(snapshot_path, format || "1"); + + if (!copy_file(snapshot_path, path)) + exit(1); + + if (as_string(cleanup_cache) == "1") + cleanup_rule_condition_cache(cache_dir); + if (as_string(clear_snapshot) == "1") + unlink_file(snapshot_path); +} + +function fixture_reload_state_values(data, format) { + data = object_or_empty(data); + let settings = fixture_settings(data); + let sections = fixture_section_list(data); + + return reload_state_values_from_sources( + format, + settings, + sections, + fixture_servers(data), + fixture_dnsmasq(data), + fixture_legacy_dnsmasq_present(data), + fixture_mwan3_active(data) + ); +} + +let mode = ARGV[0]; + +if (mode == "file-first-line") + file_first_line(ARGV[1]); +else if (mode == "get") + read_state_value(ARGV[1], ARGV[2]); +else if (mode == "has-key") + exit(state_has_key(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "time-sync-needed") + exit(time_sync_needed(ARGV[1]) ? 0 : 1); +else if (mode == "sync-time-if-needed") + sync_time_if_needed(); +else if (mode == "mark-pending-reload") + mark_pending_reload(ARGV[1], ARGV[2]); +else if (mode == "consume-pending-reload") + exit(consume_pending_reload(ARGV[1]) ? 0 : 1); +else if (mode == "run-pending-reload-if-requested") + run_pending_reload_if_requested(ARGV[1], ARGV[2]); +else if (mode == "acquire-runtime-dir-lock") + exit(acquire_runtime_dir_lock(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "acquire-runtime-dir-lock-wait") + exit(acquire_runtime_dir_lock_wait(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "release-runtime-dir-lock") + release_runtime_dir_lock(ARGV[1]); +else if (mode == "reload-sing-box-runtime") + reload_sing_box_runtime(); +else if (mode == "clear-reload-state") + clear_reload_state(ARGV[1], ARGV[2]); +else if (mode == "remove-file") + remove_file(ARGV[1]); +else if (mode == "capture-reload-state") + capture_reload_state(ARGV[1], ARGV[2] || "1"); +else if (mode == "write-current-reload-state-clean") + write_current_reload_state_clean(ARGV[1], ARGV[2] || "1", ARGV[3]); +else if (mode == "write-captured-reload-state") + write_captured_reload_state(ARGV[1], ARGV[2], ARGV[3] || "1", ARGV[4], ARGV[5], ARGV[6]); +else if (mode == "write-reload-state") + write_reload_state(ARGV[1], reload_state_values_from_args(2)); +else if (mode == "write-current-reload-state") + write_reload_state(ARGV[1], current_reload_state_values(ARGV[2] || "1")); +else if (mode == "reload-state-text-fixture") { + let data = fixture_data(ARGV[1]); + print(reload_state_text(fixture_reload_state_values(data, ARGV[2] || "1"))); +} +else if (mode == "response-success") + exit(response_success() ? 0 : 1); +else if (mode == "stdin-first-field") + stdin_first_field(); +else if (mode == "sing-box-service-pid") + sing_box_service_pid(); +else if (mode == "sing-box-service-running") + exit(sing_box_service_running() ? 0 : 1); +else if (mode == "podkop-running") + exit(podkop_running(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +else if (mode == "podkop-stably-running") + exit(podkop_stably_running(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +else if (mode == "wait-podkop-stable-start") + exit(wait_podkop_stable_start(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +else if (mode == "list-has-remote-references" || mode == "list-has-remote-sing-box-rulesets") + exit(list_has_remote_references(ARGV[1]) ? 0 : 1); +else if (mode == "community-service-has-subnet-list") + exit(community_service_has_subnet_list(ARGV[1]) ? 0 : 1); +else if (mode == "filter-community-subnet-lists") + filter_community_subnet_lists(ARGV[1]); +else if (mode == "rule-has-list-update-source") + exit(rule_has_list_update_source(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]) ? 0 : 1); +else if (mode == "rule-has-nft-list-update-source") + exit(rule_has_nft_list_update_source(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +else if (mode == "rule-has-subscription-update-source") + exit(rule_has_subscription_update_source(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "service-trigger-signature") + exit(print_signature_hash(service_trigger_signature_body(uci_settings())) ? 0 : 1); +else if (mode == "service-trigger-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(service_trigger_signature_body(fixture_settings(data))) ? 0 : 1); +} +else if (mode == "dnsmasq-signature") + exit(print_signature_hash(dnsmasq_signature_body(uci_settings(), uci_dnsmasq(), uci_exists("dhcp.podkop_plus"))) ? 0 : 1); +else if (mode == "dnsmasq-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(dnsmasq_signature_body(fixture_settings(data), fixture_dnsmasq(data), fixture_legacy_dnsmasq_present(data))) ? 0 : 1); +} +else if (mode == "sing-box-signature") + exit(print_signature_hash(sing_box_signature_body(uci_settings(), uci_sections("section"), uci_servers(), mwan3_active())) ? 0 : 1); +else if (mode == "sing-box-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(sing_box_signature_body(fixture_settings(data), fixture_section_list(data), fixture_servers(data), fixture_mwan3_active(data))) ? 0 : 1); +} +else if (mode == "nft-signature") + exit(print_signature_hash(nft_runtime_signature_body(uci_settings(), uci_sections("section"))) ? 0 : 1); +else if (mode == "nft-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(nft_runtime_signature_body(fixture_settings(data), fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "zapret-queue-signature") + exit(print_signature_hash(action_queue_signature_body(uci_sections("section"), "zapret", "zapret_queue.section")) ? 0 : 1); +else if (mode == "zapret-queue-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(action_queue_signature_body(fixture_section_list(data), "zapret", "zapret_queue.section")) ? 0 : 1); +} +else if (mode == "zapret-runtime-signature") + exit(print_signature_hash(zapret_runtime_signature_body(uci_sections("section"))) ? 0 : 1); +else if (mode == "zapret-runtime-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(zapret_runtime_signature_body(fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "zapret2-queue-signature") + exit(print_signature_hash(action_queue_signature_body(uci_sections("section"), "zapret2", "zapret2_queue.section")) ? 0 : 1); +else if (mode == "zapret2-queue-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(action_queue_signature_body(fixture_section_list(data), "zapret2", "zapret2_queue.section")) ? 0 : 1); +} +else if (mode == "zapret2-runtime-signature") + exit(print_signature_hash(zapret2_runtime_signature_body(uci_sections("section"))) ? 0 : 1); +else if (mode == "zapret2-runtime-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(zapret2_runtime_signature_body(fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "byedpi-runtime-signature") + exit(print_signature_hash(byedpi_runtime_signature_body(uci_sections("section"))) ? 0 : 1); +else if (mode == "byedpi-runtime-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(byedpi_runtime_signature_body(fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "dont-touch-dhcp") + print(dont_touch_dhcp_value(uci_settings()), "\n"); +else if (mode == "dont-touch-dhcp-fixture") { + let data = fixture_data(ARGV[1]); + print(dont_touch_dhcp_value(fixture_settings(data)), "\n"); +} +else if (mode == "settings-update-interval") + print(settings_update_interval(uci_settings()), "\n"); +else if (mode == "settings-update-interval-fixture") { + let data = fixture_data(ARGV[1]); + print(settings_update_interval(fixture_settings(data)), "\n"); +} +else if (mode == "remote-ruleset-update-interval") { + let update_interval = settings_update_interval(uci_settings()); + print(update_interval != "" ? update_interval : as_string(ARGV[1]), "\n"); +} +else if (mode == "subscription-update-interval") + print(section_subscription_update_interval(uci_section(ARGV[1])), "\n"); +else if (mode == "subscription-update-interval-fixture") { + let data = fixture_data(ARGV[1]); + let sections = fixture_section_list(data); + let name = as_string(ARGV[2]); + for (let section in sections) { + section = object_or_empty(section); + if (section_name(section) == name) { + print(section_subscription_update_interval(section), "\n"); + exit(0); + } + } + print("\n"); +} +else if (mode == "urltest-check-interval") + print(section_urltest_check_interval(uci_section(ARGV[1])), "\n"); +else if (mode == "urltest-check-interval-fixture") { + let data = fixture_data(ARGV[1]); + let sections = fixture_section_list(data); + let name = as_string(ARGV[2]); + for (let section in sections) { + section = object_or_empty(section); + if (section_name(section) == name) { + print(section_urltest_check_interval(section), "\n"); + exit(0); + } + } + print("\n"); +} +else if (mode == "list-update-signature") + exit(print_signature_hash(list_update_signature_body(uci_sections("section"))) ? 0 : 1); +else if (mode == "list-update-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(list_update_signature_body(fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "cron-signature") + exit(print_signature_hash(cron_signature_body(uci_settings(), uci_sections("section"))) ? 0 : 1); +else if (mode == "cron-signature-fixture") { + let data = fixture_data(ARGV[1]); + exit(print_signature_hash(cron_signature_body(fixture_settings(data), fixture_section_list(data))) ? 0 : 1); +} +else if (mode == "urltest-enabled-sections") + print_urltest_enabled_sections(uci_sections("section")); +else if (mode == "urltest-enabled-sections-fixture") { + let data = fixture_data(ARGV[1]); + print_urltest_enabled_sections(fixture_section_list(data)); +} +else if (mode == "has-remote-sing-box-ruleset-sources") + exit(has_remote_sing_box_ruleset_sources_from_sections(uci_sections("section")) ? 0 : 1); +else if (mode == "has-remote-sing-box-ruleset-sources-fixture") + exit(has_remote_sing_box_ruleset_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1); +else if (mode == "has-list-update-sources") + exit(has_list_update_sources_from_sections(uci_sections("section")) ? 0 : 1); +else if (mode == "has-list-update-sources-fixture") + exit(has_list_update_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1); +else if (mode == "has-subscription-update-sources") + exit(has_subscription_update_sources_from_sections(uci_sections("section")) ? 0 : 1); +else if (mode == "has-subscription-update-sources-fixture") + exit(has_subscription_update_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1); +else if (mode == "has-nft-list-update-sources") + exit(has_nft_list_update_sources_from_sections(uci_sections("section")) ? 0 : 1); +else if (mode == "has-nft-list-update-sources-fixture") + exit(has_nft_list_update_sources_from_sections(fixture_sections(ARGV[1])) ? 0 : 1); +else { + warn("Usage: service/state.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/service/ui.uc b/podkop/files/usr/lib/service/ui.uc new file mode 100644 index 00000000..812513b9 --- /dev/null +++ b/podkop/files/usr/lib/service/ui.uc @@ -0,0 +1,1344 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const BIN_PATH = getenv("PODKOP_BIN") || "/usr/bin/podkop-plus"; +const SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || "/etc/init.d/podkop-plus"; +const SERVICE_NAME = getenv("PODKOP_SERVICE_NAME") || "podkop-plus"; +const STATE_DIR = getenv("PODKOP_UI_STATE_DIR") || "/var/run/podkop-plus/ui-state"; +const SERVICE_ACTION_DIR = getenv("PODKOP_UI_SERVICE_ACTION_DIR") || STATE_DIR + "/service-actions"; +const SERVICE_ACTION_LOCK_DIR = getenv("PODKOP_UI_SERVICE_ACTION_LOCK_DIR") || STATE_DIR + "/service-actions.lock"; +const LATENCY_ACTION_DIR = getenv("PODKOP_UI_LATENCY_ACTION_DIR") || STATE_DIR + "/latency-actions"; +const COMPONENT_ACTION_DIR = getenv("PODKOP_UI_COMPONENT_ACTION_DIR") || getenv("UPDATES_JOB_DIR") || "/var/run/podkop-plus/component-actions"; +const SUBSCRIPTION_ACTION_DIR = getenv("PODKOP_UI_SUBSCRIPTION_ACTION_DIR") || getenv("PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR") || "/var/run/podkop-plus/subscription-update-jobs"; +const SING_BOX_VERSION_CACHE_FILE = getenv("PODKOP_UI_SING_BOX_VERSION_CACHE_FILE") || STATE_DIR + "/sing-box-version"; +const SING_BOX_VERSION_STATE_FILE = getenv("PODKOP_UI_SING_BOX_VERSION_STATE_FILE") || "/etc/podkop-plus/sing-box-version"; +const SING_BOX_VARIANT_STATE_FILE = getenv("PODKOP_UI_SING_BOX_VARIANT_STATE_FILE") || "/etc/podkop-plus/sing-box-variant"; +const ACTION_FINISHED_TTL_MINUTES = getenv("PODKOP_UI_ACTION_FINISHED_TTL_MINUTES") || "60"; +const ACTION_ACKED_TTL_SECONDS = getenv("PODKOP_UI_ACTION_ACKED_TTL_SECONDS") || "15"; +const ACTION_STALE_GRACE_SECONDS = getenv("PODKOP_UI_ACTION_STALE_GRACE_SECONDS") || "15"; +const SERVICE_ACTION_TIMEOUT_SECONDS = getenv("PODKOP_UI_SERVICE_ACTION_TIMEOUT_SECONDS") || "120"; +const SERVICE_ACTION_SETTLE_SECONDS = getenv("PODKOP_UI_SERVICE_ACTION_SETTLE_SECONDS") || "2"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "PodkopPlusTable"; +const RT_TABLE_NAME = getenv("RT_TABLE_NAME") || "podkop"; +const SB_DNS_INBOUND_ADDRESS = getenv("SB_DNS_INBOUND_ADDRESS") || "127.0.0.42"; +const ZAPRET_PROVIDER_NFQWS_BIN = getenv("ZAPRET_PROVIDER_NFQWS_BIN") || "/opt/zapret/nfq/nfqws"; +const ZAPRET2_PROVIDER_NFQWS2_BIN = getenv("ZAPRET2_PROVIDER_NFQWS2_BIN") || "/opt/zapret2/nfq2/nfqws2"; +const BYEDPI_BIN = getenv("BYEDPI_BIN") || "/usr/bin/ciadpi"; + +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) { + 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 json_text(value) { + return sprintf("%J", value) + "\n"; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return command_success(command_from_args(args)); +} + +function now_seconds() { + return int(clock()[0]); +} + +function ensure_dir(path) { + command_success_from_args([ "mkdir", "-p", path ]); +} + +function ensure_dirs() { + for (let dir in [ + STATE_DIR, + SERVICE_ACTION_DIR, + LATENCY_ACTION_DIR, + COMPONENT_ACTION_DIR, + SUBSCRIPTION_ACTION_DIR + ]) + ensure_dir(dir); +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function write_state_file(path, value) { + path = as_string(path); + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + + if (!write_file(tmp_path, json_text(value))) { + fs.unlink(tmp_path); + return false; + } + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + return false; + } + return true; +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function remove_state_file(path) { + path = as_string(path); + let base = path; + let suffix = ".json"; + if (length(base) >= length(suffix) && substr(base, length(base) - length(suffix)) == suffix) + base = substr(base, 0, length(base) - length(suffix)); + remove_file(path); + remove_file(base + ".out"); + remove_file(base + ".out.json"); +} + +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 unsigned_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9]/) != null) + return null; + return int(value); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function path_basename(path) { + let parts = split(as_string(path), "/"); + return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : ""; +} + +function str_remove_suffix(value, suffix) { + value = as_string(value); + suffix = as_string(suffix); + if (length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix) + return substr(value, 0, length(value) - length(suffix)); + return value; +} + +function job_id_from_path(path) { + return str_remove_suffix(path_basename(path), ".json"); +} + +function valid_action_state(value) { + return type(value) == "object" && (value.running === true || value.running === false); +} + +function read_state_paths() { + let result = { + service: [], + latency: [], + component: [], + subscription: [] + }; + + for (let line in split(read_stdin(), "\n")) { + line = trim(as_string(line)); + if (line == "") + continue; + + let tab = index(line, "\t"); + if (tab <= 0) + continue; + + let kind = substr(line, 0, tab); + let path = substr(line, tab + 1); + let value = read_json_file(path); + if (!valid_action_state(value)) + continue; + + value.job_id = job_id_from_path(path); + + if (type(result[kind]) == "array") + push(result[kind], value); + } + + return result; +} + +function service_status_text(running, enabled) { + if (arg_bool(running)) + return arg_bool(enabled) ? "running & enabled" : "running but disabled"; + + return arg_bool(enabled) ? "stopped but enabled" : "stopped & disabled"; +} + +function print_service_status_text(running, enabled) { + print(service_status_text(running, enabled), "\n"); +} + +function ui_state_json() { + let action_state = read_state_paths(); + let podkop_running = arg_number(ARGV[1]); + let podkop_enabled = arg_number(ARGV[2]); + let podkop_status = as_string(ARGV[3]); + let podkop_dns_configured = arg_number(ARGV[4]); + let sing_box_running = arg_number(ARGV[5]); + let sing_box_enabled = arg_number(ARGV[6]); + let sing_box_status = as_string(ARGV[7]); + + if (podkop_status == "") + podkop_status = service_status_text(podkop_running, podkop_enabled); + + if (sing_box_status == "") + sing_box_status = service_status_text(sing_box_running, sing_box_enabled); + + write_json({ + service: { + podkop: { + running: podkop_running, + enabled: podkop_enabled, + status: podkop_status, + dns_configured: podkop_dns_configured + }, + sing_box: { + running: sing_box_running, + enabled: sing_box_enabled, + status: sing_box_status + } + }, + capabilities: { + sing_box_extended: arg_number(ARGV[8]), + sing_box_tiny: arg_number(ARGV[9]), + sing_box_compressed: arg_number(ARGV[10]), + sing_box_tailscale: arg_number(ARGV[11]), + zapret_installed: arg_number(ARGV[12]), + zapret2_installed: arg_number(ARGV[13]), + byedpi_installed: arg_number(ARGV[14]), + server_inbounds_enabled_count: arg_number(ARGV[15]) + }, + actions: action_state + }); +} + +function action_start_response(success, job_id, message) { + write_json({ + success: arg_bool(success), + job_id: as_string(job_id), + message: as_string(message) + }); +} + +function service_action_valid(action) { + action = as_string(action); + return action == "start" || action == "stop" || action == "restart" || action == "reload"; +} + +function latency_type_valid(latency_type) { + latency_type = as_string(latency_type); + return latency_type == "group" || latency_type == "proxy" || latency_type == "proxy_list"; +} + +function service_action_expected_running(action) { + action = as_string(action); + + if (action == "start" || action == "restart" || action == "reload") + print("1\n"); + else if (action == "stop") + print("0\n"); + else + exit(1); +} + +function running_service_action(action, source, started_at) { + write_json({ + success: true, + running: true, + kind: "service", + action: as_string(action), + source: as_string(source), + message: "Service action is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }); +} + +function running_latency_action(latency_type, section, tag, started_at) { + write_json({ + success: true, + running: true, + kind: "latency", + latency_type: as_string(latency_type), + section: as_string(section), + tag: as_string(tag), + message: "Latency test is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }); +} + +function 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 finished_action_state(path, success, message, exit_code, updated_at) { + let value = object_or_empty(read_json_file(path)); + value.success = arg_bool(success); + value.running = false; + value.message = as_string(message); + value.exit_code = as_string(exit_code) == "" ? null : arg_number(exit_code); + value.updated_at = arg_number(updated_at); + write_json(value); +} + +function stale_action_state(path, message, updated_at) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.success = false; + value.running = false; + value.message = as_string(message); + value.exit_code = null; + value.updated_at = arg_number(updated_at); + } + write_json(value); +} + +function ack_action_state(path, acked_at) { + let value = object_or_empty(read_json_file(path)); + if (value.running === false) + value.acked_at = arg_number(acked_at); + write_json(value); +} + +function action_ack_expired(path, now_value, ttl_value) { + let value = read_json_file(path); + if (type(value) != "object") + exit(1); + + let acked_at = unsigned_number(value.acked_at); + let now = unsigned_number(now_value); + let ttl = unsigned_number(ttl_value); + + if (acked_at == null || now == null || now <= 0 || ttl == null) + exit(1); + + exit(now - acked_at >= ttl ? 0 : 1); +} + +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 job_state_path(dir, job_id) { + dir = as_string(dir); + job_id = as_string(job_id); + + if (job_id == "" || job_id == "." || job_id == ".." || match(job_id, /[^A-Za-z0-9._-]/) != null) + exit(1); + + print(dir, "/", job_id, ".json\n"); +} + +function job_started_at_within_grace(value, now, grace_seconds) { + let started_at = arg_number(value); + now = arg_number(now); + grace_seconds = arg_number(grace_seconds); + + if (started_at <= 0 || now <= 0) + return false; + + return now - started_at < grace_seconds; +} + +function job_pid_valid(pid) { + pid = as_string(pid); + return pid != "" && match(pid, /^[0-9]+$/) != null; +} + +function job_refresh_plan(path, now, grace_seconds) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) { + print("skip\n"); + return; + } + + let within_grace = job_started_at_within_grace(value.started_at, now, grace_seconds); + let pid = as_string(value.pid || ""); + if (!job_pid_valid(pid)) { + print(within_grace ? "skip\n" : "stale\n"); + return; + } + + print("pid\t", pid, "\t", within_grace ? "0" : "1", "\n"); +} + +function active_service_action(dir) { + for (let path in fs.glob(as_string(dir) + "/*.json")) { + let value = read_json_file(path); + if (type(value) == "object" && value.running === true && as_string(value.action) != "") { + print(as_string(value.action), "\n"); + return; + } + } + + exit(1); +} + +function active_service_action_value() { + for (let path in fs.glob(SERVICE_ACTION_DIR + "/*.json")) { + let value = read_json_file(path); + if (type(value) == "object" && value.running === true && as_string(value.action) != "") + return as_string(value.action); + } + + return ""; +} + +function file_executable(path) { + let stat = fs.stat(as_string(path)); + return stat != null && stat.mode != null && (int(stat.mode) & 73) != 0; +} + +function first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function valid_job_id(job_id) { + job_id = as_string(job_id); + return job_id != "" && job_id != "." && job_id != ".." && match(job_id, /[^A-Za-z0-9._-]/) == null; +} + +function job_state_path_value(dir, job_id) { + if (!valid_job_id(job_id)) + return ""; + return as_string(dir) + "/" + as_string(job_id) + ".json"; +} + +function job_id() { + let stamp = clock(); + return sprintf("%d-%d", stamp[0], stamp[1]); +} + +function running_service_action_value(action, source, started_at) { + return { + success: true, + running: true, + kind: "service", + action: as_string(action), + source: as_string(source), + message: "Service action is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }; +} + +function running_latency_action_value(latency_type, section, tag, started_at) { + return { + success: true, + running: true, + kind: "latency", + latency_type: as_string(latency_type), + section: as_string(section), + tag: as_string(tag), + message: "Latency test is running", + pid: null, + started_at: arg_number(started_at), + updated_at: null, + exit_code: null + }; +} + +function finished_action_state_value(path, success, message, exit_code, updated_at) { + let value = object_or_empty(read_json_file(path)); + value.success = arg_bool(success); + value.running = false; + value.message = as_string(message); + value.exit_code = as_string(exit_code) == "" ? null : arg_number(exit_code); + value.updated_at = arg_number(updated_at); + return value; +} + +function stale_action_state_value(path, message, updated_at) { + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.success = false; + value.running = false; + value.message = as_string(message); + value.exit_code = null; + value.updated_at = arg_number(updated_at); + } + return value; +} + +function ack_action_state_value(path, acked_at) { + let value = object_or_empty(read_json_file(path)); + if (value.running === false) + value.acked_at = arg_number(acked_at); + return value; +} + +function set_running_job_pid_file(path, pid) { + pid = as_string(pid); + if (!job_pid_valid(pid)) + return false; + + let value = object_or_empty(read_json_file(path)); + if (value.running === true) { + value.pid = pid; + return write_state_file(path, value); + } + + return false; +} + +function write_finished_action_state(path, success, message, exit_code) { + return write_state_file(path, finished_action_state_value(path, success, message, exit_code, now_seconds())); +} + +function write_stale_action_state(path, message) { + return write_state_file(path, stale_action_state_value(path, message, now_seconds())); +} + +function pid_running(pid) { + pid = as_string(pid); + return job_pid_valid(pid) && command_success_from_args([ "kill", "-0", pid ]); +} + +function refresh_pid_job_state(path, stale_message) { + let value = read_json_file(path); + if (type(value) != "object" || value.running !== true) + return; + + let now = now_seconds(); + let within_grace = job_started_at_within_grace(value.started_at, now, ACTION_STALE_GRACE_SECONDS); + let pid = as_string(value.pid || ""); + + if (job_pid_valid(pid) && pid_running(pid)) + return; + + if (!within_grace) + write_stale_action_state(path, stale_message); +} + +function state_file_ack_expired(path) { + let value = read_json_file(path); + if (type(value) != "object") + return false; + + let acked_at = unsigned_number(value.acked_at); + let now = now_seconds(); + let ttl = unsigned_number(ACTION_ACKED_TTL_SECONDS); + return acked_at != null && now > 0 && ttl != null && now - acked_at >= ttl; +} + +function cleanup_dir(dir) { + dir = as_string(dir); + for (let path in fs.glob(dir + "/*.json")) { + let value = read_json_file(path); + if (!valid_action_state(value)) { + remove_state_file(path); + continue; + } + if (value.running === false && state_file_ack_expired(path)) + remove_state_file(path); + } + + let old = command_output_from_args([ "find", dir, "-type", "f", "-name", "*.json", "-mmin", "+" + as_string(ACTION_FINISHED_TTL_MINUTES) ]); + for (let path in split(old, "\n")) { + path = trim(as_string(path)); + if (path == "") + continue; + let value = read_json_file(path); + if (type(value) == "object" && value.running === false) + remove_state_file(path); + } +} + +function refresh_action_dirs() { + ensure_dirs(); + cleanup_dir(SERVICE_ACTION_DIR); + cleanup_dir(LATENCY_ACTION_DIR); + cleanup_dir(COMPONENT_ACTION_DIR); + cleanup_dir(SUBSCRIPTION_ACTION_DIR); + + for (let path in fs.glob(SERVICE_ACTION_DIR + "/*.json")) + refresh_pid_job_state(path, "Service action worker exited unexpectedly"); + for (let path in fs.glob(LATENCY_ACTION_DIR + "/*.json")) + refresh_pid_job_state(path, "Latency test worker exited unexpectedly"); + for (let path in fs.glob(COMPONENT_ACTION_DIR + "/*.json")) + refresh_pid_job_state(path, "Component action worker exited unexpectedly"); + for (let path in fs.glob(SUBSCRIPTION_ACTION_DIR + "/*.json")) + refresh_pid_job_state(path, "Subscription update worker exited unexpectedly"); +} + +function action_state_from_dir(dir) { + let result = []; + + for (let path in fs.glob(as_string(dir) + "/*.json")) { + let value = read_json_file(path); + if (!valid_action_state(value)) + continue; + + value.job_id = job_id_from_path(path); + push(result, value); + } + + return result; +} + +function action_state_from_dirs() { + return { + service: action_state_from_dir(SERVICE_ACTION_DIR), + latency: action_state_from_dir(LATENCY_ACTION_DIR), + component: action_state_from_dir(COMPONENT_ACTION_DIR), + subscription: action_state_from_dir(SUBSCRIPTION_ACTION_DIR) + }; +} + +function acquire_dir_lock(lock_dir) { + lock_dir = as_string(lock_dir); + let owner_pid = trim(command_output("sh -c 'echo $$'")); + if (command_success_from_args([ "mkdir", lock_dir ])) { + write_file(lock_dir + "/pid", owner_pid + "\n"); + return true; + } + + let current_owner_pid = first_line(lock_dir + "/pid"); + if (pid_running(current_owner_pid)) + return false; + + remove_file(lock_dir + "/pid"); + command_success_from_args([ "rmdir", lock_dir ]); + if (!command_success_from_args([ "mkdir", lock_dir ])) + return false; + + write_file(lock_dir + "/pid", owner_pid + "\n"); + return true; +} + +function release_dir_lock(lock_dir) { + remove_file(as_string(lock_dir) + "/pid"); + command_success_from_args([ "rmdir", lock_dir ]); +} + +function service_enabled() { + return file_executable("/etc/rc.d/S99" + SERVICE_NAME); +} + +function sing_box_enabled() { + return file_executable("/etc/rc.d/S99sing-box"); +} + +function sing_box_running() { + let service_json = "{\"name\":\"sing-box\"}"; + if (command_success("ubus call service list " + shell_quote(service_json) + " 2>/dev/null | " + + command_from_args([ "ucode", LIB_DIR + "/diagnostics/status.uc", "service-list-instance-running", "sing-box" ]))) + return true; + + return command_success_from_args([ "pgrep", "-x", "sing-box" ]) || + command_success_from_args([ "pgrep", "-f", "^/usr/bin/sing-box[[:space:]]" ]); +} + +function network_configured() { + if (!command_success_from_args([ "nft", "list", "table", "inet", NFT_TABLE_NAME ])) + return false; + if (index(command_output_from_args([ "ip", "rule", "show" ]), "lookup " + RT_TABLE_NAME) < 0) + return false; + return trim(command_output_from_args([ "ip", "route", "show", "table", RT_TABLE_NAME ])) != ""; +} + +function podkop_running() { + return sing_box_running() && network_configured(); +} + +function dns_configured() { + return index(uci_core.get("dhcp.@dnsmasq[0].server"), SB_DNS_INBOUND_ADDRESS) >= 0; +} + +function marker_is(expected) { + return first_line(SING_BOX_VARIANT_STATE_FILE) == as_string(expected); +} + +function tiny_package_installed() { + if (command_success_from_args([ "sh", "-c", "command -v apk" ])) + return command_success_from_args([ "apk", "info", "-e", "sing-box-tiny" ]); + + let installed = command_output_from_args([ "opkg", "list-installed" ]); + for (let line in split(installed, "\n")) + if (split(trim(as_string(line)), /[ \t]+/)[0] == "sing-box-tiny") + return true; + + return false; +} + +function component_action_running_for(component) { + component = as_string(component); + for (let path in fs.glob(COMPONENT_ACTION_DIR + "/*.json")) { + let value = read_json_file(path); + if (type(value) == "object" && value.running === true && as_string(value.component) == component) + return true; + } + + return false; +} + +function sing_box_signature() { + let fields = split(trim(command_output_from_args([ "ls", "-ln", "/usr/bin/sing-box" ])), /[ \t]+/); + if (length(fields) < 8) + return ""; + + return join(":", [ fields[4], fields[5], fields[6], fields[7] ]); +} + +function sing_box_version_state() { + return first_line(SING_BOX_VERSION_STATE_FILE); +} + +function sing_box_version() { + if (!command_success_from_args([ "sh", "-c", "command -v sing-box" ])) + return ""; + + if (marker_is("extended-compressed")) + return sing_box_version_state(); + + let signature = sing_box_signature(); + if (signature != "" && fs.stat(SING_BOX_VERSION_CACHE_FILE) != null) { + let data = split(as_string(fs.readfile(SING_BOX_VERSION_CACHE_FILE)), "\n"); + if ((data[0] || "") == signature && as_string(data[1] || "") != "") + return as_string(data[1]); + } + + let first = split(command_output_from_args([ "sing-box", "version" ]), "\n")[0] || ""; + let fields = split(trim(as_string(first)), /[ \t]+/); + let version = length(fields) > 0 ? fields[length(fields) - 1] : ""; + if (version == "") + return ""; + + if (signature != "") { + ensure_dir(STATE_DIR); + write_file(SING_BOX_VERSION_CACHE_FILE, signature + "\n" + version + "\n"); + } + + return version; +} + +function server_inbounds_enabled_count() { + let count = 0; + for (let section in uci_core.section_objects(CONFIG_NAME, "server")) { + section = object_or_empty(section); + let enabled = section.enabled == null ? "1" : as_string(section.enabled); + if (enabled != "0") + count++; + } + return count; +} + +function capability_flags() { + let result = { + sing_box_extended: 0, + sing_box_tiny: 0, + sing_box_compressed: 0, + sing_box_tailscale: 0, + zapret_installed: file_executable(ZAPRET_PROVIDER_NFQWS_BIN) ? 1 : 0, + zapret2_installed: file_executable(ZAPRET2_PROVIDER_NFQWS2_BIN) ? 1 : 0, + byedpi_installed: file_executable(BYEDPI_BIN) ? 1 : 0, + server_inbounds_enabled_count: 0 + }; + + if (command_success_from_args([ "sh", "-c", "command -v sing-box" ])) { + if (marker_is("extended-compressed")) { + result.sing_box_extended = 1; + result.sing_box_compressed = 1; + result.sing_box_tailscale = 1; + } + else if (marker_is("extended")) { + result.sing_box_extended = 1; + result.sing_box_tailscale = 1; + } + else if (tiny_package_installed() || marker_is("tiny")) { + result.sing_box_tiny = 1; + } + else if (component_action_running_for("sing_box")) { + result.sing_box_tailscale = 1; + } + else { + let output = command_output_from_args([ "sing-box", "version" ]); + let first = split(output, "\n")[0] || ""; + let fields = split(trim(as_string(first)), /[ \t]+/); + let version = length(fields) > 0 ? fields[length(fields) - 1] : ""; + if (index(version, "extended") >= 0) { + result.sing_box_extended = 1; + result.sing_box_tailscale = 1; + } + else { + let tags = ""; + for (let line in split(output, "\n")) { + line = as_string(line); + if (substr(line, 0, 5) == "Tags:") { + tags = trim(substr(line, 5)); + break; + } + } + if (match(tags, /(^|[,: \t])with_tailscale([, \t]|$)/) != null) + result.sing_box_tailscale = 1; + if (result.sing_box_tailscale == 0) + result.sing_box_tiny = 1; + } + } + } + + result.server_inbounds_enabled_count = server_inbounds_enabled_count(); + return result; +} + +function ui_capabilities_json() { + write_json(capability_flags()); +} + +function current_ui_state_json() { + refresh_action_dirs(); + + let capabilities = capability_flags(); + let podkop_is_running = podkop_running() ? 1 : 0; + let podkop_is_enabled = service_enabled() ? 1 : 0; + let sing_box_is_running = sing_box_running() ? 1 : 0; + let sing_box_is_enabled = sing_box_enabled() ? 1 : 0; + let podkop_status = service_status_text(podkop_is_running, podkop_is_enabled); + let sing_box_status = service_status_text(sing_box_is_running, sing_box_is_enabled); + let active_action = active_service_action_value(); + + if (active_action == "start") + podkop_status = "starting"; + else if (active_action == "stop") + podkop_status = "stopping"; + else if (active_action == "restart") + podkop_status = "restarting"; + else if (active_action == "reload") + podkop_status = "reloading"; + + write_json({ + service: { + podkop: { + running: podkop_is_running, + enabled: podkop_is_enabled, + status: podkop_status, + dns_configured: dns_configured() ? 1 : 0 + }, + sing_box: { + running: sing_box_is_running, + enabled: sing_box_is_enabled, + status: sing_box_status + } + }, + capabilities, + actions: action_state_from_dirs() + }); +} + +function service_action_expected_running_value(action) { + action = as_string(action); + if (action == "start" || action == "restart" || action == "reload") + return 1; + if (action == "stop") + return 0; + return -1; +} + +function service_action_reached_expected_state(action) { + let expected = service_action_expected_running_value(action); + if (expected < 0) + return false; + return expected == 1 ? podkop_running() : !podkop_running(); +} + +function service_action_wait_for_expected_state(action, timeout, settle_seconds) { + timeout = arg_number(timeout || SERVICE_ACTION_TIMEOUT_SECONDS); + settle_seconds = arg_number(settle_seconds || SERVICE_ACTION_SETTLE_SECONDS); + let deadline = now_seconds() + timeout; + let stable_seconds = 0; + + while (true) { + if (service_action_reached_expected_state(action)) { + stable_seconds++; + if (stable_seconds >= settle_seconds) + return true; + } + else { + stable_seconds = 0; + } + + if (now_seconds() >= deadline) + return false; + system("sleep 1"); + } +} + +function begin_service_action_if_idle(action, source) { + action = as_string(action); + source = as_string(source || "ui"); + if (!service_action_valid(action)) + return { status: 1, job_id: "" }; + + ensure_dirs(); + if (!acquire_dir_lock(SERVICE_ACTION_LOCK_DIR)) + return { status: 2, job_id: "" }; + + refresh_action_dirs(); + if (active_service_action_value() != "") { + release_dir_lock(SERVICE_ACTION_LOCK_DIR); + return { status: 2, job_id: "" }; + } + + let id = job_id(); + let path = job_state_path_value(SERVICE_ACTION_DIR, id); + if (path == "" || !write_state_file(path, running_service_action_value(action, source, now_seconds()))) { + release_dir_lock(SERVICE_ACTION_LOCK_DIR); + return { status: 1, job_id: "" }; + } + + release_dir_lock(SERVICE_ACTION_LOCK_DIR); + return { status: 0, job_id: id }; +} + +function begin_service_action_mode(action, source) { + let result = begin_service_action_if_idle(action, source || "ui"); + if (result.status == 0) + print(result.job_id, "\n"); + exit(result.status); +} + +function finish_service_action(job_id_value, success, message, exit_code) { + let path = job_state_path_value(SERVICE_ACTION_DIR, job_id_value); + if (path == "" || fs.stat(path) == null) + return false; + return write_finished_action_state(path, success, message, exit_code); +} + +function finish_service_action_mode(job_id_value, success, message, exit_code) { + exit(finish_service_action(job_id_value, success, message, exit_code) ? 0 : 1); +} + +function launch_worker(args) { + let command_args = [ "ucode", "-L", LIB_DIR, LIB_DIR + "/service/ui.uc" ]; + for (let arg in args) + push(command_args, arg); + + let command = command_env({ + PODKOP_CONFIG_NAME: CONFIG_NAME, + PODKOP_LIB: LIB_DIR, + PODKOP_BIN: BIN_PATH, + PODKOP_SERVICE_INIT: SERVICE_INIT, + PODKOP_SERVICE_NAME: SERVICE_NAME, + PODKOP_UI_STATE_DIR: STATE_DIR, + PODKOP_UI_SERVICE_ACTION_DIR: SERVICE_ACTION_DIR, + PODKOP_UI_SERVICE_ACTION_LOCK_DIR: SERVICE_ACTION_LOCK_DIR, + PODKOP_UI_LATENCY_ACTION_DIR: LATENCY_ACTION_DIR, + PODKOP_UI_COMPONENT_ACTION_DIR: COMPONENT_ACTION_DIR, + PODKOP_UI_SUBSCRIPTION_ACTION_DIR: SUBSCRIPTION_ACTION_DIR, + PODKOP_UI_SING_BOX_VERSION_CACHE_FILE: SING_BOX_VERSION_CACHE_FILE, + PODKOP_UI_SING_BOX_VERSION_STATE_FILE: SING_BOX_VERSION_STATE_FILE, + PODKOP_UI_SING_BOX_VARIANT_STATE_FILE: SING_BOX_VARIANT_STATE_FILE, + PODKOP_UI_ACTION_FINISHED_TTL_MINUTES: ACTION_FINISHED_TTL_MINUTES, + PODKOP_UI_ACTION_ACKED_TTL_SECONDS: ACTION_ACKED_TTL_SECONDS, + PODKOP_UI_ACTION_STALE_GRACE_SECONDS: ACTION_STALE_GRACE_SECONDS, + PODKOP_UI_SERVICE_ACTION_TIMEOUT_SECONDS: SERVICE_ACTION_TIMEOUT_SECONDS, + PODKOP_UI_SERVICE_ACTION_SETTLE_SECONDS: SERVICE_ACTION_SETTLE_SECONDS, + NFT_TABLE_NAME, + RT_TABLE_NAME, + SB_DNS_INBOUND_ADDRESS, + ZAPRET_PROVIDER_NFQWS_BIN, + ZAPRET2_PROVIDER_NFQWS2_BIN, + BYEDPI_BIN + }) + " " + + command_from_args(command_args) + + " >/dev/null 2>&1 & echo $!"; + return trim(command_output("sh -c " + shell_quote(command))); +} + +function finish_service_action_after_command(action, job_id_value, status, spawn_waiter) { + status = arg_number(status); + if (as_string(job_id_value) == "") + return 0; + + let path = job_state_path_value(SERVICE_ACTION_DIR, job_id_value); + if (path == "" || fs.stat(path) == null) + return 0; + + if (status != 0) { + write_finished_action_state(path, false, "Service " + as_string(action) + " failed", status); + return 0; + } + + if (!service_enabled() && !podkop_running()) { + write_finished_action_state(path, true, "Service " + as_string(action) + " completed", 0); + return 0; + } + + if (spawn_waiter) { + let pid = launch_worker([ "service-action-wait-worker", path, as_string(action), as_string(job_id_value) ]); + if (pid != "") + set_running_job_pid_file(path, pid); + return 0; + } + + if (service_action_wait_for_expected_state(action, SERVICE_ACTION_TIMEOUT_SECONDS, SERVICE_ACTION_SETTLE_SECONDS)) + write_finished_action_state(path, true, "Service " + as_string(action) + " completed", 0); + else + write_finished_action_state(path, false, "Service " + as_string(action) + " did not reach expected state", 1); + return 0; +} + +function finish_service_action_after_command_mode(action, job_id_value, status) { + finish_service_action_after_command(action, job_id_value, status, true); +} + +function update_service_action_pid_mode(job_id_value, pid) { + let path = job_state_path_value(SERVICE_ACTION_DIR, job_id_value); + exit(path != "" && set_running_job_pid_file(path, pid) ? 0 : 1); +} + +function service_action_worker(path, action, job_id_value) { + let command = "PODKOP_UI_ACTION_TRACKED=1 " + command_from_args([ SERVICE_INIT, action ]) + " >/dev/null 2>&1"; + let status = command_status(command); + finish_service_action_after_command(action, job_id_value, status, false); +} + +function service_action_wait_worker(path, action, job_id_value) { + if (service_action_wait_for_expected_state(action, SERVICE_ACTION_TIMEOUT_SECONDS, SERVICE_ACTION_SETTLE_SECONDS)) + write_finished_action_state(path, true, "Service " + as_string(action) + " completed", 0); + else + write_finished_action_state(path, false, "Service " + as_string(action) + " did not reach expected state", 1); +} + +function service_action_async(action) { + action = as_string(action); + if (!service_action_valid(action)) { + action_start_response(false, "", "Invalid service action"); + exit(1); + } + + let begin = begin_service_action_if_idle(action, "ui"); + if (begin.status == 2) { + action_start_response(false, "", "Another service action is already running"); + exit(1); + } + if (begin.status != 0) { + action_start_response(false, "", "Failed to write service action state"); + exit(1); + } + + let path = job_state_path_value(SERVICE_ACTION_DIR, begin.job_id); + if (path == "") { + action_start_response(false, "", "Failed to prepare service action state"); + exit(1); + } + + let pid = launch_worker([ "service-action-worker", path, action, begin.job_id ]); + if (pid == "" || !set_running_job_pid_file(path, pid)) { + if (pid != "") + command_success_from_args([ "kill", pid ]); + action_start_response(false, "", "Failed to write service action worker pid"); + exit(1); + } + + action_start_response(true, begin.job_id, "Service " + action + " started"); +} + +function service_action_status(job_id_value) { + let path = job_state_path_value(SERVICE_ACTION_DIR, job_id_value); + if (path == "") { + action_start_response(false, "", "Invalid service action job id"); + exit(1); + } + if (fs.stat(path) == null) { + action_start_response(false, "", "Service action job was not found"); + exit(1); + } + + refresh_pid_job_state(path, "Service action worker exited unexpectedly"); + print(as_string(fs.readfile(path))); +} + +function latency_clash_method(latency_type) { + latency_type = as_string(latency_type); + if (latency_type == "group") + return { method: "get_group_latency", timeout: "10000" }; + if (latency_type == "proxy_list") + return { method: "get_proxy_latencies", timeout: "5000" }; + return { method: "get_proxy_latency", timeout: "5000" }; +} + +function latency_worker(path, latency_type, tag, timeout) { + let method = latency_clash_method(latency_type).method; + let status = command_status(command_from_args([ BIN_PATH, "clash_api", method, tag, timeout ]) + " >/dev/null 2>&1"); + if (status == 0) + write_finished_action_state(path, true, "Latency test completed", status); + else + write_finished_action_state(path, false, "Latency test failed", status); +} + +function latency_test_async(latency_type, section, tag, requested_timeout) { + latency_type = as_string(latency_type); + tag = as_string(tag); + if (!latency_type_valid(latency_type)) { + action_start_response(false, "", "Invalid latency test type"); + exit(1); + } + if (tag == "") { + action_start_response(false, "", "Latency test tag is required"); + exit(1); + } + + ensure_dirs(); + let id = job_id(); + let path = job_state_path_value(LATENCY_ACTION_DIR, id); + if (path == "" || !write_state_file(path, running_latency_action_value(latency_type, section, tag, now_seconds()))) { + action_start_response(false, "", "Failed to write latency test state"); + exit(1); + } + + let plan = latency_clash_method(latency_type); + let timeout = as_string(requested_timeout) != "" ? as_string(requested_timeout) : plan.timeout; + let pid = launch_worker([ "latency-worker", path, latency_type, tag, timeout ]); + if (pid == "" || !set_running_job_pid_file(path, pid)) { + if (pid != "") + command_success_from_args([ "kill", pid ]); + action_start_response(false, "", "Failed to write latency test worker pid"); + exit(1); + } + + action_start_response(true, id, "Latency test started"); +} + +function latency_test_status(job_id_value) { + let path = job_state_path_value(LATENCY_ACTION_DIR, job_id_value); + if (path == "") { + action_start_response(false, "", "Invalid latency test job id"); + exit(1); + } + if (fs.stat(path) == null) { + action_start_response(false, "", "Latency test job was not found"); + exit(1); + } + + refresh_pid_job_state(path, "Latency test worker exited unexpectedly"); + print(as_string(fs.readfile(path))); +} + +function action_dir(kind) { + kind = as_string(kind); + if (kind == "service") + return SERVICE_ACTION_DIR; + if (kind == "latency") + return LATENCY_ACTION_DIR; + if (kind == "component") + return COMPONENT_ACTION_DIR; + if (kind == "subscription") + return SUBSCRIPTION_ACTION_DIR; + return ""; +} + +function action_ack(kind, job_id_value) { + let dir = action_dir(kind); + if (dir == "") { + action_start_response(false, "", "Invalid UI action kind"); + exit(1); + } + + let path = job_state_path_value(dir, job_id_value); + if (path == "") { + action_start_response(false, "", "Invalid UI action job id"); + exit(1); + } + + if (fs.stat(path) == null) { + action_start_response(true, job_id_value, "UI action already acknowledged"); + return; + } + + let value = read_json_file(path); + if (type(value) == "object" && value.running === true) { + action_start_response(false, job_id_value, "UI action is still running"); + exit(1); + } + + if (!write_state_file(path, ack_action_state_value(path, now_seconds()))) { + action_start_response(false, job_id_value, "Failed to acknowledge UI action"); + exit(1); + } + + action_start_response(true, job_id_value, "UI action acknowledged"); +} + +let mode = ARGV[0] || ""; + +if (mode == "ui-state-json") + ui_state_json(); +else if (mode == "get-ui-capabilities") + ui_capabilities_json(); +else if (mode == "get-ui-state") + current_ui_state_json(); +else if (mode == "service-status-text") + print_service_status_text(ARGV[1], ARGV[2]); +else if (mode == "action-start-response") + action_start_response(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "service-action-valid") + exit(service_action_valid(ARGV[1]) ? 0 : 1); +else if (mode == "latency-type-valid") + exit(latency_type_valid(ARGV[1]) ? 0 : 1); +else if (mode == "service-action-expected-running") + service_action_expected_running(ARGV[1]); +else if (mode == "running-service-action") + running_service_action(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "running-latency-action") + running_latency_action(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "set-running-job-pid") + set_running_job_pid(ARGV[1], ARGV[2]); +else if (mode == "finished-action-state") + finished_action_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); +else if (mode == "stale-action-state") + stale_action_state(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "ack-action-state") + ack_action_state(ARGV[1], ARGV[2]); +else if (mode == "action-ack-expired") + action_ack_expired(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "json-file-field") + json_file_field(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "job-state-path") + job_state_path(ARGV[1], ARGV[2]); +else if (mode == "job-refresh-plan") + job_refresh_plan(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "active-service-action") + active_service_action(ARGV[1]); +else if (mode == "component-action-running-for") + exit(component_action_running_for(ARGV[1]) ? 0 : 1); +else if (mode == "service-action-begin-if-idle") + begin_service_action_mode(ARGV[1], ARGV[2] || "ui"); +else if (mode == "service-action-update-pid") + update_service_action_pid_mode(ARGV[1], ARGV[2]); +else if (mode == "service-action-finish") + finish_service_action_mode(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "service-action-finish-after-command") + finish_service_action_after_command_mode(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "service-action-wait-worker") + service_action_wait_worker(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "service-action-worker") + service_action_worker(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "service-action-async") + service_action_async(ARGV[1]); +else if (mode == "service-action-status") + service_action_status(ARGV[1]); +else if (mode == "latency-worker") + latency_worker(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "latency-test-async") + latency_test_async(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "latency-test-status") + latency_test_status(ARGV[1]); +else if (mode == "action-ack") + action_ack(ARGV[1], ARGV[2]); +else if (mode == "cleanup-action-dir-fixture") + cleanup_dir(ARGV[1]); +else { + warn("Usage: service/ui.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 deleted file mode 100644 index ca2e35f8..00000000 --- a/podkop/files/usr/lib/sing_box_config_facade.sh +++ /dev/null @@ -1,1154 +0,0 @@ -# shellcheck shell=dash -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_count() { - sing_box_cf_ucode_input stdin-collection-length "${1:-[]}" 2>/dev/null -} - -sing_box_cf_tags_json_csv() { - sing_box_cf_ucode_input stdin-string-array-csv "${1:-[]}" 2>/dev/null -} - -sing_box_cf_outbounds_json_count() { - sing_box_cf_ucode_input stdin-collection-length "${1:-[]}" 2>/dev/null -} - -sing_box_cf_subscription_outbound_count() { - local count="${SUBSCRIPTION_OUTBOUND_COUNT:-}" - - case "$count" in - '' | *[!0-9]*) - sing_box_cf_tags_json_count "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" - ;; - *) - printf '%s\n' "$count" - ;; - esac -} - -sing_box_cf_add_dns_server() { - local config="$1" - local type="$2" - local tag="$3" - local server="$4" - local domain_resolver="$5" - local detour="$6" - - local server_address server_port - server_address=$(url_get_host "$server") - server_port=$(url_get_port "$server") - - case "$type" in - udp) - [ -z "$server_port" ] && server_port=53 - config=$(sing_box_cm_add_udp_dns_server "$config" "$tag" "$server_address" "$server_port" "$domain_resolver" \ - "$detour") - ;; - dot) - [ -z "$server_port" ] && server_port=853 - config=$(sing_box_cm_add_tls_dns_server "$config" "$tag" "$server_address" "$server_port" "$domain_resolver" \ - "$detour") - ;; - doh) - [ -z "$server_port" ] && server_port=443 - local path headers - path=$(url_get_path "$server") - headers="" # TODO(ampetelin): implement it if necessary - config=$(sing_box_cm_add_https_dns_server "$config" "$tag" "$server_address" "$server_port" "$path" "$headers" \ - "$domain_resolver" "$detour") - ;; - *) - log "Unsupported DNS server type: $type. Aborted." "fatal" - exit 1 - ;; - esac - - echo "$config" -} - -sing_box_cf_add_mixed_inbound_and_route_rule() { - local config="$1" - local tag="$2" - local listen_address="$3" - local listen_port="$4" - local outbound="$5" - local username="$6" - local password="$7" - - config=$(sing_box_cm_add_mixed_inbound "$config" "$tag" "$listen_address" "$listen_port" "$username" "$password") - config=$(sing_box_cm_add_route_rule "$config" "" "$tag" "$outbound") - - echo "$config" -} - -sing_box_cf_add_proxy_outbound() { - local config="$1" - local section="$2" - local url="$3" - local udp_over_tcp="$4" - - url=$(sing_box_cf_ucode url-decode "$url") - url=$(url_strip_fragment "$url") - - local scheme - scheme="$(url_get_scheme "$url")" - case "$scheme" in - http | https) - local tag host port path userinfo username password tls_enabled - - tag=$(get_outbound_tag_by_section "$section") - host=$(url_get_host "$url") - port=$(url_get_port "$url") - path=$(url_get_path "$url") - if [ -z "$host" ] || [ -z "$port" ] || { [ -n "$path" ] && [ "$path" != "/" ]; } || [ "${url#*\?}" != "$url" ]; then - log "Invalid HTTP proxy URL: host and explicit port are required; path and query are not supported. Aborted." "fatal" - exit 1 - fi - userinfo=$(url_get_userinfo "$url") - if [ -n "$userinfo" ]; then - case "$userinfo" in - *:*) - username="${userinfo%%:*}" - password="${userinfo#*:}" - ;; - *) - username="$userinfo" - ;; - esac - fi - [ "$scheme" = "https" ] && tls_enabled=1 || tls_enabled=0 - config=$(sing_box_cm_add_http_outbound "$config" "$tag" "$host" "$port" "$username" "$password" "$tls_enabled") - ;; - socks4 | socks4a | socks5) - local tag host port version userinfo username password udp_over_tcp - - tag=$(get_outbound_tag_by_section "$section") - host=$(url_get_host "$url") - port=$(url_get_port "$url") - version="${scheme#socks}" - if [ "$scheme" = "socks5" ]; then - userinfo=$(url_get_userinfo "$url") - if [ -n "$userinfo" ]; then - username="${userinfo%%:*}" - password="${userinfo#*:}" - fi - fi - config="$(sing_box_cm_add_socks_outbound \ - "$config" \ - "$tag" \ - "$host" \ - "$port" \ - "$version" \ - "$username" \ - "$password" \ - "" \ - "$([ "$udp_over_tcp" = "1" ] && echo 2)" # if udp_over_tcp is enabled, enable version 2 - )" - ;; - vless) - local tag host port uuid flow packet_encoding - tag=$(get_outbound_tag_by_section "$section") - host=$(url_get_host "$url") - port=$(url_get_port "$url") - uuid=$(url_get_userinfo "$url") - flow=$(url_get_query_param "$url" "flow") - packet_encoding=$(url_get_query_param "$url" "packetEncoding") - case "$packet_encoding" in - xudp | packetaddr) ;; - *) packet_encoding="" ;; - esac - - config=$(sing_box_cm_add_vless_outbound "$config" "$tag" "$host" "$port" "$uuid" "$flow" "" "$packet_encoding") - config=$(_add_outbound_security "$config" "$tag" "$url") - config=$(_add_outbound_transport "$config" "$tag" "$url") - ;; - vmess) - local tag outbound_json - tag=$(get_outbound_tag_by_section "$section") - outbound_json="$(ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/subscription_parser.uc" share-link-outbound "$url" "$tag")" || { - log "Invalid VMess proxy URL. Aborted." "fatal" - exit 1 - } - config=$(sing_box_cm_add_raw_outbound "$config" "$tag" "$outbound_json") - ;; - ss) - local userinfo tag host port method password udp_over_tcp - - userinfo=$(url_get_userinfo "$url") - if ! sing_box_cf_ucode shadowsocks-userinfo-format-valid "$userinfo"; then - userinfo=$(base64_decode "$userinfo") - if [ $? -ne 0 ]; then - log "Cannot decode shadowsocks userinfo or it does not match the expected format. Aborted." "fatal" - exit 1 - fi - fi - - tag=$(get_outbound_tag_by_section "$section") - host=$(url_get_host "$url") - port=$(url_get_port "$url") - method="${userinfo%%:*}" - password="${userinfo#*:}" - - config=$( - sing_box_cm_add_shadowsocks_outbound \ - "$config" \ - "$tag" \ - "$host" \ - "$port" \ - "$method" \ - "$password" \ - "" \ - "$([ "$udp_over_tcp" = "1" ] && echo 2)" # if udp_over_tcp is enabled, enable version 2 - ) - ;; - trojan) - local tag host port password - tag=$(get_outbound_tag_by_section "$section") - host=$(url_get_host "$url") - port=$(url_get_port "$url") - password=$(url_get_userinfo "$url") - - config=$(sing_box_cm_add_trojan_outbound "$config" "$tag" "$host" "$port" "$password") - config=$(_add_outbound_security "$config" "$tag" "$url") - config=$(_add_outbound_transport "$config" "$tag" "$url") - ;; - hysteria2 | hy2) - local tag host port mport password obfuscator_type obfuscator_password upload_mbps download_mbps - tag=$(get_outbound_tag_by_section "$section") - host=$(url_get_host "$url") - port="$(url_get_port "$url")" - mport="$(url_get_query_param "$url" "mport")" - [ -n "$mport" ] && port="$mport" - password=$(url_get_userinfo "$url") - obfuscator_type=$(url_get_query_param "$url" "obfs") - obfuscator_password=$(url_get_query_param "$url" "obfs-password") - upload_mbps=$(url_get_query_param "$url" "upmbps") - download_mbps=$(url_get_query_param "$url" "downmbps") - - config=$(sing_box_cm_add_hysteria2_outbound "$config" "$tag" "$host" "$port" "$password" "$obfuscator_type" \ - "$obfuscator_password" "$upload_mbps" "$download_mbps") - config=$(_add_outbound_security "$config" "$tag" "$url") - ;; - *) - log "Unsupported proxy $scheme type. Aborted." "fatal" - exit 1 - ;; - esac - - echo "$config" -} - -_add_outbound_security() { - local config="$1" - local outbound_tag="$2" - local url="$3" - - local security scheme - scheme="$(url_get_scheme "$url")" - security=$(url_get_query_param "$url" "security") - if [ -z "$security" ]; then - if [ "$scheme" = "hysteria2" ] || [ "$scheme" = "hy2" ]; then - security="tls" - fi - fi - - case "$security" in - tls | reality) - local sni insecure alpn fingerprint public_key short_id - sni=$(url_get_query_param "$url" "sni") - insecure=$(_get_insecure_query_param_from_url "$url") - alpn=$(sing_box_cf_ucode tls-alpn-json-array "$(url_get_query_param "$url" "alpn")" "$(url_get_query_param "$url" "type")") - fingerprint=$(url_get_query_param "$url" "fp") - if [ "$scheme" = "hysteria2" ] || [ "$scheme" = "hy2" ]; then - fingerprint="" - fi - public_key=$(url_get_query_param "$url" "pbk") - short_id=$(url_get_query_param "$url" "sid") - - config=$( - sing_box_cm_set_tls_for_outbound \ - "$config" \ - "$outbound_tag" \ - "$sni" \ - "$([ "$insecure" = "1" ] && echo true)" \ - "$([ "$alpn" = "[]" ] && echo null || echo "$alpn")" \ - "$fingerprint" \ - "$public_key" \ - "$short_id" - ) - ;; - none) ;; - *) - log "Unknown security '$security' detected." "error" - ;; - esac - - echo "$config" -} - -_get_insecure_query_param_from_url() { - local url="$1" - - local insecure - insecure=$(url_get_query_param "$url" "allowInsecure") - if [ -z "$insecure" ]; then - insecure=$(url_get_query_param "$url" "insecure") - fi - - echo "$insecure" -} - -_add_outbound_transport() { - local config="$1" - local outbound_tag="$2" - local url="$3" - - local transport - transport=$(url_get_query_param "$url" "type") - case "$transport" in - "" | tcp | raw) ;; - http | h2) - local http_path http_hosts - http_path=$(url_get_query_param "$url" "path") - http_hosts=$(sing_box_cf_ucode csv-to-json-array "$(url_get_query_param "$url" "host")") - - config=$( - sing_box_cm_set_http_transport_for_outbound "$config" "$outbound_tag" "$http_path" "$http_hosts" - ) - ;; - ws) - local ws_path ws_host ws_early_data - ws_path=$(url_get_query_param "$url" "path") - ws_host=$(url_get_query_param "$url" "host") - ws_early_data=$(url_get_query_param "$url" "ed") - - config=$( - sing_box_cm_set_ws_transport_for_outbound "$config" "$outbound_tag" "$ws_path" "$ws_host" "$ws_early_data" - ) - ;; - grpc) - # TODO(ampetelin): Add handling of optional gRPC parameters; example links are needed. - local grpc_service_name - grpc_service_name=$(url_get_query_param "$url" "serviceName") - - config=$( - sing_box_cm_set_grpc_transport_for_outbound "$config" "$outbound_tag" "$grpc_service_name" - ) - ;; - httpupgrade) - local httpupgrade_path httpupgrade_host - httpupgrade_path=$(url_get_query_param "$url" "path") - httpupgrade_host=$(url_get_query_param "$url" "host") - - config=$( - sing_box_cm_set_httpupgrade_transport_for_outbound "$config" "$outbound_tag" "$httpupgrade_path" "$httpupgrade_host" - ) - ;; - xhttp) - local xhttp_path xhttp_host xhttp_mode xhttp_sni xhttp_extra_args xhttp_old_ifs - local xhttp_x_padding_bytes xhttp_no_grpc_header xhttp_sc_max_each_post_bytes xhttp_sc_min_posts_interval_ms \ - xhttp_sc_stream_up_server_secs xhttp_xmux - if ! is_sing_box_extended; then - log "XHTTP transport requires sing-box-extended. Install sing-box-extended and retry." "error" - echo "$config" - return 0 - fi - - xhttp_path=$(url_get_query_param "$url" "path") - xhttp_host=$(url_get_query_param "$url" "host") - xhttp_sni=$(url_get_query_param "$url" "sni") - [ -n "$xhttp_host" ] || xhttp_host="$xhttp_sni" - xhttp_mode=$(url_get_query_param "$url" "mode") - xhttp_extra_args="$(sing_box_cf_ucode xhttp-transport-extra "$url")" - xhttp_old_ifs="$IFS" - IFS="$(printf '\t')" read -r \ - xhttp_x_padding_bytes \ - xhttp_no_grpc_header \ - xhttp_sc_max_each_post_bytes \ - xhttp_sc_min_posts_interval_ms \ - xhttp_sc_stream_up_server_secs \ - xhttp_xmux </dev/null -} - -sing_box_cf_log_subscription_skips() { - local prepared_json="$1" - local context="$2" - local skipped_count reason_summary - - 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="$(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" - else - log "Skipped $skipped_count subscription outbounds $context" "warn" - fi -} - -sing_box_cf_prepared_file_skip_count() { - local prepared_path="$1" - local skipped_count - - skipped_count="$(sing_box_cf_ucode skip-count-file "$prepared_path" 2>/dev/null)" - case "$skipped_count" in - '' | *[!0-9]*) printf '0\n' ;; - *) printf '%s\n' "$skipped_count" ;; - esac -} - -sing_box_cf_log_subscription_skips_file() { - local prepared_path="$1" - local context="$2" - local skipped_count reason_summary - - skipped_count="$(sing_box_cf_prepared_file_skip_count "$prepared_path")" - [ "$skipped_count" -gt 0 ] || return 0 - - reason_summary="$(sing_box_cf_ucode skip-summary-file "$prepared_path" 2>/dev/null)" - - if [ -n "$reason_summary" ]; then - log "Skipped $skipped_count subscription outbounds $context: $reason_summary" "warn" - else - log "Skipped $skipped_count subscription outbounds $context" "warn" - fi -} - -sing_box_cf_subscription_plugin_supports() { - local outbounds_json="$1" - local records_tmp plugin_name plugin_supports_json supported - - 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 - - plugin_supports_json="$(sing_box_cf_ucode plugin-supports-from-records "$records_tmp" 2>/dev/null)" - - rm -f "$records_tmp" - [ -n "$plugin_supports_json" ] || plugin_supports_json="{}" - printf '%s\n' "$plugin_supports_json" -} - -sing_box_cf_subscription_plugin_supports_file() { - local subscription_json_path="$1" - local records_tmp plugin_name plugin_supports_json supported - - records_tmp="$(mktemp)" || return 1 - - : > "$records_tmp" - sing_box_cf_ucode subscription-plugin-names "$subscription_json_path" 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 - - plugin_supports_json="$(sing_box_cf_ucode plugin-supports-from-records "$records_tmp" 2>/dev/null)" - - rm -f "$records_tmp" - [ -n "$plugin_supports_json" ] || plugin_supports_json="{}" - printf '%s\n' "$plugin_supports_json" -} - -sing_box_cf_prepare_subscription_batch() { - local config="$1" - local outbounds_json="$2" - 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 - supports_xhttp=true - fi - plugin_supports_json="$(sing_box_cf_subscription_plugin_supports "$outbounds_json")" - [ -n "$plugin_supports_json" ] || plugin_supports_json="{}" - - outbounds_tmp="$(mktemp)" || return 1 - printf '%s' "$outbounds_json" > "$outbounds_tmp" || { - rm -f "$outbounds_tmp" - return 1 - } - - command -v ucode >/dev/null 2>&1 || { - rm -f "$outbounds_tmp" - return 1 - } - - 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 - } - - 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 - - rm -f "$outbounds_tmp" "$config_tmp" "$plugin_supports_tmp" "$prepared_tmp" - return 1 -} - -sing_box_cf_prepare_subscription_batch_file() { - local config="$1" - local subscription_json_path="$2" - local prepared_path="$3" - local outbounds_count_path="$4" - local supports_xhttp plugin_supports_json config_tmp plugin_supports_tmp - - supports_xhttp=false - if is_sing_box_extended; then - supports_xhttp=true - fi - plugin_supports_json="$(sing_box_cf_subscription_plugin_supports_file "$subscription_json_path")" - [ -n "$plugin_supports_json" ] || plugin_supports_json="{}" - - command -v ucode >/dev/null 2>&1 || return 1 - - config_tmp="$(mktemp)" || return 1 - plugin_supports_tmp="$(mktemp)" || { - rm -f "$config_tmp" - return 1 - } - - if printf '%s' "$config" > "$config_tmp" && - printf '%s' "$plugin_supports_json" > "$plugin_supports_tmp" && - sing_box_cf_ucode prepare-subscription-file \ - "$config_tmp" \ - "$subscription_json_path" \ - "$prepared_path" \ - "$outbounds_count_path" \ - "$supports_xhttp" \ - "$plugin_supports_tmp"; then - rm -f "$config_tmp" "$plugin_supports_tmp" - return 0 - fi - - rm -f "$config_tmp" "$plugin_supports_tmp" - return 1 -} - -sing_box_cf_validation_error_summary() { - local output="$1" - local summary - - summary="$(sing_box_cf_ucode_input validation-error-summary "$output" 2>/dev/null)" - [ -n "$summary" ] || summary="sing-box check failed" - printf '%s\n' "$summary" -} - -sing_box_cf_try_subscription_outbounds_batch_file() { - local config="$1" - local new_outbounds_tmp="$2" - local updated_tmp validation_tmp check_tmp validation_config check_output - - SING_BOX_CF_VALIDATED_CONFIG="" - SING_BOX_CF_VALIDATION_ERROR="" - - updated_tmp="$(mktemp)" || { - return 1 - } - validation_tmp="$(mktemp)" || { - rm -f "$updated_tmp" - return 1 - } - check_tmp="$(mktemp)" || { - rm -f "$updated_tmp" "$validation_tmp" - return 1 - } - - if ! sing_box_cf_ucode_input prepare-validation "$config" "$new_outbounds_tmp" "$updated_tmp" "$validation_tmp"; then - rm -f "$updated_tmp" "$validation_tmp" "$check_tmp" - return 1 - fi - - validation_config="$(cat "$validation_tmp" 2>/dev/null)" || validation_config="" - [ -n "$validation_config" ] || { - rm -f "$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 "$updated_tmp" "$validation_tmp" "$check_tmp" - return 1 - fi - - SING_BOX_CF_VALIDATED_CONFIG="$(cat "$updated_tmp" 2>/dev/null)" - rm -f "$updated_tmp" "$validation_tmp" "$check_tmp" - [ -n "$SING_BOX_CF_VALIDATED_CONFIG" ] || return 1 - return 0 -} - -sing_box_cf_try_subscription_outbounds_batch() { - local config="$1" - local new_outbounds="$2" - local new_outbounds_tmp status - - new_outbounds_tmp="$(mktemp)" || return 1 - if ! printf '%s' "$new_outbounds" > "$new_outbounds_tmp"; then - rm -f "$new_outbounds_tmp" - return 1 - fi - - sing_box_cf_try_subscription_outbounds_batch_file "$config" "$new_outbounds_tmp" - status=$? - rm -f "$new_outbounds_tmp" - return "$status" -} - -sing_box_cf_subscription_prepared_slice() { - local prepared_json="$1" - local start="$2" - local end="$3" - - sing_box_cf_ucode_input prepared-slice "$prepared_json" "$start" "$end" 2>/dev/null -} - -sing_box_cf_prepared_visible_count() { - local prepared_json="$1" - local count - - count="$(sing_box_cf_ucode_input prepared-visible-count "$prepared_json" 2>/dev/null)" - case "$count" in - '' | *[!0-9]*) printf '0\n' ;; - *) printf '%s\n' "$count" ;; - esac -} - -sing_box_cf_prepared_field_to_file() { - local prepared_json="$1" - local field="$2" - local output_path="$3" - local count_path="${4:-}" - - printf '%s' "$prepared_json" | - ucode "$PODKOP_LIB/sing_box_config_facade.uc" prepared-field-to-file "$field" "$output_path" "$count_path" -} - -sing_box_cf_load_prepared_state() { - local prepared_json="$1" - local source_section="${2:-$SING_BOX_CF_SOURCE_SECTION}" - local tags_tmp tags_csv_tmp names_lines_tmp link_refs_tmp names_tmp servers_tmp status - - tags_tmp="$(mktemp)" || return 1 - tags_csv_tmp="$(mktemp)" || { - rm -f "$tags_tmp" - return 1 - } - names_lines_tmp="$(mktemp)" || { - rm -f "$tags_tmp" "$tags_csv_tmp" - return 1 - } - link_refs_tmp="$(mktemp)" || { - rm -f "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" - return 1 - } - names_tmp="$(mktemp)" || { - rm -f "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" "$link_refs_tmp" - return 1 - } - servers_tmp="$(mktemp)" || { - rm -f "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" - return 1 - } - - status=1 - if printf '%s' "$prepared_json" | - ucode "$PODKOP_LIB/sing_box_config_facade.uc" prepared-state-to-files "$source_section" \ - "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp"; then - IFS= read -r SUBSCRIPTION_OUTBOUND_TAGS_JSON < "$tags_tmp" || SUBSCRIPTION_OUTBOUND_TAGS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" ] || SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - IFS= read -r SUBSCRIPTION_OUTBOUND_TAGS < "$tags_csv_tmp" || SUBSCRIPTION_OUTBOUND_TAGS="" - SUBSCRIPTION_OUTBOUND_NAMES="$(cat "$names_lines_tmp" 2>/dev/null)" - IFS= read -r SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON < "$link_refs_tmp" || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" ] || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" - IFS= read -r SUBSCRIPTION_OUTBOUND_NAMES_JSON < "$names_tmp" || SUBSCRIPTION_OUTBOUND_NAMES_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" ] || SUBSCRIPTION_OUTBOUND_NAMES_JSON="{}" - IFS= read -r SUBSCRIPTION_OUTBOUND_SERVERS_JSON < "$servers_tmp" || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" ] || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="{}" - status=0 - fi - - rm -f "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp" - return "$status" -} - -sing_box_cf_read_prepared_state_files() { - local tags_tmp="$1" - local tags_csv_tmp="$2" - local names_lines_tmp="$3" - local link_refs_tmp="$4" - local names_tmp="$5" - local servers_tmp="$6" - - IFS= read -r SUBSCRIPTION_OUTBOUND_TAGS_JSON < "$tags_tmp" || SUBSCRIPTION_OUTBOUND_TAGS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" ] || SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - IFS= read -r SUBSCRIPTION_OUTBOUND_TAGS < "$tags_csv_tmp" || SUBSCRIPTION_OUTBOUND_TAGS="" - SUBSCRIPTION_OUTBOUND_NAMES="$(cat "$names_lines_tmp" 2>/dev/null)" - IFS= read -r SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON < "$link_refs_tmp" || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" ] || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" - IFS= read -r SUBSCRIPTION_OUTBOUND_NAMES_JSON < "$names_tmp" || SUBSCRIPTION_OUTBOUND_NAMES_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" ] || SUBSCRIPTION_OUTBOUND_NAMES_JSON="{}" - IFS= read -r SUBSCRIPTION_OUTBOUND_SERVERS_JSON < "$servers_tmp" || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" ] || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="{}" -} - -sing_box_cf_append_subscription_prepared_metadata() { - local prepared_json="$1" - local tags_tmp names_lines_tmp link_refs_tmp names_tmp servers_tmp status - - tags_tmp="$(mktemp)" || return 1 - names_lines_tmp="$(mktemp)" || { - rm -f "$tags_tmp" - return 1 - } - link_refs_tmp="$(mktemp)" || { - rm -f "$tags_tmp" "$names_lines_tmp" - return 1 - } - names_tmp="$(mktemp)" || { - rm -f "$tags_tmp" "$names_lines_tmp" "$link_refs_tmp" - return 1 - } - servers_tmp="$(mktemp)" || { - rm -f "$tags_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" - return 1 - } - - status=1 - [ -n "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" ] || SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - [ -n "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" ] || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" - [ -n "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" ] || SUBSCRIPTION_OUTBOUND_NAMES_JSON="{}" - [ -n "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" ] || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="{}" - - if printf '%s' "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" > "$tags_tmp" && - printf '%s' "$SUBSCRIPTION_OUTBOUND_NAMES" > "$names_lines_tmp" && - printf '%s' "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" > "$link_refs_tmp" && - printf '%s' "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" > "$names_tmp" && - printf '%s' "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" > "$servers_tmp" && - printf '%s' "$prepared_json" | - ucode "$PODKOP_LIB/sing_box_config_facade.uc" append-prepared-state-to-files "$SING_BOX_CF_SOURCE_SECTION" \ - "$tags_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp"; then - IFS= read -r SUBSCRIPTION_OUTBOUND_TAGS_JSON < "$tags_tmp" || SUBSCRIPTION_OUTBOUND_TAGS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" ] || SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - SUBSCRIPTION_OUTBOUND_NAMES="$(cat "$names_lines_tmp" 2>/dev/null)" - IFS= read -r SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON < "$link_refs_tmp" || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" ] || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" - IFS= read -r SUBSCRIPTION_OUTBOUND_NAMES_JSON < "$names_tmp" || SUBSCRIPTION_OUTBOUND_NAMES_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" ] || SUBSCRIPTION_OUTBOUND_NAMES_JSON="{}" - IFS= read -r SUBSCRIPTION_OUTBOUND_SERVERS_JSON < "$servers_tmp" || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="" - [ -n "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" ] || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="{}" - status=0 - fi - - rm -f "$tags_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp" - return "$status" -} - -sing_box_cf_apply_subscription_batch() { - local config="$1" - local prepared_json="$2" - local tmpdir new_outbounds_tmp new_outbounds_count_tmp new_outbounds_count \ - tags_tmp tags_csv_tmp names_lines_tmp link_refs_tmp names_tmp servers_tmp - - tmpdir="$(mktemp -d)" || return 1 - new_outbounds_tmp="$tmpdir/outbounds.json" - new_outbounds_count_tmp="$tmpdir/outbounds.count" - tags_tmp="$tmpdir/tags.json" - tags_csv_tmp="$tmpdir/tags.csv" - names_lines_tmp="$tmpdir/names.lines" - link_refs_tmp="$tmpdir/link-refs.json" - names_tmp="$tmpdir/names.json" - servers_tmp="$tmpdir/servers.json" - - if ! printf '%s' "$prepared_json" | - ucode "$PODKOP_LIB/sing_box_config_facade.uc" prepared-outbounds-state-to-files "$SING_BOX_CF_SOURCE_SECTION" \ - "$new_outbounds_tmp" "$new_outbounds_count_tmp" \ - "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp"; then - rm -rf "$tmpdir" - return 1 - fi - - new_outbounds_count="$(cat "$new_outbounds_count_tmp" 2>/dev/null)" - case "$new_outbounds_count" in - '' | *[!0-9]*) - rm -rf "$tmpdir" - return 1 - ;; - esac - if [ "$new_outbounds_count" -eq 0 ]; then - rm -rf "$tmpdir" - return 1 - fi - - if ! sing_box_cf_try_subscription_outbounds_batch_file "$config" "$new_outbounds_tmp"; then - rm -rf "$tmpdir" - return 1 - fi - - sing_box_cf_read_prepared_state_files "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp" - SUBSCRIPTION_OUTBOUND_COUNT="$(sing_box_cf_tags_json_count "$SUBSCRIPTION_OUTBOUND_TAGS_JSON")" - if [ "${SUBSCRIPTION_OUTBOUND_COUNT:-0}" -le 0 ]; then - rm -rf "$tmpdir" - return 1 - fi - SING_BOX_CF_LAST_CONFIG="$SING_BOX_CF_VALIDATED_CONFIG" - rm -rf "$tmpdir" - - return 0 -} - -sing_box_cf_apply_subscription_batch_file() { - local config="$1" - local prepared_path="$2" - local tmpdir new_outbounds_tmp new_outbounds_count_tmp new_outbounds_count \ - tags_tmp tags_csv_tmp names_lines_tmp link_refs_tmp names_tmp servers_tmp - - tmpdir="$(mktemp -d)" || return 1 - new_outbounds_tmp="$tmpdir/outbounds.json" - new_outbounds_count_tmp="$tmpdir/outbounds.count" - tags_tmp="$tmpdir/tags.json" - tags_csv_tmp="$tmpdir/tags.csv" - names_lines_tmp="$tmpdir/names.lines" - link_refs_tmp="$tmpdir/link-refs.json" - names_tmp="$tmpdir/names.json" - servers_tmp="$tmpdir/servers.json" - - if ! ucode "$PODKOP_LIB/sing_box_config_facade.uc" prepared-outbounds-state-file-to-files \ - "$prepared_path" "$SING_BOX_CF_SOURCE_SECTION" \ - "$new_outbounds_tmp" "$new_outbounds_count_tmp" \ - "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp"; then - rm -rf "$tmpdir" - return 1 - fi - - new_outbounds_count="$(cat "$new_outbounds_count_tmp" 2>/dev/null)" - case "$new_outbounds_count" in - '' | *[!0-9]*) - rm -rf "$tmpdir" - return 1 - ;; - esac - if [ "$new_outbounds_count" -eq 0 ]; then - rm -rf "$tmpdir" - return 1 - fi - - if ! sing_box_cf_try_subscription_outbounds_batch_file "$config" "$new_outbounds_tmp"; then - rm -rf "$tmpdir" - return 1 - fi - - sing_box_cf_read_prepared_state_files "$tags_tmp" "$tags_csv_tmp" "$names_lines_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp" - SUBSCRIPTION_OUTBOUND_COUNT="$(sing_box_cf_tags_json_count "$SUBSCRIPTION_OUTBOUND_TAGS_JSON")" - if [ "${SUBSCRIPTION_OUTBOUND_COUNT:-0}" -le 0 ]; then - rm -rf "$tmpdir" - return 1 - fi - SING_BOX_CF_LAST_CONFIG="$SING_BOX_CF_VALIDATED_CONFIG" - rm -rf "$tmpdir" - - return 0 -} - -sing_box_cf_apply_subscription_outbounds_range() { - local start="$1" - local count="$2" - local end chunk outbounds_json display_name half rest index visible_count - - [ "$count" -gt 0 ] || return 0 - - end=$((start + count)) - chunk="$(sing_box_cf_subscription_prepared_slice "$SING_BOX_CF_FALLBACK_PREPARED_JSON" "$start" "$end")" || { - SING_BOX_CF_FALLBACK_SKIPPED_COUNT=$((SING_BOX_CF_FALLBACK_SKIPPED_COUNT + count)) - return 0 - } - - 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 - } - - if sing_box_cf_try_subscription_outbounds_batch "$SING_BOX_CF_FALLBACK_WORKING_CONFIG" "$outbounds_json"; then - SING_BOX_CF_FALLBACK_WORKING_CONFIG="$SING_BOX_CF_VALIDATED_CONFIG" - if ! sing_box_cf_append_subscription_prepared_metadata "$chunk"; then - log "Failed to append subscription metadata for rule '$SING_BOX_CF_SOURCE_SECTION'. Aborted." "fatal" - exit 1 - fi - visible_count="$(sing_box_cf_prepared_visible_count "$chunk")" - SING_BOX_CF_FALLBACK_ADDED_COUNT=$((SING_BOX_CF_FALLBACK_ADDED_COUNT + visible_count)) - return 0 - fi - - if [ "$count" -eq 1 ]; then - 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" - SING_BOX_CF_FALLBACK_SKIPPED_COUNT=$((SING_BOX_CF_FALLBACK_SKIPPED_COUNT + 1)) - return 0 - fi - - if [ "$count" -le 8 ]; then - index=0 - while [ "$index" -lt "$count" ]; do - sing_box_cf_apply_subscription_outbounds_range $((start + index)) 1 - index=$((index + 1)) - done - return 0 - fi - - half=$((count / 2)) - [ "$half" -gt 0 ] || half=1 - rest=$((count - half)) - - sing_box_cf_apply_subscription_outbounds_range "$start" "$half" - sing_box_cf_apply_subscription_outbounds_range $((start + half)) "$rest" -} - -sing_box_cf_apply_subscription_outbounds_chunked() { - local config="$1" - local prepared_json="$2" - local outbounds_count half rest - - 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 - - SING_BOX_CF_FALLBACK_WORKING_CONFIG="$config" - SING_BOX_CF_FALLBACK_PREPARED_JSON="$prepared_json" - SING_BOX_CF_FALLBACK_ADDED_COUNT=0 - SING_BOX_CF_FALLBACK_SKIPPED_COUNT=0 - SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" - SUBSCRIPTION_OUTBOUND_NAMES_JSON="{}" - SUBSCRIPTION_OUTBOUND_SERVERS_JSON="{}" - SUBSCRIPTION_OUTBOUND_NAMES="" - SUBSCRIPTION_OUTBOUND_COUNT=0 - - if [ "$outbounds_count" -le 8 ]; then - sing_box_cf_apply_subscription_outbounds_range 0 "$outbounds_count" - else - half=$((outbounds_count / 2)) - [ "$half" -gt 0 ] || half=1 - rest=$((outbounds_count - half)) - sing_box_cf_apply_subscription_outbounds_range 0 "$half" - sing_box_cf_apply_subscription_outbounds_range "$half" "$rest" - fi - - if [ "$SING_BOX_CF_FALLBACK_ADDED_COUNT" -eq 0 ]; then - return 1 - fi - - if [ "$SING_BOX_CF_FALLBACK_SKIPPED_COUNT" -gt 0 ]; then - log "Skipped $SING_BOX_CF_FALLBACK_SKIPPED_COUNT unsupported subscription outbounds during chunked fallback validation" "warn" - fi - - SUBSCRIPTION_OUTBOUND_TAGS="$(sing_box_cf_tags_json_csv "$SUBSCRIPTION_OUTBOUND_TAGS_JSON")" - SUBSCRIPTION_OUTBOUND_COUNT="$SING_BOX_CF_FALLBACK_ADDED_COUNT" - SING_BOX_CF_LAST_CONFIG="$SING_BOX_CF_FALLBACK_WORKING_CONFIG" - - return 0 -} - -sing_box_cf_add_subscription_outbounds() { - local config="$1" - local section="$2" - local subscription_json_path="$3" - local source_section="${4:-}" - local tmpdir prepared_tmp outbounds_count_tmp outbounds_count prepared_json skipped_count - - SUBSCRIPTION_OUTBOUND_TAGS="" - SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" - SUBSCRIPTION_OUTBOUND_NAMES_JSON="{}" - SUBSCRIPTION_OUTBOUND_SERVERS_JSON="{}" - SUBSCRIPTION_OUTBOUND_NAMES="" - SUBSCRIPTION_OUTBOUND_COUNT=0 - SING_BOX_CF_SOURCE_SECTION="$source_section" - SING_BOX_CF_LAST_CONFIG="$config" - - if [ ! -f "$subscription_json_path" ]; then - log "Subscription JSON file not found: $subscription_json_path" "error" - echo "$config" - return 1 - fi - - tmpdir="$(mktemp -d)" || { - echo "$config" - return 1 - } - prepared_tmp="$tmpdir/prepared.json" - outbounds_count_tmp="$tmpdir/outbounds.count" - - if ! sing_box_cf_prepare_subscription_batch_file "$config" "$subscription_json_path" "$prepared_tmp" "$outbounds_count_tmp"; then - rm -rf "$tmpdir" - echo "$config" - return 1 - fi - - outbounds_count="$(cat "$outbounds_count_tmp" 2>/dev/null)" - case "$outbounds_count" in - '' | *[!0-9]*) - rm -rf "$tmpdir" - echo "$config" - return 1 - ;; - esac - if [ "$outbounds_count" -eq 0 ]; then - log "No proxy outbounds found in subscription JSON" "error" - rm -rf "$tmpdir" - echo "$config" - return 1 - fi - - log "Found $outbounds_count proxy outbounds in subscription" "info" - - skipped_count="$(sing_box_cf_prepared_file_skip_count "$prepared_tmp")" - if [ "${skipped_count:-0}" -gt 0 ]; then - sing_box_cf_log_subscription_skips_file "$prepared_tmp" "before validation" - fi - - if sing_box_cf_apply_subscription_batch_file "$config" "$prepared_tmp"; then - log "Added $(sing_box_cf_subscription_outbound_count) subscription outbounds for rule '$section'" "info" - rm -rf "$tmpdir" - echo "$SING_BOX_CF_LAST_CONFIG" - return 0 - fi - - log "Batch subscription validation failed for rule '$section', trying chunked fallback validation" "warn" - prepared_json="$(cat "$prepared_tmp" 2>/dev/null)" || prepared_json="" - rm -rf "$tmpdir" - if [ -n "$prepared_json" ] && sing_box_cf_apply_subscription_outbounds_chunked "$config" "$prepared_json"; then - log "Added $(sing_box_cf_subscription_outbound_count) subscription outbounds for rule '$section'" "info" - echo "$SING_BOX_CF_LAST_CONFIG" - return 0 - fi - - log "No valid subscription outbounds remained after validation for rule '$section'" "error" - - echo "$config" - return 1 -} diff --git a/podkop/files/usr/lib/sing_box_config_facade.uc b/podkop/files/usr/lib/sing_box_config_facade.uc deleted file mode 100644 index 2637cda6..00000000 --- a/podkop/files/usr/lib/sing_box_config_facade.uc +++ /dev/null @@ -1,1165 +0,0 @@ -#!/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_compact_string_array(values) { - print("["); - for (let i = 0; i < length(values); i++) { - if (i > 0) - print(","); - print(sprintf("%J", as_string(values[i]))); - } - print("]\n"); -} - -function csv_to_json_array(value) { - value = as_string(value); - write_compact_string_array(value == "" ? [] : split(value, ",")); -} - -function tls_alpn_json_array(value, transport) { - value = as_string(value); - transport = lc(as_string(transport)); - if (value == "" && transport == "xhttp") { - write_compact_string_array(["h2", "http/1.1"]); - return; - } - if (value != "" && (transport == "ws" || transport == "httpupgrade")) { - write_compact_string_array(["http/1.1"]); - return; - } - write_compact_string_array(value == "" ? [] : split(value, ",")); -} - -function hex_digit_value(value) { - value = ord(lc(as_string(value))); - if (value >= 48 && value <= 57) - return value - 48; - if (value >= 97 && value <= 102) - return value - 87; - return -1; -} - -function url_decode(value) { - value = as_string(value); - - let result = ""; - for (let i = 0; i < length(value); i++) { - let c = substr(value, i, 1); - if (c == "+") { - result += " "; - continue; - } - - if (c == "%") { - let high = i + 1 < length(value) ? hex_digit_value(substr(value, i + 1, 1)) : -1; - let low = i + 2 < length(value) ? hex_digit_value(substr(value, i + 2, 1)) : -1; - if (high >= 0 && low >= 0) { - result += chr(high * 16 + low); - i += 2; - } - else { - result += "\\x"; - } - continue; - } - - result += c; - } - - return result; -} - -function write_file(path, text) { - let result = fs.writefile(path, as_string(text)); - if (result == null) - return false; - if (type(result) == "boolean" && !result) - return false; - return true; -} - -function write_file_json(path, value) { - return write_file(path, sprintf("%J", value) + "\n"); -} - -function write_text_file(path, value) { - return write_file(path, as_string(value)); -} - -function array_or_empty(value) { - return type(value) == "array" ? value : []; -} - -function object_or_empty(value) { - return type(value) == "object" ? value : {}; -} - -function xhttp_value_present(value) { - return value != null && as_string(value) != ""; -} - -function xhttp_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 xhttp_object_arg(value) { - if (type(value) == "object") - return value; - - let parsed = json_decode_text(value); - return type(parsed) == "object" ? parsed : {}; -} - -function xhttp_copy_known_settings(target, source) { - if (type(source) != "object") - return; - - for (let key in [ - "xPaddingBytes", - "x_padding_bytes", - "noGRPCHeader", - "no_grpc_header", - "scMaxEachPostBytes", - "sc_max_each_post_bytes", - "scMinPostsIntervalMs", - "sc_min_posts_interval_ms", - "scStreamUpServerSecs", - "sc_stream_up_server_secs", - "xmux" - ]) { - if (xhttp_value_present(source[key])) - target[key] = source[key]; - } -} - -function xhttp_merge_extra_settings(target, value) { - let extra = xhttp_object_arg(value); - if (type(extra) != "object") - return; - - xhttp_copy_known_settings(target, extra); - - let xhttp_settings = xhttp_object_arg(extra.xhttpSettings); - xhttp_copy_known_settings(target, xhttp_settings); - xhttp_copy_known_settings(target, xhttp_object_arg(xhttp_settings.extra)); - - let download_settings = xhttp_object_arg(extra.downloadSettings); - let download_xhttp_settings = xhttp_object_arg(download_settings.xhttpSettings); - xhttp_copy_known_settings(target, download_xhttp_settings); - xhttp_copy_known_settings(target, xhttp_object_arg(download_xhttp_settings.extra)); -} - -function xhttp_query_params(url) { - let result = {}; - url = as_string(url); - let question = index(url, "?"); - if (question < 0) - return result; - - let query = substr(url, question + 1); - let hash = index(query, "#"); - if (hash >= 0) - query = substr(query, 0, hash); - - for (let pair in split(query, "&")) { - if (pair == "") - continue; - let equals = index(pair, "="); - let key = url_decode(equals >= 0 ? substr(pair, 0, equals) : pair); - let value = equals >= 0 ? url_decode(substr(pair, equals + 1)) : ""; - if (key != "") - result[key] = value; - } - - return result; -} - -function xhttp_extra_settings(query) { - let result = {}; - query = object_or_empty(query); - xhttp_merge_extra_settings(result, query.extra); - return result; -} - -function xhttp_setting_value(query, extra_settings, camel_key, snake_key) { - query = object_or_empty(query); - extra_settings = object_or_empty(extra_settings); - for (let value in [query[camel_key], query[snake_key], extra_settings[camel_key], extra_settings[snake_key]]) { - if (xhttp_value_present(value)) - return value; - } - return null; -} - -function xhttp_non_negative_integer_value(value) { - if (type(value) == "int" || type(value) == "double") { - let number = int(value); - return number == value && number >= 0 ? number : null; - } - - value = trim(as_string(value)); - if (!xhttp_is_integer_string(value)) - return null; - - return int(value, 10); -} - -function xhttp_positive_integer_value(value) { - let number = xhttp_non_negative_integer_value(value); - return number != null && number > 0 ? number : null; -} - -function xhttp_range_value(value, positive) { - if (!xhttp_value_present(value)) - return null; - - if (type(value) == "object") { - let from = positive ? xhttp_positive_integer_value(value.from) : xhttp_non_negative_integer_value(value.from); - let to = positive ? xhttp_positive_integer_value(value.to) : xhttp_non_negative_integer_value(value.to); - return from != null && to != null && from <= to ? { from, to } : null; - } - - let number = positive ? xhttp_positive_integer_value(value) : xhttp_non_negative_integer_value(value); - if (number != null) - return number; - - value = trim(as_string(value)); - let dash = index(value, "-"); - if (dash < 0 || index(substr(value, dash + 1), "-") >= 0) - return null; - - let from = positive ? xhttp_positive_integer_value(substr(value, 0, dash)) : xhttp_non_negative_integer_value(substr(value, 0, dash)); - let to = positive ? xhttp_positive_integer_value(substr(value, dash + 1)) : xhttp_non_negative_integer_value(substr(value, dash + 1)); - return from != null && to != null && from <= to ? from + "-" + to : null; -} - -function xhttp_positive_range_or_default(value, default_value) { - let normalized = xhttp_range_value(value, true); - return normalized != null ? normalized : default_value; -} - -function xhttp_arg_value(value) { - if (value == null) - return ""; - if (type(value) == "object" || type(value) == "array") - return sprintf("%J", value); - return as_string(value); -} - -function xhttp_bool_arg(value) { - if (!xhttp_value_present(value)) - return ""; - - let normalized = lc(as_string(value)); - return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "on" ? "1" : "0"; -} - -function xhttp_object_setting_value(source, camel_key, snake_key) { - source = type(source) == "object" ? source : {}; - for (let value in [source[camel_key], source[snake_key]]) { - if (xhttp_value_present(value)) - return value; - } - return null; -} - -function xhttp_optional_xmux_range(object, key, value) { - let normalized = xhttp_range_value(value, false); - if (normalized != null) - object[key] = normalized; -} - -function xhttp_optional_xmux_integer(object, key, value) { - let normalized = xhttp_non_negative_integer_value(value); - if (normalized != null) - object[key] = normalized; -} - -function xhttp_normalize_xmux(value) { - let source = xhttp_object_arg(value); - if (type(source) != "object") - return null; - - let result = {}; - xhttp_optional_xmux_range(result, "max_concurrency", xhttp_object_setting_value(source, "maxConcurrency", "max_concurrency")); - xhttp_optional_xmux_range(result, "max_connections", xhttp_object_setting_value(source, "maxConnections", "max_connections")); - xhttp_optional_xmux_range(result, "c_max_reuse_times", xhttp_object_setting_value(source, "cMaxReuseTimes", "c_max_reuse_times")); - xhttp_optional_xmux_range(result, "h_max_request_times", xhttp_object_setting_value(source, "hMaxRequestTimes", "h_max_request_times")); - xhttp_optional_xmux_range(result, "h_max_reusable_secs", xhttp_object_setting_value(source, "hMaxReusableSecs", "h_max_reusable_secs")); - xhttp_optional_xmux_integer(result, "h_keep_alive_period", xhttp_object_setting_value(source, "hKeepAlivePeriod", "h_keep_alive_period")); - - return length(keys(result)) > 0 ? result : null; -} - -function xhttp_transport_extra(url) { - let query = xhttp_query_params(url); - let extra_settings = xhttp_extra_settings(query); - let xmux = xhttp_normalize_xmux(xhttp_setting_value(query, extra_settings, "xmux", "xmux")); - let values = [ - xhttp_arg_value(xhttp_range_value(xhttp_setting_value(query, extra_settings, "xPaddingBytes", "x_padding_bytes"), true)), - xhttp_bool_arg(xhttp_setting_value(query, extra_settings, "noGRPCHeader", "no_grpc_header")), - xhttp_arg_value(xhttp_range_value(xhttp_setting_value(query, extra_settings, "scMaxEachPostBytes", "sc_max_each_post_bytes"), true)), - xhttp_arg_value(xhttp_range_value(xhttp_setting_value(query, extra_settings, "scMinPostsIntervalMs", "sc_min_posts_interval_ms"), false)), - xhttp_arg_value(xhttp_range_value(xhttp_setting_value(query, extra_settings, "scStreamUpServerSecs", "sc_stream_up_server_secs"), false)), - xhttp_arg_value(xmux) - ]; - - print(join("\t", values), "\n"); -} - -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 strip_ansi_sgr(value) { - return replace(as_string(value), /\x1b\[[0-9;]*m/g, ""); -} - -function validation_error_summary() { - for (let line in split(read_stdin(), "\n")) { - line = trim(strip_ansi_sgr(replace(as_string(line), /\r/g, ""))); - if (line != "") { - print(line, "\n"); - return; - } - } - - print("sing-box check failed\n"); -} - -function valid_server_port(value) { - return type(value) == "int" && value >= 1 && value <= 65535; -} - -function valid_server_port_text(value) { - value = as_string(value); - if (!match(value, /^\d+$/)) - return false; - - return valid_server_port(int(value, 10)); -} - -function shadowsocks_userinfo_format_valid(value) { - return match(as_string(value), /^[^:]+:[^:]+(:[^:]+)?$/) != null; -} - -function valid_hysteria2_server_ports(value) { - if (type(value) != "array" || length(value) == 0) - return false; - - for (let item in value) { - let parts = split(as_string(item), ":"); - if (length(parts) != 2) - return false; - if (!valid_server_port_text(parts[0]) || !valid_server_port_text(parts[1])) - return false; - if (int(parts[0], 10) > int(parts[1], 10)) - return false; - } - - return true; -} - -function type_requires_server(proxy_type) { - return proxy_type == "vless" || proxy_type == "vmess" || proxy_type == "trojan" || - proxy_type == "shadowsocks" || proxy_type == "socks" || proxy_type == "http" || 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"; -} - -function internal_flag(value) { - if (value === true) - return true; - value = lc(as_string(value)); - return value == "1" || value == "true" || value == "yes" || value == "on"; -} - -function outbound_is_hidden(outbound) { - return type(outbound) == "object" && internal_flag(outbound.__podkop_hidden); -} - -function outbound_allows_group_type(outbound) { - return type(outbound) == "object" && internal_flag(outbound.__podkop_allow_group); -} - -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 == "selector" || proxy_type == "urltest") && outbound_allows_group_type(outbound)) - return ""; - 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 != "http" && 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 (proxy_type == "hysteria2" && !valid_server_port(outbound.server_port) && - !valid_hysteria2_server_ports(outbound.server_ports)) - return "missing or invalid server_port"; - if (proxy_type != "hysteria2" && 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 normalize_outbound(outbound) { - if (type(outbound) != "object" || type(outbound.transport) != "object") - return; - if (outbound.transport.type != "xhttp") - return; - - if (xhttp_value_present(outbound.transport.sc_max_each_post_bytes)) { - outbound.transport = clone(outbound.transport); - outbound.transport.sc_max_each_post_bytes = xhttp_positive_range_or_default(outbound.transport.sc_max_each_post_bytes, 1000000); - } -} - -function copy_outbound(outbound) { - let copy = {}; - for (let key, value in outbound) { - if (key != "tag" && key != "remark" && key != "share_link" && - key != "__podkop_hidden" && key != "__podkop_allow_group") - copy[key] = value; - } - normalize_outbound(copy); - return copy; -} - -function rewrite_prepared_outbound_references(outbounds, tag_map) { - for (let outbound in outbounds) { - if (type(outbound) != "object") - continue; - - let detour = as_string(outbound.detour || ""); - if (detour != "" && tag_map[detour]) - outbound.detour = tag_map[detour]; - - if (type(outbound.outbounds) == "array") { - let rewritten = []; - for (let tag in outbound.outbounds) { - tag = as_string(tag); - push(rewritten, tag_map[tag] || tag); - } - outbound.outbounds = rewritten; - } - } -} - -function candidate_outbound_list(subscription) { - 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") - continue; - if ((outbound.type == "selector" || outbound.type == "urltest") && outbound_allows_group_type(outbound)) - push(result, outbound); - else if (!skipped_types[outbound.type]) - push(result, outbound); - } - - return result; -} - -function candidate_outbounds(path) { - let subscription = object_or_empty(read_json_file(path)); - let result = candidate_outbound_list(subscription); - - write_json(result); -} - -function prepare_subscription_object(config, outbounds, supports_xhttp_arg, plugin_supports) { - if (type(config) != "object" || type(outbounds) != "array") - return null; - - 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 visible = []; - let skipped = 0; - let skipped_reason_counts = {}; - let prepared = []; - let tag_map = {}; - - 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; - let is_hidden = outbound_is_hidden(outbound); - tag_map[base_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); - push(visible, !is_hidden); - taken[tag] = true; - } - - rewrite_prepared_outbound_references(prepared, tag_map); - - return { - outbounds: prepared, - links, - source_indices, - names, - servers, - skipped, - skipped_reason_counts: length(keys(skipped_reason_counts)) > 0 ? skipped_reason_counts : [], - tags, - visible - }; -} - -function prepare_subscription(config_path, outbounds_path, output_path, supports_xhttp_arg, plugin_supports_path) { - let prepared = prepare_subscription_object( - read_json_file(config_path), - read_json_file(outbounds_path), - supports_xhttp_arg, - object_or_empty(read_json_file(plugin_supports_path)) - ); - - if (prepared == null || !write_file_json(output_path, prepared)) - exit(1); -} - -function prepare_subscription_file(config_path, subscription_path, output_path, count_path, supports_xhttp_arg, plugin_supports_path) { - let subscription = object_or_empty(read_json_file(subscription_path)); - let outbounds = candidate_outbound_list(subscription); - let prepared = prepare_subscription_object( - read_json_file(config_path), - outbounds, - supports_xhttp_arg, - object_or_empty(read_json_file(plugin_supports_path)) - ); - - if (prepared == null) - exit(1); - if (as_string(count_path) != "" && !write_text_file(count_path, length(outbounds) + "\n")) - exit(1); - if (!write_file_json(output_path, prepared)) - exit(1); -} - -function print_skip_count(prepared) { - print(as_string(prepared.skipped || 0), "\n"); -} - -function skip_count() { - print_skip_count(object_or_empty(read_stdin_json())); -} - -function skip_count_file(path) { - print_skip_count(object_or_empty(read_json_file(path))); -} - -function print_skip_summary(prepared) { - 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 skip_summary() { - print_skip_summary(object_or_empty(read_stdin_json())); -} - -function skip_summary_file(path) { - print_skip_summary(object_or_empty(read_json_file(path))); -} - -function print_plugin_names_from_outbounds(outbounds) { - 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_names() { - print_plugin_names_from_outbounds(array_or_empty(read_stdin_json())); -} - -function subscription_plugin_names(path) { - let subscription = object_or_empty(read_json_file(path)); - print_plugin_names_from_outbounds(candidate_outbound_list(subscription)); -} - -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); - validation.inbounds = []; - 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_entry_visible(prepared, index) { - let visible = array_or_empty(prepared.visible); - return index >= length(visible) || visible[index] !== false; -} - -function prepared_visible_tags(prepared) { - let tags = array_or_empty(prepared.tags); - let result = []; - for (let i = 0; i < length(tags); i++) { - if (prepared_entry_visible(prepared, i)) - push(result, tags[i]); - } - return result; -} - -function prepared_link_refs(prepared, source_section) { - let tags = array_or_empty(prepared.tags); - let source_indices = array_or_empty(prepared.source_indices); - let outbounds = array_or_empty(prepared.outbounds); - let result = {}; - - if (source_section == "") - return result; - - for (let i = 0; i < length(tags); i++) { - if (!prepared_entry_visible(prepared, i)) - continue; - let outbound = type(outbounds[i]) == "object" ? outbounds[i] : {}; - if (outbound.type == "selector" || outbound.type == "urltest") - continue; - 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++) { - if (!prepared_entry_visible(prepared, i)) - continue; - 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++) { - if (!prepared_entry_visible(prepared, i)) - continue; - let server = as_string(servers[i]); - if (server != "") - result[tags[i]] = server; - } - return result; -} - -function prepared_names_text(prepared) { - let lines = []; - let names = array_or_empty(prepared.names); - for (let i = 0; i < length(names); i++) { - if (prepared_entry_visible(prepared, i)) - push(lines, as_string(names[i])); - } - return length(lines) > 0 ? join("\n", lines) + "\n" : ""; -} - -function trim_trailing_newlines(value) { - value = as_string(value); - while (length(value) > 0) { - let last = substr(value, length(value) - 1, 1); - if (last != "\n" && last != "\r") - break; - value = substr(value, 0, length(value) - 1); - } - return value; -} - -function merge_object_values(target, source) { - target = object_or_empty(target); - for (let key, value in object_or_empty(source)) - target[key] = value; - return target; -} - -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.visible = slice(array_or_empty(prepared.visible), 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" || field == "visible") - value = array_or_empty(value); - write_json(value == null ? [] : value); -} - -function prepared_field_to_file(field, output_path, count_path) { - let value = object_or_empty(read_stdin_json())[field]; - if (field == "outbounds" || field == "tags" || field == "names" || field == "servers" || field == "links" || field == "source_indices" || field == "visible") - value = array_or_empty(value); - if (value == null) - value = []; - - let count = (type(value) == "array" || type(value) == "object") ? length(value) : 0; - if (!write_file_json(output_path, value)) - exit(1); - if (as_string(count_path) != "" && !write_text_file(count_path, count + "\n")) - exit(1); -} - -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 visible_length() { - let prepared = object_or_empty(read_stdin_json()); - print(length(prepared_visible_tags(prepared)), "\n"); -} - -function stdin_collection_length() { - let value = read_stdin_json(); - if (type(value) != "array" && type(value) != "object") - print("0\n"); - else - print(length(value), "\n"); -} - -function print_string_array_csv(values) { - let tags = []; - for (let tag in values) - push(tags, as_string(tag)); - print(join(",", tags), "\n"); -} - -function tags_csv() { - let prepared = object_or_empty(read_stdin_json()); - print_string_array_csv(prepared_visible_tags(prepared)); -} - -function stdin_string_array_csv() { - print_string_array_csv(array_or_empty(read_stdin_json())); -} - -function prepared_state_to_files(source_section, tags_path, tags_csv_path, names_lines_path, link_refs_path, names_path, servers_path) { - let prepared = object_or_empty(read_stdin_json()); - let tags = prepared_visible_tags(prepared); - let tag_values = []; - for (let tag in tags) - push(tag_values, as_string(tag)); - - if (!write_file_json(tags_path, tags) || - !write_text_file(tags_csv_path, join(",", tag_values) + "\n") || - !write_text_file(names_lines_path, prepared_names_text(prepared)) || - !write_file_json(link_refs_path, prepared_link_refs(prepared, source_section)) || - !write_file_json(names_path, prepared_names(prepared)) || - !write_file_json(servers_path, prepared_servers(prepared))) - exit(1); -} - -function prepared_outbounds_state_to_files(source_section, outbounds_path, count_path, tags_path, tags_csv_path, names_lines_path, link_refs_path, names_path, servers_path) { - let prepared = object_or_empty(read_stdin_json()); - prepared_outbounds_state_value_to_files(prepared, source_section, outbounds_path, count_path, tags_path, tags_csv_path, names_lines_path, link_refs_path, names_path, servers_path); -} - -function prepared_outbounds_state_value_to_files(prepared, source_section, outbounds_path, count_path, tags_path, tags_csv_path, names_lines_path, link_refs_path, names_path, servers_path) { - let outbounds = array_or_empty(prepared.outbounds); - let tags = prepared_visible_tags(prepared); - let tag_values = []; - for (let tag in tags) - push(tag_values, as_string(tag)); - - if (!write_file_json(outbounds_path, outbounds) || - !write_text_file(count_path, length(outbounds) + "\n") || - !write_file_json(tags_path, tags) || - !write_text_file(tags_csv_path, join(",", tag_values) + "\n") || - !write_text_file(names_lines_path, prepared_names_text(prepared)) || - !write_file_json(link_refs_path, prepared_link_refs(prepared, source_section)) || - !write_file_json(names_path, prepared_names(prepared)) || - !write_file_json(servers_path, prepared_servers(prepared))) - exit(1); -} - -function prepared_outbounds_state_file_to_files(prepared_path, source_section, outbounds_path, count_path, tags_path, tags_csv_path, names_lines_path, link_refs_path, names_path, servers_path) { - prepared_outbounds_state_value_to_files( - object_or_empty(read_json_file(prepared_path)), - source_section, - outbounds_path, - count_path, - tags_path, - tags_csv_path, - names_lines_path, - link_refs_path, - names_path, - servers_path - ); -} - -function append_prepared_state_to_files(source_section, tags_path, names_lines_path, link_refs_path, names_path, servers_path) { - let prepared = object_or_empty(read_stdin_json()); - let tags = array_or_empty(read_json_file(tags_path)); - for (let tag in prepared_visible_tags(prepared)) - push(tags, tag); - - let names_lines = trim_trailing_newlines(fs.readfile(names_lines_path)); - let next_names_lines = trim_trailing_newlines(prepared_names_text(prepared)); - if (next_names_lines != "") - names_lines = names_lines == "" ? next_names_lines : names_lines + "\n" + next_names_lines; - - if (!write_file_json(tags_path, tags) || - !write_text_file(names_lines_path, names_lines) || - !write_file_json(link_refs_path, merge_object_values(read_json_file(link_refs_path), prepared_link_refs(prepared, source_section))) || - !write_file_json(names_path, merge_object_values(read_json_file(names_path), prepared_names(prepared))) || - !write_file_json(servers_path, merge_object_values(read_json_file(servers_path), prepared_servers(prepared)))) - exit(1); -} - -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 = ""; - let first_visible = -1; - for (let i = 0; i < length(outbounds); i++) { - if (prepared_entry_visible(prepared, i)) { - first_visible = i; - break; - } - } - if (first_visible >= 0) - result = as_string(names[first_visible]); - if (result == "" && first_visible >= 0 && type(outbounds[first_visible]) == "object") - result = as_string(outbounds[first_visible].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 == "subscription-plugin-names") - subscription_plugin_names(ARGV[1]); -else if (mode == "csv-to-json-array") - csv_to_json_array(ARGV[1]); -else if (mode == "tls-alpn-json-array") - tls_alpn_json_array(ARGV[1], ARGV[2]); -else if (mode == "url-decode") - print(url_decode(ARGV[1])); -else if (mode == "xhttp-transport-extra") - xhttp_transport_extra(ARGV[1]); -else if (mode == "shadowsocks-userinfo-format-valid") - exit(shadowsocks_userinfo_format_valid(ARGV[1]) ? 0 : 1); -else if (mode == "skip-count") - skip_count(); -else if (mode == "skip-count-file") - skip_count_file(ARGV[1]); -else if (mode == "skip-summary") - skip_summary(); -else if (mode == "skip-summary-file") - skip_summary_file(ARGV[1]); -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-subscription-file") - prepare_subscription_file(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); -else if (mode == "prepare-validation") - prepare_validation(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "prepared-slice") - prepared_slice(ARGV[1], ARGV[2]); -else if (mode == "prepared-field") - field_json(ARGV[1]); -else if (mode == "prepared-field-to-file") - prepared_field_to_file(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "prepared-length") - field_length(ARGV[1]); -else if (mode == "prepared-visible-count") - visible_length(); -else if (mode == "prepared-tags-csv") - tags_csv(); -else if (mode == "stdin-collection-length") - stdin_collection_length(); -else if (mode == "stdin-string-array-csv") - stdin_string_array_csv(); -else if (mode == "validation-error-summary") - validation_error_summary(); -else if (mode == "prepared-state-to-files") - prepared_state_to_files(as_string(ARGV[1]), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); -else if (mode == "prepared-outbounds-state-to-files") - prepared_outbounds_state_to_files(as_string(ARGV[1]), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9]); -else if (mode == "prepared-outbounds-state-file-to-files") - prepared_outbounds_state_file_to_files(ARGV[1], as_string(ARGV[2]), ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10]); -else if (mode == "append-prepared-state-to-files") - append_prepared_state_to_files(as_string(ARGV[1]), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); -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 deleted file mode 100644 index 087cea42..00000000 --- a/podkop/files/usr/lib/sing_box_config_manager.sh +++ /dev/null @@ -1,329 +0,0 @@ -# -# Module: sing_box_config_manager.sh -# -# Purpose: -# Thin ash wrappers for sing_box_config_manager.uc. Shell remains the -# orchestration layer; JSON configuration mutations happen in ucode. -# - -SERVICE_TAG="__service_tag" - -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() { - sing_box_cm_ucode configure-log "$1" "$2" "$3" "$4" -} - -sing_box_cm_configure_dns() { - sing_box_cm_ucode configure-dns "$1" "$2" "$3" "$4" -} - -sing_box_cm_add_udp_dns_server() { - sing_box_cm_ucode add-udp-dns-server "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_tls_dns_server() { - sing_box_cm_ucode add-tls-dns-server "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_https_dns_server() { - sing_box_cm_ucode add-https-dns-server "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" -} - -sing_box_cm_add_fakeip_dns_server() { - sing_box_cm_ucode add-fakeip-dns-server "$1" "$2" "$3" -} - -sing_box_cm_add_tailscale_dns_server() { - sing_box_cm_ucode add-tailscale-dns-server "$1" "$2" "$3" "$4" -} - -sing_box_cm_add_dns_route_rule() { - sing_box_cm_ucode add-dns-route-rule "$1" "$2" "$3" -} - -sing_box_cm_patch_dns_route_rule() { - sing_box_cm_ucode patch-dns-route-rule "$1" "$2" "$3" "$4" -} - -sing_box_cm_add_dns_reject_rule() { - sing_box_cm_ucode add-dns-reject-rule "$1" "$2" "$3" -} - -sing_box_cm_add_tproxy_inbound() { - sing_box_cm_ucode add-tproxy-inbound "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_direct_inbound() { - sing_box_cm_ucode add-direct-inbound "$1" "$2" "$3" "$4" -} - -sing_box_cm_add_mixed_inbound() { - sing_box_cm_ucode add-mixed-inbound "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_raw_inbound() { - sing_box_cm_ucode add-raw-inbound "$1" "$2" "$3" -} - -sing_box_cm_add_raw_inbound_file() { - sing_box_cm_ucode add-raw-inbound-file "$1" "$2" "$3" -} - -sing_box_cm_add_vless_inbound() { - sing_box_cm_ucode add-vless-inbound-file "$1" "$2" "$3" "$4" "$5" -} - -sing_box_cm_add_trojan_inbound() { - sing_box_cm_ucode add-trojan-inbound-file "$1" "$2" "$3" "$4" "$5" -} - -sing_box_cm_add_vmess_inbound() { - sing_box_cm_ucode add-vmess-inbound-file "$1" "$2" "$3" "$4" "$5" -} - -sing_box_cm_add_socks_inbound() { - sing_box_cm_ucode add-socks-inbound-file "$1" "$2" "$3" "$4" "$5" -} - -sing_box_cm_add_shadowsocks_inbound() { - sing_box_cm_ucode add-shadowsocks-inbound "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_hysteria2_inbound() { - sing_box_cm_ucode add-hysteria2-inbound-file "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" -} - -sing_box_cm_add_mtproxy_inbound() { - sing_box_cm_ucode add-mtproxy-inbound-file "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" "${11}" "${12}" "${13}" "${14}" "${15}" -} - -sing_box_cm_add_tailscale_endpoint() { - sing_box_cm_ucode add-tailscale-endpoint "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" "${11}" "${12}" "${13}" -} - -sing_box_cm_set_tls_for_inbound() { - sing_box_cm_ucode set-inbound-tls "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" "${11}" "${12}" -} - -sing_box_cm_set_transport_for_inbound() { - sing_box_cm_ucode set-inbound-transport "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" -} - -sing_box_cm_add_direct_outbound() { - sing_box_cm_ucode add-direct-outbound "$1" "$2" "$3" -} - -sing_box_cm_add_socks_outbound() { - sing_box_cm_ucode add-socks-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" -} - -sing_box_cm_add_http_outbound() { - sing_box_cm_ucode add-http-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" -} - -sing_box_cm_add_shadowsocks_outbound() { - sing_box_cm_ucode add-shadowsocks-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" -} - -sing_box_cm_add_vless_outbound() { - sing_box_cm_ucode add-vless-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" -} - -sing_box_cm_add_trojan_outbound() { - sing_box_cm_ucode add-trojan-outbound "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_hysteria2_outbound() { - sing_box_cm_ucode add-hysteria2-outbound "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" -} - -sing_box_cm_set_grpc_transport_for_outbound() { - sing_box_cm_ucode set-grpc-transport "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_set_ws_transport_for_outbound() { - sing_box_cm_ucode set-ws-transport "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_set_http_transport_for_outbound() { - sing_box_cm_ucode set-http-transport "$1" "$2" "$3" "$4" -} - -sing_box_cm_set_httpupgrade_transport_for_outbound() { - sing_box_cm_ucode set-httpupgrade-transport "$1" "$2" "$3" "$4" -} - -sing_box_cm_set_xhttp_transport_for_outbound() { - sing_box_cm_ucode set-xhttp-transport "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" "${11}" -} - -sing_box_cm_set_tls_for_outbound() { - sing_box_cm_ucode set-tls "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" -} - -sing_box_cm_set_outbound_detour() { - sing_box_cm_ucode set-outbound-detour "$1" "$2" "$3" -} - -sing_box_cm_set_outbounds_detour() { - sing_box_cm_ucode set-outbounds-detour "$1" "$2" "$3" -} - -sing_box_cm_add_interface_outbound() { - sing_box_cm_ucode add-interface-outbound "$1" "$2" "$3" "$4" "$5" -} - -sing_box_cm_add_raw_outbound() { - sing_box_cm_ucode add-raw-outbound "$1" "$2" "$3" -} - -sing_box_cm_add_urltest_outbound() { - local config="$1" - local tag="$2" - local outbounds="$3" - local url="$4" - local interval="$5" - local tolerance="$6" - local idle_timeout="$7" - local interrupt="$8" - local outbounds_tmp result status - - outbounds_tmp="$(mktemp)" || return 1 - printf '%s' "$outbounds" > "$outbounds_tmp" || { - rm -f "$outbounds_tmp" - return 1 - } - - result="$(sing_box_cm_ucode add-urltest-outbound-file "$config" "$tag" "$outbounds_tmp" \ - "$url" "$interval" "$tolerance" "$idle_timeout" "$interrupt")" - status=$? - rm -f "$outbounds_tmp" - - [ "$status" -eq 0 ] || return 1 - [ -n "$result" ] || return 1 - printf '%s\n' "$result" -} - -sing_box_cm_add_selector_outbound() { - local config="$1" - local tag="$2" - local outbounds="$3" - local default="$4" - local interrupt="$5" - local outbounds_tmp result status - - outbounds_tmp="$(mktemp)" || return 1 - printf '%s' "$outbounds" > "$outbounds_tmp" || { - rm -f "$outbounds_tmp" - return 1 - } - - result="$(sing_box_cm_ucode add-selector-outbound-file "$config" "$tag" "$outbounds_tmp" "$default" "$interrupt")" - status=$? - rm -f "$outbounds_tmp" - - [ "$status" -eq 0 ] || return 1 - [ -n "$result" ] || return 1 - printf '%s\n' "$result" -} - -sing_box_cm_configure_route() { - sing_box_cm_ucode configure-route "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_route_rule() { - sing_box_cm_ucode add-route-rule "$1" "$2" "$3" "$4" -} - -sing_box_cm_add_route_rule_with_matchers() { - sing_box_cm_ucode add-route-rule-with-matchers \ - "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" "${11}" "${12}" "${13}" -} - -sing_box_cm_add_resolve_rule() { - sing_box_cm_ucode add-resolve-rule "$1" "$2" "$3" "${4:-dns-server}" -} - -sing_box_cm_patch_route_rule() { - sing_box_cm_ucode patch-route-rule "$1" "$2" "$3" "$4" -} - -sing_box_cm_patch_rule_set_reference() { - sing_box_cm_ucode patch-rule-set-reference "$1" "$2" "$3" "$4" -} - -sing_box_cm_patch_dns_route_rule_matchers() { - sing_box_cm_ucode patch-dns-route-rule-matchers "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_reject_route_rule() { - sing_box_cm_ucode add-reject-route-rule "$1" "$2" "$3" -} - -sing_box_cm_add_hijack_dns_route_rule() { - sing_box_cm_ucode add-hijack-dns-route-rule "$1" "$2" "$3" -} - -sing_box_cm_add_options_route_rule() { - sing_box_cm_ucode add-options-route-rule "$1" "$2" -} - -sing_box_cm_sniff_route_rule() { - sing_box_cm_ucode sniff-route-rule "$1" "$2" "$3" -} - -sing_box_cm_clone_route_rules_for_inbound() { - sing_box_cm_ucode clone-route-rules-for-inbound "$1" "$2" "$3" "$4" -} - -sing_box_cm_add_inline_ruleset() { - sing_box_cm_ucode add-inline-ruleset "$1" "$2" -} - -sing_box_cm_add_inline_ruleset_rule() { - sing_box_cm_ucode add-inline-ruleset-rule "$1" "$2" "$3" "$4" -} - -sing_box_cm_add_local_ruleset() { - sing_box_cm_ucode add-local-ruleset "$1" "$2" "$3" "$4" -} - -sing_box_cm_add_remote_ruleset() { - sing_box_cm_ucode add-remote-ruleset "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_add_remote_ruleset_reference() { - sing_box_cm_ucode add-remote-ruleset-reference "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" -} - -sing_box_cm_add_local_ruleset_reference() { - sing_box_cm_ucode add-local-ruleset-reference "$1" "$2" "$3" "$4" "$5" "$6" -} - -sing_box_cm_configure_cache_file() { - sing_box_cm_ucode configure-cache-file "$1" "$2" "$3" "$4" -} - -sing_box_cm_configure_clash_api() { - sing_box_cm_ucode configure-clash-api "$1" "$2" "$3" "$4" -} - -sing_box_cm_save_config_to_file() { - local config="$1" - local filepath="$2" - - printf '%s' "$config" | - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/sing_box_config_manager.uc" save-config "$filepath" -} - -_normalize_arg() { - 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 deleted file mode 100644 index 50ca68fb..00000000 --- a/podkop/files/usr/lib/sing_box_config_manager.uc +++ /dev/null @@ -1,1307 +0,0 @@ -#!/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) { - value = as_string(value); - return value == "true" || value == "1" || value == "yes" || value == "on"; -} - -function number_arg(value) { - value = as_string(value); - return value == "" ? null : int(value, 10); -} - -function normalize_port_number(value) { - value = trim(as_string(value)); - if (!match(value, /^\d+$/)) - return ""; - - let port = int(value, 10); - if (port < 1 || port > 65535) - return ""; - - return "" + port; -} - -function parse_hysteria2_server_ports(value) { - value = as_string(value); - if (index(value, ",") < 0 && index(value, "-") < 0) - return null; - - let result = []; - for (let entry in split(value, ",")) { - entry = trim(as_string(entry)); - if (entry == "") - return null; - - let dash = index(entry, "-"); - if (dash >= 0) { - if (index(substr(entry, dash + 1), "-") >= 0) - return null; - - let start = normalize_port_number(substr(entry, 0, dash)); - let end = normalize_port_number(substr(entry, dash + 1)); - if (start == "" || end == "" || int(start, 10) > int(end, 10)) - return null; - - push(result, start + ":" + end); - } - else { - let port = normalize_port_number(entry); - if (port == "") - return null; - - push(result, port + ":" + port); - } - } - - return length(result) > 0 ? result : null; -} - -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 object_file_arg(path) { - let parsed = read_json_file(path); - return type(parsed) == "object" ? 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 optional_json_value(object, key, value) { - value = as_string(value); - if (value != "") - object[key] = json_arg(value); -} - -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 patch_outbounds(config, tags, patch) { - let tag_set = {}; - for (let tag in tags) - tag_set[as_string(tag)] = true; - - for (let outbound in ensure_array(config, "outbounds")) { - if (type(outbound) == "object" && tag_set[as_string(outbound.tag)]) { - for (let key, value in patch) - outbound[key] = value; - } - } -} - -function patch_inbound(config, tag, patch) { - for (let inbound in ensure_array(config, "inbounds")) { - if (type(inbound) == "object" && inbound.tag == tag) { - for (let key, value in patch) - inbound[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_endpoint(config, endpoint) { - push(ensure_array(config, "endpoints"), endpoint); -} - -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_tailscale_dns_server(config, args) { - let server = { - type: "tailscale", - tag: as_string(args[0]), - endpoint: as_string(args[1]) - }; - if (bool_arg(args[2])) - server.accept_default_resolvers = true; - add_dns_server(config, server); -} - -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) { - let inbound = { - type: "mixed", - tag: as_string(args[0]), - listen: as_string(args[1]), - listen_port: number_arg(args[2]) - }; - let username = as_string(args[3]); - let password = as_string(args[4]); - if (username != "" && password != "") { - inbound.users = [{ - username, - password - }]; - } - push(ensure_array(config, "inbounds"), inbound); -} - -function add_raw_inbound(config, args) { - let inbound = json_arg(args[1]); - if (type(inbound) != "object") - inbound = {}; - inbound.tag = as_string(args[0]); - push(ensure_array(config, "inbounds"), inbound); -} - -function add_raw_inbound_file(config, args) { - let inbound = object_file_arg(args[1]); - inbound.tag = as_string(args[0]); - push(ensure_array(config, "inbounds"), inbound); -} - -function add_vless_inbound_file(config, args) { - push(ensure_array(config, "inbounds"), { - type: "vless", - tag: as_string(args[0]), - listen: as_string(args[1]), - listen_port: number_arg(args[2]), - users: array_file_arg(args[3]) - }); -} - -function add_trojan_inbound_file(config, args) { - push(ensure_array(config, "inbounds"), { - type: "trojan", - tag: as_string(args[0]), - listen: as_string(args[1]), - listen_port: number_arg(args[2]), - users: array_file_arg(args[3]) - }); -} - -function add_vmess_inbound_file(config, args) { - push(ensure_array(config, "inbounds"), { - type: "vmess", - tag: as_string(args[0]), - listen: as_string(args[1]), - listen_port: number_arg(args[2]), - users: array_file_arg(args[3]) - }); -} - -function add_socks_inbound_file(config, args) { - let inbound = { - type: "socks", - tag: as_string(args[0]), - listen: as_string(args[1]), - listen_port: number_arg(args[2]) - }; - let users = array_file_arg(args[3]); - if (length(users) > 0) - inbound.users = users; - push(ensure_array(config, "inbounds"), inbound); -} - -function add_shadowsocks_inbound(config, args) { - push(ensure_array(config, "inbounds"), { - type: "shadowsocks", - tag: as_string(args[0]), - listen: as_string(args[1]), - listen_port: number_arg(args[2]), - method: as_string(args[3]), - password: as_string(args[4]) - }); -} - -function add_hysteria2_inbound_file(config, args) { - let inbound = { - type: "hysteria2", - tag: as_string(args[0]), - listen: as_string(args[1]), - listen_port: number_arg(args[2]), - users: array_file_arg(args[3]) - }; - - optional_number(inbound, "up_mbps", args[4]); - optional_number(inbound, "down_mbps", args[5]); - - let obfs_type = as_string(args[6]); - let obfs_password = as_string(args[7]); - if (obfs_type != "" && obfs_password != "") { - inbound.obfs = { - type: obfs_type, - password: obfs_password - }; - } - - push(ensure_array(config, "inbounds"), inbound); -} - -function add_mtproxy_inbound_file(config, args) { - let inbound = { - type: "mtproxy", - tag: as_string(args[0]), - listen: as_string(args[1]), - listen_port: number_arg(args[2]), - users: array_file_arg(args[3]) - }; - - optional_number(inbound, "concurrency", args[4]); - optional_number(inbound, "domain_fronting_port", args[5]); - optional_string(inbound, "domain_fronting_ip", args[6]); - if (bool_arg(args[7])) - inbound.domain_fronting_proxy_protocol = true; - optional_string(inbound, "prefer_ip", args[8]); - if (bool_arg(args[9])) - inbound.auto_update = true; - if (bool_arg(args[10])) - inbound.allow_fallback_on_unknown_dc = true; - optional_string(inbound, "tolerate_time_skewness", args[11]); - optional_string(inbound, "idle_timeout", args[12]); - optional_string(inbound, "handshake_timeout", args[13]); - - push(ensure_array(config, "inbounds"), inbound); -} - -function add_tailscale_endpoint(config, args) { - let endpoint = { - type: "tailscale", - tag: as_string(args[0]) - }; - - optional_string(endpoint, "state_directory", args[1]); - optional_string(endpoint, "auth_key", args[2]); - optional_string(endpoint, "control_url", args[3]); - if (bool_arg(args[4])) - endpoint.ephemeral = true; - optional_string(endpoint, "hostname", args[5]); - if (bool_arg(args[6])) - endpoint.accept_routes = true; - optional_string(endpoint, "exit_node", args[7]); - if (bool_arg(args[8])) - endpoint.exit_node_allow_lan_access = true; - - let advertise_routes = json_arg(args[9]); - if (type(advertise_routes) == "array" && length(advertise_routes) > 0) - endpoint.advertise_routes = advertise_routes; - if (bool_arg(args[10])) - endpoint.advertise_exit_node = true; - optional_string(endpoint, "udp_timeout", args[11]); - - add_endpoint(config, endpoint); -} - -function set_tls_for_inbound(config, args) { - let tag = as_string(args[0]); - let security = as_string(args[1]); - if (security == "" || security == "none") - return; - - let tls = { enabled: true }; - optional_string(tls, "server_name", args[2]); - - let alpn = json_arg(args[3]); - if (type(alpn) == "array" && length(alpn) > 0) - tls.alpn = alpn; - - if (security == "tls") { - optional_string(tls, "certificate_path", args[4]); - optional_string(tls, "key_path", args[5]); - } - else if (security == "reality") { - let handshake = { - server: as_string(args[6]), - server_port: number_arg(args[7]) - }; - let reality = { - enabled: true, - handshake, - private_key: as_string(args[8]) - }; - - let short_id = json_arg(args[9]); - if (type(short_id) == "array" && length(short_id) > 0) - reality.short_id = short_id; - optional_string(reality, "max_time_difference", args[10]); - tls.reality = reality; - } - - patch_inbound(config, tag, { tls }); -} - -function set_transport_for_inbound(config, args) { - let tag = as_string(args[0]); - let transport_type = as_string(args[1]); - if (transport_type == "" || transport_type == "tcp" || transport_type == "raw") - return; - - let transport = { type: transport_type }; - - if (transport_type == "ws") { - optional_string(transport, "path", args[2]); - if (as_string(args[3]) != "") - transport.headers = { Host: as_string(args[3]) }; - } - else if (transport_type == "grpc") { - optional_string(transport, "service_name", args[4]); - } - else if (transport_type == "http") { - optional_string(transport, "path", args[2]); - let hosts = json_arg(args[5]); - if (type(hosts) == "array" && length(hosts) > 0) - transport.host = hosts; - } - else if (transport_type == "httpupgrade") { - optional_string(transport, "path", args[2]); - optional_string(transport, "host", args[3]); - } - else if (transport_type == "xhttp") { - let mode = as_string(args[6]); - if (mode != "auto" && mode != "packet-up" && mode != "stream-up" && mode != "stream-one") - mode = "auto"; - - transport.mode = mode; - transport.path = as_string(args[2]) != "" ? as_string(args[2]) : "/"; - optional_string(transport, "host", args[3]); - transport.headers = {}; - transport.x_padding_bytes = "100-1000"; - transport.no_sse_header = false; - transport.sc_max_each_post_bytes = 1000000; - transport.sc_max_buffered_posts = 30; - transport.sc_stream_up_server_secs = "20-80"; - transport.server_max_header_bytes = 8192; - } - - patch_inbound(config, tag, { transport }); -} - -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_http_outbound(config, args) { - let outbound = { - type: "http", - tag: as_string(args[0]), - server: as_string(args[1]), - server_port: number_arg(args[2]) - }; - optional_string(outbound, "username", args[3]); - optional_string(outbound, "password", args[4]); - if (bool_arg(args[5])) - outbound.tls = { enabled: true }; - 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 server_port = as_string(args[2]); - let outbound = { - type: "hysteria2", - tag: as_string(args[0]), - server: as_string(args[1]), - password: as_string(args[3]) - }; - let server_ports = parse_hysteria2_server_ports(server_port); - if (server_ports != null) - outbound.server_ports = server_ports; - else if (index(server_port, ",") >= 0 || index(server_port, "-") >= 0) - outbound.server_ports = [ server_port ]; - else - outbound.server_port = number_arg(server_port); - - 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]); - optional_json_value(transport, "x_padding_bytes", args[4]); - if (as_string(args[5]) != "") - transport.no_grpc_header = bool_arg(args[5]); - optional_json_value(transport, "sc_max_each_post_bytes", args[6]); - optional_json_value(transport, "sc_min_posts_interval_ms", args[7]); - optional_json_value(transport, "sc_stream_up_server_secs", args[8]); - optional_json_value(transport, "xmux", args[9]); - 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 set_outbound_detour(config, args) { - let detour = as_string(args[1]); - if (detour != "") - patch_outbound(config, as_string(args[0]), { detour }); -} - -function set_outbounds_detour(config, args) { - let detour = as_string(args[1]); - if (detour != "") - patch_outbounds(config, array_arg(args[0]), { detour }); -} - -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]); - optional_number(outbound, "routing_mark", args[3]); - 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]); - optional_number(config.route, "default_mark", args[4]); -} - -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 json_arg_nonempty(value) { - let parsed = json_arg(value); - - if (type(parsed) == "array") - return length(parsed) > 0 ? parsed : null; - if (type(parsed) == "object") - return length(keys(parsed)) > 0 ? parsed : null; - - let string_value = as_string(parsed); - return string_value != "" ? parsed : null; -} - -function patch_tagged_rule_if_nonempty(rules, tag, key, value) { - value = json_arg_nonempty(value); - if (value != null) - patch_tagged_rule(rules, tag, key, value); -} - -function add_route_rule_with_matchers(config, args) { - let tag = as_string(args[0]); - let inbound = as_string(args[1]); - let outbound = as_string(args[2]); - let action = as_string(args[3]); - let route = ensure_object(config, "route"); - let rules = ensure_array(route, "rules"); - - if (action == "reject") { - let rule = { action: "reject" }; - optional_string(rule, "inbound", inbound); - rule[SERVICE_TAG] = tag; - push(rules, rule); - } - else { - let rule = { - action: "route", - inbound, - outbound - }; - rule[SERVICE_TAG] = tag; - push(rules, rule); - } - - let matcher_keys = [ - "domain", - "domain_suffix", - "domain_keyword", - "domain_regex", - "ip_cidr", - "port", - "port_range", - "source_ip_cidr" - ]; - for (let i = 0; i < length(matcher_keys); i++) - patch_tagged_rule_if_nonempty(rules, tag, matcher_keys[i], args[4 + i]); -} - -function copy_rule_matchers(rule) { - let copied = {}; - for (let key in ["inbound", "source_ip_cidr", "domain", "domain_suffix", "domain_keyword", "domain_regex", "rule_set", "port", "port_range"]) { - 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 patch_rule_set_reference(config, args) { - let route_tag = as_string(args[0]); - let dns_tag = as_string(args[1]); - let rule_set_tag = as_string(args[2]); - - if (route_tag != "" && rule_set_tag != "") - patch_tagged_rule(ensure_array(ensure_object(config, "route"), "rules"), route_tag, "rule_set", rule_set_tag); - if (dns_tag != "" && rule_set_tag != "") - patch_tagged_rule(ensure_array(ensure_object(config, "dns"), "rules"), dns_tag, "rule_set", rule_set_tag); -} - -function patch_dns_route_rule_matchers(config, args) { - let rules = ensure_array(ensure_object(config, "dns"), "rules"); - let tag = as_string(args[0]); - let matcher_keys = [ - "domain", - "domain_suffix", - "domain_keyword", - "domain_regex" - ]; - for (let i = 0; i < length(matcher_keys); i++) - patch_tagged_rule_if_nonempty(rules, tag, matcher_keys[i], args[1 + i]); -} - -function add_reject_route_rule(config, args) { - let rule = { - action: "reject" - }; - optional_string(rule, "inbound", 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 clone(value) { - try { - return json(sprintf("%J", value)); - } - catch (e) { - return null; - } -} - -function value_contains(value, item) { - if (type(value) == "array") { - for (let entry in value) { - if (entry == item) - return true; - } - return false; - } - return value == item; -} - -function clone_route_rules_for_inbound(config, args) { - let source_inbound = as_string(args[0]); - let target_inbound = as_string(args[1]); - let skip_domain = as_string(args[2]); - let route = ensure_object(config, "route"); - let rules = ensure_array(route, "rules"); - let cloned_rules = []; - - for (let rule in rules) { - if (type(rule) != "object") - continue; - if (rule.action != "route" && rule.action != "reject") - continue; - if (!value_contains(rule.inbound, source_inbound)) - continue; - if (skip_domain != "" && value_contains(rule.domain, skip_domain)) - continue; - if (rule.source_ip_cidr != null) - continue; - - let cloned = clone(rule); - if (type(cloned) != "object") - continue; - cloned.inbound = target_inbound; - push(cloned_rules, cloned); - } - - for (let cloned_rule in cloned_rules) - push(rules, cloned_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 add_remote_ruleset_reference(config, args) { - add_remote_ruleset(config, [ - args[2], - args[3], - args[4], - args[5], - args[6] - ]); - patch_rule_set_reference(config, [ - args[0], - args[1], - args[2] - ]); -} - -function add_local_ruleset_reference(config, args) { - add_local_ruleset(config, [ - args[2], - args[3], - args[4] - ]); - patch_rule_set_reference(config, [ - args[0], - args[1], - args[2] - ]); -} - -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-tailscale-dns-server": add_tailscale_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-raw-inbound": add_raw_inbound, - "add-raw-inbound-file": add_raw_inbound_file, - "add-vless-inbound-file": add_vless_inbound_file, - "add-trojan-inbound-file": add_trojan_inbound_file, - "add-vmess-inbound-file": add_vmess_inbound_file, - "add-socks-inbound-file": add_socks_inbound_file, - "add-shadowsocks-inbound": add_shadowsocks_inbound, - "add-hysteria2-inbound-file": add_hysteria2_inbound_file, - "add-mtproxy-inbound-file": add_mtproxy_inbound_file, - "add-tailscale-endpoint": add_tailscale_endpoint, - "set-inbound-tls": set_tls_for_inbound, - "set-inbound-transport": set_transport_for_inbound, - "add-direct-outbound": add_direct_outbound, - "add-socks-outbound": add_socks_outbound, - "add-http-outbound": add_http_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, - "set-outbound-detour": set_outbound_detour, - "set-outbounds-detour": set_outbounds_detour, - "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-route-rule-with-matchers": add_route_rule_with_matchers, - "add-resolve-rule": add_resolve_rule, - "patch-route-rule": patch_route_rule, - "patch-rule-set-reference": patch_rule_set_reference, - "patch-dns-route-rule-matchers": patch_dns_route_rule_matchers, - "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, - "clone-route-rules-for-inbound": clone_route_rules_for_inbound, - "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, - "add-remote-ruleset-reference": add_remote_ruleset_reference, - "add-local-ruleset-reference": add_local_ruleset_reference, - "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/sing_box_runtime.sh b/podkop/files/usr/lib/sing_box_runtime.sh deleted file mode 100644 index 9b3817a1..00000000 --- a/podkop/files/usr/lib/sing_box_runtime.sh +++ /dev/null @@ -1,2503 +0,0 @@ -# shellcheck shell=ash - -sing_box_runtime_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/sing_box_runtime.uc" "$@" -} - -sing_box_managed_service_installed() { - [ -r /etc/init.d/sing-box ] || return 1 - grep -q "${SB_MANAGED_SERVICE_MARKER:-Podkop Plus managed sing-box service for binary variants}" /etc/init.d/sing-box 2>/dev/null -} - -sing_box_install_managed_service_script() { - local tmp_file - - tmp_file="/etc/init.d/sing-box.podkop-plus.$$" - cat >"$tmp_file" <<'EOF' -#!/bin/sh /etc/rc.common -# Podkop Plus managed sing-box service for binary variants - -USE_PROCD=1 -START=99 -PROG="/usr/bin/sing-box" - -start_service() { - config_load "sing-box" - local enabled config_file working_directory - local log_stderr - - config_get_bool enabled "main" "enabled" "0" - [ "$enabled" -eq "1" ] || return 0 - - config_get config_file "main" "conffile" "/etc/sing-box/config.json" - config_get working_directory "main" "workdir" "/usr/share/sing-box" - config_get_bool log_stderr "main" "log_stderr" "1" - - procd_open_instance - procd_set_param command "$PROG" run -c "$config_file" -D "$working_directory" - procd_set_param file "$config_file" - procd_set_param stderr "$log_stderr" - procd_set_param limits core="unlimited" - procd_set_param limits nofile="1000000 1000000" - procd_set_param respawn - procd_close_instance -} - -service_triggers() { - procd_add_reload_trigger "sing-box" -} -EOF - - chmod 0755 "$tmp_file" && - mv -f "$tmp_file" /etc/init.d/sing-box -} - -sing_box_remove_managed_service_script() { - sing_box_managed_service_installed || return 0 - - /etc/init.d/sing-box stop >/dev/null 2>&1 || true - /etc/init.d/sing-box disable >/dev/null 2>&1 || true - rm -f /etc/init.d/sing-box -} - -sing_box_disable_service_config() { - command -v uci >/dev/null 2>&1 || return 0 - - uci -q get sing-box.main >/dev/null 2>&1 || - uci -q set sing-box.main=sing-box >/dev/null 2>&1 || true - uci -q set sing-box.main.enabled='0' >/dev/null 2>&1 || true - uci -q commit sing-box >/dev/null 2>&1 || true -} - -sing_box_prepare_service_disabled() { - sing_box_disable_service_config - [ -x /etc/init.d/sing-box ] && /etc/init.d/sing-box stop >/dev/null 2>&1 || true - [ -x /etc/init.d/sing-box ] && /etc/init.d/sing-box disable >/dev/null 2>&1 || true -} - -sing_box_configure_service() { - local sing_box_enabled sing_box_user sing_box_config_path sing_box_conffile - - if is_sing_box_compressed_marker_set; then - sing_box_install_managed_service_script || { - log "Failed to install managed sing-box service for compressed binary. Aborted." "fatal" - exit 1 - } - fi - - sing_box_enabled="$(uci_get "sing-box" "main" "enabled")" - sing_box_user="$(uci_get "sing-box" "main" "user")" - - if [ "$sing_box_enabled" -ne 1 ]; then - uci_set "sing-box" "main" "enabled" 1 - uci_commit "sing-box" - log "sing-box service has been enabled" - fi - - if [ "$sing_box_user" != "root" ]; then - uci_set "sing-box" "main" "user" "root" - uci_commit "sing-box" - log "sing-box service user has been changed to root" - fi - - config_get sing_box_config_path "settings" "config_path" - sing_box_conffile="$(uci_get "sing-box" "main" "conffile")" - log "sing-box config path: $sing_box_config_path" "debug" - log "sing-box service conffile: $sing_box_conffile" "debug" - if [ "$sing_box_conffile" != "$sing_box_config_path" ]; then - uci_set "sing-box" "main" "conffile" "$sing_box_config_path" - uci_commit "sing-box" - log "Configuration file path has been set to $sing_box_config_path" - fi - - [ -f /etc/rc.d/S99sing-box ] && log "Disable sing-box" && /etc/init.d/sing-box disable -} - -sing_box_init_config() { - local config='{"log":{},"dns":{},"ntp":{},"certificate":{},"endpoints":[],"inbounds":[],"outbounds":[],"route":{},"services":[],"experimental":{}}' - - PODKOP_RULE_CONDITION_CACHE_ENABLED=1 - rm -rf /tmp/podkop-plus-rule-cache.* 2>/dev/null || true - PODKOP_RULE_CONDITION_CACHE_DIR="" - PODKOP_RULE_CONDITION_CACHE_CLEANED=1 - PODKOP_DNS_ROUTE_RULE_TAGS="" - PODKOP_URLTEST_SELECTOR_SWITCHES="" - configured_rulesets="" - sing_box_configure_log - sing_box_configure_inbounds - sing_box_configure_dns - sing_box_configure_route - sing_box_configure_experimental - sing_box_additional_inbounds - sing_box_configure_outbounds - sing_box_save_config -} - -sing_box_configure_log() { - log "Configure the log section of a sing-box JSON configuration" - - local log_level - config_get log_level "settings" "log_level" "warn" - config=$(sing_box_cm_configure_log "$config" false "$log_level" false) -} - -sing_box_configure_inbounds() { - log "Configure the inbounds section of a sing-box JSON configuration" - - config=$( - sing_box_cm_add_tproxy_inbound \ - "$config" "$SB_TPROXY_INBOUND_TAG" "$SB_TPROXY_INBOUND_ADDRESS" "$SB_TPROXY_INBOUND_PORT" true true - ) - config=$( - sing_box_cm_add_direct_inbound "$config" "$SB_DNS_INBOUND_TAG" "$SB_DNS_INBOUND_ADDRESS" "$SB_DNS_INBOUND_PORT" - ) - - config_foreach configure_server_inbound "server" -} - -sing_box_configure_outbounds() { - log "Configure the outbounds section of a sing-box JSON configuration" - - config=$(sing_box_cm_add_direct_outbound "$config" "$SB_DIRECT_OUTBOUND_TAG") - - config_foreach configure_outbound_handler "section" -} - -urltest_filter_mode_json() { - local mode="$1" - local tags_json="$2" - local names_json="$3" - local countries_json="$4" - local include_names_json="$5" - local include_regex_json="$6" - local include_countries_json="$7" - local exclude_names_json="$8" - local exclude_regex_json="$9" - local exclude_countries_json="${10}" - local tmpdir result status - - tmpdir="$(mktemp -d)" || return 1 - - if ! printf '%s' "$tags_json" > "$tmpdir/tags" || - ! printf '%s' "$names_json" > "$tmpdir/names" || - ! printf '%s' "$countries_json" > "$tmpdir/countries" || - ! printf '%s' "$include_names_json" > "$tmpdir/include_names" || - ! printf '%s' "$include_regex_json" > "$tmpdir/include_regex" || - ! printf '%s' "$include_countries_json" > "$tmpdir/include_countries" || - ! printf '%s' "$exclude_names_json" > "$tmpdir/exclude_names" || - ! printf '%s' "$exclude_regex_json" > "$tmpdir/exclude_regex" || - ! printf '%s' "$exclude_countries_json" > "$tmpdir/exclude_countries"; then - rm -f "$tmpdir"/* - rmdir "$tmpdir" - return 1 - fi - - result="$( - sing_box_runtime_ucode urltest-filter-mode "$mode" \ - "$tmpdir/tags" \ - "$tmpdir/names" \ - "$tmpdir/countries" \ - "$tmpdir/include_names" \ - "$tmpdir/include_regex" \ - "$tmpdir/include_countries" \ - "$tmpdir/exclude_names" \ - "$tmpdir/exclude_regex" \ - "$tmpdir/exclude_countries" 2>/dev/null - )" - status=$? - rm -f "$tmpdir"/* - rmdir "$tmpdir" - - [ "$status" -eq 0 ] || return 1 - [ -n "$result" ] || result="[]" - printf '%s\n' "$result" -} - -is_ipv6_literal() { - sing_box_runtime_ucode valid-ipv6-literal "$1" >/dev/null 2>&1 -} - -normalize_country_server_key() { - sing_box_runtime_ucode normalize-country-server-key "$1" -} - -resolve_country_server_ip() { - local server="$1" - local normalized ip - - normalized="$(normalize_country_server_key "$server")" - [ -n "$normalized" ] || return 1 - - if is_ipv4 "$normalized" || is_ipv6_literal "$normalized"; then - printf '%s\n' "$normalized" - return 0 - fi - - is_domain "$normalized" || return 1 - - if command -v dig >/dev/null 2>&1; then - ip="$( - dig +short "$normalized" A +time=2 +tries=1 2>/dev/null | - sing_box_runtime_ucode stdin-first-dns-a-address 2>/dev/null - )" - if [ -z "$ip" ]; then - ip="$( - dig +short "$normalized" AAAA +time=2 +tries=1 2>/dev/null | - sing_box_runtime_ucode stdin-first-dns-aaaa-address 2>/dev/null - )" - fi - elif command -v nslookup >/dev/null 2>&1; then - ip="$( - nslookup "$normalized" 2>/dev/null | - sing_box_runtime_ucode stdin-first-nslookup-address 2>/dev/null - )" - fi - - [ -n "$ip" ] || return 1 - printf '%s\n' "$ip" -} - -read_section_outbound_country_cache() { - local section="$1" - local cache_path countries_json - - case "$section" in - "" | */* | *..*) - printf '{}\n' - return 0 - ;; - esac - - cache_path="$(get_section_cache_path "$section")" - if [ -s "$cache_path" ]; then - countries_json="$(sing_box_runtime_ucode section-countries "$cache_path" 2>/dev/null)" - if [ -n "$countries_json" ]; then - printf '%s\n' "$countries_json" - return 0 - fi - fi - - printf '{}\n' -} - -country_is_lookup_ips() { - local ips_json="$1" - local ip_count start end batch_json body_tmp http_code body_error - local ips_tmp result_tmp result_json status - - ips_tmp="$(mktemp)" || return 1 - result_tmp="$(mktemp)" || return 1 - : > "$result_tmp" - - printf '%s' "$ips_json" > "$ips_tmp" || { - rm -f "$ips_tmp" "$result_tmp" - return 1 - } - - ip_count="$(printf '%s' "$ips_json" | sing_box_runtime_ucode stdin-length 2>/dev/null)" - case "$ip_count" in - '' | *[!0-9]*) - rm -f "$ips_tmp" "$result_tmp" - return 1 - ;; - esac - - start=0 - while [ "$start" -lt "$ip_count" ]; do - end=$((start + 100)) - batch_json="$(sing_box_runtime_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 "$ips_tmp" "$result_tmp" - return 1 - } - - http_code="$(curl -sS -m 10 -o "$body_tmp" -w '%{http_code}' \ - -H 'Content-Type: application/json' \ - -d "$batch_json" \ - 'https://api.country.is/' 2>/dev/null || true)" - - body_error="$(sing_box_runtime_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" - break - fi - - if [ "$http_code" = "200" ]; then - sing_box_runtime_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" - break - else - log "Server country lookup failed" "warn" - fi - - rm -f "$body_tmp" - start="$end" - [ "$start" -lt "$ip_count" ] && sleep 1 - done - - result_json="$(sing_box_runtime_ucode tsv-to-object "$result_tmp" 2>/dev/null)" - status=$? - rm -f "$ips_tmp" "$result_tmp" - - [ "$status" -eq 0 ] || return 1 - [ -n "$result_json" ] || result_json="{}" - printf '%s\n' "$result_json" -} - -detect_server_countries_for_tags() { - local servers_json="$1" - local section="${2:-}" - local cache_tmp servers_tmp cache_json cached_countries_json missing_tmp resolved_tmp ips_json ip_country_json ip_country_tmp - local result_json tag server ip - local server_count missing_count resolved_count unresolved_count log_prefix - - server_count="$(printf '%s' "$servers_json" | sing_box_runtime_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 - - if [ -n "$section" ]; then - log_prefix="rule '$section'" - else - log_prefix="current proxy rule" - fi - - log "Detecting server countries for $log_prefix" "info" - - cache_json="$(read_section_outbound_country_cache "$section")" - 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="$(sing_box_runtime_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 "$servers_tmp" "$cache_tmp" "$missing_tmp" - printf '%s\n' "$cached_countries_json" - return 0 - } - - sing_box_runtime_ucode missing-servers-tsv "$servers_tmp" "$cache_tmp" > "$missing_tmp" 2>/dev/null || true - - missing_count="$(sing_box_runtime_ucode file-line-count "$missing_tmp" 2>/dev/null)" - case "$missing_count" in - '' | *[!0-9]*) missing_count=0 ;; - esac - while IFS="$(printf '\t')" read -r tag server || [ -n "$tag" ]; do - [ -n "$tag" ] || continue - ip="$(resolve_country_server_ip "$server")" || continue - [ -n "$ip" ] || continue - printf '%s\t%s\n' "$tag" "$ip" >> "$resolved_tmp" - done < "$missing_tmp" - - resolved_count="$(sing_box_runtime_ucode file-line-count "$resolved_tmp" 2>/dev/null)" - case "$resolved_count" in - '' | *[!0-9]*) resolved_count=0 ;; - esac - - if [ ! -s "$resolved_tmp" ]; then - if [ "$missing_count" -gt 0 ]; then - log "Server country detection for $log_prefix could not resolve some servers" "warn" - fi - rm -f "$servers_tmp" "$cache_tmp" "$missing_tmp" "$resolved_tmp" - printf '%s\n' "$cached_countries_json" - return 0 - fi - - 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="$(sing_box_runtime_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" | sing_box_runtime_ucode object-nonempty >/dev/null 2>&1; then - log "Server country detection for $log_prefix returned no countries" "warn" - fi - - 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 - } - result_json="$(sing_box_runtime_ucode server-countries-result "$servers_tmp" "$cache_tmp" "$resolved_tmp" "$ip_country_tmp" 2>/dev/null)" - [ -n "$result_json" ] || result_json="$cached_countries_json" - - rm -f "$servers_tmp" "$cache_tmp" "$missing_tmp" "$resolved_tmp" "$ip_country_tmp" - log "Server country detection for $log_prefix completed" "info" - printf '%s\n' "$result_json" -} - -detect_server_countries_from_names() { - local names_json="$1" - local names_tmp result status - - names_tmp="$(mktemp)" || { - printf '{}\n' - return 0 - } - - printf '%s' "$names_json" > "$names_tmp" || { - rm -f "$names_tmp" - printf '{}\n' - return 0 - } - - result="$(sing_box_runtime_ucode countries-from-flag-names "$names_tmp" 2>/dev/null)" - status=$? - rm -f "$names_tmp" - - [ "$status" -eq 0 ] || result="{}" - [ -n "$result" ] || result="{}" - printf '%s\n' "$result" -} - -filter_urltest_outbounds() { - local section="$1" - local tags_json="$2" - local names_json="$3" - local countries_json="$4" - local country_filter_enabled="$5" - local filter_mode filtered_json filtered_count original_count - local include_names_json include_regex_json include_countries_json - local exclude_names_json exclude_regex_json exclude_countries_json - - config_get filter_mode "$section" "urltest_filter_mode" "disabled" - - case "$filter_mode" in - disabled) - printf '%s\n' "$tags_json" - return 0 - ;; - include | exclude | mixed) ;; - *) - printf '%s\n' "$tags_json" - return 0 - ;; - esac - - include_names_json="$(config_list_to_json "$section" "urltest_include_outbounds")" - include_regex_json="$(config_list_to_json "$section" "urltest_include_regex")" - exclude_names_json="$(config_list_to_json "$section" "urltest_exclude_outbounds")" - exclude_regex_json="$(config_list_to_json "$section" "urltest_exclude_regex")" - - include_countries_json="[]" - exclude_countries_json="[]" - if [ "$country_filter_enabled" -eq 1 ]; then - include_countries_json="$(config_list_to_json "$section" "urltest_include_countries" | - sing_box_runtime_ucode normalized-country-list 2>/dev/null)" - [ -n "$include_countries_json" ] || include_countries_json="[]" - exclude_countries_json="$(config_list_to_json "$section" "urltest_exclude_countries" | - sing_box_runtime_ucode normalized-country-list 2>/dev/null)" - [ -n "$exclude_countries_json" ] || exclude_countries_json="[]" - fi - - filtered_json="$( - urltest_filter_mode_json "$filter_mode" "$tags_json" "$names_json" "$countries_json" \ - "$include_names_json" "$include_regex_json" "$include_countries_json" \ - "$exclude_names_json" "$exclude_regex_json" "$exclude_countries_json" - )" - - [ -n "$filtered_json" ] || filtered_json="$tags_json" - filtered_count="$(printf '%s' "$filtered_json" | sing_box_runtime_ucode stdin-length 2>/dev/null)" - original_count="$(printf '%s' "$tags_json" | sing_box_runtime_ucode stdin-length 2>/dev/null)" - - if [ -z "$filtered_count" ] || [ "$filtered_count" -eq 0 ]; then - if [ "$filter_mode" = "include" ]; then - log "URLTest whitelist matched no outbounds for rule '$section'; URLTest will be disabled for this rule" "warn" - elif [ "$filter_mode" = "mixed" ]; then - log "URLTest mixed filters matched no outbounds for rule '$section'; URLTest will be disabled for this rule" "warn" - else - log "URLTest filters excluded all outbounds for rule '$section'; URLTest will be disabled for this rule" "warn" - fi - printf '[]\n' - return 0 - fi - - if [ -n "$original_count" ] && [ "$filtered_count" -lt "$original_count" ]; then - if [ "$filter_mode" = "include" ]; then - log "URLTest whitelist selected $filtered_count of $original_count outbound(s) for rule '$section'" "info" - elif [ "$filter_mode" = "mixed" ]; then - log "URLTest mixed filters selected $filtered_count of $original_count outbound(s) for rule '$section'" "info" - else - log "URLTest filters excluded $((original_count - filtered_count)) outbound(s) for rule '$section'" "info" - fi - fi - - printf '%s\n' "$filtered_json" -} - -final_urltest_outbounds() { - local config_json="$1" - local tags_json="$2" - local tmpdir config_tmp tags_tmp result status - - tmpdir="$(mktemp -d)" || return 1 - config_tmp="$tmpdir/config.json" - tags_tmp="$tmpdir/tags.json" - - status=1 - if printf '%s' "$config_json" >"$config_tmp" && - printf '%s' "$tags_json" >"$tags_tmp"; then - result="$( - sing_box_runtime_ucode final-urltest-outbounds "$config_tmp" "$tags_tmp" 2>/dev/null - )" - status=$? - fi - - rm -rf "$tmpdir" - [ "$status" -eq 0 ] || return 1 - [ -n "$result" ] || result="[]" - printf '%s\n' "$result" -} - -get_urltest_check_interval_for_config() { - local section="$1" - local urltest_check_interval - - urltest_check_interval="$(get_urltest_check_interval_for_rule "$section")" - if [ -n "$urltest_check_interval" ]; then - printf '%s\n' "$urltest_check_interval" - else - printf '%s\n' "$SING_BOX_DISABLED_UPDATE_INTERVAL" - fi -} - -get_urltest_idle_timeout_for_config() { - local section="$1" - local urltest_check_interval interval_seconds default_idle_seconds - - urltest_check_interval="$(get_urltest_check_interval_for_rule "$section")" - if [ -z "$urltest_check_interval" ]; then - printf '%s\n' "$SING_BOX_DISABLED_UPDATE_INTERVAL" - return 0 - fi - - interval_seconds="$(duration_to_seconds "$urltest_check_interval" 2>/dev/null)" || { - printf '\n' - return 0 - } - default_idle_seconds="$(duration_to_seconds "$SING_BOX_URLTEST_DEFAULT_IDLE_TIMEOUT")" - - if [ "$interval_seconds" -gt "$default_idle_seconds" ]; then - printf '%s\n' "$urltest_check_interval" - else - printf '\n' - fi -} - -get_outbound_detour_tag_for_rule() { - local section="$1" - local detour_enabled detour_section - - config_get_bool detour_enabled "$section" "outbound_detour_enabled" 0 - [ "$detour_enabled" -eq 1 ] || return 0 - - config_get detour_section "$section" "outbound_detour_section" - [ -n "$detour_section" ] || return 0 - - get_outbound_tag_by_section "$detour_section" -} - -proxy_group_append_outbound() { - local tag="$1" - local link="${2:-}" - local display_name="${3:-$tag}" - local server="${4:-}" - local tmpdir tags_tmp links_tmp names_tmp servers_tmp - - tmpdir="$(mktemp -d)" || { - log "Failed to append outbound '$tag' for proxy rule '$PROXY_GROUP_SECTION'. Aborted." "fatal" - exit 1 - } - tags_tmp="$tmpdir/tags.json" - links_tmp="$tmpdir/links.json" - names_tmp="$tmpdir/names.json" - servers_tmp="$tmpdir/servers.json" - - [ -n "$PROXY_GROUP_OUTBOUND_TAGS_JSON" ] || PROXY_GROUP_OUTBOUND_TAGS_JSON="[]" - [ -n "$PROXY_GROUP_LINKS_JSON" ] || PROXY_GROUP_LINKS_JSON="{}" - [ -n "$PROXY_GROUP_NAMES_JSON" ] || PROXY_GROUP_NAMES_JSON="{}" - [ -n "$PROXY_GROUP_SERVERS_JSON" ] || PROXY_GROUP_SERVERS_JSON="{}" - - if printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" > "$tags_tmp" && - printf '%s' "$PROXY_GROUP_LINKS_JSON" > "$links_tmp" && - printf '%s' "$PROXY_GROUP_NAMES_JSON" > "$names_tmp" && - printf '%s' "$PROXY_GROUP_SERVERS_JSON" > "$servers_tmp" && - sing_box_runtime_ucode append-proxy-group-outbound-state \ - "$tags_tmp" "$links_tmp" "$names_tmp" "$servers_tmp" "$tag" "$link" "$display_name" "$server"; then - PROXY_GROUP_OUTBOUND_TAGS_JSON="$(cat "$tags_tmp" 2>/dev/null)" - [ -n "$PROXY_GROUP_OUTBOUND_TAGS_JSON" ] || PROXY_GROUP_OUTBOUND_TAGS_JSON="[]" - PROXY_GROUP_LINKS_JSON="$(cat "$links_tmp" 2>/dev/null)" - [ -n "$PROXY_GROUP_LINKS_JSON" ] || PROXY_GROUP_LINKS_JSON="{}" - PROXY_GROUP_NAMES_JSON="$(cat "$names_tmp" 2>/dev/null)" - [ -n "$PROXY_GROUP_NAMES_JSON" ] || PROXY_GROUP_NAMES_JSON="{}" - PROXY_GROUP_SERVERS_JSON="$(cat "$servers_tmp" 2>/dev/null)" - [ -n "$PROXY_GROUP_SERVERS_JSON" ] || PROXY_GROUP_SERVERS_JSON="{}" - rm -rf "$tmpdir" - return 0 - fi - - rm -rf "$tmpdir" - log "Failed to append outbound '$tag' for proxy rule '$PROXY_GROUP_SECTION'. Aborted." "fatal" - exit 1 -} - -proxy_group_add_manual_link() { - local link="$1" - local section="$2" - local udp_over_tcp="$3" - local outbound_tag display_name server - - PROXY_GROUP_MANUAL_INDEX=$((PROXY_GROUP_MANUAL_INDEX + 1)) - config="$(sing_box_cf_add_proxy_outbound "$config" "$section-$PROXY_GROUP_MANUAL_INDEX" "$link" "$udp_over_tcp")" - 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" | sing_box_runtime_ucode outbound-server-by-tag "$outbound_tag" 2>/dev/null)" - proxy_group_append_outbound "$outbound_tag" "$link" "$display_name" "$server" -} - -proxy_group_merge_subscription_metadata() { - local tmpdir tags_tmp link_refs_tmp names_tmp servers_tmp sub_tags_tmp sub_link_refs_tmp sub_names_tmp sub_servers_tmp status - - tmpdir="$(mktemp -d)" || return 1 - tags_tmp="$tmpdir/tags.json" - link_refs_tmp="$tmpdir/link-refs.json" - names_tmp="$tmpdir/names.json" - servers_tmp="$tmpdir/servers.json" - sub_tags_tmp="$tmpdir/sub-tags.json" - sub_link_refs_tmp="$tmpdir/sub-link-refs.json" - sub_names_tmp="$tmpdir/sub-names.json" - sub_servers_tmp="$tmpdir/sub-servers.json" - - status=1 - [ -n "$PROXY_GROUP_OUTBOUND_TAGS_JSON" ] || PROXY_GROUP_OUTBOUND_TAGS_JSON="[]" - [ -n "$PROXY_GROUP_LINK_REFS_JSON" ] || PROXY_GROUP_LINK_REFS_JSON="{}" - [ -n "$PROXY_GROUP_NAMES_JSON" ] || PROXY_GROUP_NAMES_JSON="{}" - [ -n "$PROXY_GROUP_SERVERS_JSON" ] || PROXY_GROUP_SERVERS_JSON="{}" - [ -n "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" ] || SUBSCRIPTION_OUTBOUND_TAGS_JSON="[]" - [ -n "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" ] || SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON="{}" - [ -n "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" ] || SUBSCRIPTION_OUTBOUND_NAMES_JSON="{}" - [ -n "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" ] || SUBSCRIPTION_OUTBOUND_SERVERS_JSON="{}" - - if printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" > "$tags_tmp" && - printf '%s' "$PROXY_GROUP_LINK_REFS_JSON" > "$link_refs_tmp" && - printf '%s' "$PROXY_GROUP_NAMES_JSON" > "$names_tmp" && - printf '%s' "$PROXY_GROUP_SERVERS_JSON" > "$servers_tmp" && - printf '%s' "$SUBSCRIPTION_OUTBOUND_TAGS_JSON" > "$sub_tags_tmp" && - printf '%s' "$SUBSCRIPTION_OUTBOUND_LINK_REFS_JSON" > "$sub_link_refs_tmp" && - printf '%s' "$SUBSCRIPTION_OUTBOUND_NAMES_JSON" > "$sub_names_tmp" && - printf '%s' "$SUBSCRIPTION_OUTBOUND_SERVERS_JSON" > "$sub_servers_tmp" && - sing_box_runtime_ucode merge-proxy-group-subscription-state \ - "$tags_tmp" "$link_refs_tmp" "$names_tmp" "$servers_tmp" \ - "$sub_tags_tmp" "$sub_link_refs_tmp" "$sub_names_tmp" "$sub_servers_tmp"; then - IFS= read -r PROXY_GROUP_OUTBOUND_TAGS_JSON < "$tags_tmp" || PROXY_GROUP_OUTBOUND_TAGS_JSON="" - [ -n "$PROXY_GROUP_OUTBOUND_TAGS_JSON" ] || PROXY_GROUP_OUTBOUND_TAGS_JSON="[]" - IFS= read -r PROXY_GROUP_LINK_REFS_JSON < "$link_refs_tmp" || PROXY_GROUP_LINK_REFS_JSON="" - [ -n "$PROXY_GROUP_LINK_REFS_JSON" ] || PROXY_GROUP_LINK_REFS_JSON="{}" - IFS= read -r PROXY_GROUP_NAMES_JSON < "$names_tmp" || PROXY_GROUP_NAMES_JSON="" - [ -n "$PROXY_GROUP_NAMES_JSON" ] || PROXY_GROUP_NAMES_JSON="{}" - IFS= read -r PROXY_GROUP_SERVERS_JSON < "$servers_tmp" || PROXY_GROUP_SERVERS_JSON="" - [ -n "$PROXY_GROUP_SERVERS_JSON" ] || PROXY_GROUP_SERVERS_JSON="{}" - status=0 - fi - - rm -rf "$tmpdir" - return "$status" -} - -proxy_group_add_subscription_source() { - local section="$1" - local index="$2" - local entry="$3" - local source_section subscription_json_path - - if ! parse_subscription_source_entry "$entry"; then - log "Invalid subscription source for rule '$section': $SUBSCRIPTION_SOURCE_PARSE_ERROR. Aborted." "fatal" - exit 1 - fi - - source_section="$(subscription_source_id "$section" "$index")" - if subscription_source_is_marked_unavailable "$source_section"; then - log "Skipping unavailable subscription source '$index' for rule '$section'" "warn" - return 0 - fi - - if ! ensure_subscription_cache_for_source \ - "$section" "$source_section" "$SUBSCRIPTION_SOURCE_URL" "$SUBSCRIPTION_SOURCE_USER_AGENT" 0 "$index"; then - subscription_mark_unavailable_source "$source_section" - log "Skipping unavailable subscription source '$index' for rule '$section'" "warn" - return 0 - fi - - subscription_json_path="$(get_subscription_json_path "$source_section")" - if ! sing_box_cf_add_subscription_outbounds "$config" "$section" "$subscription_json_path" "$source_section" > /dev/null; then - log "Skipping subscription source '$index' for rule '$section': no valid proxy outbounds found" "warn" - return 0 - fi - - config="$SING_BOX_CF_LAST_CONFIG" - if ! proxy_group_merge_subscription_metadata; then - log "Failed to merge subscription metadata for rule '$section'. Aborted." "fatal" - exit 1 - fi -} - -configure_outbound_handler() { - local section="$1" - local action - - rule_is_enabled "$section" || return 0 - if subscription_section_is_deferred "$section"; then - log "Skipping outbound for deferred subscription rule '$section' until its cache is recovered" "warn" - write_subscription_metadata_json "$section" "" - write_subscription_outbound_link_cache "$section" "{}" "{}" - write_outbound_metadata "$section" "{}" "{}" "{}" - return 0 - fi - - action="$(get_rule_action "$section")" - - if [ "$action" = "zapret" ]; then - write_subscription_metadata_json "$section" "" - if ! is_zapret_installed; then - return 0 - fi - - local routing_mark outbound_tag zapret_index - - zapret_index="$(get_zapret_rule_index "$section")" - if [ "${zapret_index:-0}" -le 0 ]; then - log "Unable to resolve Zapret index for rule '$section'. Aborted." "fatal" - exit 1 - fi - - routing_mark="$(get_zapret_rule_mark_value "$zapret_index")" - outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$(sing_box_cm_add_direct_outbound "$config" "$outbound_tag" "$routing_mark") - return 0 - fi - - if [ "$action" = "zapret2" ]; then - write_subscription_metadata_json "$section" "" - if ! is_zapret2_installed; then - return 0 - fi - - local routing_mark outbound_tag zapret2_index - - zapret2_index="$(get_zapret2_rule_index "$section")" - if [ "${zapret2_index:-0}" -le 0 ]; then - log "Unable to resolve Zapret2 index for rule '$section'. Aborted." "fatal" - exit 1 - fi - - routing_mark="$(get_zapret2_rule_mark_value "$zapret2_index")" - outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$(sing_box_cm_add_direct_outbound "$config" "$outbound_tag" "$routing_mark") - return 0 - fi - - if [ "$action" = "byedpi" ]; then - if ! is_byedpi_installed; then - return 0 - fi - - local outbound_tag byedpi_index byedpi_port - - byedpi_index="$(get_byedpi_rule_index "$section")" - if [ "${byedpi_index:-0}" -le 0 ]; then - log "Unable to resolve ByeDPI index for rule '$section'. Aborted." "fatal" - exit 1 - fi - - byedpi_port="$(get_byedpi_rule_port "$byedpi_index")" - outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$(sing_box_cm_add_socks_outbound "$config" "$outbound_tag" "$BYEDPI_LISTEN_ADDRESS" "$byedpi_port" "5" "" "" "" "") - write_subscription_metadata_json "$section" "" - return 0 - fi - - if [ "$action" = "outbound" ]; then - local outbound_json outbound_tag detour_tag - - config_get outbound_json "$section" "outbound_json" - if [ -z "$outbound_json" ]; then - log "JSON outbound is not set for rule '$section'. Aborted." "fatal" - exit 1 - fi - - outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$(sing_box_cf_add_json_outbound "$config" "$section" "$outbound_json") - detour_tag="$(get_outbound_detour_tag_for_rule "$section")" - if [ -n "$detour_tag" ]; then - if ! config="$(sing_box_cm_set_outbound_detour "$config" "$outbound_tag" "$detour_tag")"; then - log "Failed to apply outbound detour for JSON outbound rule '$section'. Aborted." "fatal" - exit 1 - fi - fi - write_subscription_outbound_link_cache "$section" "{}" "{}" - write_subscription_metadata_json "$section" "" - return 0 - fi - - if [ "$action" = "proxy" ]; then - local selector_proxy_links udp_over_tcp urltest_enabled urltest_tag selector_tag selector_outbounds selector_default \ - urltest_check_interval urltest_idle_timeout urltest_tolerance urltest_testing_url outbounds_count \ - detect_server_country urltest_filter_mode urltest_country_filter_enabled urltest_outbounds urltest_outbounds_count has_subscription_sources \ - metadata_countries_json metadata_tmpfile metadata_count detour_tag - - config_get selector_proxy_links "$section" "selector_proxy_links" - config_get udp_over_tcp "$section" "enable_udp_over_tcp" - config_get_bool urltest_enabled "$section" "urltest_enabled" 0 - config_get detect_server_country "$section" "detect_server_country" "$SERVER_COUNTRY_METHOD_FLAG_EMOJI" - detect_server_country="$(normalize_detect_server_country_method "$detect_server_country")" - config_get urltest_filter_mode "$section" "urltest_filter_mode" "disabled" - detour_tag="$(get_outbound_detour_tag_for_rule "$section")" - rule_has_subscription_urls "$section" && has_subscription_sources=1 || has_subscription_sources=0 - - PROXY_GROUP_SECTION="$section" - PROXY_GROUP_MANUAL_INDEX=0 - PROXY_GROUP_OUTBOUND_TAGS_JSON="[]" - PROXY_GROUP_LINKS_JSON="{}" - PROXY_GROUP_LINK_REFS_JSON="{}" - PROXY_GROUP_NAMES_JSON="{}" - PROXY_GROUP_SERVERS_JSON="{}" - PROXY_GROUP_COUNTRIES_JSON="{}" - metadata_countries_json="{}" - - config_list_foreach "$section" "selector_proxy_links" proxy_group_add_manual_link "$section" "$udp_over_tcp" - - metadata_tmpfile="" - if [ "$has_subscription_sources" -eq 1 ]; then - metadata_tmpfile="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${section}.metadata-section.XXXXXX" 2>/dev/null || true)" - if [ -n "$metadata_tmpfile" ]; then - printf '[]\n' > "$metadata_tmpfile" - SUBSCRIPTION_SECTION_METADATA_TMP="$metadata_tmpfile" - else - SUBSCRIPTION_SECTION_METADATA_TMP="" - fi - for_each_subscription_source "$section" proxy_group_add_subscription_source - SUBSCRIPTION_SECTION_METADATA_TMP="" - - if [ -n "$metadata_tmpfile" ]; then - metadata_count="$(subscription_cache_ucode json-length "$metadata_tmpfile" 2>/dev/null)" - if [ -n "$metadata_count" ] && [ "$metadata_count" -gt 0 ]; then - write_subscription_metadata_json "$section" "$metadata_tmpfile" - else - write_subscription_metadata_json "$section" "" - fi - rm -f "$metadata_tmpfile" - fi - else - write_subscription_metadata_json "$section" "" - fi - - if [ "$urltest_enabled" -eq 1 ] && urltest_filter_mode_filters_enabled "$urltest_filter_mode" && - [ "$detect_server_country" = "$SERVER_COUNTRY_METHOD_COUNTRY_IS" ]; then - PROXY_GROUP_COUNTRIES_JSON="$(detect_server_countries_for_tags "$PROXY_GROUP_SERVERS_JSON" "$section")" - [ -n "$PROXY_GROUP_COUNTRIES_JSON" ] || PROXY_GROUP_COUNTRIES_JSON="{}" - metadata_countries_json="$PROXY_GROUP_COUNTRIES_JSON" - elif [ "$urltest_enabled" -eq 1 ] && urltest_filter_mode_filters_enabled "$urltest_filter_mode" && - [ "$detect_server_country" = "$SERVER_COUNTRY_METHOD_FLAG_EMOJI" ]; then - PROXY_GROUP_COUNTRIES_JSON="$(detect_server_countries_from_names "$PROXY_GROUP_NAMES_JSON")" - [ -n "$PROXY_GROUP_COUNTRIES_JSON" ] || PROXY_GROUP_COUNTRIES_JSON="{}" - fi - - outbounds_count="$(printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" | sing_box_runtime_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 - fi - - if [ -n "$detour_tag" ]; then - if ! config="$(sing_box_cm_set_outbounds_detour "$config" "$PROXY_GROUP_OUTBOUND_TAGS_JSON" "$detour_tag")"; then - log "Failed to apply outbound detour for proxy rule '$section'. Aborted." "fatal" - exit 1 - fi - fi - - selector_tag="$(get_outbound_tag_by_section "$section")" - - if [ "$urltest_enabled" -eq 1 ]; then - urltest_check_interval="$(get_urltest_check_interval_for_config "$section")" - urltest_idle_timeout="$(get_urltest_idle_timeout_for_config "$section")" - config_get urltest_tolerance "$section" "urltest_tolerance" 50 - config_get urltest_testing_url "$section" "urltest_testing_url" "https://www.gstatic.com/generate_204" - urltest_country_filter_enabled=0 - if urltest_filter_mode_filters_enabled "$urltest_filter_mode"; then - urltest_country_filter_enabled=1 - fi - - urltest_outbounds="$(final_urltest_outbounds "$config" "$PROXY_GROUP_OUTBOUND_TAGS_JSON")" || { - log "Failed to build final outbound list for URLTest rule '$section'. Aborted." "fatal" - exit 1 - } - [ -n "$urltest_outbounds" ] || urltest_outbounds="[]" - - urltest_outbounds="$(filter_urltest_outbounds "$section" "$urltest_outbounds" \ - "$PROXY_GROUP_NAMES_JSON" "$PROXY_GROUP_COUNTRIES_JSON" "$urltest_country_filter_enabled")" - [ -n "$urltest_outbounds" ] || urltest_outbounds="[]" - urltest_outbounds_count="$(printf '%s' "$urltest_outbounds" | sing_box_runtime_ucode stdin-length 2>/dev/null)" - case "$urltest_outbounds_count" in - '' | *[!0-9]*) urltest_outbounds_count=0 ;; - esac - - if [ "$urltest_outbounds_count" -gt 0 ]; then - urltest_tag="$(get_outbound_tag_by_section "$section-urltest")" - selector_outbounds="$(printf '%s' "$PROXY_GROUP_OUTBOUND_TAGS_JSON" | - sing_box_runtime_ucode array-append-string "$urltest_tag" 2>/dev/null)" - if [ -z "$selector_outbounds" ]; then - log "Failed to build selector outbounds for proxy rule '$section'. Aborted." "fatal" - exit 1 - fi - - config="$(sing_box_cm_add_urltest_outbound "$config" "$urltest_tag" "$urltest_outbounds" \ - "$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance" "$urltest_idle_timeout" "true")" - if list_has_item "$PODKOP_URLTEST_NEW_ENABLED_SECTIONS" "$section"; then - schedule_urltest_selector_switch "$selector_tag" "$urltest_tag" - 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" | sing_box_runtime_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 - fi - - 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" | sing_box_runtime_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 - fi - - config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$PROXY_GROUP_OUTBOUND_TAGS_JSON" "$selector_default" "true")" - fi - - write_subscription_outbound_link_cache "$section" "$PROXY_GROUP_LINKS_JSON" "$PROXY_GROUP_LINK_REFS_JSON" - write_outbound_metadata "$section" "$PROXY_GROUP_NAMES_JSON" "$metadata_countries_json" "$PROXY_GROUP_SERVERS_JSON" - return 0 - fi - - if [ "$action" = "vpn" ]; then - local interface_name domain_resolver_enabled domain_resolver_dns_type domain_resolver_dns_server \ - domain_resolver_dns_server_address outbound_tag domain_resolver_tag dns_domain_resolver outbound_mark - - config_get interface_name "$section" "interface" - config_get_bool domain_resolver_enabled "$section" "domain_resolver_enabled" 0 - config_get domain_resolver_dns_type "$section" "domain_resolver_dns_type" - config_get domain_resolver_dns_server "$section" "domain_resolver_dns_server" - - if [ -z "$interface_name" ]; then - log "VPN interface is not set for rule '$section'. Aborted." "fatal" - exit 1 - fi - - outbound_tag="$(get_outbound_tag_by_section "$section")" - - if [ "$domain_resolver_enabled" -eq 1 ]; then - domain_resolver_dns_server_address="$(url_get_host "$domain_resolver_dns_server")" - if ! is_ipv4 "$domain_resolver_dns_server_address"; then - dns_domain_resolver=$SB_BOOTSTRAP_SERVER_TAG - fi - domain_resolver_tag="$(get_domain_resolver_tag "$section")" - config=$(sing_box_cf_add_dns_server "$config" "$domain_resolver_dns_type" "$domain_resolver_tag" \ - "$domain_resolver_dns_server" "$dns_domain_resolver" "$outbound_tag") - fi - - outbound_mark="$((NFT_OUTBOUND_MARK))" - config=$(sing_box_cm_add_interface_outbound "$config" "$outbound_tag" "$interface_name" "$domain_resolver_tag" "$outbound_mark") - write_subscription_metadata_json "$section" "" - return 0 - fi - - if [ "$action" = "direct" ]; then - local outbound_tag - - outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$(sing_box_cm_add_direct_outbound "$config" "$outbound_tag") - write_subscription_metadata_json "$section" "" - return 0 - fi - - case "$action" in - block) - write_subscription_metadata_json "$section" "" - return 0 - ;; - "") - log "Rule '$section' has no action. Aborted." "fatal" - ;; - *) - log "Unsupported action '$action' for rule '$section'. Aborted." "fatal" - ;; - esac - exit 1 -} - -sing_box_configure_dns() { - log "Configure the DNS section of a sing-box JSON configuration" - config=$(sing_box_cm_configure_dns "$config" "$SB_DNS_SERVER_TAG" "ipv4_only" true) - - log "Adding DNS Servers" "debug" - local dns_type dns_server bootstrap_dns_server dns_domain_resolver dns_server_address - config_get dns_type "settings" "dns_type" "doh" - config_get dns_server "settings" "dns_server" "1.1.1.1" - config_get bootstrap_dns_server "settings" "bootstrap_dns_server" "77.88.8.8" - - dns_server_address="$(url_get_host "$dns_server")" - if ! is_ipv4 "$dns_server_address"; then - dns_domain_resolver=$SB_BOOTSTRAP_SERVER_TAG - fi - - config=$(sing_box_cm_add_udp_dns_server "$config" "$SB_BOOTSTRAP_SERVER_TAG" "$bootstrap_dns_server" 53) - config=$(sing_box_cf_add_dns_server "$config" "$dns_type" "$SB_DNS_SERVER_TAG" "$dns_server" "$dns_domain_resolver") - config=$(sing_box_cm_add_fakeip_dns_server "$config" "$SB_FAKEIP_DNS_SERVER_TAG" "$SB_FAKEIP_INET4_RANGE") - - log "Adding DNS Rules" - local rewrite_ttl service_domains - config_get rewrite_ttl "settings" "dns_rewrite_ttl" "60" - service_domains=$(sing_box_runtime_ucode csv-to-json-array "$FAKEIP_TEST_DOMAIN,$CHECK_PROXY_IP_DOMAIN") - - config=$(sing_box_cm_add_dns_reject_rule "$config" "query_type" "HTTPS") - config=$(sing_box_cm_add_dns_reject_rule "$config" "domain_suffix" '"use-application-dns.net"') - configure_tailscale_server_dns_bypass - config=$(sing_box_cm_add_dns_route_rule "$config" "$SB_FAKEIP_DNS_SERVER_TAG" "$SB_SERVICE_FAKEIP_DNS_RULE_TAG") - config=$(sing_box_cm_patch_dns_route_rule "$config" "$SB_SERVICE_FAKEIP_DNS_RULE_TAG" "rewrite_ttl" "$rewrite_ttl") - config=$(sing_box_cm_patch_dns_route_rule "$config" "$SB_SERVICE_FAKEIP_DNS_RULE_TAG" "domain" "$service_domains") -} - -configure_tailscale_server_dns_bypass() { - config_foreach configure_tailscale_server_dns_bypass_handler "server" -} - -configure_tailscale_server_dns_bypass_handler() { - local section="$1" - local protocol inbound_tag dns_server_tag rule_tag - - server_is_enabled "$section" || return 0 - - config_get protocol "$section" "protocol" - [ "$protocol" = "tailscale" ] || return 0 - - inbound_tag="$(get_server_inbound_tag_by_section "$section")" - dns_server_tag="$(get_tailscale_dns_server_tag_by_section "$section")" - rule_tag="tailscale-server-dns-$(server_safe_filename "$section")" - - config=$(sing_box_cm_add_tailscale_dns_server "$config" "$dns_server_tag" "$inbound_tag" "1") - config=$(sing_box_cm_add_dns_route_rule "$config" "$dns_server_tag" "$rule_tag") - config=$(sing_box_cm_patch_dns_route_rule "$config" "$rule_tag" "inbound" "\"$inbound_tag\"") -} - -sing_box_configure_route() { - log "Configure the route section of a sing-box JSON configuration" - - local output_network_interface outbound_mark - outbound_mark="$((NFT_OUTBOUND_MARK))" - config_get output_network_interface "settings" "output_network_interface" - if [ -z "$output_network_interface" ]; then - if mwan3_is_active; then - log "mwan3 is active; disabling sing-box auto_detect_interface so mwan3 can control egress routing" "warn" - config=$(sing_box_cm_configure_route "$config" "$SB_DIRECT_OUTBOUND_TAG" false "$SB_DNS_SERVER_TAG" "" "$outbound_mark") - else - config=$(sing_box_cm_configure_route "$config" "$SB_DIRECT_OUTBOUND_TAG" true "$SB_DNS_SERVER_TAG" "" "$outbound_mark") - fi - else - if mwan3_is_active; then - log "mwan3 is active and Output Network Interface is set to '$output_network_interface'; sing-box egress is pinned to this interface" "warn" - fi - config=$(sing_box_cm_configure_route "$config" "$SB_DIRECT_OUTBOUND_TAG" false "$SB_DNS_SERVER_TAG" \ - "$output_network_interface" "$outbound_mark") - fi - - local sniff_inbounds - sniff_inbounds=$(sing_box_runtime_ucode csv-to-json-array "$SB_TPROXY_INBOUND_TAG,$SB_DNS_INBOUND_TAG") - config=$(sing_box_cm_sniff_route_rule "$config" "inbound" "$sniff_inbounds") - config_foreach add_server_sniff_route_rule "server" - - config=$(sing_box_cm_add_hijack_dns_route_rule "$config" "port" "53") - config=$(sing_box_cm_add_hijack_dns_route_rule "$config" "protocol" "dns") - - config=$(sing_box_cf_add_single_key_reject_rule "$config" "" "ip_version" 6) - - local disable_quic - config_get_bool disable_quic "settings" "disable_quic" 0 - if [ "$disable_quic" -eq 1 ]; then - config=$(sing_box_cf_add_single_key_reject_rule "$config" "$SB_TPROXY_INBOUND_TAG" "protocol" "quic") - fi - - local first_outbound_section - first_outbound_section="$(get_first_outbound_section)" - if [ -n "$first_outbound_section" ]; then - first_outbound_tag="$(get_outbound_tag_by_section "$first_outbound_section")" - config=$(sing_box_cf_proxy_domain "$config" "$SB_TPROXY_INBOUND_TAG" "$CHECK_PROXY_IP_DOMAIN" "$first_outbound_tag") - fi - config=$(sing_box_cf_override_domain_port "$config" "$FAKEIP_TEST_DOMAIN" 8443) - - local routing_excluded_ips - config_get routing_excluded_ips "settings" "routing_excluded_ips" - if [ -n "$routing_excluded_ips" ]; then - rule_tag="$(gen_id)" - config=$(sing_box_cm_add_route_rule "$config" "$rule_tag" "$SB_TPROXY_INBOUND_TAG" "$SB_DIRECT_OUTBOUND_TAG") - config_list_foreach "settings" "routing_excluded_ips" exclude_source_ip_from_routing_handler "$rule_tag" - fi - - config_foreach configure_route_rule_handler "section" - config_foreach configure_server_route "server" -} - -add_server_sniff_route_rule() { - local section="$1" - local inbound_tag - - server_is_enabled "$section" || return 0 - - inbound_tag="$(get_server_inbound_tag_by_section "$section")" - config=$(sing_box_cm_sniff_route_rule "$config" "inbound" "\"$inbound_tag\"") -} - -configure_server_route() { - local section="$1" - local routing_mode routing_section inbound_tag action route_rule_tag - - server_is_enabled "$section" || return 0 - - inbound_tag="$(get_server_inbound_tag_by_section "$section")" - config_get routing_mode "$section" "routing_mode" "rules" - - case "$routing_mode" in - rules) - config=$( - sing_box_cm_clone_route_rules_for_inbound \ - "$config" \ - "$SB_TPROXY_INBOUND_TAG" \ - "$inbound_tag" \ - "$CHECK_PROXY_IP_DOMAIN" - ) - ;; - direct) - route_rule_tag="$(gen_id)" - config=$(sing_box_cm_add_route_rule "$config" "$route_rule_tag" "$inbound_tag" "$SB_DIRECT_OUTBOUND_TAG") - ;; - section) - config_get routing_section "$section" "routing_section" - if [ -z "$routing_section" ]; then - log "Server '$section' routing mode is section, but routing_section is empty. Aborted." "fatal" - exit 1 - fi - action="$(get_rule_action "$routing_section")" - if [ -z "$action" ]; then - log "Server '$section' references missing routing section '$routing_section'. Aborted." "fatal" - exit 1 - fi - if ! rule_is_enabled "$routing_section"; then - log "Server '$section' references disabled routing section '$routing_section'. Aborted." "fatal" - exit 1 - fi - route_rule_tag="$(gen_id)" - create_route_rule_for_action "$routing_section" "$action" "$route_rule_tag" "$inbound_tag" - ;; - *) - log "Server '$section' has unsupported routing mode '$routing_mode'. Aborted." "fatal" - exit 1 - ;; - esac -} - -create_route_rule_for_action() { - local section="$1" - local action="$2" - local rule_tag="$3" - local inbound_tag="${4:-$SB_TPROXY_INBOUND_TAG}" - - case "$action" in - proxy | outbound | vpn | byedpi) - local outbound_tag - outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$(sing_box_cm_add_route_rule "$config" "$rule_tag" "$inbound_tag" "$outbound_tag") - ;; - zapret | zapret2) - local provider_outbound_tag - provider_outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$(sing_box_cm_add_route_rule "$config" "$rule_tag" "$inbound_tag" "$provider_outbound_tag") - ;; - direct) - local direct_outbound_tag - direct_outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$(sing_box_cm_add_route_rule "$config" "$rule_tag" "$inbound_tag" "$direct_outbound_tag") - ;; - block) - config=$(sing_box_cm_add_reject_route_rule "$config" "$rule_tag" "$inbound_tag") - ;; - *) - log "Unsupported action '$action' for rule '$section'. Aborted." "fatal" - exit 1 - ;; - esac -} - -resolve_route_rule_action_target() { - local section="$1" - local action="$2" - - ROUTE_RULE_BUILDER_ACTION="route" - ROUTE_RULE_BUILDER_OUTBOUND_TAG="" - - case "$action" in - proxy | outbound | vpn | byedpi | zapret | zapret2 | direct) - ROUTE_RULE_BUILDER_OUTBOUND_TAG="$(get_outbound_tag_by_section "$section")" - ;; - block) - ROUTE_RULE_BUILDER_ACTION="reject" - ;; - *) - log "Unsupported action '$action' for rule '$section'. Aborted." "fatal" - exit 1 - ;; - esac -} - -dns_route_rule_exists() { - local tag="$1" - - printf '%s' "$config" | sing_box_runtime_ucode dns-route-rule-exists "$SERVICE_TAG" "$tag" >/dev/null 2>&1 -} - -dns_route_rule_is_known() { - list_has_item "$PODKOP_DNS_ROUTE_RULE_TAGS" "$1" -} - -mark_dns_route_rule_known() { - local tag="$1" - - dns_route_rule_is_known "$tag" && return 0 - PODKOP_DNS_ROUTE_RULE_TAGS="${PODKOP_DNS_ROUTE_RULE_TAGS}${PODKOP_DNS_ROUTE_RULE_TAGS:+ }$tag" -} - -ensure_fakeip_dns_route_rule() { - local tag="$1" - local rewrite_ttl - - dns_route_rule_is_known "$tag" && return 0 - if dns_route_rule_exists "$tag"; then - mark_dns_route_rule_known "$tag" - return 0 - fi - - config=$(sing_box_cm_add_dns_route_rule "$config" "$SB_FAKEIP_DNS_SERVER_TAG" "$tag") - config_get rewrite_ttl "settings" "dns_rewrite_ttl" "60" - config=$(sing_box_cm_patch_dns_route_rule "$config" "$tag" "rewrite_ttl" "$rewrite_ttl") - mark_dns_route_rule_known "$tag" -} - -route_rule_has_resolve_matchers() { - local tag="$1" - - printf '%s' "$config" | sing_box_runtime_ucode route-rule-has-resolve-matchers "$SERVICE_TAG" "$tag" >/dev/null 2>&1 -} - -configure_rule_set_reference_handler() { - local reference="$1" - local route_rule_tag="$2" - local dns_rule_tag="" - - resolve_ruleset_reference "$reference" - if [ -n "$ENSURED_RULESET_TAG" ]; then - if [ "$ENSURED_RULESET_KIND" = "domains" ]; then - ensure_fakeip_dns_route_rule "$SB_FAKEIP_RULESET_DNS_RULE_TAG" - dns_rule_tag="$SB_FAKEIP_RULESET_DNS_RULE_TAG" - else - log "Skip DNS FakeIP rule patch for '$reference' ruleset kind '$ENSURED_RULESET_KIND'" "debug" - fi - config=$(sing_box_cm_patch_rule_set_reference "$config" "$route_rule_tag" "$dns_rule_tag" "$ENSURED_RULESET_TAG") - fi -} - -get_domain_ip_list_ruleset_tag() { - local section="$1" - - get_ruleset_tag "$section" "lists" "" -} - -get_domain_ip_list_ruleset_path() { - local section="$1" - - printf '%s/%s.json\n' "$TMP_RULESET_FOLDER" "$(get_domain_ip_list_ruleset_tag "$section")" -} - -reset_domain_ip_list_ruleset() { - local section="$1" - local ruleset_filepath - - ruleset_filepath="$(get_domain_ip_list_ruleset_path "$section")" - - rm -f "$ruleset_filepath" - create_source_rule_set "$ruleset_filepath" >/dev/null 2>&1 -} - -source_ruleset_has_rules() { - local filepath="$1" - - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" has-rules "$filepath" >/dev/null 2>&1 -} - -source_ruleset_has_domain_matchers() { - local filepath="$1" - - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/rulesets.uc" has-domain-matchers "$filepath" >/dev/null 2>&1 -} - -cleanup_empty_domain_ip_list_ruleset() { - local section="$1" - local ruleset_filepath - - ruleset_filepath="$(get_domain_ip_list_ruleset_path "$section")" - source_ruleset_has_rules "$ruleset_filepath" && return 0 - - rm -f "$ruleset_filepath" - return 1 -} - -ensure_domain_ip_list_ruleset() { - local section="$1" - local route_rule_tag="$2" - local ruleset_tag ruleset_filepath dns_rule_tag="" - - ruleset_tag="$(get_domain_ip_list_ruleset_tag "$section")" - ruleset_filepath="$(get_domain_ip_list_ruleset_path "$section")" - - if ! cleanup_empty_domain_ip_list_ruleset "$section"; then - log "Domain/IP list ruleset for '$section' is empty, skipping route rule_set" "debug" - return 0 - fi - - if source_ruleset_has_domain_matchers "$ruleset_filepath"; then - ensure_fakeip_dns_route_rule "$SB_FAKEIP_RULESET_DNS_RULE_TAG" - dns_rule_tag="$SB_FAKEIP_RULESET_DNS_RULE_TAG" - fi - config=$(sing_box_cm_add_local_ruleset_reference "$config" "$route_rule_tag" "$dns_rule_tag" "$ruleset_tag" "source" "$ruleset_filepath") -} - -import_domain_ip_list_file_into_rulesets() { - local filepath="$1" - local section="$2" - local domains_tmpfile subnets_tmpfile ruleset_filepath status - - [ -f "$filepath" ] || return 0 - - domains_tmpfile="$(mktemp)" - subnets_tmpfile="$(mktemp)" - ruleset_filepath="$(get_domain_ip_list_ruleset_path "$section")" - status=0 - - split_domain_or_subnet_file "$filepath" "$domains_tmpfile" "$subnets_tmpfile" || status=1 - if [ "$status" -eq 0 ]; then - import_plain_domain_list_to_local_source_ruleset_chunked "$domains_tmpfile" "$ruleset_filepath" || status=1 - import_plain_subnet_list_to_local_source_ruleset_chunked "$subnets_tmpfile" "$ruleset_filepath" || status=1 - add_plain_subnet_file_to_nft_for_section "$section" "$subnets_tmpfile" || status=1 - fi - - rm -f "$domains_tmpfile" "$subnets_tmpfile" - return "$status" -} - -import_domain_ip_list_reference_into_rulesets() { - local reference="$1" - local section="$2" - local tmpfile http_proxy_address status download_status - status=0 - - case "$reference" in - http://* | https://*) - tmpfile="$(mktemp)" - http_proxy_address="$(get_service_proxy_address lists)" - download_to_file "$reference" "$tmpfile" "$http_proxy_address" - download_status="$?" - if [ "$download_status" -eq 0 ] && [ -s "$tmpfile" ]; then - convert_crlf_to_lf "$tmpfile" - import_domain_ip_list_file_into_rulesets "$tmpfile" "$section" || status=1 - else - log "Failed to download remote domain/IP list $reference; skipping it until the next successful update" "warn" - fi - rm -f "$tmpfile" - return "$status" - ;; - *) - import_domain_ip_list_file_into_rulesets "$reference" "$section" - ;; - esac -} - -configure_domain_ip_lists() { - local section="$1" - local route_rule_tag="$2" - - reset_domain_ip_list_ruleset "$section" - config_list_foreach "$section" "domain_ip_lists" configure_domain_ip_lists_reference_handler "$section" - ensure_domain_ip_list_ruleset "$section" "$route_rule_tag" -} - -configure_domain_ip_lists_reference_handler() { - local reference="$1" - local section="$2" - - case "$reference" in - http://* | https://*) return 0 ;; - esac - - import_domain_ip_list_reference_into_rulesets "$reference" "$section" -} - -add_plain_subnet_file_to_nft_for_section() { - local section="$1" - local filepath="$2" - local ports - - [ -s "$filepath" ] || return 0 - - ports="$(get_rule_ports_commas_string "$section")" - if [ -n "$ports" ]; then - nft_add_ip_port_set_elements_from_ip_file_chunked "$filepath" "$NFT_TABLE_NAME" "$NFT_IP_PORT_SET_NAME" "$ports" - else - nft_add_set_elements_from_file_chunked "$filepath" "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME" - fi -} - -extract_json_ruleset_nft_files_for_section() { - local json_file="$1" - local unscoped_output_file="$2" - local scoped_output_file="$3" - local section="$4" - local port_values_json port_ranges_json - - port_values_json="[]" - port_ranges_json="[]" - if [ -n "$section" ]; then - port_values_json="$(get_rule_port_values_json_array "$section")" - port_ranges_json="$(get_rule_port_ranges_json_array "$section")" - fi - - extract_ip_cidr_nft_elements_from_json_ruleset_to_files \ - "$json_file" "$unscoped_output_file" "$scoped_output_file" "$port_values_json" "$port_ranges_json" -} - -add_extracted_ruleset_subnets_to_nft_for_section() { - local section="$1" - local unscoped_file="$2" - local scoped_file="$3" - local label="$4" - local has_entries - - has_entries=0 - - if [ -s "$unscoped_file" ]; then - nft_add_set_elements_from_file_chunked "$unscoped_file" "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME" || return 1 - has_entries=1 - fi - - if [ -s "$scoped_file" ]; then - nft_add_ip_port_set_elements_from_file_chunked "$scoped_file" "$NFT_TABLE_NAME" "$NFT_IP_PORT_SET_NAME" || return 1 - has_entries=1 - fi - - if [ "$has_entries" -eq 0 ]; then - log "$label has no ip_cidr entries for nftables" "warn" - fi - - return 0 -} - -rebuild_domain_ip_lists_from_rule() { - local section="$1" - local references - - rule_is_enabled "$section" || return 0 - config_get references "$section" "domain_ip_lists" - [ -n "$references" ] || return 0 - - reset_domain_ip_list_ruleset "$section" - config_list_foreach "$section" "domain_ip_lists" import_domain_ip_list_reference_into_rulesets "$section" - cleanup_empty_domain_ip_list_ruleset "$section" || true -} - -rule_has_primary_matchers() { - local section="$1" - local domain domain_suffix domain_keyword domain_regex ip_cidr ports community_lists rule_set rule_set_with_subnets domain_ip_lists - - domain="$(get_rule_condition_commas_string "$section" "domain" "domains")" - domain_suffix="$(get_rule_condition_commas_string "$section" "domain_suffix" "domains")" - domain_keyword="$(get_rule_condition_commas_string "$section" "domain_keyword" "generic")" - domain_regex="$(get_rule_condition_commas_string "$section" "domain_regex" "generic")" - ip_cidr="$(get_rule_condition_commas_string "$section" "ip_cidr" "subnets")" - ports="$(get_rule_ports_commas_string "$section")" - config_get community_lists "$section" "community_lists" - config_get rule_set "$section" "rule_set" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - - if [ -n "$domain" ] || - [ -n "$domain_suffix" ] || - [ -n "$domain_keyword" ] || - [ -n "$domain_regex" ] || - [ -n "$ip_cidr" ] || - [ -n "$ports" ] || - [ -n "$community_lists" ] || - [ -n "$rule_set" ] || - [ -n "$rule_set_with_subnets" ] || - [ -n "$domain_ip_lists" ]; then - return 0 - fi - - return 1 -} - -configure_route_rule_handler() { - local section="$1" - local action route_rule_tag ip_values_json ip_values port_values_json port_ranges_json \ - domain_json domain_suffix_json domain_keyword_json domain_regex_json source_ip_values_json \ - ports community_lists rule_set rule_set_with_subnets domain_ip_lists - - rule_is_enabled "$section" || return 0 - if subscription_section_is_deferred "$section"; then - log "Skipping route rule '$section' until its subscription cache is recovered" "warn" - return 0 - fi - - action="$(get_rule_action "$section")" - [ -n "$action" ] || return 0 - - if [ "$action" = "zapret" ] && ! is_zapret_installed; then - return 0 - fi - - if [ "$action" = "zapret2" ] && ! is_zapret2_installed; then - return 0 - fi - - if [ "$action" = "byedpi" ] && ! is_byedpi_installed; then - return 0 - fi - - log "Configuring route rule '$section' with action '$action'" - - configure_fully_routed_ips_for_section "$section" "$action" - - domain_json="$(get_rule_condition_json_array "$section" "domain" "domains")" - domain_suffix_json="$(get_rule_condition_json_array "$section" "domain_suffix" "domains")" - domain_keyword_json="$(get_rule_condition_json_array "$section" "domain_keyword" "generic")" - domain_regex_json="$(get_rule_condition_json_array "$section" "domain_regex" "generic")" - ip_values_json="$(get_rule_condition_json_array "$section" "ip_cidr" "subnets")" - port_values_json="$(get_rule_port_values_json_array "$section")" - port_ranges_json="$(get_rule_port_ranges_json_array "$section")" - source_ip_values_json="$(get_rule_condition_json_array "$section" "source_ip_cidr" "subnets")" - ports="$(get_rule_ports_commas_string "$section")" - config_get community_lists "$section" "community_lists" - config_get rule_set "$section" "rule_set" - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - config_get domain_ip_lists "$section" "domain_ip_lists" - - if [ "$domain_json" = "[]" ] && - [ "$domain_suffix_json" = "[]" ] && - [ "$domain_keyword_json" = "[]" ] && - [ "$domain_regex_json" = "[]" ] && - [ "$ip_values_json" = "[]" ] && - [ "$port_values_json" = "[]" ] && - [ "$port_ranges_json" = "[]" ] && - [ -z "$community_lists" ] && - [ -z "$rule_set" ] && - [ -z "$rule_set_with_subnets" ] && - [ -z "$domain_ip_lists" ]; then - log "Rule '$section' has no destination matchers and action '$action', skipping regular route creation" "warn" - return 0 - fi - - route_rule_tag="$(gen_id)" - resolve_route_rule_action_target "$section" "$action" - - config=$(sing_box_cm_add_route_rule_with_matchers \ - "$config" \ - "$route_rule_tag" \ - "$SB_TPROXY_INBOUND_TAG" \ - "$ROUTE_RULE_BUILDER_OUTBOUND_TAG" \ - "$ROUTE_RULE_BUILDER_ACTION" \ - "$domain_json" \ - "$domain_suffix_json" \ - "$domain_keyword_json" \ - "$domain_regex_json" \ - "$ip_values_json" \ - "$port_values_json" \ - "$port_ranges_json" \ - "$source_ip_values_json") - - if [ "$domain_json" != "[]" ] || - [ "$domain_suffix_json" != "[]" ] || - [ "$domain_keyword_json" != "[]" ] || - [ "$domain_regex_json" != "[]" ]; then - ensure_fakeip_dns_route_rule "$SB_FAKEIP_DNS_RULE_TAG" - config=$(sing_box_cm_patch_dns_route_rule_matchers \ - "$config" \ - "$SB_FAKEIP_DNS_RULE_TAG" \ - "$domain_json" \ - "$domain_suffix_json" \ - "$domain_keyword_json" \ - "$domain_regex_json") - fi - - if [ "$ip_values_json" != "[]" ]; then - if [ "$PODKOP_NFT_POPULATE_ENABLED" = "1" ]; then - ip_values="$(get_rule_condition_commas_string "$section" "ip_cidr" "subnets")" - add_section_ip_cidr_to_nft_sets "$section" "$ip_values" - fi - fi - - if [ "$PODKOP_NFT_POPULATE_ENABLED" = "1" ]; then - add_section_ports_to_nft_set_if_needed "$section" - fi - - config_list_foreach "$section" "community_lists" configure_community_list_handler "$section" "$route_rule_tag" - config_list_foreach "$section" "rule_set" configure_rule_set_reference_handler "$route_rule_tag" - config_list_foreach "$section" "rule_set_with_subnets" configure_rule_set_reference_handler "$route_rule_tag" - if [ -n "$domain_ip_lists" ]; then - configure_domain_ip_lists "$section" "$route_rule_tag" - fi - - if [ "$action" = "proxy" ] || [ "$action" = "outbound" ] || [ "$action" = "vpn" ] || [ "$action" = "byedpi" ]; then - local resolve_real_ip_for_routing - if [ "$action" = "byedpi" ]; then - resolve_real_ip_for_routing=1 - else - config_get_bool resolve_real_ip_for_routing "$section" "resolve_real_ip_for_routing" 0 - fi - if [ "$resolve_real_ip_for_routing" -eq 1 ]; then - if route_rule_has_resolve_matchers "$route_rule_tag"; then - config=$(sing_box_cm_add_resolve_rule "$config" "$route_rule_tag" "$(gen_id)" "$SB_DNS_SERVER_TAG") - log "Added resolve rule for '$section' rule" "debug" - else - log "Resolve real IP is enabled for '$section', but no domain or rule-set matchers found" "warn" - fi - fi - fi -} - -configure_fully_routed_ips_for_section() { - local section="$1" - local action="$2" - - local fully_routed_ips rule_tag - - if [ "$action" = "zapret" ] && ! is_zapret_installed; then - return 0 - fi - - if [ "$action" = "zapret2" ] && ! is_zapret2_installed; then - return 0 - fi - - if [ "$action" = "byedpi" ] && ! is_byedpi_installed; then - return 0 - fi - - config_get fully_routed_ips "$section" "fully_routed_ips" - if [ -n "$fully_routed_ips" ]; then - rule_tag="$(gen_id)" - create_route_rule_for_action "$section" "$action" "$rule_tag" - config_list_foreach "$section" "fully_routed_ips" configure_fully_routed_ip_route_rule "$rule_tag" - fi -} - -configure_fully_routed_ip_route_rule() { - local source_ip="$1" - local rule_tag="$2" - - populate_fully_routed_ip_nft "$source_ip" - config=$(sing_box_cm_patch_route_rule "$config" "$rule_tag" "source_ip_cidr" "$source_ip") -} - -exclude_source_ip_from_routing_handler() { - local source_ip="$1" - local rule_tag="$2" - - config=$(sing_box_cm_patch_route_rule "$config" "$rule_tag" "source_ip_cidr" "$source_ip") -} - -configure_community_list_handler() { - local tag="$1" - local section="$2" - local route_rule_tag="$3" - - local ruleset_tag format url update_interval detour - ruleset_tag="$(get_ruleset_tag "$section" "$tag" "community")" - format="binary" - url="$(get_community_ruleset_url "$tag")" - detour="$(get_download_detour_tag)" - update_interval="$(get_remote_ruleset_update_interval)" - - ensure_fakeip_dns_route_rule "$SB_FAKEIP_RULESET_DNS_RULE_TAG" - config=$(sing_box_cm_add_remote_ruleset_reference \ - "$config" \ - "$route_rule_tag" \ - "$SB_FAKEIP_RULESET_DNS_RULE_TAG" \ - "$ruleset_tag" \ - "$format" \ - "$url" \ - "$detour" \ - "$update_interval") -} - -sing_box_configure_experimental() { - log "Configure the experimental section of a sing-box JSON configuration" - - log "Configuring cache database" - local cache_file - config_get cache_file "settings" "cache_path" "/tmp/sing-box/cache.db" - config=$(sing_box_cm_configure_cache_file "$config" true "$cache_file" true) - - log "Configuring Clash API" - local enable_yacd enable_yacd_wan_access clash_api_controller_address - config_get_bool enable_yacd "settings" "enable_yacd" 0 - config_get_bool enable_yacd_wan_access "settings" "enable_yacd_wan_access" 0 - - if [ "$enable_yacd" -eq 1 ] && [ "$enable_yacd_wan_access" -eq 1 ]; then - clash_api_controller_address="0.0.0.0" - else - clash_api_controller_address="$(get_service_listen_address)" - if [ -z "$clash_api_controller_address" ]; then - log "Could not determine the listening IP address for the Clash API controller. It will run only on localhost." "warn" - clash_api_controller_address="127.0.0.1" - fi - fi - - if [ "$enable_yacd" -eq 1 ]; then - log "YACD is enabled, enabling Clash API with downloadable YACD" "debug" - local yacd_secret_key external_controller_ui - config_get yacd_secret_key "settings" "yacd_secret_key" - external_controller_ui="ui" - - config=$( - sing_box_cm_configure_clash_api \ - "$config" \ - "$clash_api_controller_address:$SB_CLASH_API_CONTROLLER_PORT" \ - "$external_controller_ui" \ - "$yacd_secret_key" - ) - else - log "YACD is disabled, enabling Clash API in online mode" "debug" - config=$( - sing_box_cm_configure_clash_api "$config" "$clash_api_controller_address:$SB_CLASH_API_CONTROLLER_PORT" - ) - fi -} - -sing_box_additional_inbounds() { - log "Configure the additional inbounds of a sing-box JSON configuration" - - if download_via_proxy_any_enabled; then - local download_lists_via_proxy_section section_outbound_tag - config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section" - section_outbound_tag="$(get_outbound_tag_by_section "$download_lists_via_proxy_section")" - config=$( - sing_box_cf_add_mixed_inbound_and_route_rule \ - "$config" \ - "$SB_SERVICE_MIXED_INBOUND_TAG" \ - "$SB_SERVICE_MIXED_INBOUND_ADDRESS" \ - "$SB_SERVICE_MIXED_INBOUND_PORT" \ - "$section_outbound_tag" - ) - fi - - config_foreach configure_section_mixed_proxy "section" -} - -configure_section_mixed_proxy() { - local section="$1" - local action - - rule_is_enabled "$section" || return 0 - subscription_section_is_deferred "$section" && return 0 - - action="$(get_rule_action "$section")" - [ "$action" = "proxy" ] || [ "$action" = "outbound" ] || [ "$action" = "vpn" ] || [ "$action" = "byedpi" ] || [ "$action" = "zapret" ] || [ "$action" = "zapret2" ] || return 0 - if [ "$action" = "byedpi" ] && ! is_byedpi_installed; then - return 0 - fi - if [ "$action" = "zapret" ] && ! is_zapret_installed; then - return 0 - fi - if [ "$action" = "zapret2" ] && ! is_zapret2_installed; then - return 0 - fi - - local mixed_inbound_enabled mixed_proxy_port mixed_inbound_tag mixed_outbound_tag mixed_proxy_address \ - mixed_proxy_auth_enabled mixed_proxy_username mixed_proxy_password - config_get_bool mixed_inbound_enabled "$section" "mixed_proxy_enabled" 0 - mixed_proxy_address="$(get_service_listen_address)" - if [ -z "$mixed_proxy_address" ]; then - log "Could not determine the listening IP address for the Mixed Proxy. The proxy will not be created." "warn" - return 1 - fi - config_get mixed_proxy_port "$section" "mixed_proxy_port" - if [ "$mixed_inbound_enabled" -eq 1 ]; then - config_get_bool mixed_proxy_auth_enabled "$section" "mixed_proxy_auth_enabled" 0 - if [ "$mixed_proxy_auth_enabled" -eq 1 ]; then - config_get mixed_proxy_username "$section" "mixed_proxy_username" - config_get mixed_proxy_password "$section" "mixed_proxy_password" - if [ -z "$mixed_proxy_username" ] || [ -z "$mixed_proxy_password" ]; then - log "Mixed Proxy authentication for '$section' is enabled, but username or password is empty. The proxy will not be created." "warn" - return 1 - fi - fi - mixed_inbound_tag="$(get_inbound_tag_by_section "$section-mixed")" - mixed_outbound_tag="$(get_outbound_tag_by_section "$section")" - config=$( - sing_box_cf_add_mixed_inbound_and_route_rule \ - "$config" \ - "$mixed_inbound_tag" \ - "$mixed_proxy_address" \ - "$mixed_proxy_port" \ - "$mixed_outbound_tag" \ - "$mixed_proxy_username" \ - "$mixed_proxy_password" - ) - fi -} - -sing_box_save_config() { - local sing_box_config_path temp_file_path current_config_hash temp_config_hash hash_line - config_get sing_box_config_path "settings" "config_path" - temp_file_path="$(mktemp)" - - log "Save sing-box temporary config to $temp_file_path" "debug" - sing_box_cm_save_config_to_file "$config" "$temp_file_path" - - sing_box_config_check "$temp_file_path" - - hash_line="$(md5sum "$sing_box_config_path" 2> /dev/null || true)" - set -- $hash_line - current_config_hash="$1" - hash_line="$(md5sum "$temp_file_path")" - set -- $hash_line - temp_config_hash="$1" - log "Current sing-box config hash: $current_config_hash" "debug" - log "Temporary sing-box config hash: $temp_config_hash" "debug" - if [ "$current_config_hash" != "$temp_config_hash" ]; then - log "sing-box configuration has changed and will be updated" - mv "$temp_file_path" "$sing_box_config_path" - else - log "sing-box configuration is unchanged" - rm "$temp_file_path" - fi -} - -sing_box_config_check() { - local config_path="$1" - - if ! sing-box -c "$config_path" check > /dev/null 2>&1; then - log "Sing-box configuration $config_path is invalid. Aborted." "fatal" - exit 1 - fi -} - -import_builtin_subnets_from_rule() { - local section="$1" - - rule_is_enabled "$section" || return 0 - BUILTIN_SUBNET_IMPORT_STATUS=0 - config_list_foreach "$section" "community_lists" import_builtin_subnets_reference_handler "$section" - return "$BUILTIN_SUBNET_IMPORT_STATUS" -} - -import_builtin_subnets_reference_handler() { - local reference="$1" - local section="$2" - - if list_has_item "$COMMUNITY_SERVICES" "$reference"; then - import_community_service_subnet_list_handler "$reference" "$section" || BUILTIN_SUBNET_IMPORT_STATUS=1 - fi - return 0 -} - -import_rule_sets_with_subnets_from_rule() { - local section="$1" - local rule_set_with_subnets - - rule_is_enabled "$section" || return 0 - - config_get rule_set_with_subnets "$section" "rule_set_with_subnets" - if [ -n "$rule_set_with_subnets" ]; then - RULE_SET_WITH_SUBNETS_IMPORT_STATUS=0 - log "Importing subnets from rule sets with subnets for '$section' section" - config_list_foreach "$section" "rule_set_with_subnets" import_rule_set_with_subnets_reference_handler "$section" - return "$RULE_SET_WITH_SUBNETS_IMPORT_STATUS" - fi -} - -import_rule_set_with_subnets_reference_handler() { - local reference="$1" - local section="$2" - local extension format - - log "Importing subnets from rule set reference for '$section': $reference" - - case "$reference" in - /*.srs) - import_custom_ruleset_subnets_from_local "$reference" "binary" "$section" || { - log "Failed to import subnets from local SRS rule set: $reference" "error" - RULE_SET_WITH_SUBNETS_IMPORT_STATUS=1 - } - ;; - /*.json) - import_custom_ruleset_subnets_from_local "$reference" "source" "$section" || { - log "Failed to import subnets from local JSON rule set: $reference" "error" - RULE_SET_WITH_SUBNETS_IMPORT_STATUS=1 - } - ;; - http://* | https://*) - extension="$(url_get_file_extension "$reference")" - case "$extension" in - srs) - import_custom_ruleset_subnets_from_remote "$reference" "binary" "$section" || { - log "Failed to import subnets from remote SRS rule set: $reference" "error" - RULE_SET_WITH_SUBNETS_IMPORT_STATUS=1 - } - ;; - json) - import_custom_ruleset_subnets_from_remote "$reference" "source" "$section" || { - log "Failed to import subnets from remote JSON rule set: $reference" "error" - RULE_SET_WITH_SUBNETS_IMPORT_STATUS=1 - } - ;; - *) - format="$(get_inline_remote_ruleset_format "$reference")" - import_custom_ruleset_subnets_from_remote "$reference" "$format" "$section" || { - log "Failed to import subnets from remote rule set: $reference" "error" - RULE_SET_WITH_SUBNETS_IMPORT_STATUS=1 - } - ;; - esac - ;; - *) - log "Unsupported rule set reference for subnet import: $reference" "error" - RULE_SET_WITH_SUBNETS_IMPORT_STATUS=1 - ;; - esac - - return 0 -} - -import_custom_ruleset_subnets_from_local() { - local path="$1" - local format="$2" - local section="$3" - local json_tmpfile unscoped_tmpfile scoped_tmpfile - - if [ ! -f "$path" ]; then - log "Local rule set file $path not found" "error" - return 1 - fi - - json_tmpfile="$(mktemp)" - unscoped_tmpfile="$(mktemp)" - scoped_tmpfile="$(mktemp)" - - if [ "$format" = "binary" ]; then - if ! decompile_binary_ruleset "$path" "$json_tmpfile"; then - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - else - if ! cp "$path" "$json_tmpfile"; then - log "Failed to copy source rule set file $path" "error" - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - fi - - if ! extract_json_ruleset_nft_files_for_section "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" "$section"; then - log "Failed to extract ip_cidr entries from rule set $path" "error" - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - if ! add_extracted_ruleset_subnets_to_nft_for_section "$section" "$unscoped_tmpfile" "$scoped_tmpfile" "Rule set $path"; then - log "Failed to add subnets from rule set $path to nftables" "error" - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" -} - -import_custom_ruleset_subnets_from_remote() { - local url="$1" - local format="$2" - local section="$3" - local remote_tmpfile json_tmpfile unscoped_tmpfile scoped_tmpfile http_proxy_address download_status - - remote_tmpfile="$(mktemp)" - json_tmpfile="$(mktemp)" - unscoped_tmpfile="$(mktemp)" - scoped_tmpfile="$(mktemp)" - http_proxy_address="$(get_service_proxy_address lists)" - - download_to_file "$url" "$remote_tmpfile" "$http_proxy_address" - download_status="$?" - - if [ "$download_status" -ne 0 ] || [ ! -s "$remote_tmpfile" ]; then - log "Failed to download remote rule set $url; skipping it until the next successful update" "warn" - rm -f "$remote_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 0 - fi - - if [ "$format" = "binary" ]; then - if ! decompile_binary_ruleset "$remote_tmpfile" "$json_tmpfile"; then - rm -f "$remote_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - else - if ! cp "$remote_tmpfile" "$json_tmpfile"; then - log "Failed to copy downloaded source rule set $url" "error" - rm -f "$remote_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - fi - - if ! extract_json_ruleset_nft_files_for_section "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" "$section"; then - log "Failed to extract ip_cidr entries from rule set $url" "error" - rm -f "$remote_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - if ! add_extracted_ruleset_subnets_to_nft_for_section "$section" "$unscoped_tmpfile" "$scoped_tmpfile" "Rule set $url"; then - log "Failed to add subnets from rule set $url to nftables" "error" - rm -f "$remote_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - rm -f "$remote_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" -} - -ensure_discord_udp_fakeip_rule() { - nft_create_ipv4_set "$NFT_TABLE_NAME" "$NFT_DISCORD_SET_NAME" - - if nft list chain inet "$NFT_TABLE_NAME" mangle 2> /dev/null | - sing_box_runtime_ucode stdin-contains "iifname @$NFT_INTERFACE_SET_NAME ip daddr @$NFT_DISCORD_SET_NAME udp dport { 19000-20000, 50000-65535 } meta mark set $NFT_FAKEIP_MARK" >/dev/null 2>&1; then - return 0 - fi - - nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr \ - "@$NFT_DISCORD_SET_NAME" udp dport '{ 19000-20000, 50000-65535 }' meta mark set "$NFT_FAKEIP_MARK" counter -} - -import_community_service_subnet_list_handler() { - local service="$1" - local section="$2" - local ports status download_status - - ports="$(get_rule_ports_commas_string "$section")" - - case "$service" in - "twitter") - URL=$SUBNETS_TWITTER - ;; - "meta") - URL=$SUBNETS_META - ;; - "telegram") - URL=$SUBNETS_TELERAM - ;; - "cloudflare") - URL=$SUBNETS_CLOUDFLARE - ;; - "hetzner") - URL=$SUBNETS_HETZNER - ;; - "ovh") - URL=$SUBNETS_OVH - ;; - "digitalocean") - URL=$SUBNETS_DIGITALOCEAN - ;; - "cloudfront") - URL=$SUBNETS_CLOUDFRONT - ;; - "discord") - URL=$SUBNETS_DISCORD - [ -n "$ports" ] || ensure_discord_udp_fakeip_rule - ;; - "roblox") - URL=$SUBNETS_ROBLOX - ;; - *) return 0 ;; - esac - - local tmpfile http_proxy_address - tmpfile=$(mktemp) - http_proxy_address="$(get_service_proxy_address lists)" - - download_to_file "$URL" "$tmpfile" "$http_proxy_address" - download_status="$?" - - if [ "$download_status" -ne 0 ] || [ ! -s "$tmpfile" ]; then - log "Failed to download built-in $service subnet list; skipping it until the next successful update" "warn" - rm -f "$tmpfile" - return 0 - fi - - status=0 - if [ "$service" = "discord" ] && [ -z "$ports" ]; then - nft_add_set_elements_from_file_chunked "$tmpfile" "$NFT_TABLE_NAME" "$NFT_DISCORD_SET_NAME" || status=1 - else - add_plain_subnet_file_to_nft_for_section "$section" "$tmpfile" || status=1 - fi - - rm -f "$tmpfile" - return "$status" -} - -import_domains_from_remote_domain_lists() { - local section="$1" - local remote_domain_lists - - rule_is_enabled "$section" || return 0 - - config_get remote_domain_lists "$section" "remote_domain_lists" - if [ -n "$remote_domain_lists" ]; then - log "Importing domains from remote domain lists for '$section' section" - config_list_foreach "$section" "remote_domain_lists" import_domains_from_remote_domain_list_handler "$section" - fi -} - -import_domains_from_remote_domain_list_handler() { - local url="$1" - local section="$2" - - log "Importing domains from URL: $url" - - local file_extension - file_extension=$(url_get_file_extension "$url") - log "Detected file extension: '$file_extension'" "debug" - case "$file_extension" in - json | srs) - log "No update needed - sing-box manages updates automatically." - ;; - *) - log "Import domains from a remote plain-text list" - import_domains_from_remote_plain_file "$url" "$section" - ;; - esac -} - -import_domains_from_remote_plain_file() { - local url="$1" - local section="$2" - - local tmpfile http_proxy_address download_status ruleset_tag ruleset_filepath - tmpfile=$(mktemp) - http_proxy_address="$(get_service_proxy_address lists)" - - download_to_file "$url" "$tmpfile" "$http_proxy_address" - download_status="$?" - - if [ "$download_status" -ne 0 ] || [ ! -s "$tmpfile" ]; then - log "Failed to download remote domain list $url; skipping it until the next successful update" "warn" - rm -f "$tmpfile" - return 0 - fi - - convert_crlf_to_lf "$tmpfile" - ruleset_tag=$(get_ruleset_tag "$section" "remote" "domains") - ruleset_filepath="$TMP_RULESET_FOLDER/$ruleset_tag.json" - import_plain_domain_list_to_local_source_ruleset_chunked "$tmpfile" "$ruleset_filepath" - - rm -f "$tmpfile" -} - -import_subnets_from_remote_subnet_lists() { - local section="$1" - local remote_subnet_lists - - rule_is_enabled "$section" || return 0 - - config_get remote_subnet_lists "$section" "remote_subnet_lists" - if [ -n "$remote_subnet_lists" ]; then - REMOTE_SUBNET_IMPORT_STATUS=0 - log "Importing subnets from remote subnet lists for '$section' section" - config_list_foreach "$section" "remote_subnet_lists" import_subnets_from_remote_subnet_list_handler "$section" - return "$REMOTE_SUBNET_IMPORT_STATUS" - fi -} - -import_subnets_from_remote_subnet_list_handler() { - local url="$1" - local section="$2" - - log "Importing subnets from URL: $url" - - local file_extension - file_extension="$(url_get_file_extension "$url")" - log "Detected file extension: '$file_extension'" "debug" - case "$file_extension" in - json) - log "Import subnets from a remote JSON list" "info" - import_subnets_from_remote_json_file "$url" "$section" || REMOTE_SUBNET_IMPORT_STATUS=1 - ;; - srs) - log "Import subnets from a remote SRS list" "info" - import_subnets_from_remote_srs_file "$url" "$section" || REMOTE_SUBNET_IMPORT_STATUS=1 - ;; - *) - log "Import subnets from a remote plain-text list" "info" - import_subnets_from_remote_plain_file "$url" "$section" || REMOTE_SUBNET_IMPORT_STATUS=1 - ;; - esac - return 0 -} - -import_subnets_from_remote_json_file() { - local url="$1" - local section="$2" - local json_tmpfile unscoped_tmpfile scoped_tmpfile http_proxy_address download_status - json_tmpfile="$(mktemp)" - unscoped_tmpfile="$(mktemp)" - scoped_tmpfile="$(mktemp)" - http_proxy_address="$(get_service_proxy_address lists)" - - download_to_file "$url" "$json_tmpfile" "$http_proxy_address" - download_status="$?" - - if [ "$download_status" -ne 0 ] || [ ! -s "$json_tmpfile" ]; then - log "Failed to download remote JSON subnet list $url; skipping it until the next successful update" "warn" - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 0 - fi - - if ! extract_json_ruleset_nft_files_for_section "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" "$section"; then - log "Failed to extract ip_cidr entries from remote JSON list $url" "error" - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - if ! add_extracted_ruleset_subnets_to_nft_for_section "$section" "$unscoped_tmpfile" "$scoped_tmpfile" "Remote JSON rule set $url"; then - log "Failed to add subnets from remote JSON list $url to nftables" "error" - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - rm -f "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" -} - -import_subnets_from_remote_srs_file() { - local url="$1" - local section="$2" - - local binary_tmpfile json_tmpfile unscoped_tmpfile scoped_tmpfile http_proxy_address download_status - binary_tmpfile="$(mktemp)" - json_tmpfile="$(mktemp)" - unscoped_tmpfile="$(mktemp)" - scoped_tmpfile="$(mktemp)" - http_proxy_address="$(get_service_proxy_address lists)" - - download_to_file "$url" "$binary_tmpfile" "$http_proxy_address" - download_status="$?" - - if [ "$download_status" -ne 0 ] || [ ! -s "$binary_tmpfile" ]; then - log "Failed to download remote SRS subnet list $url; skipping it until the next successful update" "warn" - rm -f "$binary_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 0 - fi - - if ! decompile_binary_ruleset "$binary_tmpfile" "$json_tmpfile"; then - log "Failed to decompile binary rule set file" "error" - rm -f "$binary_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - if ! extract_json_ruleset_nft_files_for_section "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" "$section"; then - log "Failed to extract ip_cidr entries from remote SRS list $url" "error" - rm -f "$binary_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - if ! add_extracted_ruleset_subnets_to_nft_for_section "$section" "$unscoped_tmpfile" "$scoped_tmpfile" "Remote SRS rule set $url"; then - log "Failed to add subnets from remote SRS list $url to nftables" "error" - rm -f "$binary_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" - return 1 - fi - - rm -f "$binary_tmpfile" "$json_tmpfile" "$unscoped_tmpfile" "$scoped_tmpfile" -} - -import_subnets_from_remote_plain_file() { - local url="$1" - local section="$2" - - local tmpfile http_proxy_address ruleset_tag ruleset_filepath status download_status - tmpfile=$(mktemp) - http_proxy_address="$(get_service_proxy_address lists)" - - download_to_file "$url" "$tmpfile" "$http_proxy_address" - download_status="$?" - - if [ "$download_status" -ne 0 ] || [ ! -s "$tmpfile" ]; then - log "Failed to download remote plain subnet list $url; skipping it until the next successful update" "warn" - rm -f "$tmpfile" - return 0 - fi - - convert_crlf_to_lf "$tmpfile" - - ruleset_tag=$(get_ruleset_tag "$section" "remote" "subnets") - ruleset_filepath="$TMP_RULESET_FOLDER/$ruleset_tag.json" - status=0 - import_plain_subnet_list_to_local_source_ruleset_chunked "$tmpfile" "$ruleset_filepath" || status=1 - add_plain_subnet_file_to_nft_for_section "$section" "$tmpfile" || status=1 - - rm -f "$tmpfile" - return "$status" -} - -## Support functions -get_service_proxy_address() { - local purpose="${1:-lists}" - - if download_via_proxy_enabled_for_purpose "$purpose"; then - echo "$SB_SERVICE_MIXED_INBOUND_ADDRESS:$SB_SERVICE_MIXED_INBOUND_PORT" - else - echo "" - fi -} - -get_download_detour_tag() { - if download_via_proxy_enabled_for_purpose lists; then - local download_lists_via_proxy_section section_outbound_tag - config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section" - section_outbound_tag="$(get_outbound_tag_by_section "$download_lists_via_proxy_section")" - echo "$section_outbound_tag" - else - echo "" - fi -} - -_determine_first_outbound_section() { - local section="$1" - local action - - rule_is_enabled "$section" || return 0 - subscription_section_is_deferred "$section" && return 0 - - action="$(get_rule_action "$section")" - if [ "$action" = "proxy" ] || [ "$action" = "outbound" ] || [ "$action" = "vpn" ] || - { [ "$action" = "byedpi" ] && is_byedpi_installed; } || - { [ "$action" = "zapret" ] && is_zapret_installed; } || - { [ "$action" = "zapret2" ] && is_zapret2_installed; }; then - [ -z "$first_section" ] && first_section="$1" - fi -} - -get_first_outbound_section() { - local first_section="" - - config_foreach _determine_first_outbound_section "section" - - echo "$first_section" -} - -get_device_ipv4_address() { - local device="$1" - - ip -4 addr show dev "$device" 2> /dev/null | sing_box_runtime_ucode ip-addr-first-inet4 2>/dev/null -} - -get_service_listen_address() { - local service_listen_address interface - - config_get service_listen_address "settings" "service_listen_address" - if [ -n "$service_listen_address" ]; then - log "Attention! The service_listen_address option is being used, overriding the automatic detection of the listening IP address!" "warn" - echo "$service_listen_address" - return 0 - fi - - interface="lan" - network_get_ipaddr service_listen_address "$interface" - if [ -n "$service_listen_address" ]; then - echo "$service_listen_address" - return 0 - fi - - local source_network_interfaces - config_get source_network_interfaces "settings" "source_network_interfaces" "br-lan" - for interface in $source_network_interfaces; do - network_get_ipaddr service_listen_address "$interface" - if [ -n "$service_listen_address" ]; then - echo "$service_listen_address" - return 0 - fi - - service_listen_address="$(get_device_ipv4_address "$interface")" - if [ -n "$service_listen_address" ]; then - echo "$service_listen_address" - return 0 - fi - done - - log "Failed to determine the listening IP address. Please open an issue to report this problem: https://github.com/ushan0v/podkop-plus/issues" "error" - return 1 -} - -## nftables -nft_list_all_traffic_from_ip() { - local ip="$1" - - if ! nft list chain inet "$NFT_TABLE_NAME" mangle | - sing_box_runtime_ucode stdin-regex-matches "ip saddr $ip" >/dev/null 2>&1; then - nft insert rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip saddr "$ip" \ - meta l4proto tcp meta mark set "$NFT_FAKEIP_MARK" counter - nft insert rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip saddr "$ip" \ - meta l4proto udp meta mark set "$NFT_FAKEIP_MARK" counter - nft insert rule inet "$NFT_TABLE_NAME" mangle ip saddr "$ip" ip daddr @localv4 return - fi -} - -# Diagnotics diff --git a/podkop/files/usr/lib/sing_box_runtime.uc b/podkop/files/usr/lib/sing_box_runtime.uc deleted file mode 100644 index 77e8bbaf..00000000 --- a/podkop/files/usr/lib/sing_box_runtime.uc +++ /dev/null @@ -1,676 +0,0 @@ -#!/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_compact_string_array(values) { - print("["); - for (let i = 0; i < length(values); i++) { - if (i > 0) - print(","); - print(sprintf("%J", as_string(values[i]))); - } - print("]\n"); -} - -function csv_to_json_array(value) { - value = as_string(value); - write_compact_string_array(value == "" ? [] : split(value, ",")); -} - -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 merge_object_values(target, source) { - target = object_or_empty(target); - for (let key, value in object_or_empty(source)) - target[key] = value; - return target; -} - -function stdin_length() { - let value = read_stdin_json(); - if (type(value) == "array" || type(value) == "object") - print(length(value), "\n"); - else - print("0\n"); -} - -function stdin_contains(needle) { - return index(read_stdin(), as_string(needle)) >= 0; -} - -function stdin_regex_matches(pattern) { - pattern = as_string(pattern); - if (pattern == "") - return false; - - try { - return match(read_stdin(), regexp(pattern)) != null; - } - catch (e) { - return false; - } -} - -function file_line_count(path) { - let data = fs.readfile(path); - let count = 0; - - if (data == null) { - print("0\n"); - return; - } - - for (let i = 0; i < length(data); i++) - if (substr(data, i, 1) == "\n") - count++; - - print(count, "\n"); -} - -function ip_addr_first_inet4() { - for (let line in split(read_stdin(), "\n")) { - let fields = split(trim(as_string(line)), /[ \t]+/); - if (length(fields) < 2 || fields[0] != "inet") - continue; - - let slash = index(fields[1], "/"); - print(slash >= 0 ? substr(fields[1], 0, slash) : fields[1], "\n"); - return; - } -} - -function stdin_first_dns_a_address() { - for (let line in split(read_stdin(), "\n")) { - line = as_string(line); - if (match(line, /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) != null) { - print(line, "\n"); - return; - } - } -} - -function stdin_first_dns_aaaa_address() { - for (let line in split(read_stdin(), "\n")) { - line = as_string(line); - if (match(line, /^[0-9A-Fa-f:]+$/) != null) { - print(line, "\n"); - return; - } - } -} - -function stdin_first_nslookup_address() { - for (let line in split(read_stdin(), "\n")) { - line = as_string(line); - if (match(line, /^Address[ \t]*[0-9]*:[ \t]*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) == null && - match(line, /^Address[ \t]*[0-9]*:[ \t]*[0-9A-Fa-f:]+$/) == null) - continue; - - let fields = split(trim(line), /[ \t]+/); - if (length(fields) > 0) - print(fields[length(fields) - 1], "\n"); - return; - } -} - -function valid_ipv6_literal(value) { - value = as_string(value); - return index(value, ":") >= 0 && match(value, /^[0-9A-Fa-f:.]+$/) != null; -} - -function stdin_first_field() { - let data = read_stdin(); - let newline = index(data, "\n"); - let line = newline >= 0 ? substr(data, 0, newline) : data; - let fields = split(trim(as_string(line)), /[ \t\r\n]+/); - - if (length(fields) > 0 && fields[0] != "") - print(fields[0], "\n"); -} - -function normalize_country_server_key(value) { - print(lc(trim(as_string(value))), "\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 array_append_string(value) { - let result = array_or_empty(read_stdin_json()); - push(result, as_string(value)); - write_json(result); -} - -function merge_proxy_group_subscription_state(tags_path, link_refs_path, names_path, servers_path, - subscription_tags_path, subscription_link_refs_path, subscription_names_path, subscription_servers_path) { - let tags = array_or_empty(read_json_file(tags_path)); - for (let tag in array_or_empty(read_json_file(subscription_tags_path))) - push(tags, tag); - - if (!write_file_json(tags_path, tags) || - !write_file_json(link_refs_path, merge_object_values(read_json_file(link_refs_path), read_json_file(subscription_link_refs_path))) || - !write_file_json(names_path, merge_object_values(read_json_file(names_path), read_json_file(subscription_names_path))) || - !write_file_json(servers_path, merge_object_values(read_json_file(servers_path), read_json_file(subscription_servers_path)))) - exit(1); -} - -function append_proxy_group_outbound_state(tags_path, links_path, names_path, servers_path, tag, link, display_name, server) { - tag = as_string(tag); - link = as_string(link); - display_name = as_string(display_name); - server = as_string(server); - - let tags = array_or_empty(read_json_file(tags_path)); - let links = object_or_empty(read_json_file(links_path)); - let names = object_or_empty(read_json_file(names_path)); - let servers = object_or_empty(read_json_file(servers_path)); - - push(tags, tag); - names[tag] = display_name; - if (link != "") - links[tag] = link; - if (server != "") - servers[tag] = server; - - if (!write_file_json(tags_path, tags) || - !write_file_json(links_path, links) || - !write_file_json(names_path, names) || - !write_file_json(servers_path, servers)) - exit(1); -} - -function contains(values, needle) { - for (let value in values) { - if (value == needle) - return true; - } - return false; -} - -function regex_matches(value, pattern) { - pattern = as_string(pattern); - if (pattern == "") - return false; - - try { - return match(as_string(value), regexp(pattern)) != null; - } - catch (e) { - return false; - } -} - -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 byte_at(value, index) { - return ord(substr(value, index, 1)); -} - -function regional_indicator_letter(value, index) { - if (index + 3 >= length(value)) - return ""; - - if (byte_at(value, index) != 240 || - byte_at(value, index + 1) != 159 || - byte_at(value, index + 2) != 135) - return ""; - - let letter = byte_at(value, index + 3) - 166; - if (letter < 0 || letter > 25) - return ""; - - return chr(65 + letter); -} - -function country_from_flag_emoji(value) { - value = as_string(value); - - for (let i = 0; i + 7 < length(value); i++) { - let first = regional_indicator_letter(value, i); - if (first == "") - continue; - - let second = regional_indicator_letter(value, i + 4); - if (second != "") - return first + second; - } - - return ""; -} - -function countries_from_flag_names(path) { - let names = object_or_empty(read_json_file(path)); - let result = {}; - - for (let tag, name in names) { - let country = country_from_flag_emoji(name); - if (country != "") - result[tag] = country; - } - - write_json(result); -} - -function urltest_regex_matching_tag_array(tags, names, regexes) { - let result = []; - - for (let tag in tags) { - let name = names[tag]; - name = name == null || as_string(name) == "" ? tag : as_string(name); - - for (let pattern in regexes) { - if (regex_matches(name, pattern)) { - push(result, tag); - break; - } - } - } - - return result; -} - -function urltest_regex_matching_tags(tags_path, names_path, regex_path) { - let tags = array_or_empty(read_json_file(tags_path)); - let names = object_or_empty(read_json_file(names_path)); - let regexes = array_or_empty(read_json_file(regex_path)); - - write_json(urltest_regex_matching_tag_array(tags, names, regexes)); -} - -function urltest_filter_array(mode, tags, names, countries, name_filter, regex_tags, country_filter) { - 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); - } - - return result; -} - -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)); - - write_json(urltest_filter_array(mode, tags, names, countries, name_filter, regex_tags, country_filter)); -} - -function urltest_filter_mode(mode, tags_path, names_path, countries_path, include_names_path, include_regex_path, include_countries_path, exclude_names_path, exclude_regex_path, exclude_countries_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 include_names = array_or_empty(read_json_file(include_names_path)); - let include_regexes = array_or_empty(read_json_file(include_regex_path)); - let include_countries = array_or_empty(read_json_file(include_countries_path)); - let exclude_names = array_or_empty(read_json_file(exclude_names_path)); - let exclude_regexes = array_or_empty(read_json_file(exclude_regex_path)); - let exclude_countries = array_or_empty(read_json_file(exclude_countries_path)); - let include_regex_tags = urltest_regex_matching_tag_array(tags, names, include_regexes); - let exclude_regex_tags = urltest_regex_matching_tag_array(tags, names, exclude_regexes); - let result; - - if (mode == "include") - result = urltest_filter_array("include", tags, names, countries, include_names, include_regex_tags, include_countries); - else if (mode == "exclude") - result = urltest_filter_array("exclude", tags, names, countries, exclude_names, exclude_regex_tags, exclude_countries); - else if (mode == "mixed") { - let included = urltest_filter_array("include", tags, names, countries, include_names, include_regex_tags, include_countries); - result = urltest_filter_array("exclude", included, names, countries, exclude_names, exclude_regex_tags, exclude_countries); - } - else { - result = tags; - } - - write_json(result); -} - -function final_urltest_outbounds(config_path, tags_path) { - let config = object_or_empty(read_json_file(config_path)); - let tags = array_or_empty(read_json_file(tags_path)); - let outbounds_by_tag = {}; - let skipped_types = { - selector: true, - urltest: true, - direct: true, - dns: true, - block: true - }; - let result = []; - - for (let outbound in array_or_empty(config.outbounds)) { - if (type(outbound) != "object") - continue; - - let tag = as_string(outbound.tag || ""); - if (tag != "") - outbounds_by_tag[tag] = outbound; - } - - for (let tag in tags) { - tag = as_string(tag); - let outbound = outbounds_by_tag[tag]; - if (type(outbound) != "object") - continue; - - let proxy_type = lc(as_string(outbound.type || "")); - if (skipped_types[proxy_type]) - continue; - - 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_country_object_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; - } - - return result; -} - -function cached_countries_for_servers(servers_path, cache_path) { - write_json(cached_country_object_for_servers(servers_path, cache_path)); -} - -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_country_object_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; - } - } - - return result; -} - -function server_countries_result(servers_path, cache_path, resolved_path, ip_country_path) { - let result = cached_country_object_for_servers(servers_path, cache_path); - - for (let tag, country in resolved_country_object_from_tsv(resolved_path, ip_country_path)) - result[tag] = 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; - } - } -} - -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 == "stdin-length") - stdin_length(); -else if (mode == "stdin-contains") - exit(stdin_contains(ARGV[1]) ? 0 : 1); -else if (mode == "stdin-regex-matches") - exit(stdin_regex_matches(ARGV[1]) ? 0 : 1); -else if (mode == "csv-to-json-array") - csv_to_json_array(ARGV[1]); -else if (mode == "file-line-count") - file_line_count(ARGV[1]); -else if (mode == "ip-addr-first-inet4") - ip_addr_first_inet4(); -else if (mode == "stdin-first-dns-a-address") - stdin_first_dns_a_address(); -else if (mode == "stdin-first-dns-aaaa-address") - stdin_first_dns_aaaa_address(); -else if (mode == "valid-ipv6-literal") - exit(valid_ipv6_literal(ARGV[1]) ? 0 : 1); -else if (mode == "stdin-first-nslookup-address") - stdin_first_nslookup_address(); -else if (mode == "stdin-first-field") - stdin_first_field(); -else if (mode == "normalize-country-server-key") - normalize_country_server_key(ARGV[1]); -else if (mode == "array-item") - array_item(ARGV[1]); -else if (mode == "array-append-string") - array_append_string(ARGV[1]); -else if (mode == "merge-proxy-group-subscription-state") - merge_proxy_group_subscription_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]); -else if (mode == "append-proxy-group-outbound-state") - append_proxy_group_outbound_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8]); -else if (mode == "normalized-country-list") - normalized_country_list(); -else if (mode == "countries-from-flag-names") - countries_from_flag_names(ARGV[1]); -else if (mode == "urltest-regex-matching-tags") - urltest_regex_matching_tags(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "urltest-filter") - urltest_filter(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); -else if (mode == "urltest-filter-mode") - urltest_filter_mode(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10]); -else if (mode == "final-urltest-outbounds") - final_urltest_outbounds(ARGV[1], ARGV[2]); -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 == "server-countries-result") - server_countries_result(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); -else if (mode == "outbound-server-by-tag") - outbound_server_by_tag(ARGV[1]); -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: sing_box_runtime.uc ...\n"); - exit(1); -} diff --git a/podkop/files/usr/lib/singbox/constants.uc b/podkop/files/usr/lib/singbox/constants.uc new file mode 100644 index 00000000..347f7183 --- /dev/null +++ b/podkop/files/usr/lib/singbox/constants.uc @@ -0,0 +1,113 @@ +#!/usr/bin/env ucode + +const DNS_SERVER_TAG = "dns-server"; +const FAKEIP_DNS_SERVER_TAG = "fakeip-server"; +const BOOTSTRAP_DNS_SERVER_TAG = "bootstrap-dns-server"; +const FAKEIP_DNS_RULE_TAG = "fakeip-dns-rule-tag"; +const FAKEIP_RULESET_DNS_RULE_TAG = "fakeip-ruleset-dns-rule-tag"; +const SERVICE_FAKEIP_DNS_RULE_TAG = "service-fakeip-dns-rule-tag"; + +const TPROXY_INBOUND_TAG = "tproxy-in"; +const TPROXY_INBOUND_ADDRESS = "0.0.0.0"; +const TPROXY_INBOUND_PORT = 1602; +const DNS_INBOUND_TAG = "dns-in"; +const DNS_INBOUND_ADDRESS = "127.0.0.42"; +const DNS_INBOUND_PORT = 53; + +const SERVICE_MIXED_INBOUND_TAG = "service-mixed-in"; +const SERVICE_MIXED_INBOUND_ADDRESS = "127.0.0.1"; +const SERVICE_MIXED_INBOUND_PORT = 4534; +const DIRECT_OUTBOUND_TAG = "direct-out"; +const OUTBOUND_MARK = 2097152; +const FAKEIP_INET4_RANGE = "198.18.0.0/15"; + +const DISABLED_UPDATE_INTERVAL = "876000h"; +const URLTEST_DEFAULT_IDLE_TIMEOUT = "30m"; +const CHECK_PROXY_IP_DOMAIN = "ip.podkop.fyi"; +const FAKEIP_TEST_DOMAIN = "fakeip.podkop.fyi"; +const TMP_SING_BOX_FOLDER = "/tmp/sing-box"; +const TMP_RULESET_FOLDER = TMP_SING_BOX_FOLDER + "/rulesets"; +const ZAPRET_ROUTE_MARK_BASE = 0x01000000; +const ZAPRET2_ROUTE_MARK_BASE = 0x01010000; +const BYEDPI_LISTEN_ADDRESS = "127.0.0.1"; +const BYEDPI_PORT_BASE = 1080; + +const RESERVED_TAGS = { + [DNS_SERVER_TAG]: true, + [FAKEIP_DNS_SERVER_TAG]: true, + [BOOTSTRAP_DNS_SERVER_TAG]: true, + [FAKEIP_DNS_RULE_TAG]: true, + [FAKEIP_RULESET_DNS_RULE_TAG]: true, + [SERVICE_FAKEIP_DNS_RULE_TAG]: true, + [TPROXY_INBOUND_TAG]: true, + [DNS_INBOUND_TAG]: true, + [SERVICE_MIXED_INBOUND_TAG]: true, + [DIRECT_OUTBOUND_TAG]: true +}; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function tag(base, postfix) { + let candidate = as_string(base) + "-" + as_string(postfix); + return RESERVED_TAGS[candidate] ? candidate + "-1" : candidate; +} + +function inbound_tag(section_name) { + return tag(section_name, "in"); +} + +function outbound_tag(section_name) { + return tag(section_name, "out"); +} + +function domain_resolver_tag(section_name) { + return tag(section_name, "domain-resolver"); +} + +function server_inbound_tag(section_name) { + return tag("server-" + as_string(section_name), "in"); +} + +function tailscale_dns_server_tag(section_name) { + return tag("server-" + as_string(section_name), "tailscale-dns"); +} + +return { + DNS_SERVER_TAG, + FAKEIP_DNS_SERVER_TAG, + BOOTSTRAP_DNS_SERVER_TAG, + FAKEIP_DNS_RULE_TAG, + FAKEIP_RULESET_DNS_RULE_TAG, + SERVICE_FAKEIP_DNS_RULE_TAG, + TPROXY_INBOUND_TAG, + TPROXY_INBOUND_ADDRESS, + TPROXY_INBOUND_PORT, + DNS_INBOUND_TAG, + DNS_INBOUND_ADDRESS, + DNS_INBOUND_PORT, + SERVICE_MIXED_INBOUND_TAG, + SERVICE_MIXED_INBOUND_ADDRESS, + SERVICE_MIXED_INBOUND_PORT, + DIRECT_OUTBOUND_TAG, + OUTBOUND_MARK, + FAKEIP_INET4_RANGE, + DISABLED_UPDATE_INTERVAL, + URLTEST_DEFAULT_IDLE_TIMEOUT, + CHECK_PROXY_IP_DOMAIN, + FAKEIP_TEST_DOMAIN, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + ZAPRET_ROUTE_MARK_BASE, + ZAPRET2_ROUTE_MARK_BASE, + BYEDPI_LISTEN_ADDRESS, + BYEDPI_PORT_BASE, + RESERVED_TAGS, + tag, + inbound_tag, + outbound_tag, + domain_resolver_tag, + server_inbound_tag, + tailscale_dns_server_tag +}; diff --git a/podkop/files/usr/lib/singbox/dns.uc b/podkop/files/usr/lib/singbox/dns.uc new file mode 100644 index 00000000..070a3cde --- /dev/null +++ b/podkop/files/usr/lib/singbox/dns.uc @@ -0,0 +1,80 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); +let runtime_constants = require("singbox.constants"); +let runtime_url = require("core.url"); + +let as_string = common.as_string; +let option = common.option; + +function valid_ipv4_octet(value) { + value = as_string(value); + return match(value, /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)$/) != null; +} + +function valid_ipv4(value) { + value = as_string(value); + if (length(value) > 0 && substr(value, length(value) - 1) == ".") + value = substr(value, 0, length(value) - 1); + + let parts = split(value, "."); + if (length(parts) != 4) + return false; + + for (let part in parts) + if (!valid_ipv4_octet(part)) + return false; + + return true; +} + +function server_from_options(tag_name, dns_type, dns_server, detour) { + let server = runtime_url.host(dns_server); + let port = runtime_url.port(dns_server); + let result = { + type: "udp", + tag: tag_name, + server, + server_port: 53 + }; + + if (dns_type == "udp") { + if (port != "") + result.server_port = int(port, 10); + } + else if (dns_type == "dot") { + result.type = "tls"; + result.server_port = port != "" ? int(port, 10) : 853; + } + else if (dns_type == "doh") { + result.type = "https"; + result.server_port = port != "" ? int(port, 10) : 443; + let path = runtime_url.path(dns_server); + if (path != "") + result.path = path; + } + else { + return { unsupported: "unsupported dns_type " + dns_type }; + } + + if (!valid_ipv4(server)) + result.domain_resolver = runtime_constants.BOOTSTRAP_DNS_SERVER_TAG; + if (as_string(detour) != "") + result.detour = as_string(detour); + + return result; +} + +function server_config(settings) { + return server_from_options( + runtime_constants.DNS_SERVER_TAG, + option(settings, "dns_type", "doh"), + option(settings, "dns_server", "1.1.1.1"), + "" + ); +} + +return { + server_from_options, + server_config +}; diff --git a/podkop/files/usr/lib/singbox/generator.uc b/podkop/files/usr/lib/singbox/generator.uc new file mode 100644 index 00000000..da0b21e3 --- /dev/null +++ b/podkop/files/usr/lib/singbox/generator.uc @@ -0,0 +1,2248 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); +let uci_core = require("core.uci"); +let runtime_constants = require("singbox.constants"); +let runtime_dns = require("singbox.dns"); +let runtime_route = require("singbox.route"); +let runtime_rulesets = require("singbox.rulesets"); +let runtime_servers = require("singbox.servers"); +let runtime_subscription = require("singbox.subscription"); +let runtime_url = require("core.url"); +let runtime_urltest = require("singbox.urltest"); +let source_rulesets = require("routing.rulesets"); +let rule_config = require("config.rule"); +let uci = null; +let fixture_uci_data = null; +let runtime_settings_cache = null; +let runtime_ruleset_folder = runtime_constants.TMP_RULESET_FOLDER; + +let as_string = common.as_string; +let read_json_file = common.read_json_file; +let read_stdin = common.read_stdin; +let read_stdin_json = common.read_stdin_json; +let write_json = common.write_json; +let write_compact_string_array = common.write_compact_string_array; +let csv_to_json_array = common.csv_to_json_array; +let write_file_json = common.write_json_file; +let write_json_file = common.write_json_file; +let strip_internal_fields = common.strip_internal_fields; +let array_or_empty = common.array_or_empty; +let object_or_empty = common.object_or_empty; +let option = common.option; +let list_option = common.list_option; +let bool_option = common.bool_option; +let int_option = common.int_option; +let url_decode = runtime_url.decode; +let url_scheme = runtime_url.scheme; +let url_fragment = runtime_url.fragment; +let url_strip_fragment_value = runtime_url.strip_fragment; +let url_host = runtime_url.host; +let url_port = runtime_url.port; +let url_userinfo = runtime_url.userinfo; +let url_path = runtime_url.path; +let url_query_params = runtime_url.query_params; + +const CONFIG_NAME = "podkop-plus"; + +function atomic_write_json_file(path, value) { + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); + + if (!write_json_file(tmp_path, value)) + return false; + if (!fs.rename(tmp_path, path)) { + fs.unlink(tmp_path); + return false; + } + return true; +} + +function fixture_section_list(type_name) { + let value = object_or_empty(fixture_uci_data)[type_name]; + if (type(value) == "array") + return value; + if (type(value) == "object") + return [ value ]; + + let plural = object_or_empty(fixture_uci_data)[type_name + "s"]; + return type(plural) == "array" ? plural : []; +} + +function fixture_get_section(section_name) { + let fixture = object_or_empty(fixture_uci_data); + if (section_name == "settings" && type(fixture.settings) == "object") + return fixture.settings; + + for (let type_name in [ "settings", "server", "section" ]) { + for (let section in fixture_section_list(type_name)) { + if (as_string(section[".name"]) == section_name) + return section; + } + } + + return {}; +} + +function fixture_cursor(path) { + fixture_uci_data = object_or_empty(read_json_file(path)); + return { + load: function(_config_name) { + return true; + }, + get_all: function(_config_name, section_name) { + return fixture_get_section(section_name); + }, + foreach: function(_config_name, type_name, callback) { + for (let section in fixture_section_list(type_name)) + callback(section); + } + }; +} + +function use_fixture_cursor(path) { + uci = fixture_cursor(path); + runtime_settings_cache = null; +} + +function runtime_uci_cursor() { + return { + load: function(package_name) { + return uci_core.load(package_name); + }, + get_all: function(package_name, section_name) { + return uci_core.get_all(package_name, section_name); + }, + foreach: function(package_name, type_name, callback) { + for (let section in uci_core.section_objects(package_name, type_name)) + callback(section); + } + }; +} + +function uci_cursor() { + if (uci == null) + uci = runtime_uci_cursor(); + return uci; +} + +function runtime_generate_unsupported(reason) { + warn("unsupported: ", reason, "\n"); + exit(2); +} + +function valid_section_name(name) { + return match(name, /^[A-Za-z0-9_]+$/); +} + +function section_enabled(section) { + return bool_option(section, "enabled", true); +} + +function runtime_settings() { + if (runtime_settings_cache == null) + runtime_settings_cache = object_or_empty(uci_cursor().get_all(CONFIG_NAME, "settings")); + return runtime_settings_cache; +} + +function settings_update_interval() { + let settings = runtime_settings(); + if (!bool_option(settings, "list_update_enabled", true)) + return ""; + + let update_interval = option(settings, "update_interval", "1d"); + return update_interval != "" ? update_interval : "1d"; +} + +function remote_ruleset_update_interval() { + let update_interval = settings_update_interval(); + return update_interval != "" ? update_interval : runtime_constants.DISABLED_UPDATE_INTERVAL; +} + +function internal_flag(value) { + return value === true || value == 1 || value == "1" || value == "true" || value == "yes"; +} + +function tag(base, postfix) { + return runtime_constants.tag(base, postfix); +} + +function outbound_tag(section_name) { + return runtime_constants.outbound_tag(section_name); +} + +function download_via_proxy_any_enabled(settings) { + return bool_option(settings, "download_lists_via_proxy", false) || + bool_option(settings, "download_subscriptions_via_proxy", false) || + bool_option(settings, "download_components_via_proxy", false); +} + +function download_detour_tag(settings) { + if (!bool_option(settings, "download_lists_via_proxy", false)) + return ""; + + let section_name = option(settings, "download_lists_via_proxy_section", ""); + return section_name == "" ? "" : outbound_tag(section_name); +} + +function ruleset_tag(section_name, name, kind) { + kind = as_string(kind); + return kind == "" + ? section_name + "-" + name + "-ruleset" + : section_name + "-" + name + "-" + kind + "-ruleset"; +} + +function ruleset_registered(config, tag_name) { + for (let rule_set in array_or_empty(config.route && config.route.rule_set)) { + if (type(rule_set) == "object" && rule_set.tag == tag_name) + return true; + } + return false; +} + +function ensure_custom_ruleset(config, reference) { + let tag_name; + let kind = runtime_rulesets.kind_from_reference_hint(reference); + + if (runtime_rulesets.is_community(reference)) { + tag_name = "builtin-" + reference + "-ruleset"; + kind = "domains"; + if (!ruleset_registered(config, tag_name)) { + let rule_set = { + type: "remote", + tag: tag_name, + format: "binary", + url: runtime_rulesets.community_url(reference) + }; + let detour = download_detour_tag(runtime_settings()); + if (detour != "") + rule_set.download_detour = detour; + rule_set.update_interval = remote_ruleset_update_interval(); + push(config.route.rule_set, rule_set); + } + return { tag: tag_name, kind }; + } + + tag_name = "inline-custom-" + runtime_rulesets.hash12(reference) + "-ruleset"; + if (kind == "unknown") + kind = "domains"; + if (ruleset_registered(config, tag_name)) + return { tag: tag_name, kind }; + + let extension = runtime_rulesets.file_extension(reference); + if (substr(reference, 0, 1) == "/") { + if (extension != "srs" && extension != "json") + runtime_generate_unsupported("local rule_set extension is not supported by runtime generator"); + push(config.route.rule_set, { + type: "local", + tag: tag_name, + format: extension == "json" ? "source" : "binary", + path: reference + }); + } + else if (substr(reference, 0, 7) == "http://" || substr(reference, 0, 8) == "https://") { + let rule_set = { + type: "remote", + tag: tag_name, + format: runtime_rulesets.remote_format(reference), + url: reference + }; + let detour = download_detour_tag(runtime_settings()); + if (detour != "") + rule_set.download_detour = detour; + rule_set.update_interval = remote_ruleset_update_interval(); + push(config.route.rule_set, rule_set); + } + else { + runtime_generate_unsupported("rule_set reference is not supported by runtime generator"); + } + + return { tag: tag_name, kind }; +} + +function clash_api_config(settings, service_address) { + let controller = as_string(service_address || ""); + if (bool_option(settings, "enable_yacd", false) && bool_option(settings, "enable_yacd_wan_access", false)) + controller = "0.0.0.0"; + else if (controller == "") + controller = "127.0.0.1"; + + let result = { + external_controller: controller + ":9090" + }; + if (bool_option(settings, "enable_yacd", false)) { + result.external_ui = "ui"; + let secret = option(settings, "yacd_secret_key", ""); + if (secret != "") + result.secret = secret; + } + return result; +} + +function cli_bool(value) { + return value === true || value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function base_config(settings, service_address, runtime_context) { + let log_level = option(settings, "log_level", "warn"); + let bootstrap_dns_server = option(settings, "bootstrap_dns_server", "77.88.8.8"); + let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60"); + let cache_path = option(settings, "cache_path", "/tmp/sing-box/cache.db"); + let dns_server = runtime_dns.server_config(settings); + if (dns_server.unsupported) + runtime_generate_unsupported(dns_server.unsupported); + + return { + log: { + disabled: false, + level: log_level, + timestamp: false + }, + dns: { + servers: [ + { type: "udp", tag: runtime_constants.BOOTSTRAP_DNS_SERVER_TAG, server: bootstrap_dns_server, server_port: 53 }, + dns_server, + { type: "fakeip", tag: runtime_constants.FAKEIP_DNS_SERVER_TAG, inet4_range: runtime_constants.FAKEIP_INET4_RANGE } + ], + rules: [ + { action: "reject", query_type: "HTTPS" }, + { action: "reject", domain_suffix: "use-application-dns.net" }, + { + action: "route", + server: runtime_constants.FAKEIP_DNS_SERVER_TAG, + rewrite_ttl, + domain: [ runtime_constants.FAKEIP_TEST_DOMAIN, runtime_constants.CHECK_PROXY_IP_DOMAIN ] + } + ], + final: runtime_constants.DNS_SERVER_TAG, + strategy: "ipv4_only", + independent_cache: true + }, + ntp: {}, + certificate: {}, + endpoints: [], + inbounds: [ + { type: "tproxy", tag: runtime_constants.TPROXY_INBOUND_TAG, listen: runtime_constants.TPROXY_INBOUND_ADDRESS, listen_port: runtime_constants.TPROXY_INBOUND_PORT, tcp_fast_open: true, udp_fragment: true }, + { type: "direct", tag: runtime_constants.DNS_INBOUND_TAG, listen: runtime_constants.DNS_INBOUND_ADDRESS, listen_port: runtime_constants.DNS_INBOUND_PORT } + ], + outbounds: [ + { type: "direct", tag: runtime_constants.DIRECT_OUTBOUND_TAG } + ], + route: runtime_route.config(settings, runtime_context), + services: [], + experimental: { + cache_file: { + enabled: true, + path: cache_path, + store_fakeip: true + }, + clash_api: clash_api_config(settings, service_address) + } + }; +} + +function unsupported_setting(settings, key) { + let value = option(settings, key, ""); + return value != "" && value != "0"; +} + +function check_supported_settings(settings) { +} + +function supported_subscription_outbound(outbound) { + if (type(outbound) != "object") + return false; + let t = as_string(outbound.type); + if (t == "direct" || t == "selector" || t == "urltest" || t == "dns" || t == "block") + return false; + return t == "vless" || t == "vmess" || t == "trojan" || t == "shadowsocks" || + t == "socks" || t == "http" || t == "hysteria2"; +} + +function copy_subscription_outbound(outbound, new_tag) { + let copy = {}; + for (let key, value in outbound) { + if (key != "tag" && key != "remark" && key != "share_link" && + key != "__podkop_hidden" && key != "__podkop_allow_group") + copy[key] = value; + } + copy.tag = new_tag; + return copy; +} + +function unique_tag(base, taken) { + base = as_string(base); + if (base == "") + base = "server"; + if (!taken[base]) + return base; + for (let i = 1; i < 100000; i++) { + let candidate = base + "-" + i; + if (!taken[candidate]) + return candidate; + } + return base + "-overflow"; +} + +function add_subscription_source_with_state(config, section_name, source_index, source_entry, taken, selector_tags, state) { + let source_section = runtime_subscription.source_id(section_name, source_index); + if (!runtime_subscription.source_cache_is_current(source_section, source_entry)) + return 0; + + let outbounds = runtime_subscription.read_source_outbounds(source_section); + if (length(outbounds) == 0) + return 0; + + runtime_subscription.merge_source_metadata(state, section_name, source_section, source_index, source_entry); + for (let i = 0; i < length(outbounds); i++) { + let outbound = outbounds[i]; + if (!supported_subscription_outbound(outbound)) + runtime_generate_unsupported("subscription outbound type is not supported by runtime generator"); + let display_name = as_string(outbound.remark || outbound.tag || ("server-" + (i + 1))); + let base = as_string(outbound.tag || outbound.remark || ("server-" + (i + 1))); + let new_tag = unique_tag(base, taken); + taken[new_tag] = true; + push(config.outbounds, copy_subscription_outbound(outbound, new_tag)); + if (!internal_flag(outbound.__podkop_hidden)) { + push(selector_tags, new_tag); + runtime_subscription.remember_visible_outbound(state, new_tag, source_section, source_index, i + 1, display_name, outbound); + } + } + return length(outbounds); +} + +function duration_to_seconds(value) { + value = as_string(value); + if (value == "") + return null; + if (match(value, /^[0-9]+$/) != null) + return int(value, 10); + + let suffix = substr(value, length(value) - 1); + let number = substr(value, 0, length(value) - 1); + if (match(number, /^[0-9]+$/) == null) + return null; + + let multiplier = null; + if (suffix == "s") + multiplier = 1; + else if (suffix == "m") + multiplier = 60; + else if (suffix == "h") + multiplier = 3600; + else if (suffix == "d") + multiplier = 86400; + + return multiplier == null ? null : int(number, 10) * multiplier; +} + +function urltest_check_interval(section) { + let interval = option(section, "urltest_check_interval", ""); + return interval != "" ? interval : "3m"; +} + +function urltest_idle_timeout(section) { + let interval = urltest_check_interval(section); + let interval_seconds = duration_to_seconds(interval); + let default_idle_seconds = duration_to_seconds(runtime_constants.URLTEST_DEFAULT_IDLE_TIMEOUT); + return interval_seconds != null && interval_seconds > default_idle_seconds ? interval : ""; +} + +function supported_urltest_filter_mode(mode) { + return mode == "include" || mode == "exclude" || mode == "mixed"; +} + +function urltest_country_metadata(section, state) { + let metadata = object_or_empty(object_or_empty(state.outboundMetadata).countries); + let detect_method = option(section, "detect_server_country", "flag_emoji"); + if (detect_method == "flag_emoji") + return runtime_urltest.countries_from_flag_names(object_or_empty(object_or_empty(state.outboundMetadata).names)); + return metadata; +} + +function urltest_filtered_outbounds(section, selector_tags, state) { + let filter_mode = option(section, "urltest_filter_mode", "disabled"); + if (filter_mode == "" || filter_mode == "disabled") + return selector_tags; + if (!supported_urltest_filter_mode(filter_mode)) + return selector_tags; + + return runtime_urltest.filter_mode( + filter_mode, + selector_tags, + object_or_empty(object_or_empty(state.outboundMetadata).names), + urltest_country_metadata(section, state), + list_option(section, "urltest_include_outbounds"), + list_option(section, "urltest_include_regex"), + list_option(section, "urltest_include_countries"), + list_option(section, "urltest_exclude_outbounds"), + list_option(section, "urltest_exclude_regex"), + list_option(section, "urltest_exclude_countries") + ); +} + +function add_proxy_selector(config, section, selector_tags, state) { + let section_name = section[".name"]; + let selector_tag = outbound_tag(section_name); + let selector_outbounds = selector_tags; + let selector_default = selector_tags[0]; + + if (bool_option(section, "urltest_enabled", false)) { + let urltest_outbounds = urltest_filtered_outbounds(section, selector_tags, state); + + if (length(urltest_outbounds) > 0) { + let urltest_tag = outbound_tag(section_name + "-urltest"); + push(config.outbounds, { + type: "urltest", + tag: urltest_tag, + outbounds: urltest_outbounds, + url: option(section, "urltest_testing_url", "https://www.gstatic.com/generate_204"), + interval: urltest_check_interval(section), + tolerance: int_option(section, "urltest_tolerance", "50"), + interrupt_exist_connections: true + }); + let idle_timeout = urltest_idle_timeout(section); + if (idle_timeout != "") + config.outbounds[length(config.outbounds) - 1].idle_timeout = idle_timeout; + + selector_outbounds = []; + for (let tag in selector_tags) + push(selector_outbounds, tag); + push(selector_outbounds, urltest_tag); + selector_default = urltest_tag; + } + } + + push(config.outbounds, { + type: "selector", + tag: selector_tag, + outbounds: selector_outbounds, + default: selector_default, + interrupt_exist_connections: true + }); +} + +function outbound_detour_tag_for_section(section) { + if (!bool_option(section, "outbound_detour_enabled", false)) + return ""; + + let detour_section = option(section, "outbound_detour_section", ""); + return detour_section == "" ? "" : outbound_tag(detour_section); +} + +function apply_detour_to_outbound(config, tag_name, detour_tag) { + if (detour_tag == "") + return; + for (let outbound in array_or_empty(config.outbounds)) { + if (type(outbound) == "object" && outbound.tag == tag_name) + outbound.detour = detour_tag; + } +} + +function apply_detour_to_outbounds(config, tag_names, detour_tag) { + if (detour_tag == "") + return; + for (let tag_name in tag_names) + apply_detour_to_outbound(config, tag_name, detour_tag); +} + +function mixed_proxy_enabled_action(action) { + return action == "proxy" || action == "outbound" || action == "vpn" || action == "direct" || + action == "byedpi" || action == "zapret" || action == "zapret2"; +} + +function add_mixed_proxy_for_section(config, section, service_address) { + if (!bool_option(section, "mixed_proxy_enabled", false)) + return; + + let action = option(section, "action", ""); + if (!mixed_proxy_enabled_action(action)) + runtime_generate_unsupported("mixed proxy inbound is not supported for action " + action); + + let listen_port_value = option(section, "mixed_proxy_port", ""); + if (match(listen_port_value, /^[0-9]+$/) == null) + runtime_generate_unsupported("mixed proxy port is invalid"); + let listen_port = int(listen_port_value, 10); + if (listen_port < 1 || listen_port > 65535) + runtime_generate_unsupported("mixed proxy port is invalid"); + + let listen = as_string(service_address || ""); + if (listen == "") + runtime_generate_unsupported("mixed proxy listen address is not set"); + + let inbound = { + type: "mixed", + tag: runtime_constants.inbound_tag(section[".name"] + "-mixed"), + listen, + listen_port + }; + + if (bool_option(section, "mixed_proxy_auth_enabled", false)) { + let username = option(section, "mixed_proxy_username", ""); + let password = option(section, "mixed_proxy_password", ""); + if (username == "" || password == "") + runtime_generate_unsupported("mixed proxy authentication is enabled but username or password is empty"); + inbound.users = [{ username, password }]; + } + push(config.inbounds, inbound); + push(config.route.rules, { + action: "route", + inbound: inbound.tag, + outbound: runtime_constants.outbound_tag(section[".name"]) + }); +} + +function add_service_mixed_proxy(config, settings) { + if (!download_via_proxy_any_enabled(settings)) + return; + + let outbound = download_detour_tag(settings); + if (outbound == "") + runtime_generate_unsupported("download via proxy section is not set"); + + push(config.inbounds, { + type: "mixed", + tag: runtime_constants.SERVICE_MIXED_INBOUND_TAG, + listen: runtime_constants.SERVICE_MIXED_INBOUND_ADDRESS, + listen_port: runtime_constants.SERVICE_MIXED_INBOUND_PORT + }); + push(config.route.rules, { + action: "route", + inbound: runtime_constants.SERVICE_MIXED_INBOUND_TAG, + outbound + }); +} + +function parse_port(value) { + value = as_string(value); + if (match(value, /^[0-9]+$/) == null) + return null; + let port = int(value, 10); + return port >= 1 && port <= 65535 ? port : null; +} + +function bool_query(value) { + return value == "1" || value == "true"; +} + +function base64_decode_value(value) { + value = replace(as_string(value), /[\r\n\t ]/g, ""); + value = replace(replace(value, /-/g, "+"), /_/g, "/"); + while (length(value) % 4 != 0) + value += "="; + try { + return b64dec(value); + } + catch (e) { + return null; + } +} + +function shadowsocks_userinfo_valid(value) { + value = as_string(value); + 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 split_host_port(value) { + value = as_string(value); + if (substr(value, 0, 1) == "[") { + let close = index(value, "]"); + if (close > 0 && substr(value, close + 1, 1) == ":") + return [ substr(value, 1, close - 1), substr(value, close + 2) ]; + } + + let colon = rindex(value, ":"); + if (colon < 0) + return [ "", "" ]; + return [ substr(value, 0, colon), substr(value, colon + 1) ]; +} + +function tls_alpn_array(value, transport) { + value = as_string(value); + transport = lc(as_string(transport)); + if (value == "" && transport == "xhttp") + return [ "h2", "http/1.1" ]; + if (value != "" && (transport == "ws" || transport == "httpupgrade")) + return [ "http/1.1" ]; + return value == "" ? [] : split(value, ","); +} + +function apply_link_tls(outbound, scheme, query) { + query = object_or_empty(query); + let security = as_string(query.security || ""); + if (security == "" && (scheme == "hysteria2" || scheme == "hy2")) + security = "tls"; + + if (security == "" || security == "none") + return; + if (security != "tls" && security != "reality") { + warn("unknown manual proxy link security '", security, "' ignored\n"); + return; + } + + let tls = { enabled: true }; + if (as_string(query.sni || "") != "") + tls.server_name = as_string(query.sni); + if (bool_query(query.allowInsecure || query.insecure || "")) + tls.insecure = true; + + let alpn = tls_alpn_array(query.alpn, query.type); + if (length(alpn) > 0) + tls.alpn = alpn; + + if (scheme != "hysteria2" && scheme != "hy2" && as_string(query.fp || "") != "") { + tls.utls = { + enabled: true, + fingerprint: as_string(query.fp) + }; + } + if (security == "reality") { + tls.reality = { + enabled: true, + public_key: as_string(query.pbk || ""), + short_id: as_string(query.sid || "") + }; + } + outbound.tls = tls; +} + +function csv_array(value) { + value = as_string(value); + if (value == "") + return []; + let result = []; + for (let item in split(value, ",")) { + item = trim(as_string(item)); + if (item != "") + push(result, item); + } + return result; +} + +function optional_query_string(object, key, value) { + value = as_string(value); + if (value != "") + object[key] = value; +} + +function optional_query_number(object, key, value) { + value = as_string(value); + if (value != "" && match(value, /^[0-9]+$/) != null) + object[key] = int(value, 10); +} + +function apply_link_transport(outbound, query) { + let transport = lc(as_string(object_or_empty(query).type || "")); + if (transport == "" || transport == "tcp" || transport == "raw") + return; + + if (transport == "h2") + transport = "http"; + + let result = { type: transport }; + if (transport == "http") { + optional_query_string(result, "path", query.path); + let hosts = csv_array(query.host); + if (length(hosts) > 0) + result.host = hosts; + } + else if (transport == "ws") { + result.path = as_string(query.path || ""); + if (as_string(query.host || "") != "") + result.headers = { Host: as_string(query.host) }; + optional_query_number(result, "max_early_data", query.ed); + } + else if (transport == "grpc") { + optional_query_string(result, "service_name", query.serviceName); + } + else if (transport == "httpupgrade") { + optional_query_string(result, "path", query.path); + optional_query_string(result, "host", query.host); + } + else if (transport == "xhttp") { + let mode = as_string(query.mode || "auto"); + if (mode != "auto" && mode != "packet-up" && mode != "stream-up" && mode != "stream-one") + mode = "auto"; + result.mode = mode; + result.path = as_string(query.path || "") != "" ? as_string(query.path) : "/"; + result.x_padding_bytes = "100-1000"; + result.no_grpc_header = false; + result.sc_max_each_post_bytes = 1000000; + result.sc_min_posts_interval_ms = 30; + optional_query_string(result, "host", as_string(query.host || "") != "" ? query.host : query.sni); + } + else { + warn("unknown manual proxy link transport '", transport, "' ignored\n"); + return; + } + + outbound.transport = result; +} + +function manual_http_outbound(link, tag_name) { + let scheme = url_scheme(link); + let host = url_host(link); + let port = parse_port(url_port(link)); + let path = url_path(link); + if (host == "" || port == null || (path != "" && path != "/") || index(link, "?") >= 0) + runtime_generate_unsupported("manual HTTP proxy link is invalid"); + + let outbound = { + type: "http", + tag: tag_name, + server: host, + server_port: port + }; + let userinfo = url_userinfo(link); + if (userinfo != "") { + let colon = index(userinfo, ":"); + outbound.username = colon >= 0 ? substr(userinfo, 0, colon) : userinfo; + if (colon >= 0) + outbound.password = substr(userinfo, colon + 1); + } + if (scheme == "https") + outbound.tls = { enabled: true }; + return outbound; +} + +function manual_socks_outbound(link, tag_name, udp_over_tcp) { + let scheme = url_scheme(link); + let host = url_host(link); + let port = parse_port(url_port(link)); + if (host == "" || port == null) + runtime_generate_unsupported("manual SOCKS proxy link is invalid"); + + let outbound = { + type: "socks", + tag: tag_name, + server: host, + server_port: port, + version: scheme == "socks4" || scheme == "socks4a" ? "4" : "5" + }; + if (scheme == "socks5") { + let userinfo = url_userinfo(link); + if (userinfo != "") { + let colon = index(userinfo, ":"); + outbound.username = colon >= 0 ? substr(userinfo, 0, colon) : userinfo; + if (colon >= 0) + outbound.password = substr(userinfo, colon + 1); + } + } + if (udp_over_tcp) + outbound.udp_over_tcp = { enabled: true, version: 2 }; + return outbound; +} + +function manual_shadowsocks_outbound(link, tag_name, udp_over_tcp) { + let raw = url_strip_fragment_value(url_decode(link)); + let body = substr(raw, 5); + let question = index(body, "?"); + let query_string = ""; + if (question >= 0) { + query_string = substr(body, question + 1); + body = substr(body, 0, question); + } + + let at = rindex(body, "@"); + let userinfo = ""; + let hostport = ""; + if (at >= 0) { + userinfo = substr(body, 0, at); + hostport = substr(body, at + 1); + } + else { + let decoded = base64_decode_value(body); + if (decoded == null) + runtime_generate_unsupported("manual Shadowsocks proxy link is invalid"); + at = rindex(decoded, "@"); + if (at < 0) + runtime_generate_unsupported("manual Shadowsocks proxy link is invalid"); + userinfo = substr(decoded, 0, at); + hostport = substr(decoded, at + 1); + } + + userinfo = url_decode(userinfo); + if (!shadowsocks_userinfo_valid(userinfo)) { + let decoded = base64_decode_value(userinfo); + if (decoded == null) + runtime_generate_unsupported("manual Shadowsocks proxy link is invalid"); + userinfo = decoded; + } + + let cred_colon = index(userinfo, ":"); + let host_port = split_host_port(hostport); + let port = parse_port(host_port[1]); + if (cred_colon <= 0 || host_port[0] == "" || port == null) + runtime_generate_unsupported("manual Shadowsocks proxy link is invalid"); + + let outbound = { + type: "shadowsocks", + tag: tag_name, + server: host_port[0], + server_port: port, + method: substr(userinfo, 0, cred_colon), + password: substr(userinfo, cred_colon + 1) + }; + let query = url_query_params("ss://placeholder/?" + query_string); + if (as_string(query.plugin || "") != "") + outbound.plugin = as_string(query.plugin); + if (as_string(query["plugin-opts"] || "") != "") + outbound.plugin_opts = as_string(query["plugin-opts"]); + if (udp_over_tcp) + outbound.udp_over_tcp = { enabled: true, version: 2 }; + return outbound; +} + +function manual_vless_outbound(link, tag_name) { + let query = url_query_params(link); + + let host = url_host(link); + let port = parse_port(url_port(link)); + let uuid = url_userinfo(link); + if (host == "" || port == null || uuid == "") + runtime_generate_unsupported("manual VLESS proxy link is invalid"); + + let outbound = { + type: "vless", + tag: tag_name, + server: host, + server_port: port, + uuid + }; + let flow = as_string(query.flow || ""); + if (flow != "") + outbound.flow = flow; + let packet_encoding = as_string(query.packetEncoding || ""); + if (packet_encoding == "xudp" || packet_encoding == "packetaddr") + outbound.packet_encoding = packet_encoding; + apply_link_tls(outbound, "vless", query); + apply_link_transport(outbound, query); + return outbound; +} + +function vmess_json_value(value) { + return value == null ? "" : as_string(value); +} + +function manual_vmess_outbound(link, tag_name) { + let encoded = substr(url_strip_fragment_value(link), 8); + let decoded = base64_decode_value(encoded); + if (decoded == null) + runtime_generate_unsupported("manual VMess proxy link is invalid"); + + if (index(decoded, "\r") >= 0 || index(decoded, "\n") >= 0) + decoded = replace(decoded, /[\r\n]/g, ""); + decoded = trim(decoded); + + let vmess; + try { + vmess = json(decoded); + } + catch (e) { + runtime_generate_unsupported("manual VMess proxy link is invalid"); + } + if (type(vmess) != "object") + runtime_generate_unsupported("manual VMess proxy link is invalid"); + + let host = vmess_json_value(vmess.add); + let port = parse_port(vmess_json_value(vmess.port)); + let uuid = vmess_json_value(vmess.id); + if (host == "" || port == null || uuid == "") + runtime_generate_unsupported("manual VMess proxy link is invalid"); + + let outbound = { + type: "vmess", + tag: tag_name, + server: host, + server_port: port, + uuid, + security: vmess_json_value(vmess.scy) != "" ? vmess_json_value(vmess.scy) : "auto" + }; + + if (vmess_json_value(vmess.aid) != "") + outbound.alter_id = int(vmess.aid || 0); + + let network = lc(vmess_json_value(vmess.net)); + if (vmess.tls === true || vmess.tls == "tls" || vmess.tls == "true") { + let tls = { enabled: true }; + optional_query_string(tls, "server_name", vmess_json_value(vmess.sni)); + let alpn = tls_alpn_array(vmess_json_value(vmess.alpn), network); + if (length(alpn) > 0) + tls.alpn = alpn; + if (vmess_json_value(vmess.fp) != "") { + tls.utls = { + enabled: true, + fingerprint: vmess_json_value(vmess.fp) + }; + } + outbound.tls = tls; + } + + if (network == "ws") { + outbound.transport = { + type: "ws", + path: vmess_json_value(vmess.path) != "" ? vmess_json_value(vmess.path) : "/" + }; + if (vmess_json_value(vmess.host) != "") + outbound.transport.headers = { Host: vmess_json_value(vmess.host) }; + } + else if (network == "grpc") { + outbound.transport = { type: "grpc" }; + optional_query_string(outbound.transport, "service_name", vmess_json_value(vmess.path)); + } + else if (network == "http" || network == "h2") { + outbound.transport = { type: "http" }; + optional_query_string(outbound.transport, "path", vmess_json_value(vmess.path)); + let hosts = csv_array(vmess_json_value(vmess.host)); + if (length(hosts) > 0) + outbound.transport.host = hosts; + } + + return outbound; +} + +function manual_trojan_outbound(link, tag_name) { + let query = url_query_params(link); + + let host = url_host(link); + let port = parse_port(url_port(link)); + let password = url_userinfo(link); + if (host == "" || port == null || password == "") + runtime_generate_unsupported("manual Trojan proxy link is invalid"); + + let outbound = { + type: "trojan", + tag: tag_name, + server: host, + server_port: port, + password + }; + apply_link_tls(outbound, "trojan", query); + apply_link_transport(outbound, query); + return outbound; +} + +function manual_hysteria2_outbound(link, tag_name) { + let query = url_query_params(link); + let host = url_host(link); + let port = parse_port(as_string(query.mport || "") != "" ? query.mport : url_port(link)); + let password = url_userinfo(link); + if (host == "" || port == null || password == "") + runtime_generate_unsupported("manual Hysteria2 proxy link is invalid"); + + let outbound = { + type: "hysteria2", + tag: tag_name, + server: host, + server_port: port, + password + }; + if (as_string(query.obfs || "") != "") + outbound.obfs = { type: as_string(query.obfs), password: as_string(query["obfs-password"] || "") }; + if (as_string(query.upmbps || "") != "") + outbound.up_mbps = int(query.upmbps, 10); + if (as_string(query.downmbps || "") != "") + outbound.down_mbps = int(query.downmbps, 10); + apply_link_tls(outbound, url_scheme(link), query); + return outbound; +} + +function manual_link_outbound(link, tag_name, udp_over_tcp) { + let scheme = url_scheme(link); + if (scheme == "vmess") + return manual_vmess_outbound(link, tag_name); + + link = url_strip_fragment_value(url_decode(link)); + scheme = url_scheme(link); + if (scheme == "http" || scheme == "https") + return manual_http_outbound(link, tag_name); + if (scheme == "socks4" || scheme == "socks4a" || scheme == "socks5") + return manual_socks_outbound(link, tag_name, udp_over_tcp); + if (scheme == "ss") + return manual_shadowsocks_outbound(link, tag_name, udp_over_tcp); + if (scheme == "vless") + return manual_vless_outbound(link, tag_name); + if (scheme == "trojan") + return manual_trojan_outbound(link, tag_name); + if (scheme == "hysteria2" || scheme == "hy2") + return manual_hysteria2_outbound(link, tag_name); + runtime_generate_unsupported("manual proxy link scheme is not supported by runtime generator yet"); +} + +function add_manual_proxy_link(config, state, section_name, manual_index, link, udp_over_tcp, taken, selector_tags) { + let tag_name = outbound_tag(section_name + "-" + manual_index); + if (taken[tag_name]) + tag_name = unique_tag(tag_name, taken); + taken[tag_name] = true; + + let outbound = manual_link_outbound(link, tag_name, udp_over_tcp); + push(config.outbounds, outbound); + push(selector_tags, tag_name); + + let display_name = url_fragment(link); + if (display_name == "") + display_name = tag_name; + state.links[tag_name] = as_string(link); + state.outboundMetadata.names[tag_name] = display_name; + if (as_string(outbound.server || "") != "") + state.servers[tag_name] = as_string(outbound.server); +} + +function add_proxy_outbound(config, section, taken) { + let section_name = section[".name"]; + + let subscription_urls = list_option(section, "subscription_urls"); + + let selector_tags = []; + let state = runtime_subscription.new_section_state(section_name); + let udp_over_tcp = bool_option(section, "enable_udp_over_tcp", false); + let manual_links = list_option(section, "selector_proxy_links"); + for (let i = 0; i < length(manual_links); i++) + add_manual_proxy_link(config, state, section_name, i + 1, manual_links[i], udp_over_tcp, taken, selector_tags); + + for (let i = 0; i < length(subscription_urls); i++) + add_subscription_source_with_state(config, section_name, i + 1, subscription_urls[i], taken, selector_tags, state); + + if (length(selector_tags) == 0) + runtime_generate_unsupported("proxy section has no usable outbounds"); + + apply_detour_to_outbounds(config, selector_tags, outbound_detour_tag_for_section(section)); + add_proxy_selector(config, section, selector_tags, state); + if (!atomic_write_json_file(runtime_subscription.section_cache_path(section_name), state)) + runtime_generate_unsupported("failed to write section cache for " + section_name); +} + +function add_json_outbound(config, section) { + let outbound_json = option(section, "outbound_json", ""); + if (outbound_json == "") + runtime_generate_unsupported("JSON outbound is not set"); + + let outbound; + try { + outbound = json(outbound_json); + } + catch (e) { + runtime_generate_unsupported("JSON outbound is invalid"); + } + + if (type(outbound) != "object") + runtime_generate_unsupported("JSON outbound is not an object"); + + outbound.tag = outbound_tag(section[".name"]); + let detour_tag = outbound_detour_tag_for_section(section); + if (detour_tag != "") + outbound.detour = detour_tag; + push(config.outbounds, outbound); +} + +function add_vpn_outbound(config, section) { + let interface_name = option(section, "interface", ""); + if (interface_name == "") + runtime_generate_unsupported("VPN interface is not set"); + + let domain_resolver = ""; + if (bool_option(section, "domain_resolver_enabled", false)) { + domain_resolver = runtime_constants.domain_resolver_tag(section[".name"]); + let dns_server = runtime_dns.server_from_options( + domain_resolver, + option(section, "domain_resolver_dns_type", ""), + option(section, "domain_resolver_dns_server", ""), + outbound_tag(section[".name"]) + ); + if (dns_server.unsupported) + runtime_generate_unsupported(dns_server.unsupported); + push(config.dns.servers, dns_server); + } + + push(config.outbounds, { + type: "direct", + tag: outbound_tag(section[".name"]), + bind_interface: interface_name, + domain_resolver, + routing_mark: runtime_constants.OUTBOUND_MARK + }); + if (domain_resolver == "") + delete config.outbounds[length(config.outbounds) - 1].domain_resolver; +} + +function enabled_action_index(sections, target_section, action_name) { + let index = 0; + for (let section in sections) { + if (option(section, "action", "") != action_name) + continue; + index++; + if (section[".name"] == target_section[".name"]) + return index; + } + return 0; +} + +function add_zapret_outbound(config, section, sections) { + let index = enabled_action_index(sections, section, "zapret"); + if (index <= 0) + runtime_generate_unsupported("unable to resolve Zapret index for " + section[".name"]); + push(config.outbounds, { + type: "direct", + tag: outbound_tag(section[".name"]), + routing_mark: runtime_constants.ZAPRET_ROUTE_MARK_BASE + index + }); +} + +function add_zapret2_outbound(config, section, sections) { + let index = enabled_action_index(sections, section, "zapret2"); + if (index <= 0) + runtime_generate_unsupported("unable to resolve Zapret2 index for " + section[".name"]); + push(config.outbounds, { + type: "direct", + tag: outbound_tag(section[".name"]), + routing_mark: runtime_constants.ZAPRET2_ROUTE_MARK_BASE + index + }); +} + +function add_byedpi_outbound(config, section, sections) { + let index = enabled_action_index(sections, section, "byedpi"); + if (index <= 0) + runtime_generate_unsupported("unable to resolve ByeDPI index for " + section[".name"]); + push(config.outbounds, { + type: "socks", + tag: outbound_tag(section[".name"]), + server: runtime_constants.BYEDPI_LISTEN_ADDRESS, + server_port: runtime_constants.BYEDPI_PORT_BASE + index - 1, + version: "5" + }); +} + +function ensure_community_ruleset(config, section_name, community) { + if (!runtime_rulesets.is_community(community)) + runtime_generate_unsupported("unknown community list " + community); + + let tag_name = ruleset_tag(section_name, community, "community"); + if (!ruleset_registered(config, tag_name)) { + push(config.route.rule_set, { + type: "remote", + tag: tag_name, + format: "binary", + url: runtime_rulesets.community_url(community), + update_interval: remote_ruleset_update_interval() + }); + } + return { + tag: tag_name, + kind: "domains" + }; +} + +function domain_ip_list_ruleset_tag(section_name) { + return ruleset_tag(section_name, "lists", ""); +} + +function domain_ip_list_ruleset_path(section_name) { + return runtime_ruleset_folder + "/" + domain_ip_list_ruleset_tag(section_name) + ".json"; +} + +function reference_is_remote(reference) { + reference = as_string(reference); + return substr(reference, 0, 7) == "http://" || substr(reference, 0, 8) == "https://"; +} + +function reference_is_local(reference) { + return substr(as_string(reference), 0, 1) == "/"; +} + +function source_file_exists(path) { + return fs.readfile(path) != null; +} + +function rebuild_local_domain_ip_list_ruleset(section_name, references) { + let ruleset_path = domain_ip_list_ruleset_path(section_name); + let has_local = false; + + for (let reference in references) { + if (reference_is_local(reference)) { + has_local = true; + break; + } + } + + if (!has_local) + return; + + fs.unlink(ruleset_path); + source_rulesets.create_source(ruleset_path); + + for (let reference in references) { + reference = as_string(reference); + if (!reference_is_local(reference)) + continue; + if (!source_file_exists(reference)) { + warn("local domain/IP list not found: ", reference, "\n"); + continue; + } + + source_rulesets.import_plain_list(reference, ruleset_path, "domain_suffix", "domains", "5000"); + source_rulesets.import_plain_list(reference, ruleset_path, "ip_cidr", "subnets", "5000"); + } +} + +function add_domain_ip_list_ruleset(config, section_name, rule_set_tags, dns_rule_set_tags, references) { + if (length(references) == 0) + return; + + rebuild_local_domain_ip_list_ruleset(section_name, references); + + let ruleset_path = domain_ip_list_ruleset_path(section_name); + if (!source_rulesets.has_rules(ruleset_path)) + return; + + let tag_name = domain_ip_list_ruleset_tag(section_name); + if (!ruleset_registered(config, tag_name)) { + push(config.route.rule_set, { + type: "local", + tag: tag_name, + format: "source", + path: ruleset_path + }); + } + + push(rule_set_tags, tag_name); + if (source_rulesets.has_domain_matchers(ruleset_path)) + push(dns_rule_set_tags, tag_name); +} + +function split_condition_text(value) { + value = replace(as_string(value), /[\t\r\n,]+/g, " "); + value = replace(value, / +/g, " "); + value = trim(value); + return value == "" ? [] : split(value, " "); +} + +function legacy_condition_values(section, key) { + let list_values = list_option(section, key); + let text_values = split_condition_text(option(section, key + "_text", "")); + + if (bool_option(section, key + "_text_mode", false) || bool_option(section, "conditions_text_mode", false)) + return text_values; + if (length(list_values) > 0) + return list_values; + return text_values; +} + +function combined_domain_source_values(section) { + let values = []; + for (let value in split_condition_text(option(section, "domain_suffix_text", ""))) + if (as_string(value) != "") + push(values, as_string(value)); + for (let value in list_option(section, "domain_suffix")) + if (as_string(value) != "") + push(values, as_string(value)); + return values; +} + +function domain_suffix_condition_value_kind(value) { + value = as_string(value); + let colon = index(value, ":"); + if (colon < 0) + return { kind: "domain_suffix", value }; + + let prefix = substr(value, 0, colon); + let body = substr(value, colon + 1); + if (prefix == "domain" || prefix == "full") + return { kind: "domain", value: body }; + if (prefix == "suffix") + return { kind: "domain_suffix", value: body }; + if (prefix == "keyword") + return { kind: "domain_keyword", value: body }; + if (prefix == "regexp" || prefix == "regex") + return { kind: "domain_regex", value: body }; + return { kind: "domain_suffix", value }; +} + +function domain_condition_values(section, key) { + let result = []; + + if (key == "domain_suffix") { + for (let value in combined_domain_source_values(section)) { + let normalized = domain_suffix_condition_value_kind(value); + if (normalized.kind == "domain_suffix") + push(result, normalized.value); + } + return result; + } + + for (let value in legacy_condition_values(section, key)) + push(result, value); + + for (let value in combined_domain_source_values(section)) { + let normalized = domain_suffix_condition_value_kind(value); + if (normalized.kind == key) + push(result, normalized.value); + } + + return result; +} + +function add_domain_array(rule, key, values) { + if (length(values) > 0) + rule[key] = values; +} + +function merge_unique_array(current, additions) { + let result = []; + let seen = {}; + let current_values = current == null ? [] : (type(current) == "array" ? current : [ current ]); + let addition_values = additions == null ? [] : (type(additions) == "array" ? additions : [ additions ]); + for (let value in current_values) { + let key = as_string(value); + if (seen[key]) + continue; + seen[key] = true; + push(result, value); + } + for (let value in addition_values) { + let key = as_string(value); + if (seen[key]) + continue; + seen[key] = true; + push(result, value); + } + return result; +} + +function find_tagged_dns_rule(config, tag_name) { + for (let rule in array_or_empty(config.dns.rules)) { + if (type(rule) == "object" && rule.__service_tag == tag_name) + return rule; + } + return null; +} + +function merge_dns_matcher_rule(config, tag_name, rule, matcher_keys) { + let existing = find_tagged_dns_rule(config, tag_name); + if (existing == null) { + rule.__service_tag = tag_name; + push(config.dns.rules, rule); + return; + } + + for (let key in matcher_keys) { + if (rule[key] == null) + continue; + existing[key] = merge_unique_array(existing[key], rule[key]); + } +} + +function single_or_array(values) { + return length(values) == 1 ? values[0] : values; +} + +function normalize_port_number_value(value) { + return rule_config.normalize_port_number_value(value); +} + +function add_port_matchers(rule, section) { + let values = []; + for (let value in list_option(section, "ports")) + push(values, value); + for (let value in split_condition_text(option(section, "ports_text", ""))) + push(values, value); + + let ports = []; + let port_ranges = []; + let seen = {}; + for (let value in values) { + value = trim(as_string(value)); + if (value == "" || seen[value]) + continue; + seen[value] = true; + + let dash = index(value, "-"); + if (dash < 0) { + let port = normalize_port_number_value(value); + if (port != null) + push(ports, port); + continue; + } + + let start = normalize_port_number_value(substr(value, 0, dash)); + let end = normalize_port_number_value(substr(value, dash + 1)); + if (start != null && end != null && start <= end) + push(port_ranges, start == end ? as_string(start) : sprintf("%d:%d", start, end)); + } + + if (length(ports) > 0) + rule.port = ports; + if (length(port_ranges) > 0) + rule.port_range = port_ranges; +} + +function add_fully_routed_ips_rule(config, section) { + let source_ip_cidr = list_option(section, "fully_routed_ips"); + if (length(source_ip_cidr) == 0) + return; + + let target = runtime_route.target(section, outbound_tag(section[".name"])); + if (target.unsupported) + runtime_generate_unsupported(target.unsupported); + + let route_rule = { + action: target.action, + inbound: runtime_constants.TPROXY_INBOUND_TAG + }; + if (target.outbound) + route_rule.outbound = target.outbound; + route_rule.source_ip_cidr = single_or_array(source_ip_cidr); + push(config.route.rules, route_rule); +} + +function add_combined_route_for_section(config, section) { + let domain = domain_condition_values(section, "domain"); + let domain_suffix = domain_condition_values(section, "domain_suffix"); + let domain_keyword = domain_condition_values(section, "domain_keyword"); + let domain_regex = domain_condition_values(section, "domain_regex"); + let ip_cidr = legacy_condition_values(section, "ip_cidr"); + let source_ip_cidr = legacy_condition_values(section, "source_ip_cidr"); + let rule_set_tags = []; + let dns_rule_set_tags = []; + let section_name = section[".name"]; + + add_fully_routed_ips_rule(config, section); + + for (let community in list_option(section, "community_lists")) { + let ensured = ensure_community_ruleset(config, section_name, as_string(community)); + push(rule_set_tags, ensured.tag); + push(dns_rule_set_tags, ensured.tag); + } + for (let reference in list_option(section, "rule_set")) { + let ensured = ensure_custom_ruleset(config, as_string(reference)); + push(rule_set_tags, ensured.tag); + if (ensured.kind == "domains") + push(dns_rule_set_tags, ensured.tag); + } + for (let reference in list_option(section, "rule_set_with_subnets")) { + let ensured = ensure_custom_ruleset(config, as_string(reference)); + push(rule_set_tags, ensured.tag); + if (ensured.kind == "domains") + push(dns_rule_set_tags, ensured.tag); + } + add_domain_ip_list_ruleset( + config, + section_name, + rule_set_tags, + dns_rule_set_tags, + list_option(section, "domain_ip_lists") + ); + + let target = runtime_route.target(section, outbound_tag(section_name)); + if (target.unsupported) + runtime_generate_unsupported(target.unsupported); + let route_rule = { + action: target.action, + inbound: runtime_constants.TPROXY_INBOUND_TAG + }; + if (target.outbound) + route_rule.outbound = target.outbound; + add_domain_array(route_rule, "domain", domain); + add_domain_array(route_rule, "domain_suffix", domain_suffix); + add_domain_array(route_rule, "domain_keyword", domain_keyword); + add_domain_array(route_rule, "domain_regex", domain_regex); + if (length(ip_cidr) > 0) + route_rule.ip_cidr = ip_cidr; + if (length(source_ip_cidr) > 0) + route_rule.source_ip_cidr = source_ip_cidr; + add_port_matchers(route_rule, section); + if (length(rule_set_tags) > 0) + route_rule.rule_set = single_or_array(rule_set_tags); + + let has_route_matchers = route_rule.domain != null || route_rule.domain_suffix != null || + route_rule.domain_keyword != null || route_rule.domain_regex != null || + route_rule.ip_cidr != null || route_rule.port != null || route_rule.port_range != null || + route_rule.rule_set != null; + if (has_route_matchers) { + let resolve = runtime_route.resolve_rule_for_section(section, route_rule); + if (type(resolve) == "object" && resolve.warning) + warn(resolve.warning, "\n"); + else if (type(resolve) == "object" && resolve.rule) + push(config.route.rules, resolve.rule); + push(config.route.rules, route_rule); + } + + let rewrite_ttl = int_option(runtime_settings(), "dns_rewrite_ttl", "60"); + if (length(domain) > 0 || length(domain_suffix) > 0 || length(domain_keyword) > 0 || length(domain_regex) > 0) { + let dns_rule = { + action: "route", + server: runtime_constants.FAKEIP_DNS_SERVER_TAG, + rewrite_ttl + }; + add_domain_array(dns_rule, "domain", domain); + add_domain_array(dns_rule, "domain_suffix", domain_suffix); + add_domain_array(dns_rule, "domain_keyword", domain_keyword); + add_domain_array(dns_rule, "domain_regex", domain_regex); + merge_dns_matcher_rule(config, runtime_constants.FAKEIP_DNS_RULE_TAG, dns_rule, + [ "domain", "domain_suffix", "domain_keyword", "domain_regex" ]); + } + if (length(dns_rule_set_tags) > 0) { + merge_dns_matcher_rule(config, runtime_constants.FAKEIP_RULESET_DNS_RULE_TAG, { + action: "route", + server: runtime_constants.FAKEIP_DNS_SERVER_TAG, + rewrite_ttl, + rule_set: single_or_array(dns_rule_set_tags) + }, [ "rule_set" ]); + } +} + +function unsupported_matcher_key(section) { + let unsupported_options = [ + "subnet", "subnet_text", + "local_domain_lists", "local_subnet_lists", + "remote_domain_lists", "remote_subnet_lists" + ]; + for (let key in unsupported_options) { + if (length(list_option(section, key)) > 0 || option(section, key, "") != "") + return key; + } + return ""; +} + +function add_routing_excluded_ips(config, settings) { + let source_ip_cidr = list_option(settings, "routing_excluded_ips"); + if (length(source_ip_cidr) == 0) + return; + + push(config.route.rules, { + action: "route", + inbound: runtime_constants.TPROXY_INBOUND_TAG, + outbound: runtime_constants.DIRECT_OUTBOUND_TAG, + source_ip_cidr: single_or_array(source_ip_cidr) + }); +} + +function add_outbound_for_section(config, section, taken, sections) { + let action = option(section, "action", ""); + let section_name = section[".name"]; + if (!valid_section_name(section_name)) + runtime_generate_unsupported("section name is not safe for runtime generator"); + let unsupported_matcher = unsupported_matcher_key(section); + if (unsupported_matcher != "") + runtime_generate_unsupported("section has unsupported matcher " + unsupported_matcher); + + if (action == "proxy") + add_proxy_outbound(config, section, taken); + else if (action == "outbound") + add_json_outbound(config, section); + else if (action == "vpn") + add_vpn_outbound(config, section); + else if (action == "zapret") + add_zapret_outbound(config, section, sections); + else if (action == "zapret2") + add_zapret2_outbound(config, section, sections); + else if (action == "byedpi") + add_byedpi_outbound(config, section, sections); + else if (action == "direct") + push(config.outbounds, { type: "direct", tag: outbound_tag(section_name) }); + else if (action == "block") { + /* route-only action */ + } + else { + runtime_generate_unsupported("unsupported action " + action); + } +} + +function reserve_section_outbound_tags(sections, taken) { + for (let section in sections) { + let action = option(section, "action", ""); + if (action == "proxy" || action == "direct" || action == "outbound" || action == "vpn" || + action == "byedpi" || action == "zapret" || action == "zapret2") + taken[outbound_tag(section[".name"])] = true; + } +} + +function add_route_for_section(config, section) { + add_combined_route_for_section(config, section); +} + +function add_service_route_rules(config, sections) { + let first = null; + for (let section in sections) { + let action = option(section, "action", ""); + if (action == "proxy" || action == "direct" || action == "outbound" || action == "vpn" || + action == "byedpi" || action == "zapret" || action == "zapret2") { + first = section; + break; + } + } + if (first != null) { + push(config.route.rules, { + action: "route", + inbound: runtime_constants.TPROXY_INBOUND_TAG, + outbound: outbound_tag(first[".name"]), + domain: runtime_constants.CHECK_PROXY_IP_DOMAIN + }); + } + push(config.route.rules, { + action: "route-options", + domain: runtime_constants.FAKEIP_TEST_DOMAIN, + override_port: 8443 + }); +} + +function enabled_sections() { + let result = []; + uci_cursor().foreach(CONFIG_NAME, "section", function(section) { + if (section_enabled(section)) + push(result, section); + }); + return result; +} + +function enabled_servers() { + let result = []; + uci_cursor().foreach(CONFIG_NAME, "server", function(section) { + if (section_enabled(section)) + push(result, section); + }); + return result; +} + +function section_by_name(sections, name) { + name = as_string(name); + for (let section in sections) + if (as_string(section[".name"]) == name) + return section; + return null; +} + +function add_server_routes(config, servers, sections) { + for (let server in servers) { + runtime_servers.add_sniff_rule(config, server); + + let inbound = runtime_constants.server_inbound_tag(server[".name"]); + let routing_mode = option(server, "routing_mode", "rules"); + if (routing_mode == "rules") { + runtime_servers.clone_rules_for_inbound( + config, + runtime_constants.TPROXY_INBOUND_TAG, + inbound, + runtime_constants.CHECK_PROXY_IP_DOMAIN + ); + } + else if (routing_mode == "direct") { + push(config.route.rules, { + action: "route", + inbound, + outbound: runtime_constants.DIRECT_OUTBOUND_TAG + }); + } + else if (routing_mode == "section") { + let routing_section_name = option(server, "routing_section", ""); + let routing_section = section_by_name(sections, routing_section_name); + if (routing_section == null) + runtime_generate_unsupported("server references missing routing section " + routing_section_name); + let target = runtime_route.target(routing_section, outbound_tag(routing_section[".name"])); + if (target.unsupported) + runtime_generate_unsupported(target.unsupported); + let rule = { + action: target.action, + inbound + }; + if (target.outbound) + rule.outbound = target.outbound; + push(config.route.rules, rule); + } + else { + runtime_generate_unsupported("unsupported server routing_mode " + routing_mode); + } + } +} + +function generate_config(output_path, service_address, mwan3_active) { + let cursor = uci_cursor(); + cursor.load(CONFIG_NAME); + runtime_settings_cache = object_or_empty(cursor.get_all(CONFIG_NAME, "settings")); + let settings = runtime_settings_cache; + check_supported_settings(settings); + + let sections = enabled_sections(); + let servers = enabled_servers(); + if (length(sections) == 0 && length(servers) == 0) + runtime_generate_unsupported("no enabled sections"); + + let config = base_config(settings, service_address, { mwan3_active: cli_bool(mwan3_active) }); + let taken = { [runtime_constants.DIRECT_OUTBOUND_TAG]: true }; + reserve_section_outbound_tags(sections, taken); + for (let server in servers) + runtime_servers.add_server(config, server); + for (let section in sections) + add_outbound_for_section(config, section, taken, sections); + add_service_route_rules(config, sections); + add_routing_excluded_ips(config, settings); + for (let section in sections) + add_route_for_section(config, section); + add_server_routes(config, servers, sections); + add_service_mixed_proxy(config, settings); + for (let section in sections) + add_mixed_proxy_for_section(config, section, service_address); + + strip_internal_fields(config); + if (!write_json_file(output_path, config)) { + warn("failed to write ", output_path, "\n"); + exit(1); + } +} + +function generate_config_fixture(fixture_path, output_path, service_address, mwan3_active) { + use_fixture_cursor(fixture_path); + runtime_subscription.set_section_cache_dir(output_path + ".section-cache"); + runtime_ruleset_folder = output_path + ".rulesets"; + generate_config(output_path, service_address, mwan3_active); +} + +function merge_object_values(target, source) { + target = object_or_empty(target); + for (let key, value in object_or_empty(source)) + target[key] = value; + return target; +} + +function stdin_length() { + let value = read_stdin_json(); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function stdin_contains(needle) { + return index(read_stdin(), as_string(needle)) >= 0; +} + +function stdin_regex_matches(pattern) { + pattern = as_string(pattern); + if (pattern == "") + return false; + + try { + return match(read_stdin(), regexp(pattern)) != null; + } + catch (e) { + return false; + } +} + +function file_line_count(path) { + let data = fs.readfile(path); + let count = 0; + + if (data == null) { + print("0\n"); + return; + } + + for (let i = 0; i < length(data); i++) + if (substr(data, i, 1) == "\n") + count++; + + print(count, "\n"); +} + +function ip_addr_first_inet4() { + for (let line in split(read_stdin(), "\n")) { + let fields = split(trim(as_string(line)), /[ \t]+/); + if (length(fields) < 2 || fields[0] != "inet") + continue; + + let slash = index(fields[1], "/"); + print(slash >= 0 ? substr(fields[1], 0, slash) : fields[1], "\n"); + return; + } +} + +function stdin_first_dns_a_address() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) != null) { + print(line, "\n"); + return; + } + } +} + +function stdin_first_dns_aaaa_address() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^[0-9A-Fa-f:]+$/) != null) { + print(line, "\n"); + return; + } + } +} + +function stdin_first_nslookup_address() { + for (let line in split(read_stdin(), "\n")) { + line = as_string(line); + if (match(line, /^Address[ \t]*[0-9]*:[ \t]*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) == null && + match(line, /^Address[ \t]*[0-9]*:[ \t]*[0-9A-Fa-f:]+$/) == null) + continue; + + let fields = split(trim(line), /[ \t]+/); + if (length(fields) > 0) + print(fields[length(fields) - 1], "\n"); + return; + } +} + +function valid_ipv6_literal(value) { + value = as_string(value); + return index(value, ":") >= 0 && match(value, /^[0-9A-Fa-f:.]+$/) != null; +} + +function stdin_first_field() { + let data = read_stdin(); + let newline = index(data, "\n"); + let line = newline >= 0 ? substr(data, 0, newline) : data; + let fields = split(trim(as_string(line)), /[ \t\r\n]+/); + + if (length(fields) > 0 && fields[0] != "") + print(fields[0], "\n"); +} + +function normalize_country_server_key(value) { + print(lc(trim(as_string(value))), "\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 array_append_string(value) { + let result = array_or_empty(read_stdin_json()); + push(result, as_string(value)); + write_json(result); +} + +function merge_proxy_group_subscription_state(tags_path, link_refs_path, names_path, servers_path, + subscription_tags_path, subscription_link_refs_path, subscription_names_path, subscription_servers_path) { + let tags = array_or_empty(read_json_file(tags_path)); + for (let tag in array_or_empty(read_json_file(subscription_tags_path))) + push(tags, tag); + + if (!write_file_json(tags_path, tags) || + !write_file_json(link_refs_path, merge_object_values(read_json_file(link_refs_path), read_json_file(subscription_link_refs_path))) || + !write_file_json(names_path, merge_object_values(read_json_file(names_path), read_json_file(subscription_names_path))) || + !write_file_json(servers_path, merge_object_values(read_json_file(servers_path), read_json_file(subscription_servers_path)))) + exit(1); +} + +function append_proxy_group_outbound_state(tags_path, links_path, names_path, servers_path, tag, link, display_name, server) { + tag = as_string(tag); + link = as_string(link); + display_name = as_string(display_name); + server = as_string(server); + + let tags = array_or_empty(read_json_file(tags_path)); + let links = object_or_empty(read_json_file(links_path)); + let names = object_or_empty(read_json_file(names_path)); + let servers = object_or_empty(read_json_file(servers_path)); + + push(tags, tag); + names[tag] = display_name; + if (link != "") + links[tag] = link; + if (server != "") + servers[tag] = server; + + if (!write_file_json(tags_path, tags) || + !write_file_json(links_path, links) || + !write_file_json(names_path, names) || + !write_file_json(servers_path, servers)) + exit(1); +} + +function normalized_country_list() { + write_json(runtime_urltest.normalized_country_list(read_stdin_json())); +} + +function countries_from_flag_names(path) { + write_json(runtime_urltest.countries_from_flag_names(read_json_file(path))); +} + +function urltest_regex_matching_tags(tags_path, names_path, regex_path) { + write_json(runtime_urltest.regex_matching_tag_array( + read_json_file(tags_path), + read_json_file(names_path), + read_json_file(regex_path) + )); +} + +function urltest_filter(mode, tags_path, names_path, countries_path, names_filter_path, regex_tags_path, countries_filter_path) { + write_json(runtime_urltest.filter_array( + mode, + read_json_file(tags_path), + read_json_file(names_path), + read_json_file(countries_path), + read_json_file(names_filter_path), + read_json_file(regex_tags_path), + read_json_file(countries_filter_path) + )); +} + +function urltest_filter_mode(mode, tags_path, names_path, countries_path, include_names_path, include_regex_path, include_countries_path, exclude_names_path, exclude_regex_path, exclude_countries_path) { + write_json(runtime_urltest.filter_mode( + mode, + read_json_file(tags_path), + read_json_file(names_path), + read_json_file(countries_path), + read_json_file(include_names_path), + read_json_file(include_regex_path), + read_json_file(include_countries_path), + read_json_file(exclude_names_path), + read_json_file(exclude_regex_path), + read_json_file(exclude_countries_path) + )); +} + +function final_urltest_outbounds(config_path, tags_path) { + let config = object_or_empty(read_json_file(config_path)); + let tags = array_or_empty(read_json_file(tags_path)); + let outbounds_by_tag = {}; + let skipped_types = { + selector: true, + urltest: true, + direct: true, + dns: true, + block: true + }; + let result = []; + + for (let outbound in array_or_empty(config.outbounds)) { + if (type(outbound) != "object") + continue; + + let tag = as_string(outbound.tag || ""); + if (tag != "") + outbounds_by_tag[tag] = outbound; + } + + for (let tag in tags) { + tag = as_string(tag); + let outbound = outbounds_by_tag[tag]; + if (type(outbound) != "object") + continue; + + let proxy_type = lc(as_string(outbound.type || "")); + if (skipped_types[proxy_type]) + continue; + + 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_country_object_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; + } + + return result; +} + +function cached_countries_for_servers(servers_path, cache_path) { + write_json(cached_country_object_for_servers(servers_path, cache_path)); +} + +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_country_object_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; + } + } + + return result; +} + +function server_countries_result(servers_path, cache_path, resolved_path, ip_country_path) { + let result = cached_country_object_for_servers(servers_path, cache_path); + + for (let tag, country in resolved_country_object_from_tsv(resolved_path, ip_country_path)) + result[tag] = 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; + } + } +} + +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 == "generate-config") + generate_config(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "generate-config-fixture") + generate_config_fixture(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +else if (mode == "stdin-length") + stdin_length(); +else if (mode == "stdin-contains") + exit(stdin_contains(ARGV[1]) ? 0 : 1); +else if (mode == "stdin-regex-matches") + exit(stdin_regex_matches(ARGV[1]) ? 0 : 1); +else if (mode == "csv-to-json-array") + csv_to_json_array(ARGV[1]); +else if (mode == "ip-addr-first-inet4") + ip_addr_first_inet4(); +else if (mode == "stdin-first-dns-a-address") + stdin_first_dns_a_address(); +else if (mode == "stdin-first-dns-aaaa-address") + stdin_first_dns_aaaa_address(); +else if (mode == "stdin-first-nslookup-address") + stdin_first_nslookup_address(); +else if (mode == "stdin-first-field") + stdin_first_field(); +else if (mode == "array-append-string") + array_append_string(ARGV[1]); +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 == "object-nonempty") + exit(object_nonempty_stdin() ? 0 : 1); +else { + warn("Usage: singbox/generator.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/singbox/route.uc b/podkop/files/usr/lib/singbox/route.uc new file mode 100644 index 00000000..f725e69a --- /dev/null +++ b/podkop/files/usr/lib/singbox/route.uc @@ -0,0 +1,84 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); +let runtime_constants = require("singbox.constants"); + +let option = common.option; +let bool_option = common.bool_option; + +function bool_value(value) { + return value === true || value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function config(settings, runtime) { + let output_network_interface = option(settings, "output_network_interface", ""); + let mwan3_active = type(runtime) == "object" && bool_value(runtime.mwan3_active); + let result = { + rules: [ + { action: "sniff", inbound: [ runtime_constants.TPROXY_INBOUND_TAG, runtime_constants.DNS_INBOUND_TAG ] }, + { action: "hijack-dns", port: 53 }, + { action: "hijack-dns", protocol: "dns" }, + { action: "reject", ip_version: 6 } + ], + rule_set: [], + final: runtime_constants.DIRECT_OUTBOUND_TAG, + auto_detect_interface: output_network_interface == "" && !mwan3_active, + default_domain_resolver: runtime_constants.DNS_SERVER_TAG, + default_mark: runtime_constants.OUTBOUND_MARK + }; + + if (output_network_interface != "") + result.default_interface = output_network_interface; + if (bool_option(settings, "disable_quic", false)) + push(result.rules, { action: "reject", inbound: runtime_constants.TPROXY_INBOUND_TAG, protocol: "quic" }); + + return result; +} + +function target(section, outbound_tag_name) { + let action = option(section, "action", ""); + if (action == "proxy" || action == "direct" || action == "outbound" || action == "vpn" || + action == "byedpi" || action == "zapret" || action == "zapret2") + return { action: "route", outbound: outbound_tag_name }; + if (action == "block") + return { action: "reject" }; + return { unsupported: "unsupported action " + action }; +} + +function has_resolve_matchers(rule) { + return rule.domain != null || rule.domain_suffix != null || rule.domain_keyword != null || + rule.domain_regex != null || rule.rule_set != null; +} + +function resolve_rule_for_section(section, route_rule) { + let action = option(section, "action", ""); + let should_resolve = action == "byedpi" || + ((action == "proxy" || action == "outbound" || action == "vpn") && + bool_option(section, "resolve_real_ip_for_routing", false)); + + if (!should_resolve) + return null; + + if (!has_resolve_matchers(route_rule)) { + return { + warning: "Resolve real IP is enabled for '" + section[".name"] + "', but no domain or rule-set matchers found" + }; + } + + let resolve_rule = {}; + for (let key in [ "inbound", "source_ip_cidr", "domain", "domain_suffix", "domain_keyword", "domain_regex", "rule_set", "port", "port_range" ]) { + if (route_rule[key] != null) + resolve_rule[key] = route_rule[key]; + } + resolve_rule.action = "resolve"; + resolve_rule.server = runtime_constants.DNS_SERVER_TAG; + + return { rule: resolve_rule }; +} + +return { + config, + target, + has_resolve_matchers, + resolve_rule_for_section +}; diff --git a/podkop/files/usr/lib/singbox/rulesets.uc b/podkop/files/usr/lib/singbox/rulesets.uc new file mode 100644 index 00000000..5f3d6a45 --- /dev/null +++ b/podkop/files/usr/lib/singbox/rulesets.uc @@ -0,0 +1,141 @@ +#!/usr/bin/env ucode + +const SRS_MAIN_URL = "https://github.com/itdoginfo/allow-domains/releases/latest/download"; +const SRS_ADS_HAGEZI_PRO_URL = "https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs"; +const SRS_SUPERCELL_URL = "https://raw.githubusercontent.com/ushan0v/sing-box-supercell-ruleset/main/supercell.srs"; + +const COMMUNITY_SERVICES = { + russia_inside: true, + russia_outside: true, + ukraine_inside: true, + geoblock: true, + block: true, + porn: true, + news: true, + anime: true, + youtube: true, + hdrezka: true, + tiktok: true, + google_ai: true, + google_play: true, + hodca: true, + discord: true, + meta: true, + twitter: true, + cloudflare: true, + cloudfront: true, + digitalocean: true, + hetzner: true, + ovh: true, + telegram: true, + roblox: true, + ads_hagezi_pro: true, + supercell: true +}; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function is_community(name) { + return COMMUNITY_SERVICES[as_string(name)] === true; +} + +function community_url(name) { + name = as_string(name); + if (name == "ads_hagezi_pro") + return SRS_ADS_HAGEZI_PRO_URL; + if (name == "supercell") + return SRS_SUPERCELL_URL; + return SRS_MAIN_URL + "/" + name + ".srs"; +} + +function str_last_index(value, needle) { + value = as_string(value); + needle = as_string(needle); + if (needle == "") + return length(value); + + for (let i = length(value) - length(needle); i >= 0; i--) + if (substr(value, i, length(needle)) == needle) + return i; + + return -1; +} + +function hash12(value) { + value = as_string(value); + let first = 2166136261; + let second = 16777619; + + for (let i = 0; i < length(value); i++) { + let code = ord(substr(value, i, 1)); + first = (first * 33 + code) % 4294967296; + second = (second * 131 + code) % 4294967296; + } + + return sprintf("%06x%06x", first % 16777216, second % 16777216); +} + +function file_extension(value) { + let basename = as_string(value); + let slash = str_last_index(basename, "/"); + if (slash >= 0) + basename = substr(basename, slash + 1); + + let query = index(basename, "?"); + if (query >= 0) + basename = substr(basename, 0, query); + + let fragment = index(basename, "#"); + if (fragment >= 0) + basename = substr(basename, 0, fragment); + + let dot = str_last_index(basename, "."); + return dot >= 0 ? lc(substr(basename, dot + 1)) : ""; +} + +function kind_from_reference_hint(reference) { + reference = lc(as_string(reference)); + if (index(reference, "geosite") >= 0 || index(reference, "domain") >= 0 || + index(reference, "domains") >= 0 || index(reference, "adguard") >= 0 || + index(reference, "filter") >= 0) + return "domains"; + if (index(reference, "geoip") >= 0 || index(reference, "subnet") >= 0 || + index(reference, "subnets") >= 0 || index(reference, "cidr") >= 0) + return "subnets"; + return "unknown"; +} + +function remote_format(reference) { + return file_extension(reference) == "json" ? "source" : "binary"; +} + +function module_exports() { + return { + is_community, + community_url, + hash12, + file_extension, + kind_from_reference_hint, + remote_format + }; +} + +if ((sourcepath(1) != null && sourcepath(1) != "") || ARGV[0] == null) + return module_exports(); + +let mode = ARGV[0] || ""; + +if (mode == "file-extension") + print(file_extension(ARGV[1]), "\n"); +else if (mode == "is-community") + exit(is_community(ARGV[1]) ? 0 : 1); +else if (mode == "kind-from-reference-hint") + print(kind_from_reference_hint(ARGV[1]), "\n"); +else if (mode == "remote-format") + print(remote_format(ARGV[1]), "\n"); +else { + warn("Usage: singbox/rulesets.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/singbox/runtime.uc b/podkop/files/usr/lib/singbox/runtime.uc new file mode 100644 index 00000000..cf23f723 --- /dev/null +++ b/podkop/files/usr/lib/singbox/runtime.uc @@ -0,0 +1,790 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || "/tmp/sing-box"; +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || TMP_SING_BOX_FOLDER + "/rulesets"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || TMP_SING_BOX_FOLDER + "/subscriptions"; +const RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update"; +const SUBSCRIPTION_LINKS_DIR = getenv("PODKOP_SUBSCRIPTION_LINKS_DIR") || RUNTIME_STATE_DIR + "/subscription-links"; +const SUBSCRIPTION_METADATA_DIR = getenv("PODKOP_SUBSCRIPTION_METADATA_DIR") || RUNTIME_STATE_DIR + "/subscription-metadata"; +const OUTBOUND_METADATA_DIR = getenv("PODKOP_OUTBOUND_METADATA_DIR") || RUNTIME_STATE_DIR + "/outbound-metadata"; +const SECTION_CACHE_DIR = getenv("PODKOP_SECTION_CACHE_DIR") || RUNTIME_STATE_DIR + "/section-cache"; +const RUNTIME_CACHE_FORMAT_FILE = getenv("PODKOP_RUNTIME_CACHE_FORMAT_FILE") || RUNTIME_STATE_DIR + "/cache-format"; +const RUNTIME_CACHE_FORMAT = getenv("PODKOP_RUNTIME_CACHE_FORMAT") || "7"; +const PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/podkop-plus/subscription-cache"; +const PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const PENDING_RELOAD_FILE = getenv("PODKOP_PENDING_RELOAD_FILE") || RUNTIME_STATE_DIR + "/reload.pending"; +const SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || "/etc/init.d/podkop-plus"; +const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "PodkopPlusTable"; +const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || "podkop_plus_subnets"; +const NFT_PORT_SET_NAME = getenv("NFT_PORT_SET_NAME") || "podkop_plus_ports"; +const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || "podkop_plus_ip_ports"; +const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || "podkop_plus_interfaces"; +const NFT_LOCALV4_SET_NAME = getenv("NFT_LOCALV4_SET_NAME") || "localv4"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x00100000"; +const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1"; +const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; +const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/podkop-plus/sing-box-variant"; +const SB_VERSION_STATE_FILE = getenv("SB_VERSION_STATE_FILE") || "/etc/podkop-plus/sing-box-version"; +const SB_MANAGED_SERVICE_MARKER = getenv("SB_MANAGED_SERVICE_MARKER") || "Podkop Plus managed sing-box service for binary variants"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + for (let arg in args) + push(parts, shell_quote(arg)); + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + return join(" ", parts); +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_status(command) { + let status = int(system(command)); + return status > 255 ? int(status / 256) : status; +} + +function command_success(command) { + return command_status(command + " >/dev/null 2>&1") == 0; +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_exists(name) { + return command_success_from_args([ "command", "-v", name ]); +} + +function write_file(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function remove_file(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function remove_files(paths) { + for (let path in paths) + if (as_string(path) != "") + remove_file(path); +} + +function file_first_line(path) { + let data = fs.readfile(as_string(path)); + if (data == null) + return ""; + let newline = index(data, "\n"); + return trim(newline >= 0 ? substr(data, 0, newline) : data); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function array_or_empty(value) { + return type(value) == "array" ? value : []; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function arg_bool(value) { + value = lc(as_string(value)); + return value == "true" || value == "1" || value == "yes" || value == "on"; +} + +function bool_option(section, key, fallback) { + let value = object_or_empty(section)[key]; + return value == null ? !!fallback : arg_bool(value); +} + +function whitespace_items(value) { + let result = []; + if (type(value) == "array") { + for (let item in value) { + item = as_string(item); + if (item != "") + push(result, item); + } + return result; + } + + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function file_exists(path) { + return fs.stat(as_string(path)) != null; +} + +function parent_dir(path) { + path = as_string(path); + let slash = rindex(path, "/"); + return slash >= 0 ? substr(path, 0, slash) : ""; +} + +function ensure_dir(path) { + return command_success_from_args([ "mkdir", "-p", path ]); +} + +function ensure_parent_dir(path) { + let dir = parent_dir(path); + return dir == "" || dir == "." || ensure_dir(dir); +} + +function temp_path() { + return trim(command_output_from_args([ "mktemp" ])); +} + +function md5_file(path) { + let line = trim(command_output_from_args([ "md5sum", as_string(path) ])); + return length(line) >= 32 ? substr(line, 0, 32) : ""; +} + +function first_line_last_field(value) { + value = as_string(value); + let newline = index(value, "\n"); + let line = trim(newline >= 0 ? substr(value, 0, newline) : value); + let fields = split(line, /[ \t\r\n]+/); + return length(fields) > 0 ? as_string(fields[length(fields) - 1]) : ""; +} + +function sing_box_version_output() { + return command_exists("sing-box") ? command_output_from_args([ "sing-box", "version" ]) : ""; +} + +function sing_box_marker_is(value) { + return file_first_line(SB_VARIANT_STATE_FILE) == as_string(value); +} + +function sing_box_version_state() { + return file_first_line(SB_VERSION_STATE_FILE); +} + +function sing_box_write_version_state(version) { + version = as_string(version); + return version != "" && ensure_parent_dir(SB_VERSION_STATE_FILE) && write_file(SB_VERSION_STATE_FILE, version + "\n"); +} + +function sing_box_clear_version_state() { + remove_file(SB_VERSION_STATE_FILE); + return true; +} + +function sing_box_restore_version_state(version) { + version = as_string(version); + return version != "" ? sing_box_write_version_state(version) : sing_box_clear_version_state(); +} + +function sing_box_variant_marker() { + return file_first_line(SB_VARIANT_STATE_FILE); +} + +function sing_box_write_variant_marker(variant) { + variant = as_string(variant); + return variant != "" && ensure_parent_dir(SB_VARIANT_STATE_FILE) && write_file(SB_VARIANT_STATE_FILE, variant + "\n"); +} + +function sing_box_clear_variant_marker() { + remove_file(SB_VARIANT_STATE_FILE); + return true; +} + +function sing_box_restore_variant_marker(variant) { + variant = as_string(variant); + return variant != "" ? sing_box_write_variant_marker(variant) : sing_box_clear_variant_marker(); +} + +function sing_box_version() { + if (!command_exists("sing-box")) + return ""; + if (sing_box_marker_is("extended-compressed")) + return sing_box_version_state(); + return first_line_last_field(sing_box_version_output()); +} + +function sing_box_version_is_extended(value) { + return index(as_string(value), "extended") >= 0; +} + +function sing_box_is_extended(value) { + value = as_string(value); + if (value == "" && command_exists("sing-box") && (sing_box_marker_is("extended-compressed") || sing_box_marker_is("extended"))) + return true; + + return sing_box_version_is_extended(value != "" ? value : sing_box_version()); +} + +function output_has_build_tag(output, tag) { + tag = as_string(tag); + if (tag == "") + return false; + + for (let item in split(trim(replace(as_string(output), /[,: \t\r\n]+/g, " ")), " ")) + if (as_string(item) == tag) + return true; + return false; +} + +function sing_box_supports_tailscale(version, version_output) { + version = as_string(version); + version_output = as_string(version_output); + + if (command_exists("sing-box") && sing_box_marker_is("extended-compressed")) + return true; + if (sing_box_is_extended(version)) + return true; + if (version_output != "") + return output_has_build_tag(version_output, "with_tailscale"); + return output_has_build_tag(sing_box_version_output(), "with_tailscale"); +} + +function sing_box_package_installed(name) { + return module_success([ LIB_DIR + "/core/packages.uc", "installed", as_string(name) ]); +} + +function sing_box_is_tiny(version, version_output) { + version = as_string(version); + version_output = as_string(version_output); + + if (command_exists("sing-box") && sing_box_marker_is("extended-compressed")) + return false; + if (sing_box_is_extended(version != "" ? version : sing_box_version())) + return false; + if (sing_box_package_installed("sing-box-tiny")) + return true; + if (!sing_box_marker_is("tiny")) + return false; + return !sing_box_supports_tailscale(version, version_output); +} + +function sing_box_variant() { + let version = ""; + + if (!command_exists("sing-box")) + return "not-installed"; + if (sing_box_marker_is("extended-compressed")) + return "extended-compressed"; + + version = sing_box_version(); + if (sing_box_is_extended(version)) + return sing_box_marker_is("extended-compressed") ? "extended-compressed" : "extended"; + if (sing_box_is_tiny(version, "")) + return "tiny"; + return "stable"; +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "podkop-plus", "[" + level + "] " + as_string(message) ]); +} + +function module_command(args) { + let command_args = [ "ucode", "-L", LIB_DIR ]; + for (let arg in args) + push(command_args, arg); + return command_from_args(command_args); +} + +function module_success(args) { + return command_status(module_command(args)) == 0; +} + +function module_env_capture(env, args) { + let output_path = temp_path(); + if (output_path == "") + return { status: 1, output: "" }; + + let status = command_status(command_env(env) + " " + module_command(args) + " >" + shell_quote(output_path) + " 2>&1"); + let output = as_string(fs.readfile(output_path) || ""); + remove_file(output_path); + return { status, output }; +} + +function uci_settings() { + return object_or_empty(uci_core.get_all(CONFIG_NAME, "settings")); +} + +function uci_path(config, section, key) { + return as_string(config) + "." + as_string(section) + "." + as_string(key); +} + +function uci_get_option(config, section, key) { + return trim(uci_core.get(uci_path(config, section, key))); +} + +function ensure_sing_box_main_section() { + if (uci_core.exists("sing-box.main")) + return true; + return uci_core.set_section("sing-box.main", "sing-box"); +} + +function uci_set_option(config, section, key, value) { + if (!uci_core.exists(as_string(config) + "." + as_string(section))) + return false; + return uci_core.set(uci_path(config, section, key), value); +} + +function uci_commit(config) { + return uci_core.commit(config); +} + +function owner_pid() { + let pid = trim(command_output_from_args([ "sh", "-c", "echo $PPID" ])); + return match(pid, /^[0-9]+$/) != null ? pid : "0"; +} + +function managed_service_installed() { + let data = fs.readfile("/etc/init.d/sing-box"); + return data != null && index(as_string(data), SB_MANAGED_SERVICE_MARKER) >= 0; +} + +function managed_service_text() { + return "#!/bin/sh /etc/rc.common\n" + + "# " + SB_MANAGED_SERVICE_MARKER + "\n\n" + + "USE_PROCD=1\n" + + "START=99\n" + + "PROG=\"/usr/bin/sing-box\"\n\n" + + "start_service() {\n" + + " config_load \"sing-box\"\n" + + " local enabled config_file working_directory\n" + + " local log_stderr\n\n" + + " config_get_bool enabled \"main\" \"enabled\" \"0\"\n" + + " [ \"$enabled\" -eq \"1\" ] || return 0\n\n" + + " config_get config_file \"main\" \"conffile\" \"/etc/sing-box/config.json\"\n" + + " config_get working_directory \"main\" \"workdir\" \"/usr/share/sing-box\"\n" + + " config_get_bool log_stderr \"main\" \"log_stderr\" \"1\"\n\n" + + " procd_open_instance\n" + + " procd_set_param command \"$PROG\" run -c \"$config_file\" -D \"$working_directory\"\n" + + " procd_set_param file \"$config_file\"\n" + + " procd_set_param stderr \"$log_stderr\"\n" + + " procd_set_param limits core=\"unlimited\"\n" + + " procd_set_param limits nofile=\"1000000 1000000\"\n" + + " procd_set_param respawn\n" + + " procd_close_instance\n" + + "}\n\n" + + "service_triggers() {\n" + + " procd_add_reload_trigger \"sing-box\"\n" + + "}\n"; +} + +function sing_box_compressed_marker_set() { + return trim(as_string(fs.readfile(SB_VARIANT_STATE_FILE) || "")) == "extended-compressed"; +} + +function install_managed_service_script() { + let tmp = "/etc/init.d/sing-box.podkop-plus." + owner_pid(); + if (!write_file(tmp, managed_service_text())) + return false; + if (!command_success_from_args([ "chmod", "0755", tmp ])) { + remove_file(tmp); + return false; + } + return fs.rename(tmp, "/etc/init.d/sing-box"); +} + +function remove_managed_service_script() { + if (!managed_service_installed()) + return; + + command_success_from_args([ "/etc/init.d/sing-box", "stop" ]); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + remove_file("/etc/init.d/sing-box"); +} + +function disable_service_config() { + if (!ensure_sing_box_main_section()) + return; + uci_set_option("sing-box", "main", "enabled", "0"); + uci_commit("sing-box"); +} + +function prepare_service_disabled() { + disable_service_config(); + if (file_exists("/etc/init.d/sing-box")) { + command_success_from_args([ "/etc/init.d/sing-box", "stop" ]); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + } +} + +function configure_service() { + let settings = uci_settings(); + + if (sing_box_compressed_marker_set() && !install_managed_service_script()) { + log_message("Failed to install managed sing-box service for compressed binary. Aborted.", "fatal"); + exit(1); + } + + if (!ensure_sing_box_main_section()) + exit(1); + + let changed = false; + if (uci_get_option("sing-box", "main", "enabled") != "1") { + if (!uci_set_option("sing-box", "main", "enabled", "1")) + exit(1); + changed = true; + log_message("sing-box service has been enabled", "info"); + } + + if (uci_get_option("sing-box", "main", "user") != "root") { + if (!uci_set_option("sing-box", "main", "user", "root")) + exit(1); + changed = true; + log_message("sing-box service user has been changed to root", "info"); + } + + let config_path = option(settings, "config_path", ""); + let conffile = uci_get_option("sing-box", "main", "conffile"); + log_message("sing-box config path: " + config_path, "debug"); + log_message("sing-box service conffile: " + conffile, "debug"); + if (conffile != config_path) { + if (!uci_set_option("sing-box", "main", "conffile", config_path)) + exit(1); + changed = true; + log_message("Configuration file path has been set to " + config_path, "info"); + } + + if (changed && !uci_commit("sing-box")) + exit(1); + + if (file_exists("/etc/rc.d/S99sing-box")) { + log_message("Disable sing-box", "info"); + command_success_from_args([ "/etc/init.d/sing-box", "disable" ]); + } +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy"; + if (purpose == "subscriptions") + return "download_subscriptions_via_proxy"; + if (purpose == "components") + return "download_components_via_proxy"; + return ""; +} + +function service_proxy_address(settings, purpose) { + let option_name = download_via_proxy_option_for_purpose(purpose); + return option_name != "" && bool_option(settings, option_name, false) ? + SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + SB_SERVICE_MIXED_INBOUND_PORT : ""; +} + +function ip_addr_first_inet4(data) { + for (let line in split(as_string(data), "\n")) { + let matched = match(line, /inet[ \t]+([0-9.]+)\//); + if (matched) + return as_string(matched[1]); + } + return ""; +} + +function network_interface_ipv4(name) { + let data = command_output_from_args([ "ubus", "call", "network.interface." + as_string(name), "status" ]); + try { + let value = json(data); + let addresses = array_or_empty(object_or_empty(value)["ipv4-address"]); + if (length(addresses) > 0) + return as_string(object_or_empty(addresses[0]).address); + } + catch (e) { + } + return ""; +} + +function device_ipv4_address_value(device) { + return ip_addr_first_inet4(command_output_from_args([ "ip", "-4", "addr", "show", "dev", as_string(device) ])); +} + +function service_listen_address_value(settings) { + let configured = option(settings, "service_listen_address", ""); + if (configured != "") { + log_message("Attention! The service_listen_address option is being used, overriding the automatic detection of the listening IP address!", "warn"); + return configured; + } + + let address = network_interface_ipv4("lan"); + if (address != "") + return address; + + for (let iface in whitespace_items(option(settings, "source_network_interfaces", "br-lan"))) { + address = network_interface_ipv4(iface); + if (address != "") + return address; + address = device_ipv4_address_value(iface); + if (address != "") + return address; + } + + log_message("Failed to determine the listening IP address. Please open an issue to report this problem: https://github.com/ushan0v/podkop-plus/issues", "error"); + return ""; +} + +function subscription_cache_env() { + return { + PODKOP_CONFIG_NAME: CONFIG_NAME, + PODKOP_LIB: LIB_DIR, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + PODKOP_RUNTIME_STATE_DIR: RUNTIME_STATE_DIR, + PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR: SUBSCRIPTION_UPDATE_STATE_DIR, + PODKOP_SUBSCRIPTION_LINKS_DIR: SUBSCRIPTION_LINKS_DIR, + PODKOP_SUBSCRIPTION_METADATA_DIR: SUBSCRIPTION_METADATA_DIR, + PODKOP_OUTBOUND_METADATA_DIR: OUTBOUND_METADATA_DIR, + PODKOP_SECTION_CACHE_DIR: SECTION_CACHE_DIR, + PODKOP_RUNTIME_CACHE_FORMAT_FILE: RUNTIME_CACHE_FORMAT_FILE, + PODKOP_RUNTIME_CACHE_FORMAT: RUNTIME_CACHE_FORMAT, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR: PERSISTENT_SUBSCRIPTION_CACHE_DIR, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE: PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + PODKOP_PENDING_RELOAD_FILE: PENDING_RELOAD_FILE, + PODKOP_SERVICE_INIT: SERVICE_INIT + }; +} + +function subscription_cache_capture(args) { + let command_args = [ LIB_DIR + "/subscription/cache.uc" ]; + for (let arg in args) + push(command_args, arg); + return module_env_capture(subscription_cache_env(), command_args); +} + +function log_lines(text, level, prefix) { + for (let line in split(as_string(text), "\n")) + if (trim(as_string(line)) != "") + log_message(as_string(prefix) + as_string(line), level); +} + +function first_nonblank_line(path) { + let data = as_string(fs.readfile(path) || ""); + for (let line in split(data, "\n")) + if (trim(as_string(line)) != "") + return as_string(line); + return ""; +} + +function log_file_lines(path, level, prefix) { + log_lines(fs.readfile(path), level, prefix); +} + +function prepare_subscription_caches(prepared, no_refresh) { + let result = subscription_cache_capture([ "prepare-caches", "runtime", prepared ? "1" : "0", no_refresh ? "1" : "0" ]); + if (result.status != 0) { + log_message("subscription caches are not ready for ucode sing-box runtime generation. Aborted.", "fatal"); + log_lines(result.output, "debug", "subscription/cache.uc: "); + return null; + } + return trim(result.output); +} + +function save_config_file(temp_file_path, config_path) { + let current_hash = md5_file(config_path); + let temp_hash = md5_file(temp_file_path); + log_message("Current sing-box config hash: " + current_hash, "debug"); + log_message("Temporary sing-box config hash: " + temp_hash, "debug"); + + if (current_hash != temp_hash) { + log_message("sing-box configuration has changed and will be updated", "info"); + if (!ensure_parent_dir(config_path)) + return false; + return command_success_from_args([ "mv", "-f", temp_file_path, config_path ]); + } + + log_message("sing-box configuration is unchanged", "info"); + remove_file(temp_file_path); + return true; +} + +function init_config(populate_nft, caches_prepared, no_refresh) { + let settings = uci_settings(); + let config_path = option(settings, "config_path", ""); + if (config_path == "") { + log_message("sing-box config path is empty. Aborted.", "fatal"); + exit(1); + } + + let mwan3_active = module_success([ LIB_DIR + "/config/validator.uc", "mwan3-is-active" ]); + let output_interface = option(settings, "output_network_interface", ""); + if (mwan3_active && output_interface != "") + log_message("mwan3 is active and Output Network Interface is set to '" + output_interface + "'; sing-box egress is pinned to this interface", "warn"); + else if (mwan3_active) + log_message("mwan3 is active; disabling sing-box auto_detect_interface so mwan3 can control egress routing", "warn"); + + let deferred_sections = prepare_subscription_caches(caches_prepared, no_refresh); + if (deferred_sections == null) + exit(1); + + let temp_config = temp_path(); + let runtime_log = temp_path(); + if (temp_config == "" || runtime_log == "") { + remove_files([ temp_config, runtime_log ]); + exit(1); + } + + let generate_status = command_status( + module_command([ + LIB_DIR + "/singbox/generator.uc", + "generate-config", + temp_config, + service_listen_address_value(settings), + mwan3_active ? "1" : "0" + ]) + " >" + shell_quote(runtime_log) + " 2>&1" + ); + if (generate_status != 0) { + let reason = first_nonblank_line(runtime_log); + if (reason == "") + reason = "exit status " + generate_status; + log_message("ucode sing-box runtime generator rejected this configuration: " + reason, "fatal"); + log_file_lines(runtime_log, "debug", "ucode sing-box runtime generator: "); + remove_files([ temp_config, runtime_log ]); + exit(1); + } + log_file_lines(runtime_log, "warn", "ucode sing-box runtime generator: "); + + if (!command_success_from_args([ "sing-box", "-c", temp_config, "check" ])) { + log_message("Sing-box configuration " + temp_config + " is invalid. Aborted.", "fatal"); + remove_files([ temp_config, runtime_log ]); + exit(1); + } + + if (populate_nft && !module_success([ + LIB_DIR + "/nft/apply.uc", + "nft-populate-runtime-sets-from-uci", + "1", + deferred_sections, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_PORT_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_LOCALV4_SET_NAME, + NFT_FAKEIP_MARK + ])) { + log_message("ucode sing-box runtime generator failed to populate nft runtime sets. Aborted.", "fatal"); + remove_files([ temp_config, runtime_log ]); + exit(1); + } + + if (!save_config_file(temp_config, config_path)) { + remove_file(runtime_log); + exit(1); + } + log_message("ucode sing-box runtime generator applied", "debug"); + remove_file(runtime_log); + print(deferred_sections, "\n"); +} + +let mode = ARGV[0] || ""; + +if (mode == "configure-service") + configure_service(); +else if (mode == "init-config") + init_config(arg_bool(ARGV[1] || "1"), arg_bool(ARGV[2] || "0"), arg_bool(ARGV[3] || "0")); +else if (mode == "save-config-file-fixture") + exit(save_config_file(ARGV[1] || "", ARGV[2] || "") ? 0 : 1); +else if (mode == "managed-service-installed") + exit(managed_service_installed() ? 0 : 1); +else if (mode == "remove-managed-service-script") + remove_managed_service_script(); +else if (mode == "prepare-service-disabled") + prepare_service_disabled(); +else if (mode == "service-proxy-address") + print(service_proxy_address(uci_settings(), ARGV[1] || "lists"), "\n"); +else if (mode == "service-listen-address") { + let address = service_listen_address_value(uci_settings()); + if (address == "") + exit(1); + print(address, "\n"); +} +else if (mode == "device-ipv4-address") { + let address = device_ipv4_address_value(ARGV[1]); + if (address == "") + exit(1); + print(address, "\n"); +} +else if (mode == "ip-addr-first-inet4") + print(ip_addr_first_inet4(fs.readfile("/dev/stdin")), "\n"); +else if (mode == "version") + print(sing_box_version(), "\n"); +else if (mode == "version-output") + print(sing_box_version_output()); +else if (mode == "version-from-output") + print(first_line_last_field(fs.readfile("/dev/stdin")), "\n"); +else if (mode == "read-version-state") + print(sing_box_version_state(), "\n"); +else if (mode == "write-version-state") + exit(sing_box_write_version_state(ARGV[1]) ? 0 : 1); +else if (mode == "clear-version-state") + exit(sing_box_clear_version_state() ? 0 : 1); +else if (mode == "restore-version-state") + exit(sing_box_restore_version_state(ARGV[1]) ? 0 : 1); +else if (mode == "read-variant-marker") + print(sing_box_variant_marker(), "\n"); +else if (mode == "write-variant-marker") + exit(sing_box_write_variant_marker(ARGV[1]) ? 0 : 1); +else if (mode == "clear-variant-marker") + exit(sing_box_clear_variant_marker() ? 0 : 1); +else if (mode == "restore-variant-marker") + exit(sing_box_restore_variant_marker(ARGV[1]) ? 0 : 1); +else if (mode == "marker-is") + exit(sing_box_marker_is(ARGV[1]) ? 0 : 1); +else if (mode == "is-extended") + exit(sing_box_is_extended(ARGV[1]) ? 0 : 1); +else if (mode == "is-tiny") + exit(sing_box_is_tiny(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "supports-tailscale") + exit(sing_box_supports_tailscale(ARGV[1], ARGV[2]) ? 0 : 1); +else if (mode == "variant") + print(sing_box_variant(), "\n"); +else { + warn("Usage: singbox/runtime.uc ...\n"); + exit(1); +} diff --git a/podkop/files/usr/lib/singbox/servers.uc b/podkop/files/usr/lib/singbox/servers.uc new file mode 100644 index 00000000..ae94516a --- /dev/null +++ b/podkop/files/usr/lib/singbox/servers.uc @@ -0,0 +1,388 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); +let runtime_constants = require("singbox.constants"); + +let as_string = common.as_string; +let option = common.option; +let list_option = common.list_option; +let bool_option = common.bool_option; +let int_option = common.int_option; +let array_or_empty = common.array_or_empty; + +function safe_filename(value) { + value = as_string(value); + let result = ""; + for (let i = 0; i < length(value); i++) { + let ch = substr(value, i, 1); + result += match(ch, /^[A-Za-z0-9_.-]$/) ? ch : "_"; + } + return result; +} + +function string_to_hex(value) { + value = as_string(value); + let result = ""; + for (let i = 0; i < length(value); i++) + result += sprintf("%02x", ord(substr(value, i, 1))); + return result; +} + +function mtproto_secret(section) { + let secret = option(section, "mtproto_secret", ""); + let faketls = option(section, "mtproto_faketls", "google.com"); + if (substr(lc(secret), 0, 2) == "ee") + return secret; + return "ee" + secret + string_to_hex(faketls); +} + +function users(section, protocol) { + let name = option(section, protocol == "socks" ? "server_username" : "label", section[".name"]); + if (name == "") + name = section[".name"]; + + if (protocol == "vless") { + let user = { uuid: option(section, "server_uuid", "") }; + if (name != "") + user.name = name; + let flow = option(section, "vless_flow", ""); + if (flow != "" && flow != "none") + user.flow = flow; + return [ user ]; + } + if (protocol == "vmess") { + let user = { + uuid: option(section, "server_uuid", ""), + alterId: int_option(section, "vmess_alter_id", "0") + }; + if (name != "") + user.name = name; + return [ user ]; + } + if (protocol == "trojan" || protocol == "hysteria2") { + let user = { password: option(section, "server_password", "") }; + if (name != "") + user.name = name; + return [ user ]; + } + if (protocol == "socks") { + return [{ + username: name != "" ? name : "user", + password: option(section, "server_password", "") + }]; + } + if (protocol == "mtproto") { + let user = { secret: mtproto_secret(section) }; + if (name != "") + user.name = name; + return [ user ]; + } + return []; +} + +function effective_security(section, protocol) { + let security = option(section, "security", ""); + if (security == "") { + if (protocol == "vless") + security = "reality"; + else if (protocol == "trojan" || protocol == "hysteria2") + security = "tls"; + else + security = "none"; + } + + if (protocol == "shadowsocks" || protocol == "socks" || protocol == "mtproto" || + protocol == "tailscale" || protocol == "json_inbound") + return "none"; + if (protocol == "hysteria2") + return "tls"; + if ((protocol == "vmess" || protocol == "trojan") && security == "reality") + return protocol == "trojan" ? "tls" : "none"; + return security; +} + +function maybe_string(object, key, value) { + value = as_string(value); + if (value != "") + object[key] = value; +} + +function apply_tls(inbound, section, protocol) { + let security = effective_security(section, protocol); + if (security == "" || security == "none") + return; + + let tls = { enabled: true }; + maybe_string(tls, "server_name", option(section, "tls_server_name", "")); + let alpn = list_option(section, "tls_alpn"); + if (length(alpn) > 0) + tls.alpn = alpn; + + if (security == "tls") { + maybe_string(tls, "certificate_path", option(section, "tls_certificate_path", "")); + maybe_string(tls, "key_path", option(section, "tls_key_path", "")); + } + else if (security == "reality") { + let reality = { + enabled: true, + handshake: { + server: option(section, "reality_handshake_server", "www.microsoft.com"), + server_port: int_option(section, "reality_handshake_server_port", "443") + }, + private_key: option(section, "reality_private_key", "") + }; + let short_id = list_option(section, "reality_short_id"); + if (length(short_id) > 0) + reality.short_id = short_id; + maybe_string(reality, "max_time_difference", option(section, "reality_max_time_difference", "1m")); + tls.reality = reality; + } + + inbound.tls = tls; +} + +function apply_transport(inbound, section, protocol) { + if (protocol != "vless" && protocol != "vmess" && protocol != "trojan") + return; + + let transport_type = option(section, "transport", "tcp"); + if (transport_type == "" || transport_type == "tcp" || transport_type == "raw") + return; + + let transport = { type: transport_type }; + let path = option(section, "transport_path", ""); + let host = option(section, "transport_host", ""); + if (transport_type == "ws") { + maybe_string(transport, "path", path); + if (host != "") + transport.headers = { Host: host }; + } + else if (transport_type == "grpc") { + maybe_string(transport, "service_name", option(section, "transport_service_name", "")); + } + else if (transport_type == "http") { + maybe_string(transport, "path", path); + let hosts = list_option(section, "transport_hosts"); + if (length(hosts) > 0) + transport.host = hosts; + } + else if (transport_type == "httpupgrade") { + maybe_string(transport, "path", path); + maybe_string(transport, "host", host); + } + else if (transport_type == "xhttp") { + transport.mode = option(section, "transport_xhttp_mode", "auto"); + transport.path = path != "" ? path : "/"; + maybe_string(transport, "host", host); + transport.headers = {}; + transport.x_padding_bytes = "100-1000"; + transport.no_sse_header = false; + transport.sc_max_each_post_bytes = 1000000; + transport.sc_max_buffered_posts = 30; + transport.sc_stream_up_server_secs = "20-80"; + transport.server_max_header_bytes = 8192; + } + else { + return; + } + + inbound.transport = transport; +} + +function add_standard_inbound(config, section, protocol, tag_name) { + let inbound = { + type: protocol, + tag: tag_name, + listen: option(section, "listen", "0.0.0.0"), + listen_port: int_option(section, "listen_port", "443") + }; + + if (protocol == "shadowsocks") { + inbound.method = option(section, "shadowsocks_method", "aes-128-gcm"); + inbound.password = option(section, "server_password", ""); + } + else if (protocol == "socks") { + let server_users = users(section, protocol); + if (length(server_users) > 0) + inbound.users = server_users; + } + else if (protocol == "hysteria2") { + inbound.users = users(section, protocol); + let up_mbps = option(section, "hysteria2_up_mbps", ""); + let down_mbps = option(section, "hysteria2_down_mbps", ""); + if (up_mbps != "") + inbound.up_mbps = int(up_mbps, 10); + if (down_mbps != "") + inbound.down_mbps = int(down_mbps, 10); + let obfs_type = option(section, "hysteria2_obfs_type", ""); + let obfs_password = option(section, "hysteria2_obfs_password", ""); + if (obfs_type != "" && obfs_password != "") + inbound.obfs = { type: obfs_type, password: obfs_password }; + } + else if (protocol == "mtproto") { + inbound.type = "mtproxy"; + inbound.users = users(section, protocol); + let concurrency = option(section, "mtproto_concurrency", ""); + if (concurrency != "") + inbound.concurrency = int(concurrency, 10); + let fronting_port = option(section, "mtproto_domain_fronting_port", "443"); + if (fronting_port != "") + inbound.domain_fronting_port = int(fronting_port, 10); + maybe_string(inbound, "domain_fronting_ip", option(section, "mtproto_domain_fronting_ip", "")); + if (bool_option(section, "mtproto_domain_fronting_proxy_protocol", false)) + inbound.domain_fronting_proxy_protocol = true; + maybe_string(inbound, "prefer_ip", option(section, "mtproto_prefer_ip", "prefer-ipv4")); + if (bool_option(section, "mtproto_auto_update", false)) + inbound.auto_update = true; + if (bool_option(section, "mtproto_allow_fallback_on_unknown_dc", false)) + inbound.allow_fallback_on_unknown_dc = true; + maybe_string(inbound, "tolerate_time_skewness", option(section, "mtproto_tolerate_time_skewness", "3s")); + maybe_string(inbound, "idle_timeout", option(section, "mtproto_idle_timeout", "5m")); + maybe_string(inbound, "handshake_timeout", option(section, "mtproto_handshake_timeout", "10s")); + } + else { + inbound.users = users(section, protocol); + } + + apply_tls(inbound, section, protocol); + apply_transport(inbound, section, protocol); + push(config.inbounds, inbound); +} + +function add_json_inbound(config, section, tag_name) { + let inbound = {}; + try { + inbound = json(option(section, "inbound_json", "")); + } + catch (e) { + inbound = {}; + } + if (type(inbound) != "object") + inbound = {}; + inbound.tag = tag_name; + push(config.inbounds, inbound); +} + +function add_tailscale_endpoint(config, section, tag_name) { + let section_name = section[".name"]; + let endpoint = { + type: "tailscale", + tag: tag_name, + state_directory: "/etc/podkop-plus/tailscale/" + safe_filename(section_name), + auth_key: option(section, "tailscale_auth_key", ""), + control_url: option(section, "tailscale_control_url", "https://controlplane.tailscale.com"), + hostname: option(section, "tailscale_hostname", "podkop-" + safe_filename(section_name)) + }; + if (bool_option(section, "tailscale_accept_routes", false)) + endpoint.accept_routes = true; + let advertise_routes = list_option(section, "tailscale_advertise_routes"); + if (length(advertise_routes) > 0) + endpoint.advertise_routes = advertise_routes; + if (bool_option(section, "tailscale_advertise_exit_node", false)) + endpoint.advertise_exit_node = true; + push(config.endpoints, endpoint); +} + +function add_dns_bypass(config, section) { + if (option(section, "protocol", "vless") != "tailscale") + return; + + let section_name = section[".name"]; + let inbound = runtime_constants.server_inbound_tag(section_name); + let dns_tag = runtime_constants.tailscale_dns_server_tag(section_name); + let rule_tag = "tailscale-server-dns-" + safe_filename(section_name); + push(config.dns.servers, { + type: "tailscale", + tag: dns_tag, + endpoint: inbound, + accept_default_resolvers: true + }); + push(config.dns.rules, { + action: "route", + server: dns_tag, + inbound, + __service_tag: rule_tag + }); +} + +function add_server(config, section) { + let section_name = section[".name"]; + let protocol = option(section, "protocol", "vless"); + let tag_name = runtime_constants.server_inbound_tag(section_name); + + if (protocol == "tailscale") + add_tailscale_endpoint(config, section, tag_name); + else if (protocol == "json_inbound") + add_json_inbound(config, section, tag_name); + else + add_standard_inbound(config, section, protocol, tag_name); + add_dns_bypass(config, section); +} + +function add_sniff_rule(config, section) { + let rules = array_or_empty(config.route.rules); + let rule = { + action: "sniff", + inbound: runtime_constants.server_inbound_tag(section[".name"]) + }; + let insert_at = 0; + while (insert_at < length(rules) && type(rules[insert_at]) == "object" && rules[insert_at].action == "sniff") + insert_at++; + + let result = []; + for (let i = 0; i < insert_at; i++) + push(result, rules[i]); + push(result, rule); + for (let i = insert_at; i < length(rules); i++) + push(result, rules[i]); + config.route.rules = result; +} + +function clone(value) { + try { + return json(sprintf("%J", value)); + } + catch (e) { + return null; + } +} + +function value_contains(value, item) { + if (type(value) == "array") { + for (let entry in value) + if (entry == item) + return true; + return false; + } + return value == item; +} + +function clone_rules_for_inbound(config, source_inbound, target_inbound, skip_domain) { + let cloned_rules = []; + for (let rule in array_or_empty(config.route.rules)) { + if (type(rule) != "object") + continue; + if (rule.action != "route" && rule.action != "reject") + continue; + if (!value_contains(rule.inbound, source_inbound)) + continue; + if (skip_domain != "" && value_contains(rule.domain, skip_domain)) + continue; + if (rule.source_ip_cidr != null) + continue; + + let cloned = clone(rule); + if (type(cloned) != "object") + continue; + cloned.inbound = target_inbound; + push(cloned_rules, cloned); + } + for (let cloned_rule in cloned_rules) + push(config.route.rules, cloned_rule); +} + +return { + add_server, + add_sniff_rule, + clone_rules_for_inbound +}; diff --git a/podkop/files/usr/lib/singbox/subscription.uc b/podkop/files/usr/lib/singbox/subscription.uc new file mode 100644 index 00000000..d5772129 --- /dev/null +++ b/podkop/files/usr/lib/singbox/subscription.uc @@ -0,0 +1,258 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let common = require("core.common"); + +let as_string = common.as_string; +let read_json_file = common.read_json_file; +let array_or_empty = common.array_or_empty; +let object_or_empty = common.object_or_empty; +let object_key_count = common.object_key_count; + +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || "/tmp/sing-box/subscriptions"; +const PODKOP_RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const PODKOP_SECTION_CACHE_DIR = getenv("PODKOP_SECTION_CACHE_DIR") || PODKOP_RUNTIME_STATE_DIR + "/section-cache"; +const PODKOP_SUBSCRIPTION_METADATA_DIR = getenv("PODKOP_SUBSCRIPTION_METADATA_DIR") || PODKOP_RUNTIME_STATE_DIR + "/subscription-metadata"; +const PODKOP_RUNTIME_CACHE_FORMAT = int(getenv("PODKOP_RUNTIME_CACHE_FORMAT") || "7"); +const PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/podkop-plus/subscription-cache"; + +let section_cache_dir = PODKOP_SECTION_CACHE_DIR; + +function set_section_cache_dir(path) { + path = as_string(path); + if (path != "") + section_cache_dir = path; +} + +function source_id(section_name, index) { + return section_name + "-subscription-" + index; +} + +function section_cache_path(section_name) { + return section_cache_dir + "/" + section_name + ".json"; +} + +function legacy_metadata_path(section_name) { + return PODKOP_SUBSCRIPTION_METADATA_DIR + "/" + section_name + ".json"; +} + +function persistent_source_path(source_section, suffix) { + return PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/" + source_section + suffix; +} + +function persistent_metadata_path(source_section) { + return persistent_source_path(source_section, ".metadata.json"); +} + +function trim_string(value) { + return replace(as_string(value), /^[ \t\r\n]+|[ \t\r\n]+$/g, ""); +} + +function parse_source_entry(entry) { + entry = trim_string(entry); + let delimiter = " | "; + let delimiter_index = index(entry, delimiter); + if (delimiter_index < 0) + return { url: entry, user_agent: "" }; + + return { + url: trim_string(substr(entry, 0, delimiter_index)), + user_agent: trim_string(substr(entry, delimiter_index + length(delimiter))) + }; +} + +function file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function valid_metadata_object(value) { + return type(value) == "object" && object_key_count(value) > 1; +} + +function metadata_items_from_value(value) { + let result = []; + if (type(value) == "array") { + for (let item in value) + if (valid_metadata_object(item)) + push(result, item); + } + else if (valid_metadata_object(value)) { + push(result, value); + } + 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 || value == "") + return null; + + return int(value, 10); +} + +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); + + if (item_section != "") + return item_section == source_section; + + return item_index == source_index; +} + +function persistent_source_matches_entry(source_section, source_entry) { + if (source_entry == null || source_entry == "") + return true; + + let parsed = parse_source_entry(source_entry); + let cached_url = file_first_line(persistent_source_path(source_section, ".url")); + let cached_user_agent = file_first_line(persistent_source_path(source_section, ".user_agent")); + + if (cached_url != parsed.url) + return false; + if (parsed.user_agent != "" && cached_user_agent != parsed.user_agent) + return false; + return cached_user_agent != ""; +} + +function read_persistent_source_metadata(source_section, source_entry) { + if (!persistent_source_matches_entry(source_section, source_entry)) + return []; + return metadata_items_from_value(read_json_file(persistent_metadata_path(source_section))); +} + +function read_section_metadata(section_name, source_section, source_index) { + let cache = object_or_empty(read_json_file(section_cache_path(section_name))); + let metadata = metadata_items_from_value(cache.subscriptionMetadata); + if (length(metadata) == 0) + metadata = metadata_items_from_value(read_json_file(legacy_metadata_path(section_name))); + if (length(metadata) == 0) { + cache = object_or_empty(read_json_file(section_cache_path(source_section))); + metadata = metadata_items_from_value(cache.subscriptionMetadata); + } + if (length(metadata) == 0) + metadata = metadata_items_from_value(read_json_file(legacy_metadata_path(source_section))); + + let selected = []; + let has_source_markers = metadata_items_have_source_markers(metadata); + if (has_source_markers) { + for (let item in metadata) { + if (metadata_matches_source(item, source_index, source_section, true)) + push(selected, item); + } + } + else if (source_index > 0 && source_index <= length(metadata)) { + push(selected, metadata[source_index - 1]); + } + + return selected; +} + +function read_source_metadata(section_name, source_section, source_index, source_entry) { + let metadata = read_section_metadata(section_name, source_section, source_index); + if (length(metadata) == 0) + metadata = read_persistent_source_metadata(source_section, source_entry); + + let result = []; + for (let item in metadata) { + item.sourceIndex = source_index; + item.sourceSection = source_section; + push(result, item); + } + return result; +} + +function merge_source_metadata(state, section_name, source_section, source_index, source_entry) { + if (type(state) != "object") + return; + for (let item in read_source_metadata(section_name, source_section, source_index, source_entry)) + push(state.subscriptionMetadata, item); +} + +function remember_visible_outbound(state, tag_name, source_section, source_index, source_outbound_index, display_name, outbound) { + if (type(state) != "object") + return; + state.linkRefs[tag_name] = { + sourceSection: source_section, + sourceIndex: source_outbound_index + }; + state.outboundMetadata.names[tag_name] = display_name; + let server = as_string(outbound.server || ""); + if (server != "") + state.servers[tag_name] = server; +} + +function source_cache_is_current(source_section, source_entry) { + let parsed = parse_source_entry(source_entry); + let cached_url = file_first_line(TMP_SUBSCRIPTION_FOLDER + "/" + source_section + ".url"); + let cached_user_agent = file_first_line(TMP_SUBSCRIPTION_FOLDER + "/" + source_section + ".user_agent"); + + if (cached_url != parsed.url) + return false; + + if (parsed.user_agent != "" && cached_user_agent != parsed.user_agent) + return false; + + return true; +} + +function source_json_path(source_section) { + return TMP_SUBSCRIPTION_FOLDER + "/" + source_section + ".json"; +} + +function read_source_outbounds(source_section) { + let subscription = object_or_empty(read_json_file(source_json_path(source_section))); + return array_or_empty(subscription.outbounds); +} + +function new_section_state(section_name) { + return { + version: PODKOP_RUNTIME_CACHE_FORMAT, + section: section_name, + links: {}, + linkRefs: {}, + outboundMetadata: { + names: {}, + countries: {} + }, + servers: {}, + subscriptionMetadata: [] + }; +} + +return { + set_section_cache_dir, + source_id, + section_cache_path, + merge_source_metadata, + remember_visible_outbound, + source_cache_is_current, + read_source_outbounds, + new_section_state +}; diff --git a/podkop/files/usr/lib/singbox/urltest.uc b/podkop/files/usr/lib/singbox/urltest.uc new file mode 100644 index 00000000..14581306 --- /dev/null +++ b/podkop/files/usr/lib/singbox/urltest.uc @@ -0,0 +1,158 @@ +#!/usr/bin/env ucode + +let common = require("core.common"); + +let as_string = common.as_string; +let array_or_empty = common.array_or_empty; +let object_or_empty = common.object_or_empty; + +function contains(values, needle) { + for (let value in values) { + if (value == needle) + return true; + } + return false; +} + +function regex_matches(value, pattern) { + pattern = as_string(pattern); + if (pattern == "") + return false; + + try { + return match(as_string(value), regexp(pattern)) != null; + } + catch (e) { + return false; + } +} + +function normalized_country_list(values) { + let result = []; + for (let value in array_or_empty(values)) { + value = uc(as_string(value)); + if (length(value) == 2) + push(result, value); + } + return result; +} + +function byte_at(value, index) { + return ord(substr(value, index, 1)); +} + +function regional_indicator_letter(value, index) { + if (index + 3 >= length(value)) + return ""; + + if (byte_at(value, index) != 240 || + byte_at(value, index + 1) != 159 || + byte_at(value, index + 2) != 135) + return ""; + + let letter = byte_at(value, index + 3) - 166; + if (letter < 0 || letter > 25) + return ""; + + return chr(65 + letter); +} + +function country_from_flag_emoji(value) { + value = as_string(value); + + for (let i = 0; i + 7 < length(value); i++) { + let first = regional_indicator_letter(value, i); + if (first == "") + continue; + + let second = regional_indicator_letter(value, i + 4); + if (second != "") + return first + second; + } + + return ""; +} + +function countries_from_flag_names(names) { + names = object_or_empty(names); + let result = {}; + + for (let tag, name in names) { + let country = country_from_flag_emoji(name); + if (country != "") + result[tag] = country; + } + + return result; +} + +function regex_matching_tag_array(tags, names, regexes) { + tags = array_or_empty(tags); + names = object_or_empty(names); + regexes = array_or_empty(regexes); + let result = []; + + for (let tag in tags) { + let name = names[tag]; + name = name == null || as_string(name) == "" ? tag : as_string(name); + + for (let pattern in regexes) { + if (regex_matches(name, pattern)) { + push(result, tag); + break; + } + } + } + + return result; +} + +function filter_array(mode, tags, names, countries, name_filter, regex_tags, country_filter) { + tags = array_or_empty(tags); + names = object_or_empty(names); + countries = object_or_empty(countries); + name_filter = array_or_empty(name_filter); + regex_tags = array_or_empty(regex_tags); + country_filter = array_or_empty(country_filter); + 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); + } + + return result; +} + +function filter_mode(mode, tags, names, countries, include_names, include_regexes, include_countries, exclude_names, exclude_regexes, exclude_countries) { + include_countries = normalized_country_list(include_countries); + exclude_countries = normalized_country_list(exclude_countries); + + let include_regex_tags = regex_matching_tag_array(tags, names, include_regexes); + let exclude_regex_tags = regex_matching_tag_array(tags, names, exclude_regexes); + + if (mode == "include") + return filter_array("include", tags, names, countries, include_names, include_regex_tags, include_countries); + if (mode == "exclude") + return filter_array("exclude", tags, names, countries, exclude_names, exclude_regex_tags, exclude_countries); + if (mode == "mixed") { + let included = filter_array("include", tags, names, countries, include_names, include_regex_tags, include_countries); + return filter_array("exclude", included, names, countries, exclude_names, exclude_regex_tags, exclude_countries); + } + return array_or_empty(tags); +} + +return { + normalized_country_list, + countries_from_flag_names, + regex_matching_tag_array, + filter_array, + filter_mode +}; diff --git a/podkop/files/usr/lib/status_diagnostics.sh b/podkop/files/usr/lib/status_diagnostics.sh deleted file mode 100644 index 6dd5eac6..00000000 --- a/podkop/files/usr/lib/status_diagnostics.sh +++ /dev/null @@ -1,1726 +0,0 @@ -# shellcheck shell=ash - -cleanup_check_proxy_dir() { - local dir="$1" - - case "$dir" in - "$TMP_SING_BOX_FOLDER"/check-proxy-*) - rm -rf "$dir" - ;; - esac -} - -get_wan_ipv4_address() { - local interface ip - - for interface in wan wwan; do - ip="$( - ubus -S call "network.interface.$interface" status 2>/dev/null | - helpers_ucode network-status-ipv4-address 2>/dev/null - )" - [ -n "$ip" ] && { - printf '%s\n' "$ip" - return 0 - } - done - - if network_find_wan interface >/dev/null 2>&1; then - network_get_ipaddr ip "$interface" - [ -n "$ip" ] && printf '%s\n' "$ip" - fi -} - -firewall_port_open_for_proto() { - local port="$1" - local proto="$2" - - uci -q show firewall 2>/dev/null | - status_diagnostics_ucode firewall-port-open "$port" "$proto" >/dev/null 2>&1 -} - -firewall_required_protocols_open() { - local port="$1" - local required_proto="$2" - local proto - - for proto in $required_proto; do - firewall_port_open_for_proto "$port" "$proto" || return 1 - done - - return 0 -} - -server_required_inbound_proto() { - status_diagnostics_ucode server-required-inbound-proto "$1" -} - -server_runtime_type_for_protocol() { - status_diagnostics_ucode server-runtime-type-for-protocol "$1" -} - -server_listen_requires_firewall() { - local listen="$1" - - [ "$listen" = "0.0.0.0" ] && return 0 - [ -n "$INBOUNDS_CHECK_WAN_IP" ] && [ "$listen" = "$INBOUNDS_CHECK_WAN_IP" ] && return 0 - server_is_public_ipv4 "$listen" -} - -server_port_is_listening_for_proto() { - local listen="$1" - local port="$2" - local proto="$3" - - netstat -ln 2>/dev/null | status_diagnostics_ucode server-port-listening "$listen" "$port" "$proto" >/dev/null 2>&1 -} - -server_non_sing_box_port_listener_owners_for_proto() { - local listen="$1" - local port="$2" - local proto="$3" - - netstat -lnp 2>/dev/null | status_diagnostics_ucode server-port-conflict-owners "$listen" "$port" "$proto" -} - -server_required_port_conflict_owners() { - local listen="$1" - local port="$2" - local required_proto="$3" - local proto proto_owners owners - - for proto in $required_proto; do - proto_owners="$(server_non_sing_box_port_listener_owners_for_proto "$listen" "$port" "$proto")" - [ -n "$proto_owners" ] || continue - owners="${owners}${owners:+ }${proto_owners}" - done - - printf '%s\n' "$owners" -} - -server_required_ports_listening() { - local listen="$1" - local port="$2" - local required_proto="$3" - local proto - - for proto in $required_proto; do - server_port_is_listening_for_proto "$listen" "$port" "$proto" || return 1 - done - - return 0 -} - -resolve_public_host_ipv4s() { - local host="$1" - - [ -n "$host" ] || return 0 - - if is_ipv4 "$host"; then - printf '%s\n' "$host" - return 0 - fi - - dig +short A "$host" +timeout=2 +tries=1 2>/dev/null | - while read -r ip; do - is_ipv4 "$ip" && printf '%s\n' "$ip" - done -} - -check_inbounds_server_handler() { - local section="$1" - local label protocol listen listen_port public_host routing_mode inbound_tag expected_type required_proto - local runtime_json listening routes_configured - local firewall_required firewall_open port_conflict port_conflict_owners public_host_ips public_host_resolved public_host_public public_host_matches_wan - local host_ip item_json separator - - server_is_enabled "$section" || return 0 - INBOUNDS_CHECK_ENABLED_COUNT=$((INBOUNDS_CHECK_ENABLED_COUNT + 1)) - - config_get label "$section" "label" "$section" - config_get protocol "$section" "protocol" "vless" - config_get listen "$section" "listen" "0.0.0.0" - config_get listen_port "$section" "listen_port" - config_get public_host "$section" "public_host" - config_get routing_mode "$section" "routing_mode" "rules" - - inbound_tag="$(get_server_inbound_tag_by_section "$section")" - expected_type="$(server_runtime_type_for_protocol "$protocol")" - required_proto="$(server_required_inbound_proto "$protocol")" - - if [ "$protocol" = "tailscale" ]; then - runtime_json="$(provider_status_ucode endpoint-summary "$INBOUNDS_CHECK_CONFIG_PATH" "$inbound_tag" 2>/dev/null)" - else - runtime_json="$(provider_status_ucode inbound-summary "$INBOUNDS_CHECK_CONFIG_PATH" "$inbound_tag" 2>/dev/null)" - fi - - listening=-1 - firewall_required=0 - firewall_open=-1 - port_conflict=0 - port_conflict_owners="" - if [ "$protocol" != "tailscale" ] && [ "$protocol" != "json_inbound" ]; then - port_conflict_owners="$(server_required_port_conflict_owners "$listen" "$listen_port" "$required_proto")" - [ -z "$port_conflict_owners" ] || port_conflict=1 - - if server_required_ports_listening "$listen" "$listen_port" "$required_proto"; then - listening=1 - else - listening=0 - fi - - if server_listen_requires_firewall "$listen"; then - firewall_required=1 - if firewall_required_protocols_open "$listen_port" "$required_proto"; then - firewall_open=1 - else - firewall_open=0 - fi - fi - fi - - if provider_status_ucode has-route-rule-for-inbound "$INBOUNDS_CHECK_CONFIG_PATH" "$inbound_tag" >/dev/null 2>&1; then - routes_configured=1 - else - routes_configured=0 - fi - - if [ "$protocol" != "json_inbound" ]; then - public_host_ips="$(resolve_public_host_ipv4s "$public_host" | status_diagnostics_ucode stdin-sorted-unique-space-list 2>/dev/null)" - else - public_host_ips="" - fi - public_host_resolved=-1 - public_host_public=-1 - public_host_matches_wan=-1 - - if [ -n "$public_host" ]; then - if [ -n "$public_host_ips" ]; then - public_host_resolved=1 - public_host_public=1 - for host_ip in $public_host_ips; do - server_is_public_ipv4 "$host_ip" || public_host_public=0 - done - - if [ "$INBOUNDS_CHECK_WAN_PUBLIC" = "1" ]; then - if list_has_item "$public_host_ips" "$INBOUNDS_CHECK_WAN_IP"; then - public_host_matches_wan=1 - else - public_host_matches_wan=0 - fi - fi - else - public_host_resolved=0 - fi - fi - - item_json="$( - status_diagnostics_ucode inbound-item-json \ - "$runtime_json" \ - "$section" \ - "$label" \ - "$protocol" \ - "$routing_mode" \ - "$inbound_tag" \ - "$listen" \ - "$listen_port" \ - "$public_host" \ - "$public_host_ips" \ - "$expected_type" \ - "$required_proto" \ - "$listening" \ - "$firewall_required" \ - "$firewall_open" \ - "$port_conflict" \ - "$port_conflict_owners" \ - "$routes_configured" \ - "$public_host_resolved" \ - "$public_host_public" \ - "$public_host_matches_wan" - )" - - [ -n "$INBOUNDS_CHECK_ITEMS_JSON" ] && separator="," - INBOUNDS_CHECK_ITEMS_JSON="${INBOUNDS_CHECK_ITEMS_JSON}${separator}${item_json}" -} - -check_inbounds_config_server_handler() { - local section="$1" - - server_is_enabled "$section" || return 0 - INBOUNDS_CONFIG_ENABLED_COUNT=$((INBOUNDS_CONFIG_ENABLED_COUNT + 1)) -} - -check_inbounds_config() { - INBOUNDS_CONFIG_ENABLED_COUNT=0 - - config_foreach check_inbounds_config_server_handler "server" - - status_diagnostics_ucode inbounds-config-json "$INBOUNDS_CONFIG_ENABLED_COUNT" -} - -check_inbounds() { - local sing_box_config_path wan_ip wan_public - - config_get sing_box_config_path "settings" "config_path" - wan_ip="$(get_wan_ipv4_address)" - wan_public=0 - if server_is_public_ipv4 "$wan_ip"; then - wan_public=1 - fi - - INBOUNDS_CHECK_CONFIG_PATH="$sing_box_config_path" - INBOUNDS_CHECK_WAN_IP="$wan_ip" - INBOUNDS_CHECK_WAN_PUBLIC="$wan_public" - INBOUNDS_CHECK_ENABLED_COUNT=0 - INBOUNDS_CHECK_ITEMS_JSON="" - - config_foreach check_inbounds_server_handler "server" - - status_diagnostics_ucode inbounds-check-json \ - "$INBOUNDS_CHECK_ENABLED_COUNT" \ - "$sing_box_config_path" \ - "$wan_ip" \ - "$wan_public" \ - "[$INBOUNDS_CHECK_ITEMS_JSON]" -} - -check_proxy() { - local sing_box_config_path - config_get sing_box_config_path "settings" "config_path" - - if ! command -v sing-box > /dev/null 2>&1; then - nolog "sing-box is not installed" - return 1 - fi - - if [ ! -f "$sing_box_config_path" ]; then - nolog "Configuration file not found" - return 1 - fi - - nolog "Checking sing-box configuration..." - - if ! sing-box -c "$sing_box_config_path" check > /dev/null; then - nolog "Invalid configuration" - return 1 - fi - - status_diagnostics_ucode mask-sing-box-config "$sing_box_config_path" - - nolog "Checking proxy connection..." - - local check_proxy_dir check_proxy_config check_proxy_cache check_proxy_outbound_tag masked_response_ip - check_proxy_dir="$TMP_SING_BOX_FOLDER/check-proxy-$$" - check_proxy_config="$check_proxy_dir/config.json" - check_proxy_cache="$check_proxy_dir/cache.db" - - cleanup_check_proxy_dir "$check_proxy_dir" - mkdir -p "$check_proxy_dir" - if ! status_diagnostics_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="$(status_diagnostics_ucode check-proxy-outbound-tag "$check_proxy_config" "$CHECK_PROXY_IP_DOMAIN" 2>/dev/null)" - - attempt=1 - while [ "$attempt" -le 5 ]; do - if [ -n "$check_proxy_outbound_tag" ]; then - response=$(sing-box tools fetch ifconfig.me -c "$check_proxy_config" -D "$check_proxy_dir" \ - --disable-color -o "$check_proxy_outbound_tag" 2> /dev/null) - else - response=$(sing-box tools fetch ifconfig.me -c "$check_proxy_config" -D "$check_proxy_dir" \ - --disable-color 2> /dev/null) - fi - if printf '%s\n' "$response" | status_diagnostics_ucode proxy-response-is-retryable-error >/dev/null 2>&1; then - attempt=$((attempt + 1)) - continue - fi - masked_response_ip="$(printf '%s' "$response" | status_diagnostics_ucode proxy-response-ip-mask 2>/dev/null)" - if [ -n "$masked_response_ip" ]; then - nolog "$masked_response_ip - should match proxy IP" - cleanup_check_proxy_dir "$check_proxy_dir" - return 0 - fi - if [ $attempt -eq 5 ]; then - nolog "Failed to get valid IP address after 5 attempts" - if [ -z "$response" ]; then - nolog "Error: Empty response" - else - nolog "Error response: $response" - fi - cleanup_check_proxy_dir "$check_proxy_dir" - return 1 - fi - attempt=$((attempt + 1)) - done - - cleanup_check_proxy_dir "$check_proxy_dir" -} - -check_nft() { - if ! command -v nft > /dev/null 2>&1; then - nolog "nft is not installed" - return 1 - fi - - nolog "Checking $NFT_TABLE_NAME rules..." - - # Check if table exists - if ! nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then - nolog "❌ $NFT_TABLE_NAME not found" - return 1 - fi - - local found_hetzner=0 - local found_ovh=0 - - check_domain_list_contains() { - local section="$1" - - config_get_bool domain_list_enabled "$section" "domain_list_enabled" "0" - if [ "$domain_list_enabled" -eq 1 ]; then - config_list_foreach "$section" "domain_list" check_domain_value - fi - } - - check_domain_value() { - local domain_value="$1" - - if [ "$domain_value" = "hetzner" ]; then - found_hetzner=1 - elif [ "$domain_value" = "ovh" ]; then - found_ovh=1 - fi - } - - config_foreach check_domain_list_contains - - if [ "$found_hetzner" -eq 1 ] || [ "$found_ovh" -eq 1 ]; then - - local sets="$NFT_COMMON_SET_NAME $NFT_PORT_SET_NAME $NFT_IP_PORT_SET_NAME $NFT_INTERFACE_SET_NAME $NFT_DISCORD_SET_NAME $NFT_LOCALV4_SET_NAME" - - nolog "Sets statistics:" - for set_name in $sets; do - if nft list set inet "$NFT_TABLE_NAME" "$set_name" > /dev/null 2>&1; then - local count - count="$(nft -j list set inet "$NFT_TABLE_NAME" "$set_name" 2>/dev/null | status_diagnostics_ucode nft-set-element-count)" - echo "- $set_name: $count elements" - fi - done - - nolog "Chain configurations:" - nft list table inet "$NFT_TABLE_NAME" | status_diagnostics_ucode nft-chain-config-blocks mangle proxy - else - # Simple view as originally implemented - nolog "Sets configuration:" - nft list table inet "$NFT_TABLE_NAME" - fi - - nolog "NFT check completed" -} - -check_logs() { - if ! command -v logread > /dev/null 2>&1; then - nolog "Error: logread command not found" - return 1 - fi - - if ! logread | status_diagnostics_ucode podkop-logs; then - nolog "Logs not found" - return 1 - fi -} - -check_sing_box_logs() { - if ! command -v logread > /dev/null 2>&1; then - nolog "Error: logread command not found" - return 1 - fi - - if ! logread | status_diagnostics_ucode matching-log-tail "sing-box" 100; then - nolog "sing-box logs not found" - return 1 - fi -} - -show_sing_box_config() { - local sing_box_config_path - local visibility="${1:-masked}" - config_get sing_box_config_path "settings" "config_path" - nolog "Current sing-box configuration:" - - if [ ! -f "$sing_box_config_path" ]; then - nolog "Configuration file not found" - return 1 - fi - - if [ "$visibility" = "raw" ]; then - cat "$sing_box_config_path" - else - status_diagnostics_ucode mask-sing-box-config "$sing_box_config_path" - fi -} - -show_config() { - local visibility="${1:-masked}" - - if [ ! -f "$PODKOP_CONFIG" ]; then - nolog "Configuration file not found" - return 1 - fi - - if [ "$visibility" = "raw" ]; then - cat "$PODKOP_CONFIG" - else - status_diagnostics_ucode podkop-config-masked "$PODKOP_CONFIG" - fi -} - -show_version() { - echo "$PODKOP_VERSION" -} - -show_sing_box_version() { - local version - version="$(get_sing_box_version)" - echo "$version" -} - -fetch_latest_podkop_release_json() { - local owner repo - - owner="${PODKOP_RELEASE_REPO%%/*}" - repo="${PODKOP_RELEASE_REPO#*/}" - [ -n "$owner" ] || return 1 - [ -n "$repo" ] || return 1 - [ "$owner" != "$repo" ] || return 1 - - updates_fetch_github_release_json "$owner" "$repo" -} - -fetch_latest_podkop_version() { - local response tag - - response="$(fetch_latest_podkop_release_json)" || return 1 - tag="$(printf '%s' "$response" | updates_ucode object-get-default tag_name "" 2>/dev/null)" - - [ -n "$tag" ] || return 1 - echo "$tag" -} - -is_podkop_release_version() { - updates_ucode podkop-release-version-valid "$1" >/dev/null 2>&1 -} - -podkop_release_version_compare() { - updates_ucode podkop-release-version-compare "$1" "$2" -} - -write_podkop_latest_version_cache() { - local value="$1" - local timestamp="$2" - local cache_file="/tmp/podkop-plus.latest-version.cache" - - [ -n "$value" ] || return 0 - - { - printf '%s\n' "$value" - printf '%s\n' "$timestamp" - } > "$cache_file" -} - -get_cached_podkop_latest_version() { - local cache_file="/tmp/podkop-plus.latest-version.cache" - local cache_value - - cache_value="$(status_diagnostics_ucode file-first-line "$cache_file" "unknown" 2>/dev/null)" - echo "$cache_value" -} - -get_luci_app_version() { - if [ -f "$PODKOP_LUCI_VIEW_DIR/main.js" ]; then - status_diagnostics_ucode js-var-string-value "$PODKOP_LUCI_VIEW_DIR/main.js" "PODKOP_LUCI_APP_VERSION" - return 0 - fi - - echo "not installed" -} - -system_info_cache_is_valid() { - local now luci_app_version - - [ -s "$PODKOP_SYSTEM_INFO_CACHE_FILE" ] || return 1 - - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) now=0 ;; - esac - - luci_app_version="$(get_luci_app_version)" - status_diagnostics_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() { - local tmpfile - - ensure_runtime_dirs - - tmpfile="${PODKOP_SYSTEM_INFO_CACHE_FILE}.$$" - cat > "$tmpfile" && mv "$tmpfile" "$PODKOP_SYSTEM_INFO_CACHE_FILE" -} - -status_diagnostics_sing_box_marker_is() { - local expected="$1" - local marker="${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" - - [ -r "$marker" ] || return 1 - [ "$(sed -n '1p' "$marker" 2>/dev/null)" = "$expected" ] -} - -status_diagnostics_sing_box_tiny_package_installed() { - if command -v apk >/dev/null 2>&1; then - apk info -e sing-box-tiny >/dev/null 2>&1 - return $? - fi - - opkg_package_is_installed sing-box-tiny -} - -status_diagnostics_sing_box_component_action_running() { - command -v ui_runtime_component_action_running_for >/dev/null 2>&1 || return 1 - ui_runtime_component_action_running_for sing_box -} - -status_diagnostics_sing_box_live_probe_disabled() { - is_sing_box_compressed_marker_set && return 0 - status_diagnostics_sing_box_component_action_running -} - -status_diagnostics_sing_box_capability_flags() { - local sing_box_version="$1" - local sing_box_version_output="$2" - local extended=0 tiny=0 tailscale=0 - - if status_diagnostics_sing_box_marker_is extended || - status_diagnostics_sing_box_marker_is extended-compressed || - is_sing_box_extended "$sing_box_version"; then - extended=1 - fi - - if [ "$extended" -eq 0 ]; then - if status_diagnostics_sing_box_tiny_package_installed || - status_diagnostics_sing_box_marker_is tiny; then - tiny=1 - fi - fi - - if [ "$extended" -eq 1 ]; then - tailscale=1 - elif [ -n "$sing_box_version_output" ]; then - sing_box_supports_tailscale "$sing_box_version" "$sing_box_version_output" && tailscale=1 - elif [ "$tiny" -eq 0 ] && status_diagnostics_sing_box_component_action_running; then - tailscale=1 - fi - - printf '%s %s %s\n' "$extended" "$tiny" "$tailscale" -} - -build_system_info() { - local podkop_version podkop_latest_version luci_app_version sing_box_version sing_box_version_output sing_box_extended sing_box_tiny sing_box_compressed sing_box_tailscale zapret_version zapret_installed zapret2_version zapret2_installed byedpi_version byedpi_installed openwrt_version device_model - local generated_at capability_flags - - podkop_version="$PODKOP_VERSION" - - podkop_latest_version="$(get_cached_podkop_latest_version)" - [ -n "$podkop_latest_version" ] || podkop_latest_version="unknown" - - luci_app_version="$(get_luci_app_version)" - - if command -v sing-box > /dev/null 2>&1; then - if status_diagnostics_sing_box_live_probe_disabled; then - sing_box_version="$(read_sing_box_version_state 2>/dev/null || true)" - sing_box_version_output="" - else - sing_box_version_output="$(sing_box_version_output)" - sing_box_version="$(printf '%s\n' "$sing_box_version_output" | sing_box_version_from_output)" - fi - [ -z "$sing_box_version" ] && sing_box_version="unknown" - else - sing_box_version="not installed" - sing_box_version_output="" - fi - capability_flags="$(status_diagnostics_sing_box_capability_flags "$sing_box_version" "$sing_box_version_output")" - set -- $capability_flags - sing_box_extended="${1:-0}" - sing_box_tiny="${2:-0}" - sing_box_tailscale="${3:-0}" - sing_box_compressed=0 - if [ "$sing_box_extended" -eq 1 ] && is_sing_box_compressed_marker_set; then - sing_box_compressed=1 - fi - - zapret_installed=0 - if is_zapret_installed; then - zapret_installed=1 - zapret_version="$(get_zapret_package_version)" - [ -z "$zapret_version" ] && zapret_version="unknown" - else - zapret_version="not installed" - fi - - zapret2_installed=0 - if is_zapret2_installed; then - zapret2_installed=1 - zapret2_version="$(get_zapret2_package_version)" - [ -z "$zapret2_version" ] && zapret2_version="unknown" - else - zapret2_version="not installed" - fi - - byedpi_installed=0 - if is_byedpi_installed; then - byedpi_installed=1 - byedpi_version="$(get_byedpi_package_version)" - [ -z "$byedpi_version" ] && byedpi_version="unknown" - else - byedpi_version="not installed" - fi - - if [ -f /etc/os-release ]; then - openwrt_version="$(status_diagnostics_ucode key-value-file-value /etc/os-release OPENWRT_RELEASE 2>/dev/null)" - [ -z "$openwrt_version" ] && openwrt_version="unknown" - else - openwrt_version="unknown" - fi - - if [ -f /tmp/sysinfo/model ]; then - device_model=$(cat /tmp/sysinfo/model) - [ -z "$device_model" ] && device_model="unknown" - else - device_model="unknown" - fi - - generated_at="$(date +%s 2>/dev/null)" - case "$generated_at" in - '' | *[!0-9]*) generated_at=0 ;; - esac - - status_diagnostics_ucode system-info-json \ - "$podkop_version" "$podkop_latest_version" "$luci_app_version" \ - "$sing_box_version" "$sing_box_extended" "$sing_box_tiny" "$sing_box_compressed" "$sing_box_tailscale" \ - "$zapret_version" "$zapret_installed" \ - "$zapret2_version" "$zapret2_installed" \ - "$byedpi_version" "$byedpi_installed" \ - "$openwrt_version" "$device_model" "$generated_at" -} - -refresh_system_info_cache() { - local system_info - - system_info="$(build_system_info)" || return 1 - [ -n "$system_info" ] || return 1 - - printf '%s\n' "$system_info" | write_system_info_cache -} - -get_system_info() { - local system_info - - if system_info_cache_is_valid; then - cat "$PODKOP_SYSTEM_INFO_CACHE_FILE" - return 0 - fi - - system_info="$(build_system_info)" || return 1 - [ -n "$system_info" ] || return 1 - - printf '%s\n' "$system_info" | write_system_info_cache - printf '%s\n' "$system_info" -} - -get_server_capabilities() { - local sing_box_version sing_box_version_output sing_box_extended=0 sing_box_tiny=0 sing_box_tailscale=0 capability_flags - - if status_diagnostics_sing_box_live_probe_disabled; then - sing_box_version="$(read_sing_box_version_state 2>/dev/null || true)" - sing_box_version_output="" - else - sing_box_version_output="$(sing_box_version_output)" - sing_box_version="$(printf '%s\n' "$sing_box_version_output" | sing_box_version_from_output)" - fi - capability_flags="$(status_diagnostics_sing_box_capability_flags "$sing_box_version" "$sing_box_version_output")" - set -- $capability_flags - sing_box_extended="${1:-0}" - sing_box_tiny="${2:-0}" - sing_box_tailscale="${3:-0}" - - status_diagnostics_ucode server-capabilities-json "$sing_box_extended" "$sing_box_tiny" "$sing_box_tailscale" -} - -get_ui_capabilities() { - local sing_box_version sing_box_version_output sing_box_extended=0 sing_box_tiny=0 sing_box_compressed=0 sing_box_tailscale=0 zapret_installed=0 zapret2_installed=0 byedpi_installed=0 capability_flags - - if status_diagnostics_sing_box_live_probe_disabled; then - sing_box_version="$(read_sing_box_version_state 2>/dev/null || true)" - sing_box_version_output="" - else - sing_box_version_output="$(sing_box_version_output)" - sing_box_version="$(printf '%s\n' "$sing_box_version_output" | sing_box_version_from_output)" - fi - capability_flags="$(status_diagnostics_sing_box_capability_flags "$sing_box_version" "$sing_box_version_output")" - set -- $capability_flags - sing_box_extended="${1:-0}" - sing_box_tiny="${2:-0}" - sing_box_tailscale="${3:-0}" - if [ "$sing_box_extended" -eq 1 ] && is_sing_box_compressed_marker_set; then - sing_box_compressed=1 - fi - is_zapret_installed && zapret_installed=1 - is_zapret2_installed && zapret2_installed=1 - is_byedpi_installed && byedpi_installed=1 - - INBOUNDS_CONFIG_ENABLED_COUNT=0 - config_foreach check_inbounds_config_server_handler "server" - - status_diagnostics_ucode ui-capabilities-json \ - "$sing_box_extended" \ - "$sing_box_tiny" \ - "$sing_box_compressed" \ - "$sing_box_tailscale" \ - "$zapret_installed" \ - "$zapret2_installed" \ - "$byedpi_installed" \ - "${INBOUNDS_CONFIG_ENABLED_COUNT:-0}" -} - -get_zapret_status() { - get_zapret_status_json -} - -get_zapret2_status() { - get_zapret2_status_json -} - -neutralize_zapret_defaults() { - log "Standalone zapret is not neutralized automatically; Podkop Plus uses /opt/zapret/nfq/nfqws as an external provider and manages only its own NFQUEUE range." -} - -check_zapret_runtime() { - check_zapret_runtime_json -} - -check_zapret2_runtime() { - check_zapret2_runtime_json -} - -get_byedpi_status() { - get_byedpi_status_json -} - -check_byedpi_runtime() { - check_byedpi_runtime_json -} - -sing_box_process_is_running() { - pgrep -x "sing-box" >/dev/null 2>&1 || - pgrep -f "^/usr/bin/sing-box[[:space:]]" >/dev/null 2>&1 -} - -get_sing_box_status() { - local running=0 - local enabled=0 - local status="" - local dns_configured=0 - - # Check if service is enabled - if [ -x /etc/rc.d/S99sing-box ]; then - enabled=1 - fi - - # Check if service is running - if sing_box_process_is_running; then - running=1 - fi - - # Check DNS configuration - if dnsmasq_has_podkop_dns; then - dns_configured=1 - fi - - # Format status message - if [ $running -eq 1 ]; then - if [ $enabled -eq 1 ]; then - status="running & enabled" - else - status="running but disabled" - fi - else - if [ $enabled -eq 1 ]; then - status="stopped but enabled" - else - status="stopped & disabled" - fi - fi - - status_diagnostics_ucode service-status-json "$running" "$enabled" "$status" "$dns_configured" -} - -get_status() { - local running=0 - local enabled=0 - local status="" - local dns_configured=0 - - if podkop_is_running; then - running=1 - fi - - # Check if service is enabled - if [ -x "/etc/rc.d/S99$PODKOP_SERVICE_NAME" ]; then - enabled=1 - fi - - if dnsmasq_has_podkop_dns; then - dns_configured=1 - fi - - if [ "$running" -eq 1 ]; then - if [ "$enabled" -eq 1 ]; then - status="running & enabled" - else - status="running but disabled" - fi - else - if [ "$enabled" -eq 1 ]; then - status="stopped but enabled" - else - status="stopped & disabled" - fi - fi - - status_diagnostics_ucode service-status-json "$running" "$enabled" "$status" "$dns_configured" -} - -get_outbound_link() { - local section="$1" - local outbound_tag="$2" - - ensure_runtime_dirs - case "$section" in - "" | */* | *..*) - subscription_cache_ucode empty-link - return 0 - ;; - esac - - subscription_cache_ucode get-link \ - "$PODKOP_SECTION_CACHE_DIR" "$TMP_SUBSCRIPTION_FOLDER" "$section" "$outbound_tag" "$PODKOP_SUBSCRIPTION_LINKS_DIR" || - subscription_cache_ucode empty-link -} - -get_outbound_link_states() { - local section="$1" - - ensure_runtime_dirs - case "$section" in - "" | */* | *..*) - printf '{}\n' - return 0 - ;; - esac - - subscription_cache_ucode get-link-states "$PODKOP_SECTION_CACHE_DIR" "$section" "$PODKOP_SUBSCRIPTION_LINKS_DIR" || - printf '{}\n' -} - -get_outbound_metadata() { - local section="$1" - local metadata_path - - ensure_runtime_dirs - case "$section" in - "" | */* | *..*) - subscription_cache_ucode empty-outbound-metadata - return 0 - ;; - esac - - metadata_path="$(get_outbound_metadata_path "$section")" - subscription_cache_ucode get-outbound-metadata "$PODKOP_SECTION_CACHE_DIR" "$section" "$metadata_path" || - subscription_cache_ucode empty-outbound-metadata -} - -get_subscription_metadata() { - local section="$1" - local metadata_path - - ensure_runtime_dirs - - case "$section" in - "" | */* | *..*) - printf '{}\n' - return 0 - ;; - esac - - metadata_path="$(get_subscription_metadata_path "$section")" - subscription_cache_ucode get-subscription-metadata "$PODKOP_SECTION_CACHE_DIR" "$section" "$metadata_path" || - printf '{}\n' -} - -validate_nfqws_strategy_json() { - local raw_opt="${1:-}" - - if check_nfqws_strategy "$raw_opt"; then - status_diagnostics_ucode nfqws-strategy-validation true "" "" "" - return 0 - fi - - status_diagnostics_ucode nfqws-strategy-validation false "$NFQWS_VALIDATE_ERROR" "$NFQWS_VALIDATE_NEEDLE" "$NFQWS_VALIDATE_NEEDLES" -} - -validate_nfqws2_strategy_json() { - local raw_opt="${1:-}" - - if check_nfqws2_strategy "$raw_opt"; then - status_diagnostics_ucode nfqws-strategy-validation true "" "" "" - return 0 - fi - - status_diagnostics_ucode nfqws-strategy-validation false "$NFQWS2_VALIDATE_ERROR" "$NFQWS2_VALIDATE_NEEDLE" "$NFQWS2_VALIDATE_NEEDLES" -} - -dns_check_resolve_host() { - local host="$1" - local resolver="$2" - local resolved - - [ -n "$host" ] || return 1 - - if is_ipv4 "$host"; then - printf '%s\n' "$host" - return 0 - fi - - [ -n "$resolver" ] || return 1 - - resolved="$( - dig @"$resolver" "$host" A +short +timeout=2 +tries=1 2> /dev/null | - status_diagnostics_ucode stdin-first-ipv4-line 2>/dev/null - )" - [ -n "$resolved" ] || return 1 - - printf '%s\n' "$resolved" -} - -dns_check_dig_server_available() { - local dns_type="$1" - local dns_server="$2" - local bootstrap_dns_server="$3" - local domain="$4" - local dns_host server_port probe_server tls_hostname - - dns_host="$(url_get_host "$dns_server")" - [ -n "$dns_host" ] || dns_host="$dns_server" - server_port="$(url_get_port "$dns_server")" - tls_hostname="" - - if is_ipv4 "$dns_host"; then - probe_server="$dns_host" - else - probe_server="$(dns_check_resolve_host "$dns_host" "$bootstrap_dns_server")" - [ -n "$probe_server" ] || return 1 - tls_hostname="$dns_host" - fi - - case "$dns_type" in - dot) - if [ -n "$tls_hostname" ]; then - if [ -n "$server_port" ]; then - dig -p "$server_port" @"$probe_server" "$domain" +tls +tls-hostname="$tls_hostname" +timeout=2 +tries=1 > /dev/null 2>&1 - else - dig @"$probe_server" "$domain" +tls +tls-hostname="$tls_hostname" +timeout=2 +tries=1 > /dev/null 2>&1 - fi - else - if [ -n "$server_port" ]; then - dig -p "$server_port" @"$probe_server" "$domain" +tls +timeout=2 +tries=1 > /dev/null 2>&1 - else - dig @"$probe_server" "$domain" +tls +timeout=2 +tries=1 > /dev/null 2>&1 - fi - fi - ;; - udp) - if [ -n "$server_port" ]; then - dig -p "$server_port" @"$probe_server" "$domain" +timeout=2 +tries=1 > /dev/null 2>&1 - else - dig @"$probe_server" "$domain" +timeout=2 +tries=1 > /dev/null 2>&1 - fi - ;; - *) - return 1 - ;; - esac -} - -dns_check_doh_server_available() { - local dns_server="$1" - local bootstrap_dns_server="$2" - local dns_host server_port doh_path doh_query url http_code resolved_ip - - dns_host="$(url_get_host "$dns_server")" - [ -n "$dns_host" ] || dns_host="$dns_server" - [ -n "$dns_host" ] || return 1 - - server_port="$(url_get_port "$dns_server")" - [ -n "$server_port" ] || server_port=443 - - doh_path="$(url_get_path "$dns_server")" - [ -n "$doh_path" ] && [ "$doh_path" != "/" ] || doh_path="/dns-query" - - doh_query="AAABAAABAAAAAAAABmdvb2dsZQNjb20AAAEAAQ" - url="https://$dns_host:$server_port$doh_path?dns=$doh_query" - - if is_ipv4 "$dns_host"; then - http_code="$(curl -sS --max-time 3 -o /dev/null -w "%{http_code}" \ - -H "accept: application/dns-message" "$url" 2> /dev/null)" - else - resolved_ip="$(dns_check_resolve_host "$dns_host" "$bootstrap_dns_server")" - [ -n "$resolved_ip" ] || return 1 - - http_code="$(curl -sS --max-time 3 -o /dev/null -w "%{http_code}" \ - --resolve "$dns_host:$server_port:$resolved_ip" \ - -H "accept: application/dns-message" "$url" 2> /dev/null)" - fi - - [ "$http_code" = "200" ] -} - -dns_check_router_resolver_available() { - local domain="$1" - local address interface source_network_interfaces - - for address in 127.0.0.1 "$SB_DNS_INBOUND_ADDRESS"; do - [ -n "$address" ] || continue - if dig @"$address" "$domain" +timeout=2 +tries=1 > /dev/null 2>&1; then - return 0 - fi - done - - address="$(get_service_listen_address 2> /dev/null | status_diagnostics_ucode stdin-first-line 2>/dev/null)" - if [ -n "$address" ] && - dig @"$address" "$domain" +timeout=2 +tries=1 > /dev/null 2>&1; then - return 0 - fi - - source_network_interfaces="$(uci_get "$PODKOP_CONFIG_NAME" "settings" "source_network_interfaces")" - [ -n "$source_network_interfaces" ] || source_network_interfaces="br-lan" - - for interface in $source_network_interfaces; do - network_get_ipaddr address "$interface" - if [ -n "$address" ] && - dig @"$address" "$domain" +timeout=2 +tries=1 > /dev/null 2>&1; then - return 0 - fi - - address="$(get_device_ipv4_address "$interface")" - if [ -n "$address" ] && - dig @"$address" "$domain" +timeout=2 +tries=1 > /dev/null 2>&1; then - return 0 - fi - done - - return 1 -} - -check_dns_available() { - local dns_type dns_server bootstrap_dns_server dns_server_host bootstrap_check_domain display_dns_server dont_touch_dhcp - config_get dns_type "settings" "dns_type" - config_get dns_server "settings" "dns_server" - config_get bootstrap_dns_server "settings" "bootstrap_dns_server" - config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0 - - local dns_status=0 - local dns_on_router=0 - local bootstrap_dns_status=0 - local dhcp_config_status=1 - local domain="google.com" - - display_dns_server="$(status_diagnostics_ucode mask-dns-server "$dns_server")" - - dns_server_host="$(url_get_host "$dns_server")" - [ -n "$dns_server_host" ] || dns_server_host="$dns_server" - - case "$dns_type" in - doh) - if dns_check_doh_server_available "$dns_server" "$bootstrap_dns_server"; then - dns_status=1 - fi - ;; - dot | udp) - if dns_check_dig_server_available "$dns_type" "$dns_server" "$bootstrap_dns_server" "$domain"; then - dns_status=1 - fi - ;; - esac - - # Check if local DNS resolver is working - if dns_check_router_resolver_available "$domain"; then - dns_on_router=1 - fi - - # Check bootstrap DNS server - if [ -n "$bootstrap_dns_server" ]; then - bootstrap_check_domain="$domain" - if [ -n "$dns_server_host" ] && ! is_ipv4 "$dns_server_host"; then - bootstrap_check_domain="$dns_server_host" - fi - - if dns_check_resolve_host "$bootstrap_check_domain" "$bootstrap_dns_server" > /dev/null; then - bootstrap_dns_status=1 - fi - fi - - # Check if dnsmasq sends DNS requests through Podkop DNS. - config_load dhcp - check_dhcp_has_podkop_dns - config_load "$PODKOP_CONFIG_NAME" - - status_diagnostics_ucode dns-check-json \ - "$dns_type" \ - "$display_dns_server" \ - "$dns_status" \ - "$dns_on_router" \ - "$bootstrap_dns_server" \ - "$bootstrap_dns_status" \ - "$dhcp_config_status" \ - "$dont_touch_dhcp" -} - -check_dhcp_has_podkop_dns() { - if ! dnsmasq_default_config_is_complete; then - dhcp_config_status=0 - fi -} - -nft_chain_counter_status() { - nft list chain inet "$NFT_TABLE_NAME" "$1" 2>/dev/null | status_diagnostics_ucode nft-chain-counter-status -} - -check_nft_rules() { - local table_exist=0 - local rules_mangle_exist=0 - local rules_mangle_counters=0 - local rules_mangle_output_exist=0 - local rules_mangle_output_counters=0 - local rules_proxy_exist=0 - local rules_proxy_counters=0 - local rules_other_mark_exist=0 - - # Generate traffic through PodkopTable - curl -m 3 -s "https://$CHECK_PROXY_IP_DOMAIN/check" > /dev/null 2>&1 & - local pid1=$! - curl -m 3 -s "https://$FAKEIP_TEST_DOMAIN/check" > /dev/null 2>&1 & - local pid2=$! - - wait $pid1 2> /dev/null - wait $pid2 2> /dev/null - sleep 1 - - # Check if PodkopPlusTable exists - if nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then - table_exist=1 - - # Check mangle chain rules - if nft list chain inet "$NFT_TABLE_NAME" mangle > /dev/null 2>&1; then - read -r rules_mangle_exist rules_mangle_counters < /dev/null 2>&1; then - read -r rules_mangle_output_exist rules_mangle_output_counters < /dev/null 2>&1; then - read -r rules_proxy_exist rules_proxy_counters < /dev/null | while read -r _ family table_name; do - [ -z "$table_name" ] && continue - - [ "$table_name" = "$NFT_TABLE_NAME" ] && continue - - if nft list table "$family" "$table_name" 2> /dev/null | - status_diagnostics_ucode stdin-contains "meta mark set" >/dev/null 2>&1; then - touch /tmp/podkop_mark_check.$$ - break - fi - done - - if [ -f /tmp/podkop_mark_check.$$ ]; then - rules_other_mark_exist=1 - rm -f /tmp/podkop_mark_check.$$ - fi - - status_diagnostics_ucode nft-check-json \ - "$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" -} - -check_sing_box() { - local sing_box_installed=0 - local sing_box_version_ok=0 - local sing_box_extended=0 - local sing_box_service_exist=0 - local sing_box_autostart_disabled=0 - local sing_box_process_running=0 - local sing_box_ports_listening=0 - - # Check if sing-box is installed - if command -v sing-box > /dev/null 2>&1; then - sing_box_installed=1 - - # Check version (must be >= 1.12.4) - local version - version="$(get_sing_box_version)" - if [ -n "$version" ]; then - version="$(status_diagnostics_ucode strip-leading-v "$version")" - if is_sing_box_compressed_marker_set || is_sing_box_extended "$version"; then - sing_box_extended=1 - fi - - if is_min_package_version "$version" "1.12.4"; then - sing_box_version_ok=1 - fi - elif is_sing_box_compressed_marker_set; then - sing_box_extended=1 - fi - fi - - # Check if service exists - if [ -f /etc/init.d/sing-box ]; then - sing_box_service_exist=1 - - if ! /etc/init.d/sing-box enabled 2> /dev/null; then - sing_box_autostart_disabled=1 - fi - fi - - # Check if process is running - if sing_box_process_is_running; then - sing_box_process_running=1 - fi - - # Check if sing-box is listening on required ports - local port_53_ok=0 - local port_1602_ok=0 - - if netstat -ln 2> /dev/null | status_diagnostics_ucode stdin-contains "127.0.0.42:53"; then - port_53_ok=1 - fi - - if netstat -ln 2> /dev/null | grep -Eq "(^|[[:space:]])(127\\.0\\.0\\.1|0\\.0\\.0\\.0):$SB_TPROXY_INBOUND_PORT([[:space:]]|$)"; then - port_1602_ok=1 - fi - - # Both ports must be listening - if [ "$port_53_ok" = "1" ] && [ "$port_1602_ok" = "1" ]; then - sing_box_ports_listening=1 - fi - - status_diagnostics_ucode sing-box-check-json \ - "$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" -} - -check_fakeip() { - local fakeip_address fakeip_status - - fakeip_address="$( - dig +short @"$SB_DNS_INBOUND_ADDRESS" "$FAKEIP_TEST_DOMAIN" A +timeout=2 +tries=1 2> /dev/null | - status_diagnostics_ucode stdin-first-ipv4-line 2>/dev/null - )" - - fakeip_status="$(status_diagnostics_ucode fakeip-address-status "$fakeip_address" 2>/dev/null)" - [ -n "$fakeip_status" ] || fakeip_status=false - - status_diagnostics_ucode fakeip-check-json "$fakeip_status" "$fakeip_address" -} - -####################################### -# Clash API interface for managing proxies and groups -# Arguments: -# $1 - Action: get_proxies, get_connections, get_proxy_latency, get_proxy_latencies, -# get_group_latency, set_group_proxy, close_connection, close_all_connections -# $2 - Proxy/Group tag (required for latency and set operations), proxy tags JSON array, or connection id -# $3 - Timeout in ms (optional, defaults: 2000 for proxy, 5000 for group) or target proxy tag for set_group_proxy -# Outputs: -# JSON formatted response -# Usage: -# clash_api get_proxies -# clash_api get_connections -# clash_api get_proxy_latency [timeout] -# clash_api get_proxy_latencies [timeout] -# clash_api get_group_latency [timeout] -# clash_api set_group_proxy -# clash_api close_connection -# clash_api close_all_connections -####################################### - -clash_api_urlencode_path_segment() { - status_diagnostics_ucode url-encode "$1" -} - -clash_api() { - local action="$1" - local clash_api_controller_address CLASH_URL TEST_URL - clash_api_controller_address="$(get_service_listen_address)" - if [ -z "$clash_api_controller_address" ]; then - clash_api_controller_address="127.0.0.1" - fi - CLASH_URL="$clash_api_controller_address:$SB_CLASH_API_CONTROLLER_PORT" - TEST_URL="https://www.gstatic.com/generate_204" - - local enable_yacd_wan_access yacd_secret_key auth_header - config_get_bool enable_yacd_wan_access "settings" "enable_yacd_wan_access" 0 - config_get yacd_secret_key "settings" "yacd_secret_key" - - if [ "$enable_yacd_wan_access" -eq 1 ]; then - auth_header="Authorization: Bearer $yacd_secret_key" - else - auth_header="" - fi - - case "$action" in - get_proxies) - curl -s --header "$auth_header" "$CLASH_URL/proxies" | status_diagnostics_ucode stdin-json - ;; - - get_connections) - curl -s --header "$auth_header" "$CLASH_URL/connections" | status_diagnostics_ucode stdin-json - ;; - - get_proxy_latency) - local proxy_tag="$2" encoded_proxy_tag - local timeout="${3:-2000}" - - if [ -z "$proxy_tag" ]; then - status_diagnostics_ucode json-error "proxy_tag required" - return 1 - fi - - encoded_proxy_tag="$(clash_api_urlencode_path_segment "$proxy_tag")" || return 1 - curl -G -s "$CLASH_URL/proxies/$encoded_proxy_tag/delay" \ - --header "$auth_header" \ - --data-urlencode "url=$TEST_URL" \ - --data-urlencode "timeout=$timeout" | status_diagnostics_ucode stdin-json - ;; - - get_proxy_latencies) - local proxy_tags_json="$2" - local timeout="${3:-5000}" - local tags_tmp proxy_tag encoded_proxy_tag pids pid count failed - - if [ -z "$proxy_tags_json" ]; then - status_diagnostics_ucode json-error "proxy_tags_json required" - return 1 - fi - - tags_tmp="$(mktemp "${TMPDIR:-/tmp}/podkop-plus-clash-tags.XXXXXX")" || { - status_diagnostics_ucode json-error "failed to create temporary proxy tags file" - return 1 - } - - if ! status_diagnostics_ucode clash-proxy-tags-lines "$proxy_tags_json" >"$tags_tmp"; then - rm -f "$tags_tmp" - status_diagnostics_ucode json-error "proxy_tags_json must be a JSON array of non-empty strings" - return 1 - fi - - pids="" - count=0 - failed=0 - while IFS= read -r proxy_tag || [ -n "$proxy_tag" ]; do - [ -n "$proxy_tag" ] || continue - encoded_proxy_tag="$(clash_api_urlencode_path_segment "$proxy_tag")" || { - rm -f "$tags_tmp" - return 1 - } - ( - curl -G -s "$CLASH_URL/proxies/$encoded_proxy_tag/delay" \ - --header "$auth_header" \ - --data-urlencode "url=$TEST_URL" \ - --data-urlencode "timeout=$timeout" | status_diagnostics_ucode stdin-json >/dev/null - ) & - pids="$pids $!" - count=$((count + 1)) - done <"$tags_tmp" - rm -f "$tags_tmp" - - for pid in $pids; do - wait "$pid" || failed=1 - done - - status_diagnostics_ucode clash-proxy-latencies-result "$count" "$failed" - [ "$failed" -eq 0 ] - ;; - - get_group_latency) - local group_tag="$2" encoded_group_tag - local timeout="${3:-5000}" - - if [ -z "$group_tag" ]; then - status_diagnostics_ucode json-error "group_tag required" - return 1 - fi - - encoded_group_tag="$(clash_api_urlencode_path_segment "$group_tag")" || return 1 - curl -G -s "$CLASH_URL/group/$encoded_group_tag/delay" \ - --header "$auth_header" \ - --data-urlencode "url=$TEST_URL" \ - --data-urlencode "timeout=$timeout" | status_diagnostics_ucode stdin-json - ;; - - set_group_proxy) - local group_tag="$2" encoded_group_tag - local proxy_tag="$3" - - if [ -z "$group_tag" ] || [ -z "$proxy_tag" ]; then - status_diagnostics_ucode json-error "group_tag and proxy_tag required" - return 1 - fi - - encoded_group_tag="$(clash_api_urlencode_path_segment "$group_tag")" || return 1 - local response - response=$( - curl -X PUT -s -w "\n%{http_code}" "$CLASH_URL/proxies/$encoded_group_tag" \ - --header "$auth_header" \ - --data-raw "$(status_diagnostics_ucode clash-set-group-proxy-payload "$proxy_tag")" - ) - - printf '%s' "$response" | status_diagnostics_ucode clash-set-group-proxy-result "$group_tag" "$proxy_tag" - ;; - - close_connection) - local connection_id="$2" - - if [ -z "$connection_id" ]; then - status_diagnostics_ucode json-error "connection_id required" - return 1 - fi - - local encoded_connection_id response - encoded_connection_id="$(clash_api_urlencode_path_segment "$connection_id")" || return 1 - response=$( - curl -X DELETE -s -w "\n%{http_code}" "$CLASH_URL/connections/$encoded_connection_id" \ - --header "$auth_header" - ) - printf '%s' "$response" | status_diagnostics_ucode clash-close-connection-result "$connection_id" - ;; - - close_all_connections) - local response - response=$( - curl -X DELETE -s -w "\n%{http_code}" "$CLASH_URL/connections" \ - --header "$auth_header" - ) - printf '%s' "$response" | status_diagnostics_ucode clash-close-all-connections-result - ;; - - *) - status_diagnostics_ucode clash-unknown-action - return 1 - ;; - esac -} - -print_global() { - local message="$1" - echo "$message" -} - -status_diagnostics_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/status_diagnostics.uc" "$@" -} - -global_check() { - local PODKOP_LUCI_VERSION="Unknown" - local visibility="${2:-masked}" - if [ "$1" = "raw" ] || [ "$1" = "masked" ]; then - visibility="$1" - elif [ -n "$1" ]; then - PODKOP_LUCI_VERSION="$1" - fi - - print_global "📡 Global check run!" - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "🛠️ System info" - - local system_info_json - system_info_json=$(get_system_info) - - if [ -n "$system_info_json" ]; then - printf '%s' "$system_info_json" | status_diagnostics_ucode global-system-info || - print_global "❌ Failed to parse system info" - else - print_global "❌ Failed to get system info" - fi - - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "➡️ DNS status" - - local dns_check_json - dns_check_json=$(check_dns_available) - - if [ -n "$dns_check_json" ]; then - local dont_touch_dhcp dns_render_rc - config_get dont_touch_dhcp "settings" "dont_touch_dhcp" - - printf '%s' "$dns_check_json" | status_diagnostics_ucode global-dns-check "$dont_touch_dhcp" - dns_render_rc=$? - if [ "$dns_render_rc" -eq 10 ]; then - status_diagnostics_ucode dhcp-dnsmasq-config /etc/config/dhcp - elif [ "$dns_render_rc" -ne 0 ]; then - print_global "❌ Failed to parse DNS info" - fi - else - print_global "❌ Failed to get DNS info" - fi - - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "📦 Sing-box status" - - local singbox_check_json - singbox_check_json=$(check_sing_box) - - if [ -n "$singbox_check_json" ]; then - printf '%s' "$singbox_check_json" | status_diagnostics_ucode global-sing-box-check || - print_global "❌ Failed to parse sing-box info" - else - print_global "❌ Failed to get sing-box info" - fi - - print_global "---------------------------" - print_global "Inbounds checks" - - local inbounds_check_json - inbounds_check_json=$(check_inbounds) - - if [ -n "$inbounds_check_json" ]; then - printf '%s' "$inbounds_check_json" | status_diagnostics_ucode global-inbounds-check || - print_global "[FAIL] Failed to parse inbounds check details" - else - print_global "[FAIL] Failed to get inbounds info" - fi - - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "🧱 NFT rules status" - - local nft_check_json - nft_check_json=$(check_nft_rules) - - if [ -n "$nft_check_json" ]; then - if printf '%s' "$nft_check_json" | status_diagnostics_ucode global-nft-check; then - if printf '%s' "$nft_check_json" | status_diagnostics_ucode global-nft-other-mark-exists >/dev/null 2>&1; then - nft list ruleset | status_diagnostics_ucode nft-ruleset-other-mark-lines "$NFT_TABLE_NAME" - fi - else - print_global "❌ Failed to parse NFT rules info" - fi - else - print_global "❌ Failed to get NFT rules info" - fi - - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "📄 Podkop Plus config" - show_config "$visibility" - - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "📄 WAN config" - if uci show network.wan > /dev/null 2>&1; then - if [ "$visibility" = "raw" ]; then - cat /etc/config/network - else - status_diagnostics_ucode wan-config-masked /etc/config/network - fi - else - print_global "❌ WAN configuration not found" - fi - - uci show network 2>/dev/null | - status_diagnostics_ucode network-endpoint-host-warnings "$CLOUDFLARE_OCTETS" 2>/dev/null | - while IFS="$(printf '\t')" read -r warning_kind host || [ -n "$warning_kind" ]; do - [ -n "$host" ] || continue - if [ "$warning_kind" = "engage" ]; then - print_global "⚠️ WARP detected: $host" - continue - fi - - if [ "$warning_kind" = "prefix" ]; then - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "⚠️ WARP detected: $host" - fi - done - - uci show network 2>/dev/null | - status_diagnostics_ucode network-wireguard-route-allowed-peers 2>/dev/null | - while read -r peer_section || [ -n "$peer_section" ]; do - [ -n "$peer_section" ] || continue - local allowed_ips - allowed_ips=$(uci get "${peer_section}.allowed_ips" 2> /dev/null) - - if [ "$allowed_ips" = "0.0.0.0/0" ]; then - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "⚠️ WG Route allowed IP enabled with 0.0.0.0/0" - fi - done - - if [ -x /etc/init.d/zapret ] && /etc/init.d/zapret status >/dev/null 2>&1; then - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "⚠️ Standalone zapret service is active. Podkop Plus uses separate queues, but packet-level policy overlap is possible." - elif [ -x /etc/init.d/zapret ] && /etc/init.d/zapret enabled >/dev/null 2>&1; then - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "⚠️ Standalone zapret autostart is enabled. Podkop Plus will not modify /etc/config/zapret." - fi - - if [ -x /etc/init.d/zapret2 ] && /etc/init.d/zapret2 status >/dev/null 2>&1; then - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "⚠️ Standalone zapret2 service is active. Podkop Plus uses separate queues, but packet-level policy overlap is possible." - elif [ -x /etc/init.d/zapret2 ] && /etc/init.d/zapret2 enabled >/dev/null 2>&1; then - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "⚠️ Standalone zapret2 autostart is enabled. Podkop Plus will not modify /etc/config/zapret2." - fi - - print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - print_global "🥸 FakeIP status" - - local fakeip_check_json - fakeip_check_json=$(check_fakeip) - - if [ -n "$fakeip_check_json" ]; then - printf '%s' "$fakeip_check_json" | status_diagnostics_ucode global-fakeip-check || - print_global "❌ Failed to parse FakeIP info" - else - print_global "❌ Failed to get FakeIP info" - fi -} diff --git a/podkop/files/usr/lib/subscription/cache.uc b/podkop/files/usr/lib/subscription/cache.uc new file mode 100644 index 00000000..cefb4628 --- /dev/null +++ b/podkop/files/usr/lib/subscription/cache.uc @@ -0,0 +1,2901 @@ +#!/usr/bin/env ucode + +let fs = require("fs"); +let uci_core = require("core.uci"); + +const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus"; +const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus"; +const TMP_SING_BOX_FOLDER = getenv("TMP_SING_BOX_FOLDER") || "/tmp/sing-box"; +const TMP_RULESET_FOLDER = getenv("TMP_RULESET_FOLDER") || TMP_SING_BOX_FOLDER + "/rulesets"; +const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || TMP_SING_BOX_FOLDER + "/subscriptions"; +const PODKOP_RUNTIME_STATE_DIR = getenv("PODKOP_RUNTIME_STATE_DIR") || "/var/run/podkop-plus"; +const PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR") || PODKOP_RUNTIME_STATE_DIR + "/subscription-update"; +const PODKOP_SUBSCRIPTION_LINKS_DIR = getenv("PODKOP_SUBSCRIPTION_LINKS_DIR") || PODKOP_RUNTIME_STATE_DIR + "/subscription-links"; +const PODKOP_SUBSCRIPTION_METADATA_DIR = getenv("PODKOP_SUBSCRIPTION_METADATA_DIR") || PODKOP_RUNTIME_STATE_DIR + "/subscription-metadata"; +const PODKOP_OUTBOUND_METADATA_DIR = getenv("PODKOP_OUTBOUND_METADATA_DIR") || PODKOP_RUNTIME_STATE_DIR + "/outbound-metadata"; +const PODKOP_SECTION_CACHE_DIR = getenv("PODKOP_SECTION_CACHE_DIR") || PODKOP_RUNTIME_STATE_DIR + "/section-cache"; +const PODKOP_RUNTIME_CACHE_FORMAT_FILE = getenv("PODKOP_RUNTIME_CACHE_FORMAT_FILE") || PODKOP_RUNTIME_STATE_DIR + "/cache-format"; +const PODKOP_RUNTIME_CACHE_FORMAT = getenv("PODKOP_RUNTIME_CACHE_FORMAT") || "7"; +const PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR") || "/etc/podkop-plus/subscription-cache"; +const PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE = getenv("PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE") || PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/cache-format"; +const PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE = getenv("PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE") || PODKOP_RUNTIME_STATE_DIR + "/subscription-bootstrap-retry.pid"; +const PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR") || PODKOP_RUNTIME_STATE_DIR + "/subscription-update.lock"; +const PODKOP_PENDING_RELOAD_FILE = getenv("PODKOP_PENDING_RELOAD_FILE") || PODKOP_RUNTIME_STATE_DIR + "/reload.pending"; +const PODKOP_SERVICE_INIT = getenv("PODKOP_SERVICE_INIT") || "/etc/init.d/podkop-plus"; +const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1"; +const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; +const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/podkop-plus/sing-box-variant"; +const SB_VERSION_STATE_FILE = getenv("SB_VERSION_STATE_FILE") || "/etc/podkop-plus/sing-box-version"; +const ZAPRET_PROVIDER_NFQWS_BIN = getenv("ZAPRET_PROVIDER_NFQWS_BIN") || "/opt/zapret/nfq/nfqws"; +const ZAPRET2_PROVIDER_NFQWS2_BIN = getenv("ZAPRET2_PROVIDER_NFQWS2_BIN") || "/opt/zapret2/nfq2/nfqws2"; +const BYEDPI_BIN = getenv("BYEDPI_BIN") || "/usr/bin/ciadpi"; + +function as_string(value) { + return value == null ? "" : "" + value; +} + +function read_stdin() { + let data = fs.readfile("/dev/stdin"); + return data == null ? "" : data; +} + +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 file_first_line(path) { + let data = fs.readfile(path); + if (data == null) + exit(1); + + let newline = index(data, "\n"); + print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); +} + +function file_first_line_value(path) { + let data = fs.readfile(path); + if (data == null) + return ""; + + let newline = index(data, "\n"); + return newline >= 0 ? substr(data, 0, newline) : data; +} + +function file_has_exact_line(path, needle) { + let data = fs.readfile(path); + if (data == null) + return false; + + needle = as_string(needle); + for (let line in split(data, "\n")) + if (as_string(line) == needle) + return true; + + return false; +} + +function whitespace_values(value) { + let result = []; + for (let item in split(trim(as_string(value)), /[ \t\r\n]+/)) + if (item != "") + push(result, item); + return result; +} + +function state_list_contains(list, value) { + value = as_string(value); + if (value == "") + return false; + + for (let item in whitespace_values(list)) + if (item == value) + return true; + + return false; +} + +function append_state_list_once(list, value) { + list = trim(as_string(list)); + value = as_string(value); + + if (value == "" || state_list_contains(list, value)) { + print(list, "\n"); + return; + } + + print(list, list != "" ? " " : "", value, "\n"); +} + +function object_or_empty(value) { + return type(value) == "object" ? value : {}; +} + +function option(section, key, fallback) { + if (fallback == null) + fallback = ""; + + let value = object_or_empty(section)[key]; + if (value == null) + return fallback; + if (type(value) == "array") + return join(" ", value); + return as_string(value); +} + +function bool_value(value, fallback) { + if (value == null) + return !!fallback; + + value = lc(as_string(value)); + return value == "1" || value == "true" || value == "yes" || value == "on"; +} + +function bool_option(section, key, fallback) { + return bool_value(object_or_empty(section)[key], fallback); +} + +function unsigned_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9]/) != null) + return null; + return int(value); +} + +function duration_to_seconds_value(value) { + let rest = as_string(value); + if (rest == "") + return null; + + let total = 0.0; + let multipliers = { + ns: 0.000000001, + us: 0.000001, + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400 + }; + + while (rest != "") { + let matched = match(rest, /^([0-9]+(\.[0-9]+)?)(ns|us|ms|s|m|h|d)/); + if (!matched) + return null; + + let token = as_string(matched[0]); + let amount = matched[1] * 1; + let unit = matched[3]; + total = total + amount * multipliers[unit]; + rest = substr(rest, length(token)); + } + + if (total <= 0) + return null; + + return int(total + 0.5); +} + +function update_due_status(now_value, last_run_value, interval_value) { + let now = unsigned_number(now_value); + let interval = unsigned_number(interval_value); + + if (now == null || interval == null || interval <= 0) + return 2; + + let last_run = unsigned_number(last_run_value); + if (last_run == null) + last_run = 0; + + if (last_run > 0 && now - last_run < interval) + return 1; + + return 0; +} + +function cache_section_is_safe(section) { + section = as_string(section); + return section != "" && index(section, "/") < 0 && index(section, "..") < 0; +} + +function append_word_once(words, value) { + words = trim(as_string(words)); + value = as_string(value); + + if (value == "" || state_list_contains(words, value)) + return words; + + return words + (words != "" ? " " : "") + value; +} + +function section_name(section) { + return as_string(object_or_empty(section)[".name"]); +} + +function source_id(section, index) { + return as_string(section) + "-subscription-" + as_string(index); +} + +function subscription_metadata_path(section) { + section = as_string(section); + if (!cache_section_is_safe(section)) + return ""; + return PODKOP_SUBSCRIPTION_METADATA_DIR + "/" + section + ".json"; +} + +function outbound_metadata_path(section) { + section = as_string(section); + if (!cache_section_is_safe(section)) + return ""; + return PODKOP_OUTBOUND_METADATA_DIR + "/" + section + ".json"; +} + +function section_has_subscription_urls(section) { + return option(section, "subscription_urls", "") != ""; +} + +function section_is_subscription_proxy(section) { + return bool_option(section, "enabled", true) && + option(section, "action", "") == "proxy" && + section_has_subscription_urls(section); +} + +function section_subscription_update_interval(section) { + if (!section_has_subscription_urls(section)) + return ""; + + if (!bool_option(section, "subscription_update_enabled", true)) + return ""; + + let value = option(section, "subscription_update_interval", ""); + return value != "" ? value : "1h"; +} + +function subscription_source_count(section) { + let values = object_or_empty(section).subscription_urls; + if (type(values) == "array") + return length(values); + return as_string(values) != "" ? 1 : 0; +} + +function cache_keep_sets(sections) { + let keep = { + sections: "", + sources: "", + all: "" + }; + + for (let section in sections) { + section = object_or_empty(section); + let name = section_name(section); + if (!cache_section_is_safe(name)) + continue; + + keep.sections = append_word_once(keep.sections, name); + keep.all = append_word_once(keep.all, name); + + let count = subscription_source_count(section); + for (let i = 1; i <= count; i++) { + let source = source_id(name, i); + if (!cache_section_is_safe(source)) + continue; + + keep.sources = append_word_once(keep.sources, source); + keep.all = append_word_once(keep.all, source); + } + } + + return keep; +} + +function runtime_cache_missing(sections, section_cache_dir) { + section_cache_dir = as_string(section_cache_dir); + + for (let section in sections) { + section = object_or_empty(section); + let name = section_name(section); + if (!section_is_subscription_proxy(section)) + continue; + if (!cache_section_is_safe(name)) + continue; + if (fs.stat(section_cache_dir + "/" + name + ".json") == null) + return true; + } + + return false; +} + +function path_starts_with(path, prefix) { + path = as_string(path); + prefix = as_string(prefix); + if (prefix == "") + return false; + return path == prefix || substr(path, 0, length(prefix) + 1) == 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 cache_name_from_file(path) { + let slash = rindex(path, "/"); + let file_name = slash >= 0 ? substr(path, slash + 1) : path; + + if (file_name == "cache-format") + return ""; + if (ends_with(file_name, ".metadata.json")) + return substr(file_name, 0, length(file_name) - 14); + if (ends_with(file_name, ".json")) + return substr(file_name, 0, length(file_name) - 5); + if (ends_with(file_name, ".url")) + return substr(file_name, 0, length(file_name) - 4); + if (ends_with(file_name, ".user_agent")) + return substr(file_name, 0, length(file_name) - 11); + + return ""; +} + +function keep_names_for_path(path, keep, tmp_dir, persistent_dir, section_cache_dir, links_dir, metadata_dir, outbound_metadata_dir) { + if (path_starts_with(path, tmp_dir) || path_starts_with(path, persistent_dir)) + return keep.sources; + if (path_starts_with(path, section_cache_dir)) + return keep.all; + if (path_starts_with(path, links_dir) || path_starts_with(path, metadata_dir) || path_starts_with(path, outbound_metadata_dir)) + return keep.sections; + return ""; +} + +function stale_cache_delete_paths(sections, tmp_dir, persistent_dir, section_cache_dir, links_dir, metadata_dir, outbound_metadata_dir) { + let keep = cache_keep_sets(sections); + + for (let path in split(read_stdin(), "\n")) { + path = as_string(path); + if (path == "") + continue; + + let cache_name = cache_name_from_file(path); + if (cache_name == "") + continue; + + let keep_names = keep_names_for_path(path, keep, tmp_dir, persistent_dir, section_cache_dir, links_dir, metadata_dir, outbound_metadata_dir); + if (state_list_contains(keep_names, cache_name)) + continue; + + print(path, "\n"); + } +} + +function fixture_section_list(data) { + data = object_or_empty(data); + if (type(data.section) == "array") + return data.section; + if (type(data.section) == "object") + return [ data.section ]; + if (type(data.sections) == "array") + return data.sections; + return []; +} + +function uci_sections() { + return uci_core.section_objects(CONFIG_NAME, "section"); +} + +function maintenance_plan(sections, section_cache_dir) { + let keep = cache_keep_sets(sections); + print("sections\t", keep.sections, "\n"); + print("sources\t", keep.sources, "\n"); + print("all\t", keep.all, "\n"); + print("missing\t", runtime_cache_missing(sections, section_cache_dir) ? "1" : "0", "\n"); +} + +let auto_user_agents = { + Happ: true, + v2rayN: true, + Hiddify: true, + "Clash.Meta": true, + ClashMetaForAndroid: true +}; + +function user_agent_supported(user_agent, default_user_agent) { + user_agent = as_string(user_agent); + default_user_agent = as_string(default_user_agent); + + if (user_agent == "") + return false; + if (user_agent == default_user_agent) + return true; + + return auto_user_agents[user_agent] == true; +} + +function user_agent_matches_config(configured_user_agent, cached_user_agent, default_user_agent) { + configured_user_agent = as_string(configured_user_agent); + cached_user_agent = as_string(cached_user_agent); + + if (configured_user_agent != "") + return cached_user_agent == configured_user_agent; + + return user_agent_supported(cached_user_agent, default_user_agent); +} + +let parser_module = null; + +function subscription_parser() { + if (parser_module == null) + parser_module = require("subscription.parser"); + return parser_module; +} + +function subscription_cache_is_usable(path) { + if (fs.stat(as_string(path)) == null) + return false; + return subscription_parser().validate_subscription(path); +} + +function source_json_path(dir, source_section) { + return as_string(dir) + "/" + as_string(source_section) + ".json"; +} + +function source_url_path(dir, source_section) { + return as_string(dir) + "/" + as_string(source_section) + ".url"; +} + +function source_user_agent_path(dir, source_section) { + return as_string(dir) + "/" + as_string(source_section) + ".user_agent"; +} + +function read_text(path) { + let data = fs.readfile(as_string(path)); + return data == null ? "" : as_string(data); +} + +function write_text(path, value) { + return fs.writefile(as_string(path), as_string(value)) != null; +} + +function copy_file(source, target) { + let data = fs.readfile(as_string(source)); + if (data == null) + return false; + + let stamp = clock(); + let tmp_path = sprintf("%s.%d.%d.tmp", as_string(target), stamp[0], stamp[1]); + if (!write_file(tmp_path, data)) + return false; + if (!fs.rename(tmp_path, as_string(target))) { + fs.unlink(tmp_path); + return false; + } + return true; +} + +function unlink_path(path) { + try { + fs.unlink(as_string(path)); + } + catch (e) { + } +} + +function file_nonempty(path) { + let stat = fs.stat(as_string(path)); + return stat != null && stat.size != null && stat.size > 0; +} + +function files_equal(left, right) { + let left_data = fs.readfile(as_string(left)); + let right_data = fs.readfile(as_string(right)); + return left_data != null && right_data != null && left_data == right_data; +} + +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function command_from_args(args) { + let parts = []; + + for (let arg in args) + push(parts, shell_quote(arg)); + + return join(" ", parts); +} + +function command_env(assignments) { + let parts = []; + + for (let name, value in assignments) + push(parts, name + "=" + shell_quote(value)); + + return join(" ", parts); +} + +function command_args_with(base, extra) { + let result = []; + + for (let arg in base) + push(result, arg); + for (let arg in extra) + push(result, arg); + + return result; +} + +function command_output(command) { + let pipe = fs.popen(command, "r"); + if (!pipe) + return ""; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status != 0 || data == null) + return ""; + + return as_string(data); +} + +function command_output_from_args(args) { + return command_output(command_from_args(args)); +} + +function command_success_from_args(args) { + return system(command_from_args(args) + " >/dev/null 2>&1") == 0; +} + +function command_status_from_args(args) { + let status = int(system(command_from_args(args))); + return status > 255 ? int(status / 256) : status; +} + +function run_silent(command) { + system(command + " >/dev/null 2>&1"); +} + +function log_message(message, level) { + level = as_string(level || "info"); + command_success_from_args([ "logger", "-t", "podkop-plus", "[" + level + "] " + as_string(message) ]); +} + +function ensure_dir(path) { + run_silent("mkdir -p " + shell_quote(path)); +} + +function chmod_path(path, mode) { + run_silent("chmod " + shell_quote(mode) + " " + shell_quote(path)); +} + +function ensure_runtime_dirs() { + ensure_dir(TMP_SING_BOX_FOLDER); + ensure_dir(TMP_RULESET_FOLDER); + ensure_dir(TMP_SUBSCRIPTION_FOLDER); + ensure_dir(PODKOP_RUNTIME_STATE_DIR); + ensure_dir(PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR); + ensure_dir(PODKOP_SUBSCRIPTION_LINKS_DIR); + ensure_dir(PODKOP_SUBSCRIPTION_METADATA_DIR); + ensure_dir(PODKOP_OUTBOUND_METADATA_DIR); + ensure_dir(PODKOP_SECTION_CACHE_DIR); +} + +function clear_subscription_runtime_cache() { + run_silent("rm -rf " + + shell_quote(TMP_SUBSCRIPTION_FOLDER) + " " + + shell_quote(PODKOP_SUBSCRIPTION_LINKS_DIR) + " " + + shell_quote(PODKOP_SUBSCRIPTION_METADATA_DIR) + " " + + shell_quote(PODKOP_OUTBOUND_METADATA_DIR) + " " + + shell_quote(PODKOP_SECTION_CACHE_DIR)); +} + +function ensure_runtime_cache_format() { + ensure_dir(PODKOP_RUNTIME_STATE_DIR); + + if (file_first_line_value(PODKOP_RUNTIME_CACHE_FORMAT_FILE) != PODKOP_RUNTIME_CACHE_FORMAT) { + log_message("Runtime subscription cache format changed; clearing old subscription cache", "info"); + clear_subscription_runtime_cache(); + ensure_runtime_dirs(); + write_file(PODKOP_RUNTIME_CACHE_FORMAT_FILE, PODKOP_RUNTIME_CACHE_FORMAT + "\n"); + } + + if (file_first_line_value(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE) != PODKOP_RUNTIME_CACHE_FORMAT) { + run_silent("rm -rf " + shell_quote(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR)); + ensure_dir(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR); + chmod_path(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, "700"); + write_file(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, PODKOP_RUNTIME_CACHE_FORMAT + "\n"); + chmod_path(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, "600"); + } +} + +function remove_legacy_server_country_cache() { + unlink_path(PODKOP_RUNTIME_STATE_DIR + "/server-country-cache.json"); +} + +function temp_path(dir, section, kind) { + ensure_dir(dir); + let stamp = clock(); + return sprintf("%s/%s.%s.%d.%d", dir, as_string(section), as_string(kind), stamp[0], stamp[1]); +} + +function move_file(source, target) { + unlink_path(target); + return fs.rename(as_string(source), as_string(target)); +} + +function write_text_if_changed(path, value) { + path = as_string(path); + value = as_string(value); + + if (fs.readfile(path) == value) { + chmod_path(path, "600"); + return true; + } + + let tmp_path = sprintf("%s.%d.%d.tmp", path, clock()[0], clock()[1]); + if (!write_file(tmp_path, value)) { + unlink_path(tmp_path); + return false; + } + if (!fs.rename(tmp_path, path)) { + unlink_path(tmp_path); + return false; + } + + chmod_path(path, "600"); + return true; +} + +function copy_file_if_changed(source, target) { + if (!file_nonempty(source)) + return false; + if (files_equal(source, target)) { + chmod_path(target, "600"); + return true; + } + if (!copy_file(source, target)) + return false; + + chmod_path(target, "600"); + return true; +} + +function restore_persistent_subscription_cache(source_section, tmp_dir, persistent_dir, expected_url, expected_user_agent, default_user_agent) { + if (!cache_section_is_safe(source_section)) + return false; + + let runtime_json = source_json_path(tmp_dir, source_section); + if (subscription_cache_is_usable(runtime_json)) + return true; + + let persistent_json = source_json_path(persistent_dir, source_section); + if (!subscription_cache_is_usable(persistent_json)) + return false; + + let cached_url = read_text(source_url_path(persistent_dir, source_section)); + let cached_user_agent = read_text(source_user_agent_path(persistent_dir, source_section)); + if (cached_url != as_string(expected_url)) + return false; + if (!user_agent_matches_config(expected_user_agent, cached_user_agent, default_user_agent)) + return false; + + return copy_file(persistent_json, runtime_json) && + write_text(source_url_path(tmp_dir, source_section), cached_url) && + write_text(source_user_agent_path(tmp_dir, source_section), cached_user_agent); +} + +function section_current_usable_cache(section, tmp_dir, persistent_dir, default_user_agent) { + section = object_or_empty(section); + let name = section_name(section); + if (!section_has_subscription_urls(section) || !cache_section_is_safe(name)) + return false; + + let entries = object_or_empty(section).subscription_urls; + if (type(entries) != "array") + entries = as_string(entries) != "" ? [ as_string(entries) ] : []; + + let index = 0; + for (let entry in entries) { + index++; + let parsed = subscription_parser().parse_subscription_source_entry(entry); + if (type(parsed) != "object" || parsed.valid !== true) + continue; + + let source_section = source_id(name, index); + if (!restore_persistent_subscription_cache( + source_section, + tmp_dir, + persistent_dir, + parsed.url, + parsed.user_agent, + default_user_agent + )) + continue; + + if (!subscription_cache_is_usable(source_json_path(tmp_dir, source_section))) + continue; + + let cached_url = read_text(source_url_path(tmp_dir, source_section)); + let cached_user_agent = read_text(source_user_agent_path(tmp_dir, source_section)); + if (cached_url == as_string(parsed.url) && + user_agent_matches_config(parsed.user_agent, cached_user_agent, default_user_agent)) + return true; + } + + return false; +} + +function uci_section(section_name_value) { + return object_or_empty(uci_core.get_all(CONFIG_NAME, section_name_value)); +} + +function uci_named_section(section_name_value) { + return object_or_empty(uci_core.get_all(CONFIG_NAME, section_name_value)); +} + +function list_option(section, key) { + let value = object_or_empty(section)[key]; + if (type(value) == "array") + return value; + if (as_string(value) != "") + return [ as_string(value) ]; + return []; +} + +function find_section(sections, name) { + name = as_string(name); + for (let section in sections) { + section = object_or_empty(section); + if (section_name(section) == name) + return section; + } + return {}; +} + +function file_executable(path) { + let stat = fs.stat(as_string(path)); + if (stat == null || stat.mode == null) + return false; + + return (int(stat.mode) & 73) != 0; +} + +function download_via_proxy_option_for_purpose(purpose) { + purpose = as_string(purpose || "lists"); + if (purpose == "lists") + return "download_lists_via_proxy"; + if (purpose == "subscriptions") + return "download_subscriptions_via_proxy"; + if (purpose == "components") + return "download_components_via_proxy"; + return ""; +} + +function download_via_proxy_enabled(settings, purpose) { + let option_name = download_via_proxy_option_for_purpose(purpose); + return option_name != "" && bool_option(settings, option_name, false); +} + +function service_proxy_address(settings, purpose) { + return download_via_proxy_enabled(settings, purpose) ? + SB_SERVICE_MIXED_INBOUND_ADDRESS + ":" + SB_SERVICE_MIXED_INBOUND_PORT : ""; +} + +function sing_box_service_running() { + return command_success_from_args([ "ucode", "-L", LIB_DIR, LIB_DIR + "/service/state.uc", "sing-box-service-running" ]); +} + +function provider_action_is_available(action) { + if (action == "byedpi") + return file_executable(BYEDPI_BIN); + if (action == "zapret") + return file_executable(ZAPRET_PROVIDER_NFQWS_BIN); + if (action == "zapret2") + return file_executable(ZAPRET2_PROVIDER_NFQWS2_BIN); + return false; +} + +function section_current_usable_cache_by_name(sections, section, default_user_agent) { + return section_current_usable_cache(find_section(sections, section), TMP_SUBSCRIPTION_FOLDER, PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, default_user_agent); +} + +function subscription_bootstrap_download_section_is_ready(sections, settings, startup_blocked_sections, default_user_agent) { + if (!download_via_proxy_enabled(settings, "subscriptions")) { + log_message("Subscription startup cannot be deferred because subscription downloads via Proxy/VPN are disabled", "error"); + return false; + } + + let download_section = option(settings, "download_lists_via_proxy_section", ""); + if (download_section == "") + return false; + + if (state_list_contains(startup_blocked_sections, download_section)) { + log_message("Subscription startup cannot be deferred because selected download rule '" + download_section + "' is also waiting for an unavailable subscription cache", "error"); + return false; + } + + let section = find_section(sections, download_section); + if (!bool_option(section, "enabled", true)) + return false; + + let action = option(section, "action", ""); + if (action == "proxy") { + if (option(section, "selector_proxy_links", "") != "") + return true; + if (section_current_usable_cache_by_name(sections, download_section, default_user_agent)) + return true; + + log_message("Subscription startup cannot be deferred because selected proxy rule '" + download_section + "' has no manual proxy links and no usable subscription cache", "error"); + return false; + } + if (action == "outbound") + return option(section, "outbound_json", "") != ""; + if (action == "vpn") + return option(section, "interface", "") != ""; + + return provider_action_is_available(action); +} + +function cache_candidate_paths() { + let result = []; + for (let dir in [ + TMP_SUBSCRIPTION_FOLDER, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, + PODKOP_SECTION_CACHE_DIR, + PODKOP_SUBSCRIPTION_LINKS_DIR, + PODKOP_SUBSCRIPTION_METADATA_DIR, + PODKOP_OUTBOUND_METADATA_DIR + ]) { + let entries = fs.lsdir(dir); + if (type(entries) != "array") + continue; + for (let entry in entries) { + let path = dir + "/" + as_string(entry); + let stat = fs.stat(path); + if (stat != null && stat.type == "file") + push(result, path); + } + } + return result; +} + +function prune_stale_subscription_caches_for_sections(sections) { + ensure_runtime_dirs(); + let keep = cache_keep_sets(sections); + + for (let path in cache_candidate_paths()) { + let cache_name = cache_name_from_file(path); + if (cache_name == "") + continue; + + let keep_names = keep_names_for_path( + path, + keep, + TMP_SUBSCRIPTION_FOLDER, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, + PODKOP_SECTION_CACHE_DIR, + PODKOP_SUBSCRIPTION_LINKS_DIR, + PODKOP_SUBSCRIPTION_METADATA_DIR, + PODKOP_OUTBOUND_METADATA_DIR + ); + if (!state_list_contains(keep_names, cache_name)) + unlink_path(path); + } +} + +function append_user_agent_candidate(result, seen, candidate, default_user_agent) { + candidate = as_string(candidate); + if (!user_agent_supported(candidate, default_user_agent) || seen[candidate]) + return; + + seen[candidate] = true; + push(result, candidate); +} + +function write_user_agent_candidates(path, configured_user_agent, preferred_user_agent, default_user_agent) { + path = as_string(path); + configured_user_agent = as_string(configured_user_agent); + preferred_user_agent = as_string(preferred_user_agent); + default_user_agent = as_string(default_user_agent); + + if (configured_user_agent != "") { + if (!write_file(path, configured_user_agent + "\n")) + exit(1); + return; + } + + let result = []; + let seen = {}; + let candidates = [ + default_user_agent, + "v2rayN", + preferred_user_agent, + "Happ", + "Hiddify", + "Clash.Meta", + "ClashMetaForAndroid" + ]; + + for (let candidate in candidates) + append_user_agent_candidate(result, seen, candidate, default_user_agent); + + if (!write_file(path, length(result) > 0 ? join("\n", result) + "\n" : "")) + exit(1); +} + +function write_empty_link() { + write_stdout_json({ link: "" }); +} + +function write_empty_outbound_metadata() { + write_stdout_json({ + names: {}, + countries: {} + }); +} + +function is_array(value) { + return type(value) == "array"; +} + +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 json_length(path) { + let value = read_json(path); + if (type(value) == "array" || type(value) == "object") + print(length(value), "\n"); + else + print("0\n"); +} + +function object_has_extra_keys(path) { + return object_key_count(read_json(path)) > 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 hysteria2_server_port_entry(value) { + value = as_string(value); + let colon = index(value, ":"); + if (colon < 0) + return value; + + let start = substr(value, 0, colon); + let end = substr(value, colon + 1); + if (start == "" || end == "") + return ""; + + return start == end ? start : (start + "-" + end); +} + +function hysteria2_server_ports_uri(outbound) { + let server_ports = array_or_empty(outbound.server_ports); + if (length(server_ports) == 0) + return ""; + + let result = []; + for (let item in server_ports) { + let port = hysteria2_server_port_entry(item); + if (port != "") + push(result, port); + } + + return join(",", result); +} + +function add_query(params, key, value) { + value = as_string(value); + if (value != "") + push(params, uri_encode(key) + "=" + uri_encode(value)); +} + +function add_xhttp_extra_query(params, transport) { + let extra = {}; + for (let item in [ + ["xPaddingBytes", "x_padding_bytes"], + ["noGRPCHeader", "no_grpc_header"], + ["scMaxEachPostBytes", "sc_max_each_post_bytes"], + ["scMinPostsIntervalMs", "sc_min_posts_interval_ms"], + ["scStreamUpServerSecs", "sc_stream_up_server_secs"] + ]) { + if (transport[item[1]] != null) + extra[item[0]] = transport[item[1]]; + } + + if (type(transport.xmux) == "object") { + let xmux = {}; + for (let item in [ + ["maxConcurrency", "max_concurrency"], + ["maxConnections", "max_connections"], + ["cMaxReuseTimes", "c_max_reuse_times"], + ["hMaxRequestTimes", "h_max_request_times"], + ["hMaxReusableSecs", "h_max_reusable_secs"], + ["hKeepAlivePeriod", "h_keep_alive_period"] + ]) { + if (transport.xmux[item[1]] != null) + xmux[item[0]] = transport.xmux[item[1]]; + } + if (length(keys(xmux)) > 0) + extra.xmux = xmux; + } + + if (length(keys(extra)) > 0) + add_query(params, "extra", sprintf("%J", extra)); +} + +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); + add_xhttp_extra_query(params, transport); + } +} + +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) { + let port = hysteria2_server_ports_uri(outbound); + if (port == "" && outbound.server_port != null) + port = as_string(outbound.server_port); + + if (as_string(outbound.password) == "" || as_string(outbound.server) == "" || port == "") + 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, 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"); +} + +function persistent_metadata_path(source_section) { + return PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR + "/" + as_string(source_section) + ".metadata.json"; +} + +function remove_subscription_source_runtime_cache(source_section) { + if (!cache_section_is_safe(source_section)) + return; + + unlink_path(source_json_path(TMP_SUBSCRIPTION_FOLDER, source_section)); + unlink_path(source_url_path(TMP_SUBSCRIPTION_FOLDER, source_section)); + unlink_path(source_user_agent_path(TMP_SUBSCRIPTION_FOLDER, source_section)); +} + +function persist_subscription_cache(source_section, subscription_json_path, subscription_url, effective_user_agent, metadata_path) { + if (!cache_section_is_safe(source_section) || !subscription_cache_is_usable(subscription_json_path)) + return false; + + ensure_dir(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR); + chmod_path(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, "700"); + + let persistent_json = source_json_path(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, source_section); + let persistent_url = source_url_path(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, source_section); + let persistent_user_agent = source_user_agent_path(PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, source_section); + let persistent_metadata = persistent_metadata_path(source_section); + let previous_url = read_text(persistent_url); + let previous_user_agent = read_text(persistent_user_agent); + let can_keep_previous_metadata = previous_url == as_string(subscription_url) && + previous_user_agent == as_string(effective_user_agent) && + file_nonempty(persistent_metadata) && + object_has_extra_keys(persistent_metadata); + + if (!copy_file_if_changed(subscription_json_path, persistent_json) || + !write_text_if_changed(persistent_url, subscription_url) || + !write_text_if_changed(persistent_user_agent, effective_user_agent)) + return false; + + if (metadata_path != null && metadata_path != "" && file_nonempty(metadata_path) && object_has_extra_keys(metadata_path)) { + if (!copy_file_if_changed(metadata_path, persistent_metadata)) + return false; + } + else if (!can_keep_previous_metadata) { + unlink_path(persistent_metadata); + } + + return true; +} + +function append_persistent_source_metadata(array_path, source_index, source_section) { + let metadata_path = persistent_metadata_path(source_section); + if (!file_nonempty(metadata_path)) + return false; + + append_metadata_file(array_path, metadata_path, source_index, source_section); + return true; +} + +function append_available_cached_metadata(array_path, section, source_index, source_section) { + if (array_path == null || array_path == "") + return false; + + let before = length(array_or_empty(read_json(array_path))); + append_persistent_source_metadata(array_path, source_index, source_section) || + append_cached_metadata(array_path, PODKOP_SECTION_CACHE_DIR, section, PODKOP_SUBSCRIPTION_METADATA_DIR + "/" + section + ".json", source_index, source_section); + return length(array_or_empty(read_json(array_path))) > before; +} + +function get_device_model() { + let model = fs.readfile("/tmp/sysinfo/model"); + model = trim(as_string(model)); + return model != "" ? model : "OpenWrt Router"; +} + +function get_kernel_version() { + return trim(command_output_from_args([ "uname", "-r" ])); +} + +function sing_box_version_from_output(output) { + let first = split(as_string(output), "\n")[0] || ""; + let fields = whitespace_values(first); + return length(fields) > 0 ? fields[length(fields) - 1] : ""; +} + +function get_sing_box_version() { + if (trim(as_string(fs.readfile(SB_VARIANT_STATE_FILE))) == "extended-compressed") { + let version_state = trim(as_string(fs.readfile(SB_VERSION_STATE_FILE))); + if (version_state != "") + return version_state; + } + + if (!command_success_from_args([ "sh", "-c", "command -v sing-box" ])) + return ""; + + return sing_box_version_from_output(command_output_from_args([ "sing-box", "version" ])); +} + +function get_subscription_user_agent(custom_user_agent) { + custom_user_agent = as_string(custom_user_agent); + if (custom_user_agent != "") + return custom_user_agent; + + let version = get_sing_box_version(); + return "sing-box/" + (version != "" ? version : "unknown"); +} + +function generate_hwid() { + let mac = trim(as_string(fs.readfile("/sys/class/net/eth0/address"))); + if (mac == "") + mac = trim(as_string(fs.readfile("/sys/class/net/br-lan/address"))); + + let input = mac + "-" + get_device_model(); + let output = command_output("printf %s " + shell_quote(input) + " | md5sum"); + let hash = length(whitespace_values(output)) > 0 ? substr(whitespace_values(output)[0], 0, 16) : ""; + return substr(hash, 0, 4) + "-" + substr(hash, 4, 4) + "-" + substr(hash, 8, 4) + "-" + substr(hash, 12, 4); +} + +function user_agent_candidates(configured_user_agent, preferred_user_agent, default_user_agent) { + configured_user_agent = as_string(configured_user_agent); + if (configured_user_agent != "") + return [ configured_user_agent ]; + + let result = []; + let seen = {}; + for (let candidate in [ + default_user_agent, + "v2rayN", + preferred_user_agent, + "Happ", + "Hiddify", + "Clash.Meta", + "ClashMetaForAndroid" + ]) { + candidate = as_string(candidate); + if (user_agent_supported(candidate, default_user_agent) && !seen[candidate]) { + seen[candidate] = true; + push(result, candidate); + } + } + + return result; +} + +function download_subscription(url, filepath, http_proxy_address, headers_filepath, effective_user_agent) { + let retries = 3; + let wait_seconds = 2; + let timeout = 15; + let stamp = clock(); + let suffix = sprintf(".part.%d.%d", stamp[0], stamp[1]); + let tmpfile = filepath + suffix; + let headers_tmpfile = headers_filepath != "" ? headers_filepath + suffix : ""; + let resolution_failed = false; + + unlink_path(tmpfile); + if (headers_tmpfile != "") + unlink_path(headers_tmpfile); + + for (let attempt = 1; attempt <= retries; attempt++) { + let args = [ + "curl", "-fL", "-sS", + "--connect-timeout", timeout, + "--speed-time", timeout, + "--speed-limit", "1" + ]; + + if (http_proxy_address != "") { + push(args, "-x"); + push(args, "http://" + http_proxy_address); + } + if (headers_tmpfile != "") { + push(args, "-D"); + push(args, headers_tmpfile); + } + + push(args, "-o"); + push(args, tmpfile); + for (let header in [ + "User-Agent: " + get_subscription_user_agent(effective_user_agent), + "X-HWID: " + generate_hwid(), + "X-Device-OS: OpenWrt Linux", + "X-Device-Model: " + get_device_model(), + "X-Ver-OS: " + get_kernel_version(), + "Accept-Language: ru-RU,en,*", + "X-Device-Locale: EN" + ]) { + push(args, "-H"); + push(args, header); + } + push(args, url); + + let status = command_status_from_args(args); + if (status == 0 && file_nonempty(tmpfile)) { + move_file(tmpfile, filepath); + if (headers_filepath != "") { + if (file_nonempty(headers_tmpfile)) + move_file(headers_tmpfile, headers_filepath); + else { + unlink_path(headers_filepath); + unlink_path(headers_tmpfile); + } + } + return 0; + } + + unlink_path(tmpfile); + unlink_path(headers_tmpfile); + log_message("Attempt " + attempt + "/" + retries + " to download subscription failed", "warn"); + + if (status == 5 || status == 6) { + resolution_failed = true; + break; + } + + if (attempt < retries) + system("sleep " + int(wait_seconds)); + } + + unlink_path(tmpfile); + unlink_path(headers_tmpfile); + return resolution_failed ? 6 : 1; +} + +function copy_valid_metadata_output(metadata_tmpfile, metadata_output_path) { + if (metadata_output_path == null || metadata_output_path == "") + return; + + if (file_nonempty(metadata_tmpfile) && object_has_extra_keys(metadata_tmpfile)) + copy_file(metadata_tmpfile, metadata_output_path) || unlink_path(metadata_output_path); + else + unlink_path(metadata_output_path); +} + +function copy_persistent_metadata_output(source_section, metadata_output_path) { + if (metadata_output_path == null || metadata_output_path == "") + return; + + let metadata_path = persistent_metadata_path(source_section); + if (file_nonempty(metadata_path) && object_has_extra_keys(metadata_path)) + copy_file(metadata_path, metadata_output_path) || unlink_path(metadata_output_path); +} + +function subscription_config_is_current(section_name_value, subscription_url, subscription_user_agent, sections) { + let section = find_section(sections, section_name_value); + for (let entry in list_option(section, "subscription_urls")) { + let parsed = subscription_parser().parse_subscription_source_entry(entry); + if (type(parsed) == "object" && parsed.valid === true && + as_string(parsed.url) == as_string(subscription_url) && + as_string(parsed.user_agent) == as_string(subscription_user_agent)) + return true; + } + + return false; +} + +function get_subscription_download_proxy_address(section_name_value, settings, had_usable_cache, phase) { + let address = service_proxy_address(settings, "subscriptions"); + if (address == "") + return ""; + + if (!sing_box_service_running()) { + if (phase == "startup") + log_message("download_subscriptions_via_proxy is enabled, but sing-box is not running yet; bootstrapping subscription for rule '" + section_name_value + "' directly", "info"); + else + log_message("download_subscriptions_via_proxy is enabled, but sing-box service proxy is not running; downloading subscription for rule '" + section_name_value + "' directly", "warn"); + return ""; + } + + let download_section = option(settings, "download_lists_via_proxy_section", ""); + if (download_section != "" && section_name_value == download_section && !had_usable_cache) { + let section = find_section(uci_sections(), section_name_value); + if (option(section, "selector_proxy_links", "") == "") { + log_message("Rule '" + section_name_value + "' is selected for downloading external resources but has no usable subscription cache; downloading it directly to avoid a bootstrap loop", "warn"); + return ""; + } + } + + log_message("Downloading subscription for rule '" + section_name_value + "' via service proxy " + address, "debug"); + return address; +} + +function download_subscription_into_cache(section_name_value, subscription_url, subscription_json_path, subscription_url_cache_path, service_proxy_address_value, subscription_user_agent, cache_section, metadata_output_path, sections) { + ensure_dir(TMP_SUBSCRIPTION_FOLDER); + let subscription_user_agent_cache_path = source_user_agent_path(TMP_SUBSCRIPTION_FOLDER, cache_section); + let raw_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, cache_section, "download"); + let headers_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, cache_section, "headers"); + let normalized_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, cache_section, "normalized"); + let metadata_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, cache_section, "metadata"); + let cached_user_agent = read_text(subscription_user_agent_cache_path); + let default_user_agent = get_subscription_user_agent(""); + let parser = subscription_parser(); + + for (let effective_user_agent in user_agent_candidates(subscription_user_agent, cached_user_agent, default_user_agent)) { + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(normalized_tmpfile); + unlink_path(metadata_tmpfile); + + if (subscription_user_agent != "") + log_message("Trying configured subscription User-Agent for rule '" + section_name_value + "': " + effective_user_agent, "info"); + else if (effective_user_agent == cached_user_agent) + log_message("Trying cached subscription User-Agent for rule '" + section_name_value + "': " + effective_user_agent, "info"); + else + log_message("Trying subscription User-Agent for rule '" + section_name_value + "': " + effective_user_agent, "info"); + + let download_status = download_subscription(subscription_url, raw_tmpfile, service_proxy_address_value, headers_tmpfile, effective_user_agent); + if (download_status != 0) { + if (metadata_output_path != "") + unlink_path(metadata_output_path); + if (download_status == 6) { + log_message("Subscription download failed for rule '" + section_name_value + "' because the subscription host could not be resolved; skipping User-Agent fallbacks", "warn"); + break; + } + else if (subscription_user_agent != "") + log_message("Subscription download failed with configured User-Agent for rule '" + section_name_value + "'", "warn"); + else + log_message("Subscription download failed with User-Agent '" + effective_user_agent + "' for rule '" + section_name_value + "'; trying next fallback candidate", "warn"); + continue; + } + + if (parser.try_decode_gzip_content_file(raw_tmpfile)) + log_message("Decoded gzip-compressed subscription body for rule '" + section_name_value + "'", "info"); + + parser.extract_ui_metadata_file(headers_tmpfile, raw_tmpfile, metadata_tmpfile) || unlink_path(metadata_tmpfile); + copy_valid_metadata_output(metadata_tmpfile, metadata_output_path); + + if (!parser.normalize_content_validated(raw_tmpfile, normalized_tmpfile)) { + if (metadata_output_path != "") + unlink_path(metadata_output_path); + if (subscription_user_agent != "") + log_message("Downloaded subscription for rule '" + section_name_value + "' is invalid with configured User-Agent", "error"); + else + log_message("Downloaded subscription for rule '" + section_name_value + "' is invalid with User-Agent '" + effective_user_agent + "'; trying next fallback candidate", "warn"); + continue; + } + + if (!subscription_config_is_current(section_name_value, subscription_url, subscription_user_agent, sections)) { + log_message("Subscription source settings changed while updating rule '" + section_name_value + "'; discarding superseded download", "warn"); + if (metadata_output_path != "") + unlink_path(metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(normalized_tmpfile); + unlink_path(metadata_tmpfile); + return 4; + } + + if (subscription_user_agent != "") + log_message("Configured subscription User-Agent for rule '" + section_name_value + "' produced valid outbounds", "info"); + else + log_message("Selected subscription User-Agent for rule '" + section_name_value + "': " + effective_user_agent, "info"); + + if (files_equal(normalized_tmpfile, subscription_json_path)) { + write_text(subscription_url_cache_path, subscription_url); + write_text(subscription_user_agent_cache_path, effective_user_agent); + persist_subscription_cache(cache_section, subscription_json_path, subscription_url, effective_user_agent, metadata_tmpfile) || + log_message("Failed to persist last working subscription cache for source '" + cache_section + "'", "warn"); + if (!file_nonempty(metadata_output_path)) + copy_persistent_metadata_output(cache_section, metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(normalized_tmpfile); + unlink_path(metadata_tmpfile); + log_message("Subscription for rule '" + section_name_value + "' is unchanged", "info"); + return 2; + } + + if (file_nonempty(subscription_json_path) && parser.runtime_outbounds_equal(normalized_tmpfile, subscription_json_path)) { + if (!move_file(normalized_tmpfile, subscription_json_path)) { + if (metadata_output_path != "") + unlink_path(metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(metadata_tmpfile); + return 1; + } + + write_text(subscription_url_cache_path, subscription_url); + write_text(subscription_user_agent_cache_path, effective_user_agent); + persist_subscription_cache(cache_section, subscription_json_path, subscription_url, effective_user_agent, metadata_tmpfile) || + log_message("Failed to persist last working subscription cache for source '" + cache_section + "'", "warn"); + if (!file_nonempty(metadata_output_path)) + copy_persistent_metadata_output(cache_section, metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(metadata_tmpfile); + log_message("Subscription runtime outbounds for rule '" + section_name_value + "' are unchanged", "info"); + return 2; + } + + if (!move_file(normalized_tmpfile, subscription_json_path)) { + if (metadata_output_path != "") + unlink_path(metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(metadata_tmpfile); + return 1; + } + + write_text(subscription_url_cache_path, subscription_url); + write_text(subscription_user_agent_cache_path, effective_user_agent); + persist_subscription_cache(cache_section, subscription_json_path, subscription_url, effective_user_agent, metadata_tmpfile) || + log_message("Failed to persist last working subscription cache for source '" + cache_section + "'", "warn"); + if (!file_nonempty(metadata_output_path)) + copy_persistent_metadata_output(cache_section, metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(metadata_tmpfile); + return 0; + } + + if (metadata_output_path != "") + unlink_path(metadata_output_path); + unlink_path(raw_tmpfile); + unlink_path(headers_tmpfile); + unlink_path(normalized_tmpfile); + unlink_path(metadata_tmpfile); + if (subscription_user_agent != "") + log_message("Configured subscription User-Agent for rule '" + section_name_value + "' did not produce valid outbounds", "error"); + else + log_message("No subscription User-Agent candidate produced valid outbounds for rule '" + section_name_value + "'", "error"); + return 1; +} + +function cached_source_status(source_section, parsed) { + restore_persistent_subscription_cache( + source_section, + TMP_SUBSCRIPTION_FOLDER, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, + parsed.url, + parsed.user_agent, + get_subscription_user_agent("") + ); + + let json_path = source_json_path(TMP_SUBSCRIPTION_FOLDER, source_section); + let url_path = source_url_path(TMP_SUBSCRIPTION_FOLDER, source_section); + let user_agent_path = source_user_agent_path(TMP_SUBSCRIPTION_FOLDER, source_section); + let had_usable_cache = subscription_cache_is_usable(json_path); + if (!had_usable_cache) + unlink_path(json_path); + + let cached_url = read_text(url_path); + let cached_user_agent = read_text(user_agent_path); + if (had_usable_cache && + (cached_url != as_string(parsed.url) || !user_agent_matches_config(parsed.user_agent, cached_user_agent, get_subscription_user_agent("")))) { + remove_subscription_source_runtime_cache(source_section); + return { + usable: false, + current: false, + cached_url: "", + cached_user_agent: "" + }; + } + + return { + usable: had_usable_cache, + current: had_usable_cache, + cached_url, + cached_user_agent + }; +} + +function update_subscription_source(section_name_value, index_value, entry, phase, metadata_output_path) { + ensure_runtime_dirs(); + let sections = uci_sections(); + let settings = uci_named_section("settings"); + let index = int(index_value || 0); + let parsed = subscription_parser().parse_subscription_source_entry(entry); + if (type(parsed) != "object" || parsed.valid !== true) { + log_message("Invalid subscription URL in rule '" + section_name_value + "': " + as_string(parsed.error || "Invalid subscription source entry"), "error"); + return 1; + } + + let source_section = source_id(section_name_value, index); + let status = cached_source_status(source_section, parsed); + if (status.usable && !status.current) { + log_message("Discarding stale subscription cache for rule '" + section_name_value + "' source '" + index + "' because source settings changed", "info"); + remove_subscription_source_runtime_cache(source_section); + } + + let proxy = get_subscription_download_proxy_address(section_name_value, settings, status.usable, phase || "runtime"); + return download_subscription_into_cache( + section_name_value, + parsed.url, + source_json_path(TMP_SUBSCRIPTION_FOLDER, source_section), + source_url_path(TMP_SUBSCRIPTION_FOLDER, source_section), + proxy, + parsed.user_agent, + source_section, + as_string(metadata_output_path), + sections + ); +} + +function subscription_update_timestamp_path(section_name_value) { + return PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR + "/" + as_string(section_name_value) + ".timestamp"; +} + +function current_timestamp_value() { + let value = trim(command_output_from_args([ "date", "+%s" ])); + return unsigned_number(value) == null ? "" : value; +} + +function write_subscription_update_timestamp(section_name_value) { + let timestamp = current_timestamp_value(); + if (timestamp == "") + return; + + ensure_dir(PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR); + write_file(subscription_update_timestamp_path(section_name_value), timestamp + "\n"); +} + +function subscription_update_due_result(section) { + section = object_or_empty(section); + let interval = section_subscription_update_interval(section); + if (interval == "") + return 1; + + let seconds = duration_to_seconds_value(interval); + if (seconds == null) { + log_message("Invalid subscription_update_interval value for rule '" + section_name(section) + "': " + interval, "error"); + return 2; + } + + return update_due_status(current_timestamp_value(), file_first_line_value(subscription_update_timestamp_path(section_name(section))), seconds); +} + +function finalize_subscription_section_metadata(section_name_value, metadata_tmpfile, superseded) { + if (as_string(metadata_tmpfile) == "") + return; + + if (!superseded) { + if (length(array_or_empty(read_json(metadata_tmpfile))) > 0) + write_subscription_metadata(PODKOP_SECTION_CACHE_DIR, PODKOP_RUNTIME_CACHE_FORMAT, section_name_value, metadata_tmpfile); + else + write_subscription_metadata(PODKOP_SECTION_CACHE_DIR, PODKOP_RUNTIME_CACHE_FORMAT, section_name_value, ""); + } + + unlink_path(metadata_tmpfile); +} + +function subscription_update_section(section, force) { + section = object_or_empty(section); + if (!section_is_subscription_proxy(section)) + return 3; + + let section_name_value = section_name(section); + if (!force) { + let due_result = subscription_update_due_result(section); + if (due_result == 1) + return 3; + if (due_result != 0) + return 1; + } + + ensure_runtime_dirs(); + log_message("Updating subscriptions for rule '" + section_name_value + "'...", "info"); + + let metadata_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, section_name_value, "metadata-section"); + write_json(metadata_tmpfile, []); + + let changed = 0; + let unchanged = 0; + let failed = 0; + let superseded = 0; + let total = 0; + + for (let entry in list_option(section, "subscription_urls")) { + total++; + let source_section = source_id(section_name_value, total); + let metadata_output_path = temp_path(TMP_SUBSCRIPTION_FOLDER, source_section, "metadata-output"); + let update_result = update_subscription_source(section_name_value, total, entry, "runtime", metadata_output_path); + + if (update_result == 0) { + append_metadata_file(metadata_tmpfile, metadata_output_path, total, source_section); + changed++; + } + else if (update_result == 2) { + append_metadata_file(metadata_tmpfile, metadata_output_path, total, source_section); + unchanged++; + } + else if (update_result == 4) { + superseded++; + } + else { + append_available_cached_metadata(metadata_tmpfile, section_name_value, total, source_section); + failed++; + } + + unlink_path(metadata_output_path); + } + + if (total == 0) { + unlink_path(metadata_tmpfile); + log_message("Subscription URL is not set for rule '" + section_name_value + "'", "info"); + return 1; + } + + let update_result = 2; + if (changed > 0) + update_result = 0; + else if (failed > 0) + update_result = 1; + else if (superseded > 0) + update_result = 4; + + finalize_subscription_section_metadata(section_name_value, metadata_tmpfile, superseded > 0); + + if (update_result == 0) { + write_subscription_update_timestamp(section_name_value); + log_message("Subscriptions updated for rule '" + section_name_value + "'", "info"); + } + else if (update_result == 2) { + write_subscription_update_timestamp(section_name_value); + log_message("Subscriptions for rule '" + section_name_value + "' are unchanged", "info"); + } + else if (update_result == 4) { + log_message("Subscription update for rule '" + section_name_value + "' was superseded by newer URLs", "info"); + } + else { + log_message("Failed to download subscriptions for rule '" + section_name_value + "'", "info"); + } + + return update_result; +} + +function source_index_number(value) { + value = as_string(value); + if (value == "" || match(value, /[^0-9]/) != null) + return null; + + let result = int(value); + return result > 0 ? result : null; +} + +function subscription_update_selected_source(sections, section_name_value, source_index_value, force) { + if (!cache_section_is_safe(section_name_value)) { + log_message("Invalid subscription rule name", "error"); + return 1; + } + + let source_index = source_index_number(source_index_value); + if (source_index == null) { + log_message("Invalid subscription source index for rule '" + section_name_value + "'", "error"); + return 1; + } + + let section = find_section(sections, section_name_value); + if (!section_is_subscription_proxy(section)) { + log_message("Rule '" + section_name_value + "' has no subscription sources", "info"); + return 1; + } + + if (!force) { + let due_result = subscription_update_due_result(section); + if (due_result == 1) + return 3; + if (due_result != 0) + return 1; + } + + ensure_runtime_dirs(); + log_message("Updating subscription source '" + source_index + "' for rule '" + section_name_value + "'...", "info"); + + let current_index = 0; + let selected_entry = null; + for (let entry in list_option(section, "subscription_urls")) { + current_index++; + if (current_index == source_index) { + selected_entry = entry; + break; + } + } + + if (selected_entry == null) { + log_message("Subscription source '" + source_index + "' was not found for rule '" + section_name_value + "'", "info"); + return 1; + } + + let source_section = source_id(section_name_value, source_index); + let metadata_output_path = temp_path(TMP_SUBSCRIPTION_FOLDER, source_section, "metadata-output"); + let update_result = update_subscription_source(section_name_value, source_index, selected_entry, "runtime", metadata_output_path); + + if (update_result == 0 || update_result == 2) + write_source_metadata( + PODKOP_SECTION_CACHE_DIR, + PODKOP_RUNTIME_CACHE_FORMAT, + section_name_value, + source_index, + source_section, + metadata_output_path, + PODKOP_SUBSCRIPTION_METADATA_DIR + "/" + section_name_value + ".json" + ); + + unlink_path(metadata_output_path); + + if (update_result == 0) { + write_subscription_update_timestamp(section_name_value); + log_message("Subscription source '" + source_index + "' updated for rule '" + section_name_value + "'", "info"); + } + else if (update_result == 2) { + write_subscription_update_timestamp(section_name_value); + log_message("Subscription source '" + source_index + "' for rule '" + section_name_value + "' is unchanged", "info"); + } + else if (update_result == 4) { + log_message("Subscription source '" + source_index + "' update for rule '" + section_name_value + "' was superseded by newer URLs", "info"); + } + else { + log_message("Failed to download subscription source '" + source_index + "' for rule '" + section_name_value + "'", "info"); + } + + return update_result; +} + +function empty_subscription_update_summary() { + return { + updated: 0, + failed: 0, + unchanged: 0, + superseded: 0 + }; +} + +function add_subscription_update_result(summary, result) { + if (result == 0) + summary.updated++; + else if (result == 1) + summary.failed++; + else if (result == 2) + summary.unchanged++; + else if (result == 4) + summary.superseded++; +} + +function print_subscription_update_summary(summary) { + print(summary.updated, "\t", summary.failed, "\t", summary.unchanged, "\t", summary.superseded, "\n"); +} + +function subscription_update_request(force_value, target_section_name, target_source_index) { + let force = as_string(force_value) == "1"; + let sections = uci_sections(); + let summary = empty_subscription_update_summary(); + + target_section_name = as_string(target_section_name); + target_source_index = as_string(target_source_index); + + if (target_section_name != "") { + if (target_source_index != "") + add_subscription_update_result(summary, subscription_update_selected_source(sections, target_section_name, target_source_index, force)); + else + add_subscription_update_result(summary, subscription_update_section(find_section(sections, target_section_name), force)); + print_subscription_update_summary(summary); + return; + } + + for (let section in sections) + add_subscription_update_result(summary, subscription_update_section(section, force)); + + print_subscription_update_summary(summary); +} + +function ensure_subscription_source_for_prepare(state, section, source_index, entry, metadata_tmpfile) { + let section_name_value = section_name(section); + let parsed = subscription_parser().parse_subscription_source_entry(entry); + if (type(parsed) != "object" || parsed.valid !== true) { + log_message("Invalid subscription source for rule '" + section_name_value + "': " + as_string(parsed.error || "Invalid subscription source entry"), "error"); + state.startup_blocked = true; + return false; + } + + let source_section = source_id(section_name_value, source_index); + let cached = cached_source_status(source_section, parsed); + if (cached.usable && cached.current) { + append_available_cached_metadata(metadata_tmpfile, section_name_value, source_index, source_section); + return true; + } + + if (state.no_refresh) { + log_message("No current subscription cache for rule '" + section_name_value + "' source '" + source_index + "'; skipping refresh during scoped runtime rebuild", "warn"); + return false; + } + + let metadata_output_path = metadata_tmpfile != "" ? temp_path(TMP_SUBSCRIPTION_FOLDER, source_section, "metadata-output") : ""; + let proxy = get_subscription_download_proxy_address(section_name_value, state.settings, cached.usable, state.phase); + let update_result = download_subscription_into_cache( + section_name_value, + parsed.url, + source_json_path(TMP_SUBSCRIPTION_FOLDER, source_section), + source_url_path(TMP_SUBSCRIPTION_FOLDER, source_section), + proxy, + parsed.user_agent, + source_section, + metadata_output_path, + state.sections + ); + + if (update_result == 0 || update_result == 2) { + append_metadata_file(metadata_tmpfile, metadata_output_path, source_index, source_section); + unlink_path(metadata_output_path); + return true; + } + + unlink_path(metadata_output_path); + + if (cached.usable && cached.current) { + log_message("Keeping cached subscription for rule '" + section_name_value + "' until a fresh download succeeds", "warn"); + append_available_cached_metadata(metadata_tmpfile, section_name_value, source_index, source_section); + return true; + } + + log_message("No usable subscription cache for rule '" + section_name_value + "'", "warn"); + return false; +} + +function prepare_subscription_cache_section(state, section) { + section = object_or_empty(section); + if (!section_is_subscription_proxy(section)) + return; + + let section_name_value = section_name(section); + let metadata_tmpfile = temp_path(TMP_SUBSCRIPTION_FOLDER, section_name_value, "metadata-section"); + write_json(metadata_tmpfile, []); + + let total = 0; + let ready = 0; + let failed = 0; + for (let entry in list_option(section, "subscription_urls")) { + total++; + if (ensure_subscription_source_for_prepare(state, section, total, entry, metadata_tmpfile)) + ready++; + else { + failed++; + state.unavailable_sources = append_word_once(state.unavailable_sources, source_id(section_name_value, total)); + } + } + + if (total > 0 && ready == 0) { + if (option(section, "selector_proxy_links", "") != "") { + log_message("All subscription sources for rule '" + section_name_value + "' are unavailable; starting with manual proxy links only", "warn"); + } + else { + if (state.phase == "startup") + log_message("No usable subscription cache for rule '" + section_name_value + "' and no manual proxy links are configured; checking whether startup can be bootstrapped through the selected proxy/VPN rule", "warn"); + else + log_message("No usable subscription cache for rule '" + section_name_value + "' and no manual proxy links are configured; fast runtime generation cannot use this rule", "warn"); + state.startup_blocked_sections = append_word_once(state.startup_blocked_sections, section_name_value); + } + } + else if (failed > 0) { + log_message("Skipping unavailable subscription source(s) for rule '" + section_name_value + "'; using available outbounds", "warn"); + } + + let metadata_count = length(array_or_empty(read_json(metadata_tmpfile))); + if (metadata_count > 0) + write_subscription_metadata(PODKOP_SECTION_CACHE_DIR, PODKOP_RUNTIME_CACHE_FORMAT, section_name_value, metadata_tmpfile); + else + write_subscription_metadata(PODKOP_SECTION_CACHE_DIR, PODKOP_RUNTIME_CACHE_FORMAT, section_name_value, ""); + unlink_path(metadata_tmpfile); +} + +function prepared_runtime_cache_should_skip(sections, section_cache_dir, phase, already_prepared) { + return as_string(phase) == "runtime" && + as_string(already_prepared) == "1" && + !runtime_cache_missing(sections, section_cache_dir); +} + +function prepare_subscription_caches(phase, already_prepared, no_refresh) { + phase = as_string(phase || "startup"); + let sections = uci_sections(); + + if (prepared_runtime_cache_should_skip(sections, PODKOP_SECTION_CACHE_DIR, phase, already_prepared)) { + print("\n"); + return 0; + } + + ensure_runtime_dirs(); + let state = { + phase, + sections, + settings: uci_named_section("settings"), + startup_blocked: false, + startup_blocked_sections: "", + unavailable_sources: "", + no_refresh: as_string(no_refresh) == "1" + }; + + prune_stale_subscription_caches_for_sections(sections); + for (let section in sections) + prepare_subscription_cache_section(state, section); + + if (state.startup_blocked) + return 1; + + if (state.startup_blocked_sections == "") { + print("\n"); + return 0; + } + + if (phase == "startup" && subscription_bootstrap_download_section_is_ready(sections, state.settings, state.startup_blocked_sections, get_subscription_user_agent(""))) { + log_message("Starting temporarily without subscription-only rule(s): " + state.startup_blocked_sections + ". They will be retried through the service proxy after sing-box starts", "warn"); + print(state.startup_blocked_sections, "\n"); + return 0; + } + + for (let section in whitespace_values(state.startup_blocked_sections)) + log_message("No usable subscription cache for rule '" + section + "' and no manual proxy links are configured; startup cannot continue", "error"); + + return 1; +} + +function current_pid() { + return trim(command_output("sh -c 'echo $PPID'")); +} + +function pid_running(pid) { + pid = as_string(pid); + return match(pid, /^[0-9]+$/) != null && command_success_from_args([ "kill", "-0", pid ]); +} + +function state_ucode_status(args) { + return command_status_from_args(command_args_with([ "ucode", "-L", LIB_DIR, LIB_DIR + "/service/state.uc" ], args)) == 0; +} + +function mark_pending_subscription_recovery_reload() { + state_ucode_status([ "mark-pending-reload", PODKOP_PENDING_RELOAD_FILE, "subscription_deferred_recovery" ]); +} + +function trigger_subscription_recovery_reload(worker) { + if (worker) + command_status_from_args([ PODKOP_SERVICE_INIT, "reload", "subscription_deferred_recovery" ]); + else + mark_pending_subscription_recovery_reload(); +} + +function subscription_bootstrap_retry_result(deferred_sections) { + let sections = uci_sections(); + let default_user_agent = get_subscription_user_agent(""); + let result = { + recovered: "", + remaining: "" + }; + + for (let section in whitespace_values(deferred_sections)) { + let value = find_section(sections, section); + if (!section_is_subscription_proxy(value)) { + log_message("Deferred subscription rule '" + section + "' no longer has subscription proxy sources; removing it from bootstrap retry list", "warn"); + result.recovered = append_word_once(result.recovered, section); + continue; + } + + log_message("Retrying deferred subscription rule '" + section + "' through the service proxy", "info"); + let update_result = subscription_update_section(value, true); + if (update_result == 0 || update_result == 2) { + result.recovered = append_word_once(result.recovered, section); + continue; + } + + if (section_current_usable_cache_by_name(sections, section, default_user_agent)) { + log_message("Deferred subscription rule '" + section + "' has a usable cache after retry despite partial update errors", "warn"); + result.recovered = append_word_once(result.recovered, section); + continue; + } + + result.remaining = append_word_once(result.remaining, section); + log_message("Deferred subscription rule '" + section + "' is still unavailable; keeping it disabled for this startup", "warn"); + } + + return result; +} + +function worker_env() { + return { + PODKOP_CONFIG_NAME: CONFIG_NAME, + PODKOP_LIB: LIB_DIR, + TMP_SING_BOX_FOLDER, + TMP_RULESET_FOLDER, + TMP_SUBSCRIPTION_FOLDER, + PODKOP_RUNTIME_STATE_DIR, + PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR, + PODKOP_SUBSCRIPTION_LINKS_DIR, + PODKOP_SUBSCRIPTION_METADATA_DIR, + PODKOP_OUTBOUND_METADATA_DIR, + PODKOP_SECTION_CACHE_DIR, + PODKOP_RUNTIME_CACHE_FORMAT_FILE, + PODKOP_RUNTIME_CACHE_FORMAT, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR, + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE, + PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE, + PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR, + PODKOP_PENDING_RELOAD_FILE, + PODKOP_SERVICE_INIT, + SB_SERVICE_MIXED_INBOUND_ADDRESS, + SB_SERVICE_MIXED_INBOUND_PORT, + SB_VARIANT_STATE_FILE, + SB_VERSION_STATE_FILE, + ZAPRET_PROVIDER_NFQWS_BIN, + ZAPRET2_PROVIDER_NFQWS2_BIN, + BYEDPI_BIN + }; +} + +function launch_self_worker(args) { + let command_args = command_args_with([ "ucode", "-L", LIB_DIR, LIB_DIR + "/subscription/cache.uc" ], args); + let command = command_env(worker_env()) + " " + command_from_args(command_args) + " >/dev/null 2>&1 & echo $!"; + return trim(command_output("sh -c " + shell_quote(command))); +} + +function start_deferred_subscription_bootstrap_retry_worker(deferred_sections) { + deferred_sections = trim(as_string(deferred_sections)); + if (deferred_sections == "") + return; + + ensure_runtime_dirs(); + let existing_pid = trim(file_first_line_value(PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE)); + if (pid_running(existing_pid)) { + log_message("Subscription bootstrap retry worker is already running with PID " + existing_pid, "debug"); + return; + } + + let pid = launch_self_worker([ "deferred-bootstrap-worker", deferred_sections ]); + if (pid != "") + write_file(PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE, pid + "\n"); + log_message("Started subscription bootstrap retry worker for rule(s): " + deferred_sections, "info"); +} + +function stop_deferred_subscription_bootstrap_retry_worker() { + let pid = trim(file_first_line_value(PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE)); + if (pid_running(pid)) { + command_success_from_args([ "kill", pid ]); + log_message("Stopped subscription bootstrap retry worker", "info"); + } + unlink_path(PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE); +} + +function run_deferred_subscription_bootstrap(deferred_sections) { + deferred_sections = trim(as_string(deferred_sections)); + if (deferred_sections == "") + return; + + log_message("Waiting for sing-box service proxy before retrying deferred subscription rule(s): " + deferred_sections, "info"); + let ready = false; + for (let attempt = 1; attempt <= 10; attempt++) { + if (sing_box_service_running()) { + ready = true; + break; + } + system("sleep 1"); + } + + if (!ready) { + log_message("sing-box service proxy did not become ready in time; deferred subscription rule(s) will remain disabled until the next successful subscription update", "warn"); + start_deferred_subscription_bootstrap_retry_worker(deferred_sections); + return; + } + + let result = subscription_bootstrap_retry_result(deferred_sections); + if (result.recovered != "") { + log_message("Recovered deferred subscription rule(s): " + result.recovered + "; scheduling Podkop Plus reload", "info"); + trigger_subscription_recovery_reload(false); + } + + if (result.remaining != "") { + log_message("Some deferred subscription rule(s) are still disabled for this startup: " + result.remaining, "warn"); + start_deferred_subscription_bootstrap_retry_worker(result.remaining); + } +} + +function deferred_subscription_bootstrap_retry_worker(remaining_sections) { + remaining_sections = trim(as_string(remaining_sections)); + + while (remaining_sections != "") { + system("sleep 30"); + if (!sing_box_service_running()) { + log_message("Stopping subscription bootstrap retry worker because sing-box is not running", "warn"); + break; + } + + ensure_runtime_dirs(); + if (!state_ucode_status([ "acquire-runtime-dir-lock", PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR, current_pid() ])) { + log_message("Subscription bootstrap retry skipped because another subscription update is running", "debug"); + continue; + } + + let result = subscription_bootstrap_retry_result(remaining_sections); + state_ucode_status([ "release-runtime-dir-lock", PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR ]); + + if (result.recovered != "") { + log_message("Recovered deferred subscription rule(s): " + result.recovered + "; reloading Podkop Plus", "info"); + trigger_subscription_recovery_reload(true); + } + + remaining_sections = result.remaining; + } + + unlink_path(PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE); +} + +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 == "file-first-line") { + file_first_line(ARGV[1]); +} +else if (mode == "file-has-exact-line") { + exit(file_has_exact_line(ARGV[1], ARGV[2]) ? 0 : 1); +} +else if (mode == "state-list-contains") { + exit(state_list_contains(ARGV[1], ARGV[2]) ? 0 : 1); +} +else if (mode == "append-state-list-once") { + append_state_list_once(ARGV[1], ARGV[2]); +} +else if (mode == "maintenance-plan") { + maintenance_plan(uci_sections(), ARGV[1]); +} +else if (mode == "maintenance-plan-fixture") { + maintenance_plan(fixture_section_list(read_json(ARGV[1])), ARGV[2]); +} +else if (mode == "runtime-cache-needs-rebuild") { + exit(runtime_cache_missing(uci_sections(), ARGV[1]) ? 0 : 1); +} +else if (mode == "runtime-cache-needs-rebuild-fixture") { + exit(runtime_cache_missing(fixture_section_list(read_json(ARGV[1])), ARGV[2]) ? 0 : 1); +} +else if (mode == "prepared-runtime-cache-should-skip-fixture") { + exit(prepared_runtime_cache_should_skip(fixture_section_list(read_json(ARGV[1])), ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +} +else if (mode == "ensure-runtime-dirs") { + ensure_runtime_dirs(); +} +else if (mode == "clear-runtime-cache") { + clear_subscription_runtime_cache(); +} +else if (mode == "ensure-runtime-cache-format") { + ensure_runtime_cache_format(); +} +else if (mode == "remove-legacy-server-country-cache") { + remove_legacy_server_country_cache(); +} +else if (mode == "stale-cache-delete-paths") { + stale_cache_delete_paths(uci_sections(), ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]); +} +else if (mode == "stale-cache-delete-paths-fixture") { + stale_cache_delete_paths(fixture_section_list(read_json(ARGV[1])), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); +} +else if (mode == "section-current-usable-cache") { + exit(section_current_usable_cache(uci_section(ARGV[1]), ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1); +} +else if (mode == "restore-persistent-source") { + exit(restore_persistent_subscription_cache(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]) ? 0 : 1); +} +else if (mode == "persist-source-cache") { + exit(persist_subscription_cache(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +} +else if (mode == "section-current-usable-cache-fixture") { + let data = object_or_empty(read_json(ARGV[1])); + let target = as_string(ARGV[2]); + let selected = {}; + for (let section in fixture_section_list(data)) { + if (section_name(section) == target) { + selected = section; + break; + } + } + exit(section_current_usable_cache(selected, ARGV[3], ARGV[4], ARGV[5]) ? 0 : 1); +} +else if (mode == "user-agent-supported") { + exit(user_agent_supported(ARGV[1], ARGV[2]) ? 0 : 1); +} +else if (mode == "user-agent-matches-config") { + exit(user_agent_matches_config(ARGV[1], ARGV[2], ARGV[3]) ? 0 : 1); +} +else if (mode == "write-user-agent-candidates") { + write_user_agent_candidates(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); +} +else if (mode == "source-id") { + print(source_id(ARGV[1], int(ARGV[2] || 0)), "\n"); +} +else if (mode == "subscription-metadata-path") { + let path = subscription_metadata_path(ARGV[1]); + if (path == "") + exit(1); + print(path, "\n"); +} +else if (mode == "outbound-metadata-path") { + let path = outbound_metadata_path(ARGV[1]); + if (path == "") + exit(1); + print(path, "\n"); +} +else if (mode == "section-is-subscription-proxy") { + exit(section_is_subscription_proxy(uci_section(ARGV[1])) ? 0 : 1); +} +else if (mode == "update-source") { + exit(update_subscription_source(ARGV[1], ARGV[2], ARGV[3], ARGV[4] || "runtime", ARGV[5] || "")); +} +else if (mode == "update-section") { + exit(subscription_update_section(find_section(uci_sections(), ARGV[1] || ""), as_string(ARGV[2] || "0") == "1")); +} +else if (mode == "update-request") { + subscription_update_request(ARGV[1] || "0", ARGV[2] || "", ARGV[3] || ""); +} +else if (mode == "prepare-caches") { + exit(prepare_subscription_caches(ARGV[1] || "startup", ARGV[2] || "0", ARGV[3] || "0")); +} +else if (mode == "run-deferred-bootstrap") { + run_deferred_subscription_bootstrap(ARGV[1] || ""); +} +else if (mode == "start-deferred-bootstrap-worker") { + start_deferred_subscription_bootstrap_retry_worker(ARGV[1] || ""); +} +else if (mode == "stop-deferred-bootstrap-worker") { + stop_deferred_subscription_bootstrap_retry_worker(); +} +else if (mode == "deferred-bootstrap-worker") { + deferred_subscription_bootstrap_retry_worker(ARGV[1] || ""); +} +else if (mode == "json-length") { + json_length(ARGV[1]); +} +else if (mode == "object-has-extra-keys") { + exit(object_has_extra_keys(ARGV[1]) ? 0 : 1); +} +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)) + write_empty_link(); + 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)) + write_empty_outbound_metadata(); + else + get_outbound_metadata(cache_dir, section, ARGV[3]); +} +else if (mode == "empty-link") { + write_empty_link(); +} +else if (mode == "empty-outbound-metadata") { + write_empty_outbound_metadata(); +} +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.uc b/podkop/files/usr/lib/subscription/parser.uc similarity index 92% rename from podkop/files/usr/lib/subscription_parser.uc rename to podkop/files/usr/lib/subscription/parser.uc index 2042bcb2..bdc32df7 100644 --- a/podkop/files/usr/lib/subscription_parser.uc +++ b/podkop/files/usr/lib/subscription/parser.uc @@ -2639,6 +2639,87 @@ function temp_path(prefix) { return sprintf("/tmp/%s.%d.%d", prefix, stamp[0], stamp[1]); } +function shell_quote(value) { + return "'" + replace(as_string(value), /'/g, "'\\''") + "'"; +} + +function gzip_decode_file(input_file, output_file) { + if (as_string(input_file) == "" || as_string(output_file) == "" || !fs.stat(input_file)) + return false; + + for (let command in [ "gzip -dc", "gunzip -c", "zcat" ]) { + let pipe = fs.popen(command + " " + shell_quote(input_file) + " 2>/dev/null", "r"); + if (!pipe) + continue; + + let data = pipe.read("all"); + let status = pipe.close(); + if (status == 0 && data != null && data != "") { + if (fs.writefile(output_file, data)) + return true; + fs.unlink(output_file); + return false; + } + fs.unlink(output_file); + } + + return false; +} + +function try_decode_gzip_content_file(input_file) { + let tmp = temp_path("podkop-subscription-gzip"); + if (!gzip_decode_file(input_file, tmp)) { + fs.unlink(tmp); + return false; + } + + if (!fs.rename(tmp, input_file)) { + fs.unlink(tmp); + return false; + } + + return true; +} + +function normalized_skipped_message(path) { + let value = read_json_file(path); + if (type(value) != "object") + return ""; + + let skipped = as_string(value.skipped != null ? value.skipped : "0"); + if (match(skipped, /^[0-9]+$/) == null || int(skipped) <= 0) + return ""; + + if (as_string(value.format != null ? value.format : "") == "clash-yaml") + return "Skipped " + skipped + " invalid or unsupported Clash proxy entries"; + + return "Skipped " + skipped + " invalid or unsupported subscription links"; +} + +function extract_ui_metadata_file(headers_file, body_file, output_file) { + let tmp = temp_path("podkop-subscription-metadata"); + let raw = metadata_body_store_with_base64_fallback(body_file).values; + let headers = metadata_headers_store(headers_file).values; + for (let key, value in headers) + raw[key] = value; + + let result = metadata_ui_object(raw); + if (length(keys(result)) <= 1) { + fs.unlink(output_file); + return false; + } + + if (!fs.writefile(tmp, sprintf("%J\n", result))) { + fs.unlink(tmp); + return false; + } + if (!fs.rename(tmp, output_file)) { + fs.unlink(tmp); + return false; + } + return true; +} + function normalize_content_data(data, output_file, depth) { data = as_string(data); if (index(data, "\r") >= 0) @@ -2684,23 +2765,117 @@ function normalize_content_file(input_file, output_file) { return data == null ? false : normalize_content_data(data, output_file, 0); } -function normalized_skipped_warning(path) { - let value = read_json_file(path); - if (type(value) != "object") - return true; - - let skipped = as_string(value.skipped != null ? value.skipped : "0"); - if (match(skipped, /^[0-9]+$/) == null || int(skipped) <= 0) - return true; - - if (as_string(value.format != null ? value.format : "") == "clash-yaml") - print("Skipped ", skipped, " invalid or unsupported Clash proxy entries\n"); +function normalize_content_validated(input_file, output_file) { + let normalize_input = input_file; + let decoded_tmp = temp_path("podkop-subscription-gzip"); + let decoded = gzip_decode_file(input_file, decoded_tmp); + if (decoded) + normalize_input = decoded_tmp; else - print("Skipped ", skipped, " invalid or unsupported subscription links\n"); + fs.unlink(decoded_tmp); + + let ok = normalize_content_file(normalize_input, output_file); + if (decoded) + fs.unlink(decoded_tmp); + if (!ok) + return false; + + let message = normalized_skipped_message(output_file); + if (message != "") + warn(message, "\n"); + + return validate_subscription(output_file); +} + +function normalized_skipped_warning(path) { + let message = normalized_skipped_message(path); + if (message != "") + print(message, "\n"); return true; } +function str_last_index(value, needle) { + value = as_string(value); + needle = as_string(needle); + if (needle == "") + return length(value); + + for (let i = length(value) - length(needle); i >= 0; i--) + if (substr(value, i, length(needle)) == needle) + return i; + + return -1; +} + +function subscription_source_entry_result(valid, url, user_agent, error) { + return { + valid, + url: as_string(url), + user_agent: as_string(user_agent), + error: as_string(error) + }; +} + +function write_subscription_source_entry_tsv_result(result) { + result = type(result) == "object" ? result : subscription_source_entry_result(false, "", "", "Subscription URL cannot be empty"); + + if (!result.valid) { + print(result.error, "\n"); + return false; + } + + print(result.url, "\t", result.user_agent, "\n"); + return true; +} + +function parse_subscription_source_entry(entry) { + let delimiter = " | "; + let url = ""; + let user_agent = ""; + + entry = trim(entry); + if (entry == "") + return subscription_source_entry_result(false, "", "", "Subscription URL cannot be empty"); + + let delimiter_index = str_last_index(entry, delimiter); + if (delimiter_index >= 0) { + url = trim(substr(entry, 0, delimiter_index)); + user_agent = trim(substr(entry, delimiter_index + length(delimiter))); + if (url == "" || user_agent == "") + return subscription_source_entry_result(false, "", "", "Subscription User-Agent separator requires non-empty URL and User-Agent"); + } + else if (index(entry, " |") >= 0 || index(entry, "| ") >= 0) { + return subscription_source_entry_result(false, "", "", "Use 'URL | User-Agent' with spaces on both sides of the separator"); + } + else { + url = entry; + } + + if (!(substr(url, 0, 7) == "http://" || substr(url, 0, 8) == "https://")) + return subscription_source_entry_result(false, "", "", "Subscription URL must start with http:// or https://"); + + return subscription_source_entry_result(true, url, user_agent, ""); +} + +function parse_subscription_source_entry_tsv(entry) { + return write_subscription_source_entry_tsv_result(parse_subscription_source_entry(entry)); +} + +function module_exports() { + return { + parse_subscription_source_entry, + validate_subscription, + normalize_content_validated, + try_decode_gzip_content_file, + extract_ui_metadata_file, + runtime_outbounds_equal + }; +} + +if (sourcepath(1) != null && sourcepath(1) != "") + return module_exports(); + let mode = ARGV[0]; let ok = false; @@ -2716,6 +2891,10 @@ 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 (mode == "normalize-content-validated") + ok = normalize_content_validated(ARGV[1], ARGV[2]); +else if (mode == "try-decode-gzip-content") + ok = try_decode_gzip_content_file(ARGV[1]); else if (mode == "runtime-outbounds-equal") ok = runtime_outbounds_equal(ARGV[1], ARGV[2]); else if (mode == "normalized-skipped-warning") @@ -2728,21 +2907,29 @@ else if (mode == "metadata-ui-json") ok = metadata_ui_json(ARGV[1]); else if (mode == "metadata-extract-ui-json") ok = metadata_extract_ui_json(ARGV[1], ARGV[2]); +else if (mode == "metadata-extract-ui-file") + ok = extract_ui_metadata_file(ARGV[1], ARGV[2], ARGV[3]); +else if (mode == "parse-source-entry-tsv") + ok = parse_subscription_source_entry_tsv(ARGV[1]); else if (ARGV[0] && ARGV[1]) ok = normalize_uri_list(ARGV[0], ARGV[1]); else { - warn("Usage: subscription_parser.uc validate-subscription \n"); - warn(" subscription_parser.uc url-fragment \n"); - warn(" subscription_parser.uc share-link-outbound \n"); - warn(" subscription_parser.uc normalize-uri-list \n"); - warn(" subscription_parser.uc normalize-clash-yaml \n"); - warn(" subscription_parser.uc normalize-content \n"); - warn(" subscription_parser.uc runtime-outbounds-equal \n"); - warn(" subscription_parser.uc normalized-skipped-warning \n"); - warn(" subscription_parser.uc metadata-headers-json \n"); - warn(" subscription_parser.uc metadata-body-json \n"); - warn(" subscription_parser.uc metadata-ui-json \n"); - warn(" subscription_parser.uc metadata-extract-ui-json \n"); + warn("Usage: subscription/parser.uc validate-subscription \n"); + warn(" subscription/parser.uc url-fragment \n"); + warn(" subscription/parser.uc share-link-outbound \n"); + warn(" subscription/parser.uc normalize-uri-list \n"); + warn(" subscription/parser.uc normalize-clash-yaml \n"); + warn(" subscription/parser.uc normalize-content \n"); + warn(" subscription/parser.uc normalize-content-validated \n"); + warn(" subscription/parser.uc try-decode-gzip-content \n"); + warn(" subscription/parser.uc runtime-outbounds-equal \n"); + warn(" subscription/parser.uc normalized-skipped-warning \n"); + warn(" subscription/parser.uc metadata-headers-json \n"); + warn(" subscription/parser.uc metadata-body-json \n"); + warn(" subscription/parser.uc metadata-ui-json \n"); + warn(" subscription/parser.uc metadata-extract-ui-json \n"); + warn(" subscription/parser.uc metadata-extract-ui-file \n"); + warn(" subscription/parser.uc parse-source-entry-tsv \n"); exit(2); } diff --git a/podkop/files/usr/lib/subscription_cache.uc b/podkop/files/usr/lib/subscription_cache.uc deleted file mode 100644 index fc2b380f..00000000 --- a/podkop/files/usr/lib/subscription_cache.uc +++ /dev/null @@ -1,812 +0,0 @@ -#!/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 file_first_line(path) { - let data = fs.readfile(path); - if (data == null) - exit(1); - - let newline = index(data, "\n"); - print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); -} - -function file_has_exact_line(path, needle) { - let data = fs.readfile(path); - if (data == null) - return false; - - needle = as_string(needle); - for (let line in split(data, "\n")) - if (as_string(line) == needle) - return true; - - return false; -} - -function write_empty_link() { - write_stdout_json({ link: "" }); -} - -function write_empty_outbound_metadata() { - write_stdout_json({ - names: {}, - countries: {} - }); -} - -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 json_length(path) { - let value = read_json(path); - if (type(value) == "array" || type(value) == "object") - print(length(value), "\n"); - else - print("0\n"); -} - -function object_has_extra_keys(path) { - return object_key_count(read_json(path)) > 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 hysteria2_server_port_entry(value) { - value = as_string(value); - let colon = index(value, ":"); - if (colon < 0) - return value; - - let start = substr(value, 0, colon); - let end = substr(value, colon + 1); - if (start == "" || end == "") - return ""; - - return start == end ? start : (start + "-" + end); -} - -function hysteria2_server_ports_uri(outbound) { - let server_ports = array_or_empty(outbound.server_ports); - if (length(server_ports) == 0) - return ""; - - let result = []; - for (let item in server_ports) { - let port = hysteria2_server_port_entry(item); - if (port != "") - push(result, port); - } - - return join(",", result); -} - -function add_query(params, key, value) { - value = as_string(value); - if (value != "") - push(params, uri_encode(key) + "=" + uri_encode(value)); -} - -function add_xhttp_extra_query(params, transport) { - let extra = {}; - for (let item in [ - ["xPaddingBytes", "x_padding_bytes"], - ["noGRPCHeader", "no_grpc_header"], - ["scMaxEachPostBytes", "sc_max_each_post_bytes"], - ["scMinPostsIntervalMs", "sc_min_posts_interval_ms"], - ["scStreamUpServerSecs", "sc_stream_up_server_secs"] - ]) { - if (transport[item[1]] != null) - extra[item[0]] = transport[item[1]]; - } - - if (type(transport.xmux) == "object") { - let xmux = {}; - for (let item in [ - ["maxConcurrency", "max_concurrency"], - ["maxConnections", "max_connections"], - ["cMaxReuseTimes", "c_max_reuse_times"], - ["hMaxRequestTimes", "h_max_request_times"], - ["hMaxReusableSecs", "h_max_reusable_secs"], - ["hKeepAlivePeriod", "h_keep_alive_period"] - ]) { - if (transport.xmux[item[1]] != null) - xmux[item[0]] = transport.xmux[item[1]]; - } - if (length(keys(xmux)) > 0) - extra.xmux = xmux; - } - - if (length(keys(extra)) > 0) - add_query(params, "extra", sprintf("%J", extra)); -} - -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); - add_xhttp_extra_query(params, transport); - } -} - -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) { - let port = hysteria2_server_ports_uri(outbound); - if (port == "" && outbound.server_port != null) - port = as_string(outbound.server_port); - - if (as_string(outbound.password) == "" || as_string(outbound.server) == "" || port == "") - 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, 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 == "file-first-line") { - file_first_line(ARGV[1]); -} -else if (mode == "file-has-exact-line") { - exit(file_has_exact_line(ARGV[1], ARGV[2]) ? 0 : 1); -} -else if (mode == "json-length") { - json_length(ARGV[1]); -} -else if (mode == "object-has-extra-keys") { - exit(object_has_extra_keys(ARGV[1]) ? 0 : 1); -} -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)) - write_empty_link(); - 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)) - write_empty_outbound_metadata(); - else - get_outbound_metadata(cache_dir, section, ARGV[3]); -} -else if (mode == "empty-link") { - write_empty_link(); -} -else if (mode == "empty-outbound-metadata") { - write_empty_outbound_metadata(); -} -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.sh b/podkop/files/usr/lib/subscription_parser.sh deleted file mode 100644 index 28855078..00000000 --- a/podkop/files/usr/lib/subscription_parser.sh +++ /dev/null @@ -1,135 +0,0 @@ -# shellcheck shell=ash - -subscription_url_get_fragment() { - subscription_parser_ucode url-fragment "$1" -} - -subscription_extract_ui_metadata() { - local headers_file="$1" - local body_file="$2" - local output="$3" - - if subscription_parser_ucode metadata-extract-ui-json "$headers_file" "$body_file" > "$output" && [ -s "$output" ]; then - return 0 - fi - - rm -f "$output" - return 1 -} - -subscription_parser_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/subscription_parser.uc" "$@" -} - -subscription_validate_normalized_file() { - local output="$1" - - subscription_parser_ucode validate-subscription "$output" >/dev/null 2>&1 -} - -subscription_log_normalized_skipped() { - local output="$1" - - local message - message="$(subscription_parser_ucode normalized-skipped-warning "$output" 2>/dev/null)" - [ -n "$message" ] || return 0 - log "$message" "warn" -} - -subscription_gzip_decode_file() { - local input="$1" - local output="$2" - - [ -s "$input" ] || return 1 - - if command -v gzip >/dev/null 2>&1; then - if gzip -dc "$input" > "$output" 2>/dev/null && [ -s "$output" ]; then - return 0 - fi - rm -f "$output" - fi - - if command -v gunzip >/dev/null 2>&1; then - if gunzip -c "$input" > "$output" 2>/dev/null && [ -s "$output" ]; then - return 0 - fi - rm -f "$output" - fi - - if command -v zcat >/dev/null 2>&1; then - if zcat "$input" > "$output" 2>/dev/null && [ -s "$output" ]; then - return 0 - fi - rm -f "$output" - fi - - return 1 -} - -subscription_try_decode_gzip_content_file() { - local input="$1" - local tmpfile - - tmpfile="$(mktemp)" || return 1 - if subscription_gzip_decode_file "$input" "$tmpfile"; then - mv "$tmpfile" "$input" || { - rm -f "$tmpfile" - return 1 - } - return 0 - fi - - rm -f "$tmpfile" - return 1 -} - -subscription_normalize_content_file() { - local input="$1" - local output="$2" - local normalize_input="$input" - local decoded_tmpfile status - - decoded_tmpfile="$(mktemp)" || decoded_tmpfile="" - if [ -n "$decoded_tmpfile" ]; then - if subscription_gzip_decode_file "$input" "$decoded_tmpfile"; then - normalize_input="$decoded_tmpfile" - else - rm -f "$decoded_tmpfile" - decoded_tmpfile="" - fi - fi - - if subscription_parser_ucode normalize-content "$normalize_input" "$output"; then - subscription_log_normalized_skipped "$output" - subscription_validate_normalized_file "$output" - status=$? - else - status=1 - fi - - [ -n "$decoded_tmpfile" ] && rm -f "$decoded_tmpfile" - return "$status" -} - -subscription_runtime_outbounds_equal() { - local left="$1" - local right="$2" - - subscription_parser_ucode runtime-outbounds-equal "$left" "$right" >/dev/null 2>&1 -} - -normalize_subscription_file() { - local input="$1" - local output="$2" - local section="$3" - local tmp_output - - tmp_output="$(mktemp)" || return 1 - if ! subscription_normalize_content_file "$input" "$tmp_output"; then - log "Subscription for rule '$section' has no supported proxy entries" "error" - rm -f "$tmp_output" - return 1 - fi - - mv "$tmp_output" "$output" -} diff --git a/podkop/files/usr/lib/subscription_runtime.sh b/podkop/files/usr/lib/subscription_runtime.sh deleted file mode 100644 index 5c9ae37f..00000000 --- a/podkop/files/usr/lib/subscription_runtime.sh +++ /dev/null @@ -1,1317 +0,0 @@ -# shellcheck shell=ash - -ensure_runtime_dirs() { - mkdir -p "$TMP_SING_BOX_FOLDER" - mkdir -p "$TMP_RULESET_FOLDER" - mkdir -p "$TMP_SUBSCRIPTION_FOLDER" - mkdir -p "$PODKOP_RUNTIME_STATE_DIR" - mkdir -p "$PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR" - mkdir -p "$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR" - mkdir -p "$PODKOP_SUBSCRIPTION_LINKS_DIR" - mkdir -p "$PODKOP_SUBSCRIPTION_METADATA_DIR" - mkdir -p "$PODKOP_OUTBOUND_METADATA_DIR" - mkdir -p "$PODKOP_SECTION_CACHE_DIR" -} - -get_subscription_json_path() { - local section="$1" - - echo "$TMP_SUBSCRIPTION_FOLDER/${section}.json" -} - -get_subscription_url_cache_path() { - local section="$1" - - echo "$TMP_SUBSCRIPTION_FOLDER/${section}.url" -} - -get_subscription_user_agent_cache_path() { - local section="$1" - - echo "$TMP_SUBSCRIPTION_FOLDER/${section}.user_agent" -} - -subscription_cache_section_is_safe() { - local section="$1" - - case "$section" in - "" | */* | *..*) - return 1 - ;; - esac - - return 0 -} - -get_persistent_subscription_json_path() { - local section="$1" - - echo "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR/${section}.json" -} - -get_persistent_subscription_url_cache_path() { - local section="$1" - - echo "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR/${section}.url" -} - -get_persistent_subscription_user_agent_cache_path() { - local section="$1" - - echo "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR/${section}.user_agent" -} - -get_subscription_update_timestamp_path() { - local section="$1" - - echo "$PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR/${section}.timestamp" -} - -get_subscription_links_path() { - local section="$1" - - echo "$PODKOP_SUBSCRIPTION_LINKS_DIR/${section}.json" -} - -get_subscription_metadata_path() { - local section="$1" - - echo "$PODKOP_SUBSCRIPTION_METADATA_DIR/${section}.json" -} - -get_outbound_metadata_path() { - local section="$1" - - echo "$PODKOP_OUTBOUND_METADATA_DIR/${section}.json" -} - -get_section_cache_path() { - local section="$1" - - echo "$PODKOP_SECTION_CACHE_DIR/${section}.json" -} - -clear_subscription_runtime_cache() { - rm -rf \ - "$TMP_SUBSCRIPTION_FOLDER" \ - "$PODKOP_SUBSCRIPTION_LINKS_DIR" \ - "$PODKOP_SUBSCRIPTION_METADATA_DIR" \ - "$PODKOP_OUTBOUND_METADATA_DIR" \ - "$PODKOP_SECTION_CACHE_DIR" -} - -ensure_runtime_cache_format() { - local current_format persistent_format - - mkdir -p "$PODKOP_RUNTIME_STATE_DIR" - current_format="$(subscription_cache_ucode file-first-line "$PODKOP_RUNTIME_CACHE_FORMAT_FILE" 2>/dev/null)" - - if [ "$current_format" != "$PODKOP_RUNTIME_CACHE_FORMAT" ]; then - log "Runtime subscription cache format changed; clearing old subscription cache" "info" - clear_subscription_runtime_cache - ensure_runtime_dirs - printf '%s\n' "$PODKOP_RUNTIME_CACHE_FORMAT" > "$PODKOP_RUNTIME_CACHE_FORMAT_FILE" - fi - - persistent_format="$(subscription_cache_ucode file-first-line "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE" 2>/dev/null)" - if [ "$persistent_format" != "$PODKOP_RUNTIME_CACHE_FORMAT" ]; then - rm -rf "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR" - mkdir -p "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR" - chmod 700 "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR" 2>/dev/null || true - printf '%s\n' "$PODKOP_RUNTIME_CACHE_FORMAT" > "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE" - chmod 600 "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE" 2>/dev/null || true - fi -} - -remove_legacy_server_country_cache() { - rm -f "$PODKOP_RUNTIME_STATE_DIR/server-country-cache.json" -} - -subscription_cache_ucode() { - ucode "$PODKOP_LIB/subscription_cache.uc" "$@" -} - -subscription_cache_tmpfile() { - local section="$1" - local kind="$2" - - mktemp "$TMP_SUBSCRIPTION_FOLDER/${section}.${kind}.XXXXXX" 2>/dev/null -} - -write_text_file_if_changed() { - local path="$1" - local value="$2" - local tmp - - tmp="${path}.$$" - printf '%s' "$value" > "$tmp" || { - rm -f "$tmp" - return 1 - } - - if [ -f "$path" ] && cmp -s "$tmp" "$path"; then - rm -f "$tmp" - chmod 600 "$path" 2>/dev/null || true - return 0 - fi - - mv "$tmp" "$path" || { - rm -f "$tmp" - return 1 - } - chmod 600 "$path" 2>/dev/null || true -} - -copy_file_if_changed() { - local source="$1" - local target="$2" - local tmp - - [ -s "$source" ] || return 1 - - if [ -f "$target" ] && cmp -s "$source" "$target"; then - chmod 600 "$target" 2>/dev/null || true - return 0 - fi - - tmp="${target}.$$" - cp "$source" "$tmp" || { - rm -f "$tmp" - return 1 - } - mv "$tmp" "$target" || { - rm -f "$tmp" - return 1 - } - chmod 600 "$target" 2>/dev/null || true -} - -persist_subscription_cache() { - local source_section="$1" - local subscription_json_path="$2" - local subscription_url="$3" - local effective_user_agent="$4" - local already_validated="${5:-0}" - local persistent_json_path persistent_url_path persistent_user_agent_path - - subscription_cache_section_is_safe "$source_section" || return 1 - [ "$already_validated" -eq 1 ] || subscription_cache_is_usable "$subscription_json_path" || return 1 - mkdir -p "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR" || return 1 - chmod 700 "$PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR" 2>/dev/null || true - - persistent_json_path="$(get_persistent_subscription_json_path "$source_section")" - persistent_url_path="$(get_persistent_subscription_url_cache_path "$source_section")" - persistent_user_agent_path="$(get_persistent_subscription_user_agent_cache_path "$source_section")" - - copy_file_if_changed "$subscription_json_path" "$persistent_json_path" && - write_text_file_if_changed "$persistent_url_path" "$subscription_url" && - write_text_file_if_changed "$persistent_user_agent_path" "$effective_user_agent" -} - -restore_persistent_subscription_cache() { - local source_section="$1" - local subscription_json_path="$2" - local subscription_url_cache_path="$3" - local subscription_user_agent_cache_path="$4" - local expected_url="$5" - local expected_user_agent="$6" - local persistent_json_path persistent_url_path persistent_user_agent_path \ - cached_subscription_url cached_subscription_user_agent tmp_json - - subscription_cache_section_is_safe "$source_section" || return 1 - subscription_cache_is_usable "$subscription_json_path" && return 0 - - persistent_json_path="$(get_persistent_subscription_json_path "$source_section")" - persistent_url_path="$(get_persistent_subscription_url_cache_path "$source_section")" - persistent_user_agent_path="$(get_persistent_subscription_user_agent_cache_path "$source_section")" - - subscription_cache_is_usable "$persistent_json_path" || return 1 - cached_subscription_url="$(cat "$persistent_url_path" 2>/dev/null)" - cached_subscription_user_agent="$(cat "$persistent_user_agent_path" 2>/dev/null)" - - [ "$cached_subscription_url" = "$expected_url" ] || return 1 - subscription_cached_user_agent_matches_config "$expected_user_agent" "$cached_subscription_user_agent" || return 1 - - mkdir -p "$TMP_SUBSCRIPTION_FOLDER" || return 1 - tmp_json="$(subscription_cache_tmpfile "$source_section" "restore")" || return 1 - cp "$persistent_json_path" "$tmp_json" || { - rm -f "$tmp_json" - return 1 - } - mv "$tmp_json" "$subscription_json_path" || { - rm -f "$tmp_json" - return 1 - } - printf '%s' "$cached_subscription_url" > "$subscription_url_cache_path" || return 1 - printf '%s' "$cached_subscription_user_agent" > "$subscription_user_agent_cache_path" || return 1 - - log "Restored last working subscription cache for source '$source_section'" "info" -} - -write_subscription_outbound_link_cache() { - local section="$1" - local links_json="$2" - local link_refs_json="${3:-}" - local links_tmp link_refs_tmp status - - ensure_runtime_dirs - [ -n "$links_json" ] || links_json="{}" - [ -n "$link_refs_json" ] || link_refs_json="{}" - - links_tmp="$(subscription_cache_tmpfile "$section" "links")" || return 1 - link_refs_tmp="$(subscription_cache_tmpfile "$section" "link-refs")" || { - rm -f "$links_tmp" - return 1 - } - - printf '%s' "$links_json" > "$links_tmp" || { - rm -f "$links_tmp" "$link_refs_tmp" - return 1 - } - printf '%s' "$link_refs_json" > "$link_refs_tmp" || { - rm -f "$links_tmp" "$link_refs_tmp" - return 1 - } - - subscription_cache_ucode write-link-cache \ - "$PODKOP_SECTION_CACHE_DIR" "$PODKOP_RUNTIME_CACHE_FORMAT" "$section" \ - "$links_tmp" "$link_refs_tmp" - status=$? - rm -f "$links_tmp" "$link_refs_tmp" - return "$status" -} - -write_outbound_metadata() { - local section="$1" - local names_json="$2" - local countries_json="$3" - local servers_json="${4:-}" - local names_tmp countries_tmp servers_tmp status - - ensure_runtime_dirs - [ -n "$names_json" ] || names_json="{}" - [ -n "$countries_json" ] || countries_json="{}" - [ -n "$servers_json" ] || servers_json="{}" - - names_tmp="$(subscription_cache_tmpfile "$section" "names")" || return 1 - countries_tmp="$(subscription_cache_tmpfile "$section" "countries")" || { - rm -f "$names_tmp" - return 1 - } - servers_tmp="$(subscription_cache_tmpfile "$section" "servers")" || { - rm -f "$names_tmp" "$countries_tmp" - return 1 - } - - printf '%s' "$names_json" > "$names_tmp" || { - rm -f "$names_tmp" "$countries_tmp" "$servers_tmp" - return 1 - } - printf '%s' "$countries_json" > "$countries_tmp" || { - rm -f "$names_tmp" "$countries_tmp" "$servers_tmp" - return 1 - } - printf '%s' "$servers_json" > "$servers_tmp" || { - rm -f "$names_tmp" "$countries_tmp" "$servers_tmp" - return 1 - } - - subscription_cache_ucode write-outbound-metadata \ - "$PODKOP_SECTION_CACHE_DIR" "$PODKOP_RUNTIME_CACHE_FORMAT" "$section" \ - "$names_tmp" "$countries_tmp" "$servers_tmp" - status=$? - rm -f "$names_tmp" "$countries_tmp" "$servers_tmp" - return "$status" -} - -subscription_metadata_append_file() { - local array_path="$1" - local metadata_path="$2" - local source_index="${3:-0}" - local source_section="${4:-}" - - [ -n "$array_path" ] || return 0 - [ -n "$metadata_path" ] || return 0 - - subscription_cache_ucode append-metadata-file "$array_path" "$metadata_path" "$source_index" "$source_section" || true -} - -subscription_metadata_append_cached_source() { - local array_path="$1" - local section="$2" - local source_index="${3:-0}" - local source_section="${4:-}" - local legacy_target_path - - [ -n "$array_path" ] || return 0 - [ -n "$section" ] || return 0 - - legacy_target_path="$(get_subscription_metadata_path "$section")" - subscription_cache_ucode append-cached-metadata \ - "$array_path" "$PODKOP_SECTION_CACHE_DIR" "$section" "$legacy_target_path" \ - "$source_index" "$source_section" -} - -write_subscription_metadata_json() { - local section="$1" - local metadata_json_path="$2" - - ensure_runtime_dirs - [ -n "$metadata_json_path" ] || metadata_json_path="/dev/null" - - subscription_cache_ucode write-subscription-metadata \ - "$PODKOP_SECTION_CACHE_DIR" "$PODKOP_RUNTIME_CACHE_FORMAT" "$section" "$metadata_json_path" -} - -write_subscription_source_metadata_json() { - local section="$1" - local source_index="$2" - local source_section="$3" - local metadata_json_path="$4" - local legacy_target_path - - ensure_runtime_dirs - [ -n "$metadata_json_path" ] || metadata_json_path="/dev/null" - legacy_target_path="$(get_subscription_metadata_path "$section")" - - 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" -} - -subscription_source_id() { - local section="$1" - local index="$2" - - printf '%s-subscription-%s\n' "$section" "$index" -} - -subscription_mark_unavailable_source() { - local source_section="$1" - - subscription_source_is_marked_unavailable "$source_section" && return 0 - SUBSCRIPTION_UNAVAILABLE_SOURCES="${SUBSCRIPTION_UNAVAILABLE_SOURCES} -${source_section}" -} - -subscription_source_is_marked_unavailable() { - local source_section="$1" - - case " -${SUBSCRIPTION_UNAVAILABLE_SOURCES} -" in - *" -${source_section} -"*) - return 0 - ;; - esac - - return 1 -} - -parse_subscription_source_entry() { - local entry="$1" - local delimiter=" | " - local url user_agent - - SUBSCRIPTION_SOURCE_URL="" - SUBSCRIPTION_SOURCE_USER_AGENT="" - SUBSCRIPTION_SOURCE_PARSE_ERROR="" - - entry="$(printf '%s' "$entry" | trim_string)" - if [ -z "$entry" ]; then - SUBSCRIPTION_SOURCE_PARSE_ERROR="Subscription URL cannot be empty" - return 1 - fi - - case "$entry" in - *"$delimiter"*) - url="${entry%$delimiter*}" - user_agent="${entry##*$delimiter}" - url="$(printf '%s' "$url" | trim_string)" - user_agent="$(printf '%s' "$user_agent" | trim_string)" - if [ -z "$url" ] || [ -z "$user_agent" ]; then - SUBSCRIPTION_SOURCE_PARSE_ERROR="Subscription User-Agent separator requires non-empty URL and User-Agent" - return 1 - fi - ;; - *" |"* | *"| "*) - SUBSCRIPTION_SOURCE_PARSE_ERROR="Use 'URL | User-Agent' with spaces on both sides of the separator" - return 1 - ;; - *) - url="$entry" - user_agent="" - ;; - esac - - case "$url" in - http://* | https://*) ;; - *) - SUBSCRIPTION_SOURCE_PARSE_ERROR="Subscription URL must start with http:// or https://" - return 1 - ;; - esac - - SUBSCRIPTION_SOURCE_URL="$url" - SUBSCRIPTION_SOURCE_USER_AGENT="$user_agent" - return 0 -} - -for_each_subscription_source_dispatch() { - local entry="$1" - local section="$2" - local callback="$3" - shift 3 - - SUBSCRIPTION_SOURCE_INDEX=$((SUBSCRIPTION_SOURCE_INDEX + 1)) - "$callback" "$section" "$SUBSCRIPTION_SOURCE_INDEX" "$entry" "$@" -} - -for_each_subscription_source() { - local section="$1" - local callback="$2" - local subscription_urls - shift 2 - - SUBSCRIPTION_SOURCE_INDEX=0 - config_get subscription_urls "$section" "subscription_urls" - [ -n "$subscription_urls" ] || return 0 - - config_list_foreach "$section" "subscription_urls" for_each_subscription_source_dispatch "$section" "$callback" "$@" -} - -rule_has_subscription_urls() { - local section="$1" - local subscription_urls - - config_get subscription_urls "$section" "subscription_urls" - - [ -n "$subscription_urls" ] -} - -subscription_config_match_handler() { - local entry="$1" - local expected_url="$2" - local expected_user_agent="$3" - - parse_subscription_source_entry "$entry" >/dev/null 2>&1 || return 0 - if [ "$SUBSCRIPTION_SOURCE_URL" = "$expected_url" ] && - [ "$SUBSCRIPTION_SOURCE_USER_AGENT" = "$expected_user_agent" ]; then - SUBSCRIPTION_CONFIG_MATCH_FOUND=1 - fi -} - -subscription_config_is_current() { - local section="$1" - local subscription_url="$2" - local subscription_user_agent="${3:-}" - local subscription_urls - - config_get subscription_urls "$section" "subscription_urls" - [ -n "$subscription_urls" ] || return 1 - - SUBSCRIPTION_CONFIG_MATCH_FOUND=0 - config_list_foreach "$section" "subscription_urls" subscription_config_match_handler "$subscription_url" "$subscription_user_agent" - [ "$SUBSCRIPTION_CONFIG_MATCH_FOUND" -eq 1 ] -} - -subscription_auto_user_agent_is_supported() { - local user_agent="$1" - local default_user_agent="${2:-}" - - [ -n "$user_agent" ] || return 1 - [ -n "$default_user_agent" ] || default_user_agent="$(get_subscription_user_agent)" - [ "$user_agent" = "$default_user_agent" ] && return 0 - - case "$user_agent" in - Happ | v2rayN | Hiddify | Clash.Meta | ClashMetaForAndroid) - return 0 - ;; - esac - - return 1 -} - -subscription_cached_user_agent_matches_config() { - local configured_user_agent="${1:-}" - local cached_user_agent="${2:-}" - - if [ -n "$configured_user_agent" ]; then - [ "$cached_user_agent" = "$configured_user_agent" ] - return $? - fi - - subscription_auto_user_agent_is_supported "$cached_user_agent" -} - -subscription_write_user_agent_candidates() { - local output_path="$1" - local configured_user_agent="${2:-}" - local preferred_user_agent="${3:-}" - local default_user_agent candidate - - if [ -n "$configured_user_agent" ]; then - printf '%s\n' "$configured_user_agent" > "$output_path" - return $? - fi - - : > "$output_path" || return 1 - - default_user_agent="$(get_subscription_user_agent)" - for candidate in "$default_user_agent" "v2rayN" "$preferred_user_agent" "Happ" "Hiddify" "Clash.Meta" "ClashMetaForAndroid"; do - [ -n "$candidate" ] || continue - subscription_auto_user_agent_is_supported "$candidate" "$default_user_agent" || continue - file_has_exact_line "$output_path" "$candidate" && continue - printf '%s\n' "$candidate" >> "$output_path" || return 1 - done -} - -file_has_exact_line() { - local path="$1" - local needle="$2" - local line - - [ -f "$path" ] || return 1 - while IFS= read -r line || [ -n "$line" ]; do - [ "$line" = "$needle" ] && return 0 - done < "$path" - - return 1 -} - -subscription_cache_is_usable() { - local subscription_json_path="$1" - - [ -s "$subscription_json_path" ] || return 1 - - validate_subscription_file "$subscription_json_path" -} - -subscription_append_word_once() { - local list="$1" - local word="$2" - - [ -n "$word" ] || { - printf '%s\n' "$list" - return 0 - } - - list_has_item "$list" "$word" && { - printf '%s\n' "$list" - return 0 - } - - printf '%s\n' "${list}${list:+ }${word}" -} - -subscription_mark_startup_blocked_section() { - local section="$1" - - SUBSCRIPTION_STARTUP_BLOCKED_SECTIONS="$(subscription_append_word_once "$SUBSCRIPTION_STARTUP_BLOCKED_SECTIONS" "$section")" -} - -subscription_startup_blocked_section_is_marked() { - local section="$1" - - list_has_item "$SUBSCRIPTION_STARTUP_BLOCKED_SECTIONS" "$section" -} - -subscription_mark_deferred_section() { - local section="$1" - - SUBSCRIPTION_DEFERRED_SECTIONS="$(subscription_append_word_once "$SUBSCRIPTION_DEFERRED_SECTIONS" "$section")" -} - -subscription_section_is_deferred() { - local section="$1" - - list_has_item "$SUBSCRIPTION_DEFERRED_SECTIONS" "$section" -} - -subscription_section_usable_cache_handler() { - local section="$1" - local index="$2" - local entry="$3" - local source_section subscription_json_path subscription_url_cache_path subscription_user_agent_cache_path \ - cached_subscription_url cached_subscription_user_agent - - [ "$SUBSCRIPTION_SECTION_USABLE_CACHE_FOUND" -eq 0 ] || return 0 - parse_subscription_source_entry "$entry" >/dev/null 2>&1 || return 0 - - source_section="$(subscription_source_id "$section" "$index")" - subscription_json_path="$(get_subscription_json_path "$source_section")" - subscription_url_cache_path="$(get_subscription_url_cache_path "$source_section")" - subscription_user_agent_cache_path="$(get_subscription_user_agent_cache_path "$source_section")" - - restore_persistent_subscription_cache \ - "$source_section" "$subscription_json_path" "$subscription_url_cache_path" "$subscription_user_agent_cache_path" \ - "$SUBSCRIPTION_SOURCE_URL" "$SUBSCRIPTION_SOURCE_USER_AGENT" || true - - subscription_cache_is_usable "$subscription_json_path" || return 0 - cached_subscription_url="$(cat "$subscription_url_cache_path" 2>/dev/null)" - cached_subscription_user_agent="$(cat "$subscription_user_agent_cache_path" 2>/dev/null)" - - if [ "$cached_subscription_url" = "$SUBSCRIPTION_SOURCE_URL" ] && - subscription_cached_user_agent_matches_config "$SUBSCRIPTION_SOURCE_USER_AGENT" "$cached_subscription_user_agent"; then - SUBSCRIPTION_SECTION_USABLE_CACHE_FOUND=1 - fi -} - -subscription_section_has_current_usable_cache() { - local section="$1" - - rule_has_subscription_urls "$section" || return 1 - - SUBSCRIPTION_SECTION_USABLE_CACHE_FOUND=0 - for_each_subscription_source "$section" subscription_section_usable_cache_handler - [ "$SUBSCRIPTION_SECTION_USABLE_CACHE_FOUND" -eq 1 ] -} - -subscription_bootstrap_download_section_is_ready() { - local download_lists_via_proxy_section action selector_proxy_links outbound_json interface_name - - download_via_proxy_enabled_for_purpose subscriptions || { - log "Subscription startup cannot be deferred because subscription downloads via Proxy/VPN are disabled" "error" - return 1 - } - - config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section" - [ -n "$download_lists_via_proxy_section" ] || return 1 - - if subscription_startup_blocked_section_is_marked "$download_lists_via_proxy_section"; then - log "Subscription startup cannot be deferred because selected download rule '$download_lists_via_proxy_section' is also waiting for an unavailable subscription cache" "error" - return 1 - fi - - rule_is_enabled "$download_lists_via_proxy_section" || return 1 - - action="$(get_rule_action "$download_lists_via_proxy_section")" - case "$action" in - proxy) - config_get selector_proxy_links "$download_lists_via_proxy_section" "selector_proxy_links" - if [ -n "$selector_proxy_links" ]; then - return 0 - fi - - if subscription_section_has_current_usable_cache "$download_lists_via_proxy_section"; then - return 0 - fi - - log "Subscription startup cannot be deferred because selected proxy rule '$download_lists_via_proxy_section' has no manual proxy links and no usable subscription cache" "error" - return 1 - ;; - outbound) - config_get outbound_json "$download_lists_via_proxy_section" "outbound_json" - [ -n "$outbound_json" ] || return 1 - return 0 - ;; - vpn) - config_get interface_name "$download_lists_via_proxy_section" "interface" - [ -n "$interface_name" ] || return 1 - return 0 - ;; - byedpi) - is_byedpi_installed - return $? - ;; - zapret) - is_zapret_installed - return $? - ;; - zapret2) - is_zapret2_installed - return $? - ;; - *) - return 1 - ;; - esac -} - -subscription_defer_startup_blocked_sections() { - local section - - for section in $SUBSCRIPTION_STARTUP_BLOCKED_SECTIONS; do - subscription_mark_deferred_section "$section" - done -} - -subscription_log_startup_blocked_sections() { - local section - - for section in $SUBSCRIPTION_STARTUP_BLOCKED_SECTIONS; do - log "No usable subscription cache for rule '$section' and no manual proxy links are configured; startup cannot continue" "error" - done -} - -get_subscription_download_proxy_address() { - local section="$1" - local had_usable_cache="${2:-0}" - local phase="${3:-runtime}" - local service_proxy_address download_lists_via_proxy_section selector_proxy_links - - service_proxy_address="$(get_service_proxy_address subscriptions)" - [ -n "$service_proxy_address" ] || return 0 - - if ! sing_box_service_is_running; then - if [ "$phase" = "startup" ]; then - log "download_subscriptions_via_proxy is enabled, but sing-box is not running yet; bootstrapping subscription for rule '$section' directly" "info" - else - log "download_subscriptions_via_proxy is enabled, but sing-box service proxy is not running; downloading subscription for rule '$section' directly" "warn" - fi - return 0 - fi - - config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section" - if [ -n "$download_lists_via_proxy_section" ] && - [ "$section" = "$download_lists_via_proxy_section" ] && - [ "$had_usable_cache" -eq 0 ]; then - config_get selector_proxy_links "$section" "selector_proxy_links" - if [ -z "$selector_proxy_links" ]; then - log "Rule '$section' is selected for downloading external resources but has no usable subscription cache; downloading it directly to avoid a bootstrap loop" "warn" - return 0 - fi - fi - - log "Downloading subscription for rule '$section' via service proxy $service_proxy_address" "debug" - printf '%s\n' "$service_proxy_address" -} - -download_subscription_into_cache() { - local section="$1" - local subscription_url="$2" - local subscription_json_path="$3" - local subscription_url_cache_path="$4" - local service_proxy_address="$5" - local subscription_user_agent="${6:-}" - local cache_section="${7:-$section}" - local metadata_output_path="${8:-}" - local subscription_user_agent_cache_path raw_tmpfile headers_tmpfile normalized_tmpfile metadata_tmpfile \ - user_agents_tmpfile cached_subscription_user_agent effective_user_agent download_status - - mkdir -p "$TMP_SUBSCRIPTION_FOLDER" - subscription_user_agent_cache_path="$(get_subscription_user_agent_cache_path "$cache_section")" - raw_tmpfile="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${cache_section}.download.XXXXXX")" || return 1 - headers_tmpfile="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${cache_section}.headers.XXXXXX")" || { - rm -f "$raw_tmpfile" - return 1 - } - normalized_tmpfile="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${cache_section}.normalized.XXXXXX")" || { - rm -f "$raw_tmpfile" "$headers_tmpfile" - return 1 - } - metadata_tmpfile="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${cache_section}.metadata.XXXXXX")" || { - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" - return 1 - } - user_agents_tmpfile="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${cache_section}.user-agents.XXXXXX")" || { - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" "$metadata_tmpfile" - return 1 - } - - cached_subscription_user_agent="$(subscription_cache_ucode file-first-line "$subscription_user_agent_cache_path" 2>/dev/null)" - if ! subscription_write_user_agent_candidates "$user_agents_tmpfile" "$subscription_user_agent" "$cached_subscription_user_agent"; then - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" "$metadata_tmpfile" "$user_agents_tmpfile" - return 1 - fi - - while IFS= read -r effective_user_agent || [ -n "$effective_user_agent" ]; do - [ -n "$effective_user_agent" ] || continue - - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" "$metadata_tmpfile" - if [ -n "$subscription_user_agent" ]; then - log "Trying configured subscription User-Agent for rule '$section': $effective_user_agent" "info" - elif [ "$effective_user_agent" = "$cached_subscription_user_agent" ]; then - log "Trying cached subscription User-Agent for rule '$section': $effective_user_agent" "info" - else - log "Trying subscription User-Agent for rule '$section': $effective_user_agent" "info" - fi - - download_status=0 - download_subscription "$subscription_url" "$raw_tmpfile" "$service_proxy_address" 3 2 15 "$headers_tmpfile" "$effective_user_agent" || download_status="$?" - if [ "$download_status" -ne 0 ]; then - [ -n "$metadata_output_path" ] && rm -f "$metadata_output_path" - if [ "$download_status" -eq 6 ]; then - log "Subscription download failed for rule '$section' because the subscription host could not be resolved; skipping User-Agent fallbacks" "warn" - break - elif [ -n "$subscription_user_agent" ]; then - log "Subscription download failed with configured User-Agent for rule '$section'" "warn" - else - log "Subscription download failed with User-Agent '$effective_user_agent' for rule '$section'; trying next fallback candidate" "warn" - fi - continue - fi - - if subscription_try_decode_gzip_content_file "$raw_tmpfile"; then - log "Decoded gzip-compressed subscription body for rule '$section'" "info" - fi - - 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" ] && subscription_cache_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" - fi - fi - - if ! subscription_normalize_content_file "$raw_tmpfile" "$normalized_tmpfile"; then - [ -n "$metadata_output_path" ] && rm -f "$metadata_output_path" - if [ -n "$subscription_user_agent" ]; then - log "Downloaded subscription for rule '$section' is invalid with configured User-Agent" "error" - else - log "Downloaded subscription for rule '$section' is invalid with User-Agent '$effective_user_agent'; trying next fallback candidate" "warn" - fi - continue - fi - - if ! subscription_config_is_current "$section" "$subscription_url" "$subscription_user_agent"; then - log "Subscription source settings changed while updating rule '$section'; discarding superseded download" "warn" - [ -n "$metadata_output_path" ] && rm -f "$metadata_output_path" - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" "$metadata_tmpfile" "$user_agents_tmpfile" - return 4 - fi - - if [ -n "$subscription_user_agent" ]; then - log "Configured subscription User-Agent for rule '$section' produced valid outbounds" "info" - else - log "Selected subscription User-Agent for rule '$section': $effective_user_agent" "info" - fi - - if [ -f "$subscription_json_path" ] && cmp -s "$normalized_tmpfile" "$subscription_json_path"; then - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" "$metadata_tmpfile" "$user_agents_tmpfile" - printf '%s' "$subscription_url" > "$subscription_url_cache_path" - printf '%s' "$effective_user_agent" > "$subscription_user_agent_cache_path" - persist_subscription_cache "$cache_section" "$subscription_json_path" "$subscription_url" "$effective_user_agent" 1 || - log "Failed to persist last working subscription cache for source '$cache_section'" "warn" - log "Subscription for rule '$section' is unchanged" "info" - return 2 - fi - - if [ -f "$subscription_json_path" ] && - subscription_runtime_outbounds_equal "$normalized_tmpfile" "$subscription_json_path"; then - mv "$normalized_tmpfile" "$subscription_json_path" || { - [ -n "$metadata_output_path" ] && rm -f "$metadata_output_path" - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" "$metadata_tmpfile" "$user_agents_tmpfile" - return 1 - } - - rm -f "$raw_tmpfile" "$headers_tmpfile" "$metadata_tmpfile" "$user_agents_tmpfile" - printf '%s' "$subscription_url" > "$subscription_url_cache_path" - printf '%s' "$effective_user_agent" > "$subscription_user_agent_cache_path" - persist_subscription_cache "$cache_section" "$subscription_json_path" "$subscription_url" "$effective_user_agent" 1 || - log "Failed to persist last working subscription cache for source '$cache_section'" "warn" - log "Subscription runtime outbounds for rule '$section' are unchanged" "info" - return 2 - fi - - mv "$normalized_tmpfile" "$subscription_json_path" || { - [ -n "$metadata_output_path" ] && rm -f "$metadata_output_path" - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" "$metadata_tmpfile" "$user_agents_tmpfile" - return 1 - } - - rm -f "$raw_tmpfile" "$headers_tmpfile" "$metadata_tmpfile" "$user_agents_tmpfile" - printf '%s' "$subscription_url" > "$subscription_url_cache_path" - printf '%s' "$effective_user_agent" > "$subscription_user_agent_cache_path" - persist_subscription_cache "$cache_section" "$subscription_json_path" "$subscription_url" "$effective_user_agent" 1 || - log "Failed to persist last working subscription cache for source '$cache_section'" "warn" - return 0 - done < "$user_agents_tmpfile" - - [ -n "$metadata_output_path" ] && rm -f "$metadata_output_path" - rm -f "$raw_tmpfile" "$headers_tmpfile" "$normalized_tmpfile" "$metadata_tmpfile" "$user_agents_tmpfile" - if [ -n "$subscription_user_agent" ]; then - log "Configured subscription User-Agent for rule '$section' did not produce valid outbounds" "error" - else - log "No subscription User-Agent candidate produced valid outbounds for rule '$section'" "error" - fi - return 1 -} - -rule_is_subscription_proxy() { - local section="$1" - local action - - rule_is_enabled "$section" || return 1 - - action="$(get_rule_action "$section")" - [ "$action" = "proxy" ] || return 1 - - rule_has_subscription_urls "$section" -} - -ensure_subscription_cache_for_source() { - local section="$1" - local source_section="$2" - local subscription_url="$3" - local subscription_user_agent="$4" - local fatal_on_error="${5:-0}" - local source_index="${6:-0}" - local download_phase="${7:-runtime}" - local subscription_json_path subscription_url_cache_path subscription_user_agent_cache_path metadata_output_path \ - cached_subscription_url cached_subscription_user_agent - local had_usable_cache cache_needs_refresh service_proxy_address update_result - - if [ -z "$subscription_url" ]; then - log "Subscription URL is not set for rule '$section'. Aborted." "fatal" - exit 1 - fi - - mkdir -p "$TMP_SUBSCRIPTION_FOLDER" - subscription_json_path="$(get_subscription_json_path "$source_section")" - subscription_url_cache_path="$(get_subscription_url_cache_path "$source_section")" - subscription_user_agent_cache_path="$(get_subscription_user_agent_cache_path "$source_section")" - cached_subscription_url="" - cached_subscription_user_agent="" - had_usable_cache=0 - cache_needs_refresh=0 - - restore_persistent_subscription_cache \ - "$source_section" "$subscription_json_path" "$subscription_url_cache_path" "$subscription_user_agent_cache_path" \ - "$subscription_url" "$subscription_user_agent" || true - - if subscription_cache_is_usable "$subscription_json_path"; then - had_usable_cache=1 - else - rm -f "$subscription_json_path" - fi - - if [ -f "$subscription_url_cache_path" ]; then - cached_subscription_url="$(cat "$subscription_url_cache_path" 2> /dev/null)" - fi - - if [ -f "$subscription_user_agent_cache_path" ]; then - cached_subscription_user_agent="$(cat "$subscription_user_agent_cache_path" 2> /dev/null)" - fi - - if [ "$had_usable_cache" -eq 0 ] || - [ "$cached_subscription_url" != "$subscription_url" ] || - ! subscription_cached_user_agent_matches_config "$subscription_user_agent" "$cached_subscription_user_agent"; then - cache_needs_refresh=1 - fi - - if [ "$cache_needs_refresh" -eq 0 ]; then - if [ -n "$SUBSCRIPTION_SECTION_METADATA_TMP" ]; then - if subscription_metadata_append_cached_source "$SUBSCRIPTION_SECTION_METADATA_TMP" "$section" "$source_index" "$source_section"; then - return 0 - fi - - return 0 - else - return 0 - fi - fi - - metadata_output_path="" - if [ -n "$SUBSCRIPTION_SECTION_METADATA_TMP" ]; then - metadata_output_path="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${source_section}.metadata-output.XXXXXX" 2>/dev/null || true)" - fi - - service_proxy_address="$(get_subscription_download_proxy_address "$section" "$had_usable_cache" "$download_phase")" - - download_subscription_into_cache \ - "$section" "$subscription_url" "$subscription_json_path" "$subscription_url_cache_path" "$service_proxy_address" "$subscription_user_agent" "$source_section" "$metadata_output_path" - update_result="$?" - case "$update_result" in - 0 | 2) - subscription_metadata_append_file "$SUBSCRIPTION_SECTION_METADATA_TMP" "$metadata_output_path" "$source_index" "$source_section" - rm -f "$metadata_output_path" - return 0 - ;; - esac - - rm -f "$metadata_output_path" - - if [ "$had_usable_cache" -eq 1 ] && - [ "$cached_subscription_url" = "$subscription_url" ] && - subscription_cached_user_agent_matches_config "$subscription_user_agent" "$cached_subscription_user_agent"; then - log "Keeping cached subscription for rule '$section' until a fresh download succeeds" "warn" - subscription_metadata_append_cached_source "$SUBSCRIPTION_SECTION_METADATA_TMP" "$section" "$source_index" "$source_section" || true - return 0 - fi - - if [ "$fatal_on_error" -eq 1 ]; then - log "Failed to download subscription for rule '$section'. Aborted." "fatal" - exit 1 - fi - - log "No usable subscription cache for rule '$section'" "warn" - return 1 -} - -prepare_subscription_cache_source() { - local section="$1" - local index="$2" - local entry="$3" - local source_section - - SUBSCRIPTION_SECTION_SOURCE_TOTAL=$((SUBSCRIPTION_SECTION_SOURCE_TOTAL + 1)) - - if ! parse_subscription_source_entry "$entry"; then - log "Invalid subscription source for rule '$section': $SUBSCRIPTION_SOURCE_PARSE_ERROR" "error" - subscription_startup_blocked=1 - return 0 - fi - - source_section="$(subscription_source_id "$section" "$index")" - if ensure_subscription_cache_for_source \ - "$section" "$source_section" "$SUBSCRIPTION_SOURCE_URL" "$SUBSCRIPTION_SOURCE_USER_AGENT" 0 "$index" "startup"; then - SUBSCRIPTION_SECTION_SOURCE_READY=$((SUBSCRIPTION_SECTION_SOURCE_READY + 1)) - else - SUBSCRIPTION_SECTION_SOURCE_FAILED=$((SUBSCRIPTION_SECTION_SOURCE_FAILED + 1)) - subscription_mark_unavailable_source "$source_section" - fi -} - -prepare_subscription_cache_for_startup() { - local section="$1" - local metadata_tmpfile metadata_count selector_proxy_links - - rule_is_subscription_proxy "$section" || return 0 - - mkdir -p "$TMP_SUBSCRIPTION_FOLDER" - metadata_tmpfile="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${section}.metadata-section.XXXXXX" 2>/dev/null || true)" - if [ -n "$metadata_tmpfile" ]; then - printf '[]\n' > "$metadata_tmpfile" - SUBSCRIPTION_SECTION_METADATA_TMP="$metadata_tmpfile" - else - SUBSCRIPTION_SECTION_METADATA_TMP="" - fi - - SUBSCRIPTION_SECTION_SOURCE_TOTAL=0 - SUBSCRIPTION_SECTION_SOURCE_READY=0 - SUBSCRIPTION_SECTION_SOURCE_FAILED=0 - for_each_subscription_source "$section" prepare_subscription_cache_source - - if [ "$SUBSCRIPTION_SECTION_SOURCE_TOTAL" -gt 0 ] && - [ "$SUBSCRIPTION_SECTION_SOURCE_READY" -eq 0 ]; then - config_get selector_proxy_links "$section" "selector_proxy_links" - if [ -n "$selector_proxy_links" ]; then - log "All subscription sources for rule '$section' are unavailable; starting with manual proxy links only" "warn" - else - log "No usable subscription cache for rule '$section' and no manual proxy links are configured; checking whether startup can be bootstrapped through the selected proxy/VPN rule" "warn" - subscription_mark_startup_blocked_section "$section" - fi - elif [ "$SUBSCRIPTION_SECTION_SOURCE_FAILED" -gt 0 ]; then - log "Skipping unavailable subscription source(s) for rule '$section'; using available outbounds" "warn" - fi - - if [ -n "$metadata_tmpfile" ]; then - metadata_count="$(subscription_cache_ucode json-length "$metadata_tmpfile" 2>/dev/null)" - if [ -n "$metadata_count" ] && [ "$metadata_count" -gt 0 ]; then - write_subscription_metadata_json "$section" "$metadata_tmpfile" - else - write_subscription_metadata_json "$section" "" - fi - rm -f "$metadata_tmpfile" - fi - SUBSCRIPTION_SECTION_METADATA_TMP="" -} - -prepare_subscription_caches_for_startup() { - subscription_startup_blocked=0 - SUBSCRIPTION_UNAVAILABLE_SOURCES="" - SUBSCRIPTION_DEFERRED_SECTIONS="" - SUBSCRIPTION_STARTUP_BLOCKED_SECTIONS="" - config_foreach prepare_subscription_cache_for_startup "section" - - [ "$subscription_startup_blocked" -eq 0 ] || return 1 - [ -n "$SUBSCRIPTION_STARTUP_BLOCKED_SECTIONS" ] || return 0 - - if subscription_bootstrap_download_section_is_ready; then - subscription_defer_startup_blocked_sections - log "Starting temporarily without subscription-only rule(s): $SUBSCRIPTION_DEFERRED_SECTIONS. They will be retried through the service proxy after sing-box starts" "warn" - return 0 - fi - - subscription_log_startup_blocked_sections - return 1 -} - -wait_for_subscription_bootstrap_service_proxy() { - local attempt - - attempt=1 - while [ "$attempt" -le 10 ]; do - if sing_box_service_is_running; then - return 0 - fi - - sleep 1 - attempt=$((attempt + 1)) - done - - return 1 -} - -retry_deferred_subscription_section() { - local section="$1" - local result - - if ! rule_is_subscription_proxy "$section"; then - log "Deferred subscription rule '$section' no longer has subscription proxy sources; removing it from bootstrap retry list" "warn" - return 0 - fi - - log "Retrying deferred subscription rule '$section' through the service proxy" "info" - subscription_update_section "$section" 1 - result="$?" - - case "$result" in - 0 | 2) - return 0 - ;; - esac - - if subscription_section_has_current_usable_cache "$section"; then - log "Deferred subscription rule '$section' has a usable cache after retry despite partial update errors" "warn" - return 0 - fi - - return 1 -} - -rebuild_runtime_after_deferred_subscription_recovery() { - local recovered_sections="$1" - - log "Recovered deferred subscription rule(s): $recovered_sections; rebuilding sing-box runtime" "info" - config_load "$PODKOP_CONFIG_NAME" - prepare_all_server_defaults - validate_runtime_settings - sing_box_configure_service - rebuild_nft_runtime - PODKOP_NFT_POPULATE_ENABLED=1 - sing_box_init_config - reload_sing_box_runtime - apply_pending_urltest_selector_switches - capture_reload_state - write_reload_state -} - -deferred_subscription_bootstrap_retry_pass() { - local section remaining_deferred recovered_sections - - [ -n "$SUBSCRIPTION_DEFERRED_SECTIONS" ] || return 0 - - remaining_deferred="" - recovered_sections="" - for section in $SUBSCRIPTION_DEFERRED_SECTIONS; do - if retry_deferred_subscription_section "$section"; then - recovered_sections="${recovered_sections}${recovered_sections:+ }$section" - else - remaining_deferred="${remaining_deferred}${remaining_deferred:+ }$section" - log "Deferred subscription rule '$section' is still unavailable; keeping it disabled for this startup" "warn" - fi - done - - [ -n "$recovered_sections" ] || { - SUBSCRIPTION_DEFERRED_SECTIONS="$remaining_deferred" - return 1 - } - - SUBSCRIPTION_DEFERRED_SECTIONS="$remaining_deferred" - SUBSCRIPTION_UNAVAILABLE_SOURCES="" - rebuild_runtime_after_deferred_subscription_recovery "$recovered_sections" - - if [ -n "$SUBSCRIPTION_DEFERRED_SECTIONS" ]; then - log "Some deferred subscription rule(s) are still disabled for this startup: $SUBSCRIPTION_DEFERRED_SECTIONS" "warn" - return 1 - fi - - return 0 -} - -subscription_bootstrap_retry_worker_cleanup() { - local status="${1:-0}" - - trap - EXIT INT TERM - rm -f "$PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE" - release_runtime_dir_lock "$PODKOP_RELOAD_LOCK_DIR" - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - exit "$status" -} - -deferred_subscription_bootstrap_retry_worker() { - local remaining_sections="$1" - - trap 'subscription_bootstrap_retry_worker_cleanup "$?"' EXIT INT TERM - - while [ -n "$remaining_sections" ]; do - sleep 30 - - if ! sing_box_service_is_running; then - log "Stopping subscription bootstrap retry worker because sing-box is not running" "warn" - break - fi - - config_load "$PODKOP_CONFIG_NAME" - ensure_runtime_dirs - - if ! acquire_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR"; then - log "Subscription bootstrap retry skipped because another subscription update is running" "debug" - continue - fi - - if ! acquire_runtime_dir_lock "$PODKOP_RELOAD_LOCK_DIR"; then - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - log "Subscription bootstrap retry skipped because Podkop Plus reload is running" "debug" - continue - fi - - SUBSCRIPTION_DEFERRED_SECTIONS="$remaining_sections" - SUBSCRIPTION_UNAVAILABLE_SOURCES="" - - if deferred_subscription_bootstrap_retry_pass; then - release_runtime_dir_lock "$PODKOP_RELOAD_LOCK_DIR" - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - break - fi - - remaining_sections="$SUBSCRIPTION_DEFERRED_SECTIONS" - release_runtime_dir_lock "$PODKOP_RELOAD_LOCK_DIR" - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - done -} - -start_deferred_subscription_bootstrap_retry_worker() { - local existing_pid - - [ -n "$SUBSCRIPTION_DEFERRED_SECTIONS" ] || return 0 - - existing_pid="$(subscription_cache_ucode file-first-line "$PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE" 2>/dev/null)" - if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then - log "Subscription bootstrap retry worker is already running with PID $existing_pid" "debug" - return 0 - fi - - (close_inherited_service_lock_fd; deferred_subscription_bootstrap_retry_worker "$SUBSCRIPTION_DEFERRED_SECTIONS") & - printf '%s\n' "$!" > "$PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE" - log "Started subscription bootstrap retry worker for rule(s): $SUBSCRIPTION_DEFERRED_SECTIONS" "info" -} - -stop_deferred_subscription_bootstrap_retry_worker() { - local pid - - pid="$(subscription_cache_ucode file-first-line "$PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE" 2>/dev/null)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - kill "$pid" 2>/dev/null || true - log "Stopped subscription bootstrap retry worker" - fi - rm -f "$PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE" -} - -run_deferred_subscription_bootstrap() { - [ -n "$SUBSCRIPTION_DEFERRED_SECTIONS" ] || return 0 - - log "Waiting for sing-box service proxy before retrying deferred subscription rule(s): $SUBSCRIPTION_DEFERRED_SECTIONS" "info" - if ! wait_for_subscription_bootstrap_service_proxy; then - log "sing-box service proxy did not become ready in time; deferred subscription rule(s) will remain disabled until the next successful subscription update" "warn" - start_deferred_subscription_bootstrap_retry_worker - return 0 - fi - - deferred_subscription_bootstrap_retry_pass || start_deferred_subscription_bootstrap_retry_worker -} diff --git a/podkop/files/usr/lib/ui_runtime.sh b/podkop/files/usr/lib/ui_runtime.sh deleted file mode 100644 index de55bcdb..00000000 --- a/podkop/files/usr/lib/ui_runtime.sh +++ /dev/null @@ -1,788 +0,0 @@ -# shellcheck shell=ash - -PODKOP_UI_STATE_DIR="${PODKOP_UI_STATE_DIR:-/var/run/podkop-plus/ui-state}" -PODKOP_UI_SERVICE_ACTION_DIR="${PODKOP_UI_SERVICE_ACTION_DIR:-$PODKOP_UI_STATE_DIR/service-actions}" -PODKOP_UI_SERVICE_ACTION_LOCK_DIR="${PODKOP_UI_SERVICE_ACTION_LOCK_DIR:-$PODKOP_UI_STATE_DIR/service-actions.lock}" -PODKOP_UI_LATENCY_ACTION_DIR="${PODKOP_UI_LATENCY_ACTION_DIR:-$PODKOP_UI_STATE_DIR/latency-actions}" -PODKOP_UI_SING_BOX_VERSION_CACHE_FILE="${PODKOP_UI_SING_BOX_VERSION_CACHE_FILE:-$PODKOP_UI_STATE_DIR/sing-box-version}" -PODKOP_UI_SING_BOX_VERSION_STATE_FILE="${PODKOP_UI_SING_BOX_VERSION_STATE_FILE:-/etc/podkop-plus/sing-box-version}" -PODKOP_UI_SING_BOX_VARIANT_STATE_FILE="${PODKOP_UI_SING_BOX_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" -PODKOP_UI_ACTION_FINISHED_TTL_MINUTES="${PODKOP_UI_ACTION_FINISHED_TTL_MINUTES:-60}" -PODKOP_UI_ACTION_ACKED_TTL_SECONDS="${PODKOP_UI_ACTION_ACKED_TTL_SECONDS:-15}" -PODKOP_UI_ACTION_STALE_GRACE_SECONDS="${PODKOP_UI_ACTION_STALE_GRACE_SECONDS:-15}" -PODKOP_UI_SERVICE_ACTION_TIMEOUT_SECONDS="${PODKOP_UI_SERVICE_ACTION_TIMEOUT_SECONDS:-120}" -PODKOP_UI_SERVICE_ACTION_SETTLE_SECONDS="${PODKOP_UI_SERVICE_ACTION_SETTLE_SECONDS:-2}" -PODKOP_UI_COMPONENT_ACTION_DIR="${UPDATES_JOB_DIR:-/var/run/podkop-plus/component-actions}" -PODKOP_UI_SUBSCRIPTION_ACTION_DIR="${PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR:-/var/run/podkop-plus/subscription-update-jobs}" - -ui_runtime_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/ui_runtime.uc" "$@" -} - -ui_runtime_now() { - local now - - now="$(date +%s 2>/dev/null)" - case "$now" in - "" | *[!0-9]*) now=0 ;; - esac - printf '%s\n' "$now" -} - -ui_runtime_ensure_dirs() { - mkdir -p \ - "$PODKOP_UI_STATE_DIR" \ - "$PODKOP_UI_SERVICE_ACTION_DIR" \ - "$PODKOP_UI_LATENCY_ACTION_DIR" \ - "$PODKOP_UI_COMPONENT_ACTION_DIR" \ - "$PODKOP_UI_SUBSCRIPTION_ACTION_DIR" -} - -ui_runtime_acquire_dir_lock() { - local lock_dir="$1" - local owner_pid - - if mkdir "$lock_dir" 2>/dev/null; then - printf '%s\n' "$$" >"$lock_dir/pid" - return 0 - fi - - owner_pid="$(sed -n '1p' "$lock_dir/pid" 2>/dev/null)" - if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null; then - return 1 - fi - - rm -f "$lock_dir/pid" 2>/dev/null - rmdir "$lock_dir" 2>/dev/null || return 1 - mkdir "$lock_dir" 2>/dev/null || return 1 - printf '%s\n' "$$" >"$lock_dir/pid" -} - -ui_runtime_release_dir_lock() { - local lock_dir="$1" - - rm -f "$lock_dir/pid" 2>/dev/null - rmdir "$lock_dir" 2>/dev/null -} - -ui_runtime_job_id() { - printf '%s-%s\n' "$(ui_runtime_now)" "$$" -} - -ui_runtime_job_id_is_safe() { - case "$1" in - *[!A-Za-z0-9._-]* | "" | "." | "..") - return 1 - ;; - esac -} - -ui_runtime_job_state_path() { - local dir="$1" - local job_id="$2" - - ui_runtime_job_id_is_safe "$job_id" || return 1 - printf '%s/%s.json\n' "$dir" "$job_id" -} - -ui_runtime_tmp_file() { - local target_file="$1" - local tmp_file - - tmp_file="$(mktemp "${target_file}.XXXXXX" 2>/dev/null || true)" - if [ -z "$tmp_file" ]; then - tmp_file="${target_file}.$$.$(ui_runtime_now).tmp" - : >"$tmp_file" || return 1 - fi - - printf '%s\n' "$tmp_file" -} - -ui_runtime_write_state() { - local state_file="$1" - local tmp_file - shift - - tmp_file="$(ui_runtime_tmp_file "$state_file")" || return 1 - "$@" >"$tmp_file" && mv "$tmp_file" "$state_file" - - local rc=$? - rm -f "$tmp_file" 2>/dev/null - return "$rc" -} - -ui_runtime_remove_state_file() { - local state_file="$1" - - rm -f "$state_file" "${state_file%.json}.out" "${state_file%.json}.out.json" 2>/dev/null || true -} - -ui_runtime_json_field() { - local state_file="$1" - local key="$2" - local fallback="${3:-}" - - ui_runtime_ucode json-file-field "$state_file" "$key" "$fallback" 2>/dev/null -} - -ui_runtime_write_finished_state() { - local state_file="$1" - local success="$2" - local message="$3" - local exit_code="${4:-}" - local updated_at - - updated_at="$(ui_runtime_now)" - ui_runtime_write_state "$state_file" \ - ui_runtime_ucode finished-action-state "$state_file" "$success" "$message" "$exit_code" "$updated_at" -} - -ui_runtime_mark_stale_state() { - local state_file="$1" - local message="$2" - local updated_at - - updated_at="$(ui_runtime_now)" - ui_runtime_write_state "$state_file" \ - ui_runtime_ucode stale-action-state "$state_file" "$message" "$updated_at" -} - -ui_runtime_update_running_pid() { - local state_file="$1" - local pid="$2" - - case "$pid" in - "" | *[!0-9]*) return 1 ;; - esac - - ui_runtime_write_state "$state_file" ui_runtime_ucode set-running-job-pid "$state_file" "$pid" -} - -ui_runtime_started_at_is_younger_than() { - local started_at="$1" - local max_age="$2" - local now - - case "$started_at" in - "" | *[!0-9]*) return 1 ;; - esac - [ "$started_at" -gt 0 ] || return 1 - - now="$(ui_runtime_now)" - [ "$now" -gt 0 ] || return 1 - [ $((now - started_at)) -lt "$max_age" ] -} - -ui_runtime_refresh_pid_job_state() { - local state_file="$1" - local stale_message="$2" - local pid started_at - - [ "$(ui_runtime_json_field "$state_file" running false)" = "true" ] || return 0 - - pid="$(ui_runtime_json_field "$state_file" pid "")" - started_at="$(ui_runtime_json_field "$state_file" started_at 0)" - - case "$pid" in - "" | *[!0-9]*) - ui_runtime_started_at_is_younger_than "$started_at" "$PODKOP_UI_ACTION_STALE_GRACE_SECONDS" && return 0 - [ "$(ui_runtime_json_field "$state_file" running false)" = "true" ] || return 0 - ui_runtime_mark_stale_state "$state_file" "$stale_message" - return 0 - ;; - esac - - kill -0 "$pid" 2>/dev/null && return 0 - ui_runtime_started_at_is_younger_than "$started_at" "$PODKOP_UI_ACTION_STALE_GRACE_SECONDS" && return 0 - [ "$(ui_runtime_json_field "$state_file" running false)" = "true" ] || return 0 - ui_runtime_mark_stale_state "$state_file" "$stale_message" -} - -ui_runtime_service_action_reached_expected_state() { - case "$1" in - start | restart | reload) - ui_runtime_podkop_running - ;; - stop) - ! ui_runtime_podkop_running - ;; - *) - return 1 - ;; - esac -} - -ui_runtime_service_action_wait_for_expected_state() { - local action="$1" - local timeout="${2:-$PODKOP_UI_SERVICE_ACTION_TIMEOUT_SECONDS}" - local settle_seconds="${3:-$PODKOP_UI_SERVICE_ACTION_SETTLE_SECONDS}" - local now deadline stable_seconds - - now="$(ui_runtime_now)" - deadline=$((now + timeout)) - stable_seconds=0 - - while :; do - if ui_runtime_service_action_reached_expected_state "$action"; then - stable_seconds=$((stable_seconds + 1)) - [ "$stable_seconds" -ge "$settle_seconds" ] && return 0 - else - stable_seconds=0 - fi - - now="$(ui_runtime_now)" - [ "$now" -ge "$deadline" ] && return 1 - sleep 1 - done -} - -ui_runtime_refresh_service_state_file() { - local state_file="$1" - - ui_runtime_refresh_pid_job_state "$state_file" "Service action worker exited unexpectedly" -} - -ui_runtime_refresh_latency_state_file() { - ui_runtime_refresh_pid_job_state "$1" "Latency test worker exited unexpectedly" -} - -ui_runtime_refresh_component_state_file() { - ui_runtime_refresh_pid_job_state "$1" "Component action worker exited unexpectedly" -} - -ui_runtime_refresh_subscription_state_file() { - ui_runtime_refresh_pid_job_state "$1" "Subscription update worker exited unexpectedly" -} - -ui_runtime_cleanup_dir() { - local dir="$1" - local state_file running - - [ -d "$dir" ] || return 0 - - for state_file in "$dir"/*.json; do - [ -f "$state_file" ] || continue - running="$(ui_runtime_json_field "$state_file" running "")" - case "$running" in - true | false) ;; - *) - ui_runtime_remove_state_file "$state_file" - continue - ;; - esac - - if [ "$running" = "false" ] && - ui_runtime_state_file_ack_expired "$state_file"; then - ui_runtime_remove_state_file "$state_file" - fi - done - - find "$dir" -type f -name '*.json' -mmin "+$PODKOP_UI_ACTION_FINISHED_TTL_MINUTES" 2>/dev/null | - while IFS= read -r state_file; do - [ -f "$state_file" ] || continue - if [ "$(ui_runtime_json_field "$state_file" running false)" = "false" ]; then - ui_runtime_remove_state_file "$state_file" - fi - done -} - -ui_runtime_state_file_ack_expired() { - local state_file="$1" - local acked_at now - - acked_at="$(ui_runtime_json_field "$state_file" acked_at "" 2>/dev/null)" - case "$acked_at" in - "" | *[!0-9]*) return 1 ;; - esac - - now="$(ui_runtime_now)" - [ "$now" -gt 0 ] || return 1 - - [ $((now - acked_at)) -ge "$PODKOP_UI_ACTION_ACKED_TTL_SECONDS" ] -} - -ui_runtime_refresh_action_dirs() { - local state_file - - ui_runtime_cleanup_dir "$PODKOP_UI_SERVICE_ACTION_DIR" - ui_runtime_cleanup_dir "$PODKOP_UI_LATENCY_ACTION_DIR" - ui_runtime_cleanup_dir "$PODKOP_UI_COMPONENT_ACTION_DIR" - ui_runtime_cleanup_dir "$PODKOP_UI_SUBSCRIPTION_ACTION_DIR" - - if [ -d "$PODKOP_UI_SERVICE_ACTION_DIR" ]; then - for state_file in "$PODKOP_UI_SERVICE_ACTION_DIR"/*.json; do - [ -f "$state_file" ] || continue - ui_runtime_refresh_service_state_file "$state_file" - done - fi - - if [ -d "$PODKOP_UI_LATENCY_ACTION_DIR" ]; then - for state_file in "$PODKOP_UI_LATENCY_ACTION_DIR"/*.json; do - [ -f "$state_file" ] || continue - ui_runtime_refresh_latency_state_file "$state_file" - done - fi - - if [ -d "$PODKOP_UI_COMPONENT_ACTION_DIR" ]; then - for state_file in "$PODKOP_UI_COMPONENT_ACTION_DIR"/*.json; do - [ -f "$state_file" ] || continue - ui_runtime_refresh_component_state_file "$state_file" - done - fi - - if [ -d "$PODKOP_UI_SUBSCRIPTION_ACTION_DIR" ]; then - for state_file in "$PODKOP_UI_SUBSCRIPTION_ACTION_DIR"/*.json; do - [ -f "$state_file" ] || continue - ui_runtime_refresh_subscription_state_file "$state_file" - done - fi -} - -ui_runtime_state_paths() { - local state_file - - for state_file in "$PODKOP_UI_SERVICE_ACTION_DIR"/*.json; do - [ -f "$state_file" ] && printf 'service\t%s\n' "$state_file" - done - - for state_file in "$PODKOP_UI_LATENCY_ACTION_DIR"/*.json; do - [ -f "$state_file" ] && printf 'latency\t%s\n' "$state_file" - done - - for state_file in "$PODKOP_UI_COMPONENT_ACTION_DIR"/*.json; do - [ -f "$state_file" ] && printf 'component\t%s\n' "$state_file" - done - - for state_file in "$PODKOP_UI_SUBSCRIPTION_ACTION_DIR"/*.json; do - [ -f "$state_file" ] && printf 'subscription\t%s\n' "$state_file" - done -} - -ui_runtime_service_enabled() { - [ -x "/etc/rc.d/S99${PODKOP_SERVICE_NAME:-podkop-plus}" ] -} - -ui_runtime_sing_box_enabled() { - [ -x /etc/rc.d/S99sing-box ] -} - -ui_runtime_sing_box_running() { - local service_json='{"name":"sing-box"}' - - if ubus call service list "$service_json" 2>/dev/null | - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/status_diagnostics.uc" service-list-instance-running sing-box >/dev/null 2>&1; then - return 0 - fi - - pgrep -x "sing-box" >/dev/null 2>&1 || - pgrep -f "^/usr/bin/sing-box[[:space:]]" >/dev/null 2>&1 -} - -ui_runtime_network_configured() { - nft list table inet "$NFT_TABLE_NAME" >/dev/null 2>&1 || return 1 - ip rule show 2>/dev/null | grep -q "lookup ${RT_TABLE_NAME:-podkopplus}" || return 1 - ip route show table "${RT_TABLE_NAME:-podkopplus}" 2>/dev/null | grep -q . -} - -ui_runtime_podkop_running() { - ui_runtime_sing_box_running && ui_runtime_network_configured -} - -ui_runtime_dns_configured() { - uci -q get 'dhcp.@dnsmasq[0].server' 2>/dev/null | grep -q "${SB_DNS_INBOUND_ADDRESS:-127.0.0.42}" -} - -ui_runtime_sing_box_signature() { - set -- $(ls -ln /usr/bin/sing-box 2>/dev/null) || return 1 - [ -n "${5:-}" ] || return 1 - printf '%s:%s:%s:%s\n' "${5:-}" "${6:-}" "${7:-}" "${8:-}" -} - -ui_runtime_sing_box_compressed_marker_set() { - [ -r "$PODKOP_UI_SING_BOX_VARIANT_STATE_FILE" ] || return 1 - [ "$(sed -n '1p' "$PODKOP_UI_SING_BOX_VARIANT_STATE_FILE" 2>/dev/null)" = "extended-compressed" ] -} - -ui_runtime_sing_box_marker_is() { - local expected="$1" - - [ -r "$PODKOP_UI_SING_BOX_VARIANT_STATE_FILE" ] || return 1 - [ "$(sed -n '1p' "$PODKOP_UI_SING_BOX_VARIANT_STATE_FILE" 2>/dev/null)" = "$expected" ] -} - -ui_runtime_sing_box_tiny_package_installed() { - if command -v apk >/dev/null 2>&1; then - apk info -e sing-box-tiny >/dev/null 2>&1 - return $? - fi - - opkg list-installed 2>/dev/null | awk '$1 == "sing-box-tiny" { found = 1 } END { exit(found ? 0 : 1) }' -} - -ui_runtime_component_action_running_for() { - local component="$1" - local state_file - - [ -d "$PODKOP_UI_COMPONENT_ACTION_DIR" ] || return 1 - for state_file in "$PODKOP_UI_COMPONENT_ACTION_DIR"/*.json; do - [ -f "$state_file" ] || continue - [ "$(ui_runtime_json_field "$state_file" running false)" = "true" ] || continue - [ "$(ui_runtime_json_field "$state_file" component "")" = "$component" ] && return 0 - done - - return 1 -} - -ui_runtime_sing_box_version_state() { - [ -r "$PODKOP_UI_SING_BOX_VERSION_STATE_FILE" ] || return 1 - sed -n '1p' "$PODKOP_UI_SING_BOX_VERSION_STATE_FILE" 2>/dev/null -} - -ui_runtime_sing_box_version() { - local signature cache_signature cache_version version_line version - - command -v sing-box >/dev/null 2>&1 || return 1 - - if ui_runtime_sing_box_compressed_marker_set; then - ui_runtime_sing_box_version_state - return $? - fi - - signature="$(ui_runtime_sing_box_signature 2>/dev/null || true)" - if [ -n "$signature" ] && [ -r "$PODKOP_UI_SING_BOX_VERSION_CACHE_FILE" ]; then - cache_signature="$(sed -n '1p' "$PODKOP_UI_SING_BOX_VERSION_CACHE_FILE" 2>/dev/null)" - cache_version="$(sed -n '2p' "$PODKOP_UI_SING_BOX_VERSION_CACHE_FILE" 2>/dev/null)" - if [ "$cache_signature" = "$signature" ] && [ -n "$cache_version" ]; then - printf '%s\n' "$cache_version" - return 0 - fi - fi - - version_line="$(sing-box version 2>/dev/null | sed -n '1p')" - version="${version_line##* }" - [ -n "$version" ] || return 1 - - if [ -n "$signature" ]; then - mkdir -p "$PODKOP_UI_STATE_DIR" 2>/dev/null || true - { - printf '%s\n' "$signature" - printf '%s\n' "$version" - } >"$PODKOP_UI_SING_BOX_VERSION_CACHE_FILE" 2>/dev/null || true - fi - - printf '%s\n' "$version" -} - -ui_runtime_status_text() { - local running="$1" - local enabled="$2" - - if [ "$running" -eq 1 ]; then - [ "$enabled" -eq 1 ] && printf '%s\n' "running & enabled" || printf '%s\n' "running but disabled" - return 0 - fi - - [ "$enabled" -eq 1 ] && printf '%s\n' "stopped but enabled" || printf '%s\n' "stopped & disabled" -} - -ui_runtime_active_service_action() { - ui_runtime_ucode active-service-action "$PODKOP_UI_SERVICE_ACTION_DIR" 2>/dev/null -} - -ui_runtime_write_ui_state() { - local podkop_running="$1" - local podkop_enabled="$2" - local podkop_status="$3" - local dns_configured="$4" - local sing_box_running="$5" - local sing_box_enabled="$6" - local sing_box_status="$7" - local sing_box_extended="$8" - local sing_box_tiny="$9" - local sing_box_compressed="${10}" - local sing_box_tailscale="${11}" - local zapret_installed="${12}" - local zapret2_installed="${13}" - local byedpi_installed="${14}" - local server_inbounds_enabled_count="${15}" - - ui_runtime_state_paths | ui_runtime_ucode ui-state-json \ - "$podkop_running" \ - "$podkop_enabled" \ - "$podkop_status" \ - "$dns_configured" \ - "$sing_box_running" \ - "$sing_box_enabled" \ - "$sing_box_status" \ - "$sing_box_extended" \ - "$sing_box_tiny" \ - "$sing_box_compressed" \ - "$sing_box_tailscale" \ - "$zapret_installed" \ - "$zapret2_installed" \ - "$byedpi_installed" \ - "$server_inbounds_enabled_count" -} - -ui_runtime_service_action_valid() { - case "$1" in - start | stop | restart | reload) - return 0 - ;; - esac - return 1 -} - -ui_runtime_service_action_begin() { - local action="$1" - local source="${2:-ui}" - local job_id state_file - - ui_runtime_service_action_valid "$action" || return 1 - ui_runtime_ensure_dirs || return 1 - - job_id="$(ui_runtime_job_id)" - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_SERVICE_ACTION_DIR" "$job_id")" || return 1 - - ui_runtime_write_state "$state_file" \ - ui_runtime_ucode running-service-action "$action" "$source" "$(ui_runtime_now)" || return 1 - - printf '%s\n' "$job_id" -} - -ui_runtime_service_action_begin_if_idle() { - local action="$1" - local source="${2:-ui}" - local job_id result - - ui_runtime_ensure_dirs || return 1 - ui_runtime_acquire_dir_lock "$PODKOP_UI_SERVICE_ACTION_LOCK_DIR" || return 2 - - ui_runtime_refresh_action_dirs - if ui_runtime_active_service_action >/dev/null 2>&1; then - ui_runtime_release_dir_lock "$PODKOP_UI_SERVICE_ACTION_LOCK_DIR" - return 2 - fi - - job_id="$(ui_runtime_service_action_begin "$action" "$source")" - result=$? - ui_runtime_release_dir_lock "$PODKOP_UI_SERVICE_ACTION_LOCK_DIR" - [ "$result" -eq 0 ] || return "$result" - - printf '%s\n' "$job_id" -} - -ui_runtime_service_action_finish_job() { - local job_id="$1" - local success="${2:-true}" - local message="${3:-Service action completed}" - local exit_code="${4:-0}" - local state_file - - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_SERVICE_ACTION_DIR" "$job_id")" || return 1 - [ -f "$state_file" ] || return 1 - - ui_runtime_write_finished_state "$state_file" "$success" "$message" "$exit_code" -} - -ui_runtime_service_action_async() { - local action="$1" - local job_id state_file job_pid - - ui_runtime_service_action_valid "$action" || { - ui_runtime_ucode action-start-response false "" "Invalid service action" - exit 1 - } - - job_id="$(ui_runtime_service_action_begin_if_idle "$action" "ui")" - case "$?" in - 0) ;; - 2) - ui_runtime_ucode action-start-response false "" "Another service action is already running" - exit 1 - ;; - *) - ui_runtime_ucode action-start-response false "" "Failed to write service action state" - exit 1 - ;; - esac - - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_SERVICE_ACTION_DIR" "$job_id")" || { - ui_runtime_ucode action-start-response false "" "Failed to prepare service action state" - exit 1 - } - - ( - trap '' HUP - PODKOP_UI_ACTION_TRACKED=1 /etc/init.d/podkop-plus "$action" >/dev/null 2>&1 - rc="$?" - if [ "$rc" -ne 0 ]; then - ui_runtime_write_finished_state "$state_file" false "Service $action failed" "$rc" - elif ui_runtime_service_action_wait_for_expected_state "$action"; then - ui_runtime_write_finished_state "$state_file" true "Service $action completed" "$rc" - else - ui_runtime_write_finished_state "$state_file" false "Service $action did not reach expected state" 1 - fi - ) >/dev/null 2>&1 & - job_pid="$!" - - ui_runtime_update_running_pid "$state_file" "$job_pid" || { - kill "$job_pid" 2>/dev/null || true - ui_runtime_ucode action-start-response false "" "Failed to write service action worker pid" - exit 1 - } - - ui_runtime_ucode action-start-response true "$job_id" "Service $action started" -} - -ui_runtime_service_action_status() { - local job_id="$1" - local state_file - - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_SERVICE_ACTION_DIR" "$job_id")" || { - ui_runtime_ucode action-start-response false "" "Invalid service action job id" - exit 1 - } - - [ -f "$state_file" ] || { - ui_runtime_ucode action-start-response false "" "Service action job was not found" - exit 1 - } - - ui_runtime_refresh_service_state_file "$state_file" - cat "$state_file" -} - -ui_runtime_latency_type_valid() { - case "$1" in - group | proxy | proxy_list) - return 0 - ;; - esac - return 1 -} - -ui_runtime_latency_test_async() { - local latency_type="$1" - local section="$2" - local tag="$3" - local requested_timeout="$4" - local job_id state_file job_pid clash_method timeout - - ui_runtime_latency_type_valid "$latency_type" || { - ui_runtime_ucode action-start-response false "" "Invalid latency test type" - exit 1 - } - - [ -n "$tag" ] || { - ui_runtime_ucode action-start-response false "" "Latency test tag is required" - exit 1 - } - - ui_runtime_ensure_dirs || { - ui_runtime_ucode action-start-response false "" "Failed to create UI action state directory" - exit 1 - } - - job_id="$(ui_runtime_job_id)" - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_LATENCY_ACTION_DIR" "$job_id")" || { - ui_runtime_ucode action-start-response false "" "Failed to prepare latency test state" - exit 1 - } - - ui_runtime_write_state "$state_file" \ - ui_runtime_ucode running-latency-action "$latency_type" "$section" "$tag" "$(ui_runtime_now)" || { - ui_runtime_ucode action-start-response false "" "Failed to write latency test state" - exit 1 - } - - if [ "$latency_type" = "group" ]; then - clash_method="get_group_latency" - timeout="10000" - elif [ "$latency_type" = "proxy_list" ]; then - clash_method="get_proxy_latencies" - timeout="5000" - else - clash_method="get_proxy_latency" - timeout="5000" - fi - [ -n "$requested_timeout" ] && timeout="$requested_timeout" - - ( - trap '' HUP - /usr/bin/podkop-plus clash_api "$clash_method" "$tag" "$timeout" >/dev/null 2>&1 - rc="$?" - if [ "$rc" -eq 0 ]; then - ui_runtime_write_finished_state "$state_file" true "Latency test completed" "$rc" - else - ui_runtime_write_finished_state "$state_file" false "Latency test failed" "$rc" - fi - ) >/dev/null 2>&1 & - job_pid="$!" - - ui_runtime_update_running_pid "$state_file" "$job_pid" || { - kill "$job_pid" 2>/dev/null || true - ui_runtime_ucode action-start-response false "" "Failed to write latency test worker pid" - exit 1 - } - - ui_runtime_ucode action-start-response true "$job_id" "Latency test started" -} - -ui_runtime_latency_test_status() { - local job_id="$1" - local state_file - - state_file="$(ui_runtime_job_state_path "$PODKOP_UI_LATENCY_ACTION_DIR" "$job_id")" || { - ui_runtime_ucode action-start-response false "" "Invalid latency test job id" - exit 1 - } - - [ -f "$state_file" ] || { - ui_runtime_ucode action-start-response false "" "Latency test job was not found" - exit 1 - } - - ui_runtime_refresh_latency_state_file "$state_file" - cat "$state_file" -} - -ui_runtime_action_ack() { - local kind="$1" - local job_id="$2" - local dir state_file now - - case "$kind" in - service) dir="$PODKOP_UI_SERVICE_ACTION_DIR" ;; - latency) dir="$PODKOP_UI_LATENCY_ACTION_DIR" ;; - component) dir="$PODKOP_UI_COMPONENT_ACTION_DIR" ;; - subscription) dir="$PODKOP_UI_SUBSCRIPTION_ACTION_DIR" ;; - *) - ui_runtime_ucode action-start-response false "" "Invalid UI action kind" - exit 1 - ;; - esac - - state_file="$(ui_runtime_job_state_path "$dir" "$job_id")" || { - ui_runtime_ucode action-start-response false "" "Invalid UI action job id" - exit 1 - } - - if [ ! -f "$state_file" ]; then - ui_runtime_ucode action-start-response true "$job_id" "UI action already acknowledged" - return 0 - fi - - if [ "$(ui_runtime_json_field "$state_file" running false)" = "true" ]; then - ui_runtime_ucode action-start-response false "$job_id" "UI action is still running" - exit 1 - fi - - now="$(ui_runtime_now)" - ui_runtime_write_state "$state_file" ui_runtime_ucode ack-action-state "$state_file" "$now" || { - ui_runtime_ucode action-start-response false "$job_id" "Failed to acknowledge UI action" - exit 1 - } - - ui_runtime_ucode action-start-response true "$job_id" "UI action acknowledged" -} diff --git a/podkop/files/usr/lib/ui_runtime.uc b/podkop/files/usr/lib/ui_runtime.uc deleted file mode 100644 index 3ce52896..00000000 --- a/podkop/files/usr/lib/ui_runtime.uc +++ /dev/null @@ -1,274 +0,0 @@ -#!/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) { - 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 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 object_or_empty(value) { - return type(value) == "object" ? value : {}; -} - -function path_basename(path) { - let parts = split(as_string(path), "/"); - return length(parts) > 0 ? as_string(parts[length(parts) - 1]) : ""; -} - -function str_remove_suffix(value, suffix) { - value = as_string(value); - suffix = as_string(suffix); - if (length(value) >= length(suffix) && substr(value, length(value) - length(suffix)) == suffix) - return substr(value, 0, length(value) - length(suffix)); - return value; -} - -function job_id_from_path(path) { - return str_remove_suffix(path_basename(path), ".json"); -} - -function valid_action_state(value) { - return type(value) == "object" && (value.running === true || value.running === false); -} - -function read_state_paths() { - let result = { - service: [], - latency: [], - component: [], - subscription: [] - }; - - for (let line in split(read_stdin(), "\n")) { - line = trim(as_string(line)); - if (line == "") - continue; - - let tab = index(line, "\t"); - if (tab <= 0) - continue; - - let kind = substr(line, 0, tab); - let path = substr(line, tab + 1); - let value = read_json_file(path); - if (!valid_action_state(value)) - continue; - - value.job_id = job_id_from_path(path); - - if (type(result[kind]) == "array") - push(result[kind], value); - } - - return result; -} - -function service_status_text(running, enabled) { - if (arg_bool(running)) - return arg_bool(enabled) ? "running & enabled" : "running but disabled"; - - return arg_bool(enabled) ? "stopped but enabled" : "stopped & disabled"; -} - -function ui_state_json() { - let action_state = read_state_paths(); - let podkop_running = arg_number(ARGV[1]); - let podkop_enabled = arg_number(ARGV[2]); - let podkop_status = as_string(ARGV[3]); - let podkop_dns_configured = arg_number(ARGV[4]); - let sing_box_running = arg_number(ARGV[5]); - let sing_box_enabled = arg_number(ARGV[6]); - let sing_box_status = as_string(ARGV[7]); - - if (podkop_status == "") - podkop_status = service_status_text(podkop_running, podkop_enabled); - - if (sing_box_status == "") - sing_box_status = service_status_text(sing_box_running, sing_box_enabled); - - write_json({ - service: { - podkop: { - running: podkop_running, - enabled: podkop_enabled, - status: podkop_status, - dns_configured: podkop_dns_configured - }, - sing_box: { - running: sing_box_running, - enabled: sing_box_enabled, - status: sing_box_status - } - }, - capabilities: { - sing_box_extended: arg_number(ARGV[8]), - sing_box_tiny: arg_number(ARGV[9]), - sing_box_compressed: arg_number(ARGV[10]), - sing_box_tailscale: arg_number(ARGV[11]), - zapret_installed: arg_number(ARGV[12]), - zapret2_installed: arg_number(ARGV[13]), - byedpi_installed: arg_number(ARGV[14]), - server_inbounds_enabled_count: arg_number(ARGV[15]) - }, - actions: action_state - }); -} - -function action_start_response(success, job_id, message) { - write_json({ - success: arg_bool(success), - job_id: as_string(job_id), - message: as_string(message) - }); -} - -function running_service_action(action, source, started_at) { - write_json({ - success: true, - running: true, - kind: "service", - action: as_string(action), - source: as_string(source), - message: "Service action is running", - pid: null, - started_at: arg_number(started_at), - updated_at: null, - exit_code: null - }); -} - -function running_latency_action(latency_type, section, tag, started_at) { - write_json({ - success: true, - running: true, - kind: "latency", - latency_type: as_string(latency_type), - section: as_string(section), - tag: as_string(tag), - message: "Latency test is running", - pid: null, - started_at: arg_number(started_at), - updated_at: null, - exit_code: null - }); -} - -function 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 finished_action_state(path, success, message, exit_code, updated_at) { - let value = object_or_empty(read_json_file(path)); - value.success = arg_bool(success); - value.running = false; - value.message = as_string(message); - value.exit_code = as_string(exit_code) == "" ? null : arg_number(exit_code); - value.updated_at = arg_number(updated_at); - write_json(value); -} - -function stale_action_state(path, message, updated_at) { - let value = object_or_empty(read_json_file(path)); - if (value.running === true) { - value.success = false; - value.running = false; - value.message = as_string(message); - value.exit_code = null; - value.updated_at = arg_number(updated_at); - } - write_json(value); -} - -function ack_action_state(path, acked_at) { - let value = object_or_empty(read_json_file(path)); - if (value.running === false) - value.acked_at = arg_number(acked_at); - write_json(value); -} - -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 active_service_action(dir) { - for (let path in fs.glob(as_string(dir) + "/*.json")) { - let value = read_json_file(path); - if (type(value) == "object" && value.running === true && as_string(value.action) != "") { - print(as_string(value.action), "\n"); - return; - } - } - - exit(1); -} - -let mode = ARGV[0] || ""; - -if (mode == "ui-state-json") - ui_state_json(); -else if (mode == "action-start-response") - action_start_response(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "running-service-action") - running_service_action(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "running-latency-action") - running_latency_action(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); -else if (mode == "set-running-job-pid") - set_running_job_pid(ARGV[1], ARGV[2]); -else if (mode == "finished-action-state") - finished_action_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5]); -else if (mode == "stale-action-state") - stale_action_state(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "ack-action-state") - ack_action_state(ARGV[1], ARGV[2]); -else if (mode == "json-file-field") - json_file_field(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "active-service-action") - active_service_action(ARGV[1]); -else { - warn("Usage: ui_runtime.uc ...\n"); - exit(1); -} diff --git a/podkop/files/usr/lib/updater.sh b/podkop/files/usr/lib/updater.sh deleted file mode 100644 index d415260f..00000000 --- a/podkop/files/usr/lib/updater.sh +++ /dev/null @@ -1,2617 +0,0 @@ -# shellcheck shell=ash - -UPDATES_TMP_DIR="" -UPDATES_TARGET_ARCH="" -UPDATES_ARCH_CANDIDATES="" -UPDATES_ZAPRET_ARCH="" -UPDATES_ZAPRET_BUNDLE_URL="" -UPDATES_ZAPRET_BUNDLE_NAME="" -UPDATES_ZAPRET_PACKAGE_FILE="" -UPDATES_ZAPRET_PACKAGE_NAME="" -UPDATES_ZAPRET_PACKAGE_VERSION="" -UPDATES_ZAPRET_RELEASE_URL="" -UPDATES_ZAPRET2_ARCH="" -UPDATES_ZAPRET2_BUNDLE_URL="" -UPDATES_ZAPRET2_BUNDLE_NAME="" -UPDATES_ZAPRET2_PACKAGE_FILE="" -UPDATES_ZAPRET2_PACKAGE_NAME="" -UPDATES_ZAPRET2_PACKAGE_VERSION="" -UPDATES_ZAPRET2_RELEASE_URL="" -UPDATES_BYEDPI_ARCH="" -UPDATES_BYEDPI_PACKAGE_URL="" -UPDATES_BYEDPI_PACKAGE_NAME="" -UPDATES_BYEDPI_PACKAGE_FILE="" -UPDATES_BYEDPI_PACKAGE_VERSION="" -UPDATES_BYEDPI_RELEASE_URL="" -UPDATES_PODKOP_BACKEND_URL="" -UPDATES_PODKOP_RELEASE_URL="" -UPDATES_PODKOP_BACKEND_NAME="" -UPDATES_PODKOP_BACKEND_FILE="" -UPDATES_PODKOP_APP_URL="" -UPDATES_PODKOP_APP_NAME="" -UPDATES_PODKOP_APP_FILE="" -UPDATES_PODKOP_I18N_URL="" -UPDATES_PODKOP_I18N_NAME="" -UPDATES_PODKOP_I18N_FILE="" -UPDATES_SING_BOX_EXTENDED_RELEASE_TAG="" -UPDATES_SING_BOX_EXTENDED_RELEASE_URL="" -UPDATES_SING_BOX_EXTENDED_ARCH_SUFFIX="" -UPDATES_SING_BOX_EXTENDED_ASSET_URL="" -UPDATES_SING_BOX_EXTENDED_ASSET_NAME="" -UPDATES_JOB_DIR="/var/run/podkop-plus/component-actions" -UPDATES_JOB_FINISHED_TTL_MINUTES=60 -UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES=60 -UPDATES_JOB_STALE_GRACE_SECONDS=15 -UPDATES_TMP_STALE_TTL_MINUTES=30 -UPDATES_TMP_FILE_STALE_TTL_MINUTES=10 -UPDATES_LOCK_DIR="/var/run/podkop-plus/component-action.lock" -UPDATES_LOCK_HELD=0 -UPDATES_DIRECT_JOB_ID="" -UPDATES_DIRECT_JOB_STATE_FILE="" -UPDATES_PODKOP_WAS_RUNNING=0 -UPDATES_PODKOP_STOPPED_FOR_SING_BOX_CHANGE=0 - -updates_log() { - local message="$1" - local level="${2:-info}" - - log "Updates: $message" "$level" -} - -updates_json_file_get_default() { - local json_file="$1" - local key="$2" - local fallback="$3" - - updates_ucode json-file-field "$json_file" "$key" "$fallback" 2>/dev/null -} - -updates_json_file_running_is() { - local json_file="$1" - local expected="$2" - - updates_ucode job-running-is "$json_file" "$expected" >/dev/null 2>&1 -} - -updates_init_tmp_dir() { - [ -n "$UPDATES_TMP_DIR" ] && return 0 - - updates_cleanup_stale_tmp_files - - UPDATES_TMP_DIR="$(mktemp -d /tmp/podkop-plus-updates.XXXXXX 2>/dev/null || true)" - if [ -z "$UPDATES_TMP_DIR" ]; then - UPDATES_TMP_DIR="/tmp/podkop-plus-updates.$$" - mkdir -p "$UPDATES_TMP_DIR" || return 1 - fi -} - -updates_cleanup_stale_tmp_files() { - local tmp_path - - find /tmp -maxdepth 1 -type d -name 'podkop-plus-updates.*' -mmin "+$UPDATES_TMP_STALE_TTL_MINUTES" 2>/dev/null | - while IFS= read -r tmp_path; do - [ -n "$tmp_path" ] || continue - [ "$tmp_path" = "$UPDATES_TMP_DIR" ] && continue - rm -rf "$tmp_path" 2>/dev/null || true - done - - find /tmp -maxdepth 1 -type f \( -name 'podkop-plus-updates-command.*' -o -name 'podkop-plus-updates-http.*' \) -mmin "+$UPDATES_TMP_FILE_STALE_TTL_MINUTES" -delete 2>/dev/null || true -} - -updates_cleanup() { - if [ -n "$UPDATES_TMP_DIR" ]; then - rm -rf "$UPDATES_TMP_DIR" - UPDATES_TMP_DIR="" - fi - updates_cleanup_stale_tmp_files -} - -updates_acquire_component_lock() { - local owner_pid - - mkdir -p /var/run/podkop-plus || return 1 - - if mkdir "$UPDATES_LOCK_DIR" 2>/dev/null; then - printf '%s\n' "$$" >"$UPDATES_LOCK_DIR/pid" - UPDATES_LOCK_HELD=1 - return 0 - fi - - owner_pid="$(updates_ucode file-first-line "$UPDATES_LOCK_DIR/pid" 2>/dev/null)" - if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null; then - return 1 - fi - - rm -f "$UPDATES_LOCK_DIR/pid" 2>/dev/null - rmdir "$UPDATES_LOCK_DIR" 2>/dev/null || return 1 - - mkdir "$UPDATES_LOCK_DIR" 2>/dev/null || return 1 - printf '%s\n' "$$" >"$UPDATES_LOCK_DIR/pid" - UPDATES_LOCK_HELD=1 -} - -updates_release_component_lock() { - [ "$UPDATES_LOCK_HELD" -eq 1 ] || return 0 - - rm -f "$UPDATES_LOCK_DIR/pid" 2>/dev/null - rmdir "$UPDATES_LOCK_DIR" 2>/dev/null - UPDATES_LOCK_HELD=0 -} - -updates_component_action_cleanup() { - updates_cleanup - updates_release_component_lock -} - -updates_json_response() { - local success="$1" - local component="$2" - local action="$3" - local message="$4" - local current_version="${5:-}" - local latest_version="${6:-}" - local changed="${7:-0}" - local status="${8:-}" - local release_url="${9:-}" - local response_file tmp_file updated_at exit_code - - if [ -n "$UPDATES_DIRECT_JOB_STATE_FILE" ]; then - response_file="$(updates_job_tmp_file "${UPDATES_DIRECT_JOB_STATE_FILE}.response" 2>/dev/null || true)" - if [ -n "$response_file" ] && - updates_ucode updates-json-response \ - "$success" \ - "$component" \ - "$action" \ - "$message" \ - "$current_version" \ - "$latest_version" \ - "$changed" \ - "$status" \ - "$release_url" >"$response_file"; then - cat "$response_file" - - updated_at="$(date +%s 2>/dev/null)" - case "$updated_at" in - "" | *[!0-9]*) updated_at=0 ;; - esac - case "$success" in - true | 1) exit_code=0 ;; - *) exit_code=1 ;; - esac - - tmp_file="$(updates_job_tmp_file "$UPDATES_DIRECT_JOB_STATE_FILE" 2>/dev/null || true)" - if [ -n "$tmp_file" ]; then - updates_ucode updates-finish-job-state "$response_file" "$exit_code" "$updated_at" >"$tmp_file" && - mv "$tmp_file" "$UPDATES_DIRECT_JOB_STATE_FILE" - rm -f "$tmp_file" 2>/dev/null - fi - - rm -f "$response_file" 2>/dev/null - return 0 - fi - - rm -f "$response_file" 2>/dev/null - fi - - updates_ucode updates-json-response \ - "$success" \ - "$component" \ - "$action" \ - "$message" \ - "$current_version" \ - "$latest_version" \ - "$changed" \ - "$status" \ - "$release_url" -} - -updates_success() { - updates_json_response true "$@" - updates_component_action_cleanup - exit 0 -} - -updates_restart_podkop_after_failed_sing_box_change() { - [ "$UPDATES_PODKOP_STOPPED_FOR_SING_BOX_CHANGE" -eq 1 ] || return 0 - [ "$UPDATES_PODKOP_WAS_RUNNING" = "1" ] || return 0 - [ -x "$PODKOP_SERVICE_INIT" ] || return 0 - - updates_log "Restarting Podkop Plus after failed sing-box component change" - "$PODKOP_SERVICE_INIT" start >/dev/null 2>&1 || - "$PODKOP_SERVICE_INIT" restart >/dev/null 2>&1 || - true -} - -updates_fail() { - local component="$1" - local action="$2" - local message="$3" - local current_version="${4:-}" - local latest_version="${5:-}" - local status="${6:-}" - local release_url="${7:-}" - - updates_log "$message" "error" - updates_restart_podkop_after_failed_sing_box_change - updates_json_response false "$component" "$action" "$message" "$current_version" "$latest_version" 0 "$status" "$release_url" - updates_component_action_cleanup - exit 1 -} - -updates_job_json_response() { - local success="$1" - local job_id="$2" - local message="${3:-}" - - updates_ucode updates-job-json-response "$success" "$job_id" "$message" -} - -updates_job_state_path() { - local job_id="$1" - - case "$job_id" in - *[!A-Za-z0-9._-]* | "" | "." | "..") - return 1 - ;; - esac - - printf '%s/%s.json\n' "$UPDATES_JOB_DIR" "$job_id" -} - -updates_job_tmp_file() { - local target_file="$1" - local tmp_file - - tmp_file="$(mktemp "${target_file}.XXXXXX" 2>/dev/null || true)" - if [ -z "$tmp_file" ]; then - tmp_file="${target_file}.$$.$(date +%s 2>/dev/null).tmp" - : >"$tmp_file" || return 1 - fi - - printf '%s\n' "$tmp_file" -} - -updates_cleanup_component_jobs() { - local output_file state_file - - [ -d "$UPDATES_JOB_DIR" ] || return 0 - - find "$UPDATES_JOB_DIR" -type f -name '*.out' 2>/dev/null | - while IFS= read -r output_file; do - [ -f "$output_file" ] || continue - state_file="${output_file%.out}.json" - - if [ -f "$state_file" ]; then - updates_refresh_running_job_state "$state_file" - if ! updates_json_file_running_is "$state_file" true; then - rm -f "$output_file" "$output_file.json" 2>/dev/null || true - fi - fi - done - - find "$UPDATES_JOB_DIR" -type f -name '*.out' -mmin "+$UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES" 2>/dev/null | - while IFS= read -r output_file; do - [ -f "$output_file" ] || continue - state_file="${output_file%.out}.json" - - if [ -f "$state_file" ]; then - updates_refresh_running_job_state "$state_file" - if updates_json_file_running_is "$state_file" true; then - continue - fi - fi - - rm -f "$output_file" "$output_file.json" 2>/dev/null || true - done - - find "$UPDATES_JOB_DIR" -type f -name '*.out.json' -mmin "+$UPDATES_JOB_ORPHAN_OUTPUT_TTL_MINUTES" -delete 2>/dev/null || true - find "$UPDATES_JOB_DIR" -type f -name '*.json.*' -mmin "+$UPDATES_TMP_FILE_STALE_TTL_MINUTES" -delete 2>/dev/null || true - - 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 updates_json_file_running_is "$state_file" false; then - rm -f "$state_file" 2>/dev/null || true - fi - done -} - -updates_write_running_job_state() { - local state_file="$1" - local component="$2" - local action="$3" - local pid="${4:-}" - local tmp_file started_at - - started_at="$(date +%s 2>/dev/null)" - case "$started_at" in - "" | *[!0-9]*) started_at=0 ;; - esac - - mkdir -p "$UPDATES_JOB_DIR" || return 1 - tmp_file="$(updates_job_tmp_file "$state_file")" || return 1 - - updates_ucode updates-running-job-state "$component" "$action" "$pid" "$started_at" >"$tmp_file" && mv "$tmp_file" "$state_file" - - local rc=$? - rm -f "$tmp_file" 2>/dev/null - return $rc -} - -updates_update_running_job_pid() { - local state_file="$1" - local pid="$2" - local tmp_file - - case "$pid" in - "" | *[!0-9]*) return 1 ;; - esac - - tmp_file="$(updates_job_tmp_file "$state_file")" || return 1 - updates_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 - return $rc -} - -updates_begin_direct_component_job_state() { - local component="$1" - local action="$2" - local state_file - - [ "${PODKOP_UI_COMPONENT_ACTION_TRACKED:-0}" = "1" ] && return 0 - - mkdir -p "$UPDATES_JOB_DIR" || return 0 - updates_cleanup_component_jobs - - UPDATES_DIRECT_JOB_ID="$(date +%s 2>/dev/null)-$$" - state_file="$(updates_job_state_path "$UPDATES_DIRECT_JOB_ID" 2>/dev/null || true)" - [ -n "$state_file" ] || return 0 - - updates_write_running_job_state "$state_file" "$component" "$action" "$$" || return 0 - UPDATES_DIRECT_JOB_STATE_FILE="$state_file" -} - -updates_mark_stale_job_state() { - local state_file="$1" - local tmp_file - - tmp_file="$(updates_job_tmp_file "$state_file")" || return 1 - updates_ucode updates-mark-stale-job-state "$state_file" >"$tmp_file" && mv "$tmp_file" "$state_file" - - local rc=$? - rm -f "$tmp_file" 2>/dev/null - return $rc -} - -updates_started_at_is_within_stale_grace() { - local started_at="$1" - local now age - - case "$started_at" in - "" | *[!0-9]*) return 1 ;; - esac - [ "$started_at" -gt 0 ] || return 1 - - now="$(date +%s 2>/dev/null)" - case "$now" in - "" | *[!0-9]*) return 1 ;; - esac - - age=$((now - started_at)) - [ "$age" -lt "$UPDATES_JOB_STALE_GRACE_SECONDS" ] -} - -updates_refresh_running_job_state() { - local state_file="$1" - local pid started_at - - updates_json_file_running_is "$state_file" true || return 0 - - 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_started_at_is_within_stale_grace "$started_at" && return 0 - updates_json_file_running_is "$state_file" true || return 0 - updates_mark_stale_job_state "$state_file" - return 0 - ;; - esac - - if kill -0 "$pid" 2>/dev/null; then - return 0 - fi - - updates_started_at_is_within_stale_grace "$started_at" && return 0 - updates_json_file_running_is "$state_file" true || return 0 - - updates_mark_stale_job_state "$state_file" -} - -updates_write_finished_job_state() { - local state_file="$1" - local component="$2" - local action="$3" - local exit_code="$4" - local output_file="$5" - local tmp_file json_file raw_output updated_at - - tmp_file="$(updates_job_tmp_file "$state_file")" || return 1 - json_file="$output_file.json" - updated_at="$(date +%s 2>/dev/null)" - case "$updated_at" in - "" | *[!0-9]*) updated_at=0 ;; - esac - - if updates_ucode file-json-valid "$output_file" >/dev/null 2>&1; then - updates_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 - - updates_ucode file-tail-json-object "$output_file" >"$json_file" - if [ -s "$json_file" ] && updates_ucode file-json-valid "$json_file" >/dev/null 2>&1; then - updates_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 - rm -f "$json_file" - - raw_output="$(updates_ucode file-flat-snippet "$output_file" 240 2>/dev/null)" - [ -n "$raw_output" ] || raw_output="Failed to execute" - - updates_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" -} - -component_action_async() { - local component="$1" - local action="$2" - local job_id state_file output_file job_pid - - mkdir -p "$UPDATES_JOB_DIR" || { - updates_job_json_response false "" "Failed to create component action state directory" - exit 1 - } - - updates_cleanup_component_jobs - job_id="$(date +%s 2>/dev/null)-$$" - state_file="$(updates_job_state_path "$job_id")" || { - updates_job_json_response false "" "Failed to prepare component action job" - exit 1 - } - output_file="$UPDATES_JOB_DIR/$job_id.out" - - updates_write_running_job_state "$state_file" "$component" "$action" || { - updates_job_json_response false "" "Failed to write component action state" - exit 1 - } - - ( - trap '' HUP - PODKOP_UI_COMPONENT_ACTION_TRACKED=1 /usr/bin/podkop-plus component_action "$component" "$action" >"$output_file" 2>&1 - updates_write_finished_job_state "$state_file" "$component" "$action" "$?" "$output_file" - ) >/dev/null 2>&1 & - job_pid="$!" - - updates_update_running_job_pid "$state_file" "$job_pid" || { - kill "$job_pid" 2>/dev/null || true - updates_job_json_response false "" "Failed to write component action worker pid" - exit 1 - } - - updates_job_json_response true "$job_id" "Component action started" -} - -component_action_status() { - local job_id="$1" - local state_file - - mkdir -p "$UPDATES_JOB_DIR" 2>/dev/null || true - updates_cleanup_component_jobs - - state_file="$(updates_job_state_path "$job_id")" || { - updates_json_response false "unknown" "status" "Invalid component action job id" "" "" 0 "" - exit 1 - } - - if [ ! -f "$state_file" ]; then - updates_json_response false "unknown" "status" "Component action job was not found" "" "" 0 "" - exit 1 - fi - - updates_refresh_running_job_state "$state_file" - - cat "$state_file" -} - -updates_command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -updates_is_apk() { - updates_command_exists apk -} - -updates_read_openwrt_release_value() { - local key="$1" - - [ -f /etc/openwrt_release ] || return 0 - updates_ucode openwrt-release-value /etc/openwrt_release "$key" -} - -updates_get_service_proxy_address() { - local service_proxy_address - - if ! command -v get_service_proxy_address >/dev/null 2>&1; then - return 0 - fi - - if command -v sing_box_service_is_running >/dev/null 2>&1 && ! sing_box_service_is_running; then - return 0 - fi - - service_proxy_address="$(get_service_proxy_address components 2>/dev/null || true)" - printf '%s' "$service_proxy_address" -} - -updates_http_get_once() { - local url="$1" - local output_path="$2" - local service_proxy_address="${3:-}" - - if updates_command_exists curl; then - if [ -n "$service_proxy_address" ]; then - curl --connect-timeout 5 -m 30 -fsSL -x "http://$service_proxy_address" "$url" -o "$output_path" - else - curl --connect-timeout 5 -m 30 -fsSL "$url" -o "$output_path" - fi - return $? - fi - - if updates_command_exists wget; then - if [ -n "$service_proxy_address" ]; then - http_proxy="http://$service_proxy_address" https_proxy="http://$service_proxy_address" \ - wget -T 30 -q -O "$output_path" "$url" - else - wget -T 30 -q -O "$output_path" "$url" - fi - return $? - fi - - return 1 -} - -updates_http_get() { - local url="$1" - local service_proxy_address output_path - - updates_init_tmp_dir >/dev/null 2>&1 || true - if [ -n "$UPDATES_TMP_DIR" ] && [ -d "$UPDATES_TMP_DIR" ]; then - output_path="$(mktemp "$UPDATES_TMP_DIR/http.XXXXXX" 2>/dev/null || true)" - fi - [ -n "$output_path" ] || output_path="$(mktemp /tmp/podkop-plus-updates-http.XXXXXX 2>/dev/null || true)" - [ -n "$output_path" ] || return 1 - - service_proxy_address="$(updates_get_service_proxy_address)" - if [ -n "$service_proxy_address" ]; then - if updates_http_get_once "$url" "$output_path" "$service_proxy_address"; then - cat "$output_path" - rm -f "$output_path" - return 0 - fi - - rm -f "$output_path" - updates_log "HTTP request via service proxy failed for $url; retrying directly" "warn" - fi - - if updates_http_get_once "$url" "$output_path" ""; then - cat "$output_path" - rm -f "$output_path" - return 0 - fi - - rm -f "$output_path" - return 1 -} - -updates_download_file_once_with_proxy() { - local url="$1" - local output_path="$2" - local service_proxy_address="${3:-}" - - if updates_command_exists curl; then - if [ -n "$service_proxy_address" ]; then - curl --connect-timeout 5 -m 120 -fsSL -x "http://$service_proxy_address" "$url" -o "$output_path" - else - curl --connect-timeout 5 -m 120 -fsSL "$url" -o "$output_path" - fi - return $? - fi - - if updates_command_exists wget; then - if [ -n "$service_proxy_address" ]; then - http_proxy="http://$service_proxy_address" https_proxy="http://$service_proxy_address" \ - wget -T 120 -q -O "$output_path" "$url" - else - wget -T 120 -q -O "$output_path" "$url" - fi - return $? - fi - - return 1 -} - -updates_download_file_once() { - local url="$1" - local output_path="$2" - local service_proxy_address - - service_proxy_address="$(updates_get_service_proxy_address)" - if [ -n "$service_proxy_address" ]; then - if updates_download_file_once_with_proxy "$url" "$output_path" "$service_proxy_address"; then - return 0 - fi - - rm -f "$output_path" - updates_log "Download via service proxy failed for $url; retrying directly" "warn" - fi - - updates_download_file_once_with_proxy "$url" "$output_path" "" -} - -updates_download_with_retry() { - local url="$1" - local output_path="$2" - local label="$3" - local attempt=1 - local max_attempts=3 - - while [ "$attempt" -le "$max_attempts" ]; do - updates_log "Downloading $label ($attempt/$max_attempts)" - - if updates_download_file_once "$url" "$output_path" && [ -s "$output_path" ]; then - return 0 - fi - - rm -f "$output_path" - updates_log "Retrying $label" "warn" - attempt=$((attempt + 1)) - done - - return 1 -} - -updates_log_command() { - local description="$1" - local status output_file line - shift - - updates_init_tmp_dir >/dev/null 2>&1 || true - if [ -n "$UPDATES_TMP_DIR" ] && [ -d "$UPDATES_TMP_DIR" ]; then - output_file="$(mktemp "$UPDATES_TMP_DIR/command.XXXXXX" 2>/dev/null || true)" - fi - [ -n "$output_file" ] || output_file="$(mktemp /tmp/podkop-plus-updates-command.XXXXXX 2>/dev/null || true)" - [ -n "$output_file" ] || output_file="/tmp/podkop-plus-updates-command.$$" - - updates_log "$description" - "$@" >"$output_file" 2>&1 - status=$? - - while IFS= read -r line; do - [ -n "$line" ] && updates_log "$line" - done <"$output_file" - - rm -f "$output_file" - [ "$status" -eq 0 ] || updates_log "$description failed with exit code $status" "warn" - return "$status" -} - -updates_pkg_is_installed() { - local package_name="$1" - - if updates_is_apk; then - apk info -e "$package_name" >/dev/null 2>&1 - return $? - fi - - opkg_package_is_installed "$package_name" -} - -updates_get_installed_package_version() { - local package_name="$1" - - if updates_is_apk; then - apk info -e "$package_name" >/dev/null 2>&1 || return 0 - get_apk_installed_package_version "$package_name" - return 0 - fi - - get_opkg_installed_package_version "$package_name" -} - -updates_get_available_package_version() { - local package_name="$1" - - if updates_is_apk; then - apk policy "$package_name" 2>/dev/null | updates_ucode updates-apk-policy-version - return 0 - fi - - updates_opkg_package_version_from_list "$package_name" opkg list "$package_name" -} - -updates_pkg_list_update() { - if updates_is_apk; then - apk update /dev/null || true)" in - ">") echo 1 && return 0 ;; - "<") echo -1 && return 0 ;; - "=") echo 0 && return 0 ;; - esac - fi - - if updates_command_exists opkg; then - if opkg compare-versions "$lhs" ">" "$rhs" >/dev/null 2>&1; then - echo 1 - return 0 - fi - if opkg compare-versions "$lhs" "<" "$rhs" >/dev/null 2>&1; then - echo -1 - return 0 - fi - if opkg compare-versions "$lhs" "=" "$rhs" >/dev/null 2>&1; then - echo 0 - return 0 - fi - fi - - if helpers_ucode version-at-least "$lhs" "$rhs" >/dev/null 2>&1; then - echo 1 - else - echo -1 - fi -} - -updates_status_from_compare() { - local compare_result="$1" - - case "$compare_result" in - -1) printf '%s\n' "outdated" ;; - 0) printf '%s\n' "latest" ;; - 1) printf '%s\n' "dev" ;; - *) return 1 ;; - esac -} - -updates_check_success() { - local component="$1" - local current_version="$2" - local latest_version="$3" - local release_url="${4:-}" - - updates_check_success_compared "$component" "$current_version" "$latest_version" "$current_version" "$latest_version" "$release_url" -} - -updates_check_success_compared() { - local component="$1" - local current_version="$2" - local latest_version="$3" - local compare_current_version="$4" - local compare_latest_version="$5" - local release_url="${6:-}" - local compare_result status message - - compare_result="$(updates_compare_versions "$compare_current_version" "$compare_latest_version" 2>/dev/null || true)" - [ -n "$compare_result" ] || updates_fail "$component" "check_update" "Failed to compare versions" "$current_version" "$latest_version" - - status="$(updates_status_from_compare "$compare_result")" || updates_fail "$component" "check_update" "Failed to compare versions" "$current_version" "$latest_version" - - case "$status" in - latest) - message="Latest version is installed" - updates_log "$component is up to date ($current_version)" - ;; - outdated) - message="Update is available" - updates_log "$component update is available: $current_version -> $latest_version" - ;; - dev) - message="Installed version is newer than release" - updates_log "$component installed version is newer than upstream release: $current_version -> $latest_version" - ;; - esac - - updates_success "$component" "check_update" "$message" "$current_version" "$latest_version" 0 "$status" "$release_url" -} - -updates_fetch_podkop_latest_release_metadata() { - local release_json metadata - - release_json="$(fetch_latest_podkop_release_json)" || return 1 - metadata="$(printf '%s' "$release_json" | updates_ucode release-metadata-tsv 2>/dev/null)" - [ -n "$metadata" ] || return 1 - - printf '%s\n' "$metadata" -} - -updates_ensure_package_tool() { - local tool_name="$1" - local package_name="$2" - - if updates_command_exists "$tool_name"; then - return 0 - fi - - updates_log_command "Updating package lists before installing $package_name" updates_pkg_list_update || return 1 - updates_log_command "Installing bootstrap package $package_name" updates_pkg_install_name "$package_name" -} - -updates_retry_resolve() { - local description="$1" - local command_name="$2" - local attempt=1 - local max_attempts=3 - - while [ "$attempt" -le "$max_attempts" ]; do - if "$command_name"; then - return 0 - fi - - updates_log "$description failed ($attempt/$max_attempts)" "warn" - attempt=$((attempt + 1)) - sleep 2 - done - - return 1 -} - -updates_clear_version_caches() { - rm -f /tmp/podkop-plus.latest-version.cache - rm -f "$PODKOP_SYSTEM_INFO_CACHE_FILE" - rm -f /tmp/podkop-plus/system-info.json -} - -updates_sing_box_managed_service_installed() { - [ -r /etc/init.d/sing-box ] || return 1 - grep -q "${SB_MANAGED_SERVICE_MARKER:-Podkop Plus managed sing-box service for binary variants}" /etc/init.d/sing-box 2>/dev/null -} - -updates_install_managed_sing_box_service_script() { - local tmp_file - - tmp_file="/etc/init.d/sing-box.podkop-plus.$$" - cat >"$tmp_file" <<'EOF' -#!/bin/sh /etc/rc.common -# Podkop Plus managed sing-box service for binary variants - -USE_PROCD=1 -START=99 -PROG="/usr/bin/sing-box" - -start_service() { - config_load "sing-box" - local enabled config_file working_directory - local log_stderr - - config_get_bool enabled "main" "enabled" "0" - [ "$enabled" -eq "1" ] || return 0 - - config_get config_file "main" "conffile" "/etc/sing-box/config.json" - config_get working_directory "main" "workdir" "/usr/share/sing-box" - config_get_bool log_stderr "main" "log_stderr" "1" - - procd_open_instance - procd_set_param command "$PROG" run -c "$config_file" -D "$working_directory" - procd_set_param file "$config_file" - procd_set_param stderr "$log_stderr" - procd_set_param limits core="unlimited" - procd_set_param limits nofile="1000000 1000000" - procd_set_param respawn - procd_close_instance -} - -service_triggers() { - procd_add_reload_trigger "sing-box" -} -EOF - - chmod 0755 "$tmp_file" && - mv -f "$tmp_file" /etc/init.d/sing-box -} - -updates_remove_managed_sing_box_service_script() { - updates_sing_box_managed_service_installed || return 0 - - /etc/init.d/sing-box stop >/dev/null 2>&1 || true - /etc/init.d/sing-box disable >/dev/null 2>&1 || true - rm -f /etc/init.d/sing-box -} - -updates_disable_sing_box_service_config() { - updates_command_exists uci || return 0 - - uci -q get sing-box.main >/dev/null 2>&1 || - uci -q set sing-box.main=sing-box >/dev/null 2>&1 || true - uci -q set sing-box.main.enabled='0' >/dev/null 2>&1 || true - uci -q commit sing-box >/dev/null 2>&1 || true -} - -updates_prepare_sing_box_service_disabled() { - updates_disable_sing_box_service_config - [ -x /etc/init.d/sing-box ] && /etc/init.d/sing-box stop >/dev/null 2>&1 || true - [ -x /etc/init.d/sing-box ] && /etc/init.d/sing-box disable >/dev/null 2>&1 || true -} - -updates_prepare_sing_box_package_service_install() { - updates_prepare_sing_box_service_disabled - updates_remove_managed_sing_box_service_script -} - -updates_capture_podkop_running_state() { - UPDATES_PODKOP_WAS_RUNNING=0 - - [ -x "$PODKOP_SERVICE_INIT" ] || return 0 - - if "$PODKOP_SERVICE_INIT" status >/dev/null 2>&1; then - UPDATES_PODKOP_WAS_RUNNING=1 - fi -} - -updates_restart_podkop_after_successful_change() { - [ -x "$PODKOP_SERVICE_INIT" ] || return 0 - - if [ "$UPDATES_PODKOP_WAS_RUNNING" != "1" ]; then - updates_log "Podkop Plus was not running before component change; restart skipped" - updates_prepare_sing_box_service_disabled - return 0 - fi - - updates_log_command "Restarting Podkop Plus after successful component change" "$PODKOP_SERVICE_INIT" restart || true -} - -updates_stop_podkop_before_sing_box_change() { - [ "$UPDATES_PODKOP_STOPPED_FOR_SING_BOX_CHANGE" -eq 0 ] || return 0 - UPDATES_PODKOP_STOPPED_FOR_SING_BOX_CHANGE=1 - - if [ "$UPDATES_PODKOP_WAS_RUNNING" = "1" ] && [ -x "$PODKOP_SERVICE_INIT" ]; then - updates_log_command "Stopping Podkop Plus before sing-box package change" "$PODKOP_SERVICE_INIT" stop || true - fi - - if [ "$UPDATES_PODKOP_WAS_RUNNING" = "1" ] && [ -x "$PODKOP_BIN" ]; then - "$PODKOP_BIN" restore_dnsmasq >/dev/null 2>&1 || true - fi - - updates_prepare_sing_box_service_disabled -} - -updates_podkop_status_running_with_timeout() { - local output_file pid watcher rc - - updates_init_tmp_dir || return 1 - output_file="$UPDATES_TMP_DIR/podkop-status.$$" - rm -f "$output_file" - - "$PODKOP_BIN" get_status >"$output_file" 2>/dev/null & - pid="$!" - ( - sleep 6 - kill "$pid" 2>/dev/null || true - ) & - watcher="$!" - - wait "$pid" 2>/dev/null - rc="$?" - kill "$watcher" 2>/dev/null || true - wait "$watcher" 2>/dev/null || true - - [ "$rc" -eq 0 ] || return 1 - grep -Eq '"running"[[:space:]]*:[[:space:]]*1' "$output_file" -} - -updates_wait_podkop_running_after_sing_box_change() { - local waited=0 - - [ "$UPDATES_PODKOP_WAS_RUNNING" = "1" ] || return 0 - [ -x "$PODKOP_BIN" ] || return 1 - - while [ "$waited" -lt 60 ]; do - if updates_podkop_status_running_with_timeout; then - sleep 8 - updates_podkop_status_running_with_timeout && return 0 - fi - - sleep 4 - waited=$((waited + 4)) - done - - return 1 -} - -updates_resolve_arch_candidates() { - local arch_list apk_arch_list release_arch resolved old_ifs - - UPDATES_TARGET_ARCH="" - UPDATES_ARCH_CANDIDATES="" - - if updates_is_apk; then - if [ -f /etc/apk/arch ]; then - apk_arch_list="$(updates_ucode file-whitespace-list /etc/apk/arch 2>/dev/null || true)" - [ -n "$apk_arch_list" ] && arch_list="$arch_list $apk_arch_list" - fi - - apk_arch_list="$(apk --print-arch 2>/dev/null || true)" - [ -n "$apk_arch_list" ] && arch_list="$arch_list $apk_arch_list" - else - arch_list="$(updates_opkg_arch_list)" - fi - - release_arch="$(updates_read_openwrt_release_value "DISTRIB_ARCH")" - [ -n "$release_arch" ] && arch_list="$arch_list $release_arch" - - if ! updates_ucode string-has-whitespace-field "$arch_list" >/dev/null 2>&1; then - arch_list="$(uname -m 2>/dev/null || true)" - fi - - resolved="$(updates_ucode updates-arch-candidates "$arch_list" 2>/dev/null)" || return 1 - old_ifs="$IFS" - IFS="$(printf '\t')" read -r UPDATES_TARGET_ARCH UPDATES_ARCH_CANDIDATES </dev/null || true)" - [ -n "$response" ] || return 1 - printf '%s' "$response" | updates_ucode github-response-ok >/dev/null 2>&1 || return 1 - - printf '%s' "$response" -} - -updates_fetch_github_releases_json() { - local owner="$1" - local repo="$2" - local per_page="${3:-30}" - 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" | updates_ucode github-response-ok >/dev/null 2>&1 || return 1 - - printf '%s' "$response" -} - -updates_extract_arch_package_version() { - local package_name="$1" - local package_arch="$2" - - updates_ucode updates-arch-package-version "$package_name" "$package_arch" -} - -updates_select_inner_package_path() { - local bundle_file="$1" - local component="$2" - local arch="$3" - local ext="$4" - - unzip -l "$bundle_file" | updates_ucode updates-zip-inner-package-path "$component" "$arch" "$ext" -} - -updates_select_archive_member_path() { - local archive_file="$1" - local member_name="$2" - - tar -tzf "$archive_file" 2>/dev/null | updates_ucode updates-archive-member-path "$member_name" -} - -updates_opkg_arch_list() { - opkg print-architecture 2>/dev/null | updates_ucode updates-opkg-arch-list -} - -updates_opkg_package_version_from_list() { - local package_name="$1" - shift - - "$@" 2>/dev/null | updates_ucode updates-opkg-package-version "$package_name" -} - -updates_select_release_asset_name() { - local release_json="$1" - local package_prefix="$2" - local asset_ext="$3" - - printf '%s' "$release_json" | updates_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" | updates_ucode release-asset-url "$asset_name" -} - -updates_resolve_podkop_plus_release() { - local latest_version="$1" - local owner repo asset_ext release_json release_tag - - UPDATES_PODKOP_BACKEND_URL="" - UPDATES_PODKOP_RELEASE_URL="" - UPDATES_PODKOP_BACKEND_NAME="" - UPDATES_PODKOP_APP_URL="" - UPDATES_PODKOP_APP_NAME="" - UPDATES_PODKOP_I18N_URL="" - UPDATES_PODKOP_I18N_NAME="" - - owner="${PODKOP_RELEASE_REPO%%/*}" - repo="${PODKOP_RELEASE_REPO#*/}" - [ -n "$owner" ] || return 1 - [ -n "$repo" ] || return 1 - [ "$owner" != "$repo" ] || return 1 - - asset_ext="ipk" - updates_is_apk && asset_ext="apk" - - release_json="$(updates_fetch_github_release_json "$owner" "$repo")" || return 1 - [ -n "$release_json" ] || return 1 - release_tag="$(printf '%s' "$release_json" | updates_ucode object-get-default tag_name "" 2>/dev/null)" - [ "$release_tag" = "$latest_version" ] || return 1 - UPDATES_PODKOP_RELEASE_URL="$(printf '%s' "$release_json" | updates_ucode object-get-default html_url "" 2>/dev/null)" - - UPDATES_PODKOP_BACKEND_NAME="$(updates_select_release_asset_name "$release_json" "podkop-plus" "$asset_ext")" - UPDATES_PODKOP_APP_NAME="$(updates_select_release_asset_name "$release_json" "luci-app-podkop-plus" "$asset_ext")" - [ -n "$UPDATES_PODKOP_BACKEND_NAME" ] || return 1 - [ -n "$UPDATES_PODKOP_APP_NAME" ] || return 1 - - UPDATES_PODKOP_BACKEND_URL="$(updates_select_release_asset_url "$release_json" "$UPDATES_PODKOP_BACKEND_NAME")" - UPDATES_PODKOP_APP_URL="$(updates_select_release_asset_url "$release_json" "$UPDATES_PODKOP_APP_NAME")" - [ -n "$UPDATES_PODKOP_BACKEND_URL" ] || return 1 - [ -n "$UPDATES_PODKOP_APP_URL" ] || return 1 - - if updates_pkg_is_installed "luci-i18n-podkop-plus-ru"; then - UPDATES_PODKOP_I18N_NAME="$(updates_select_release_asset_name "$release_json" "luci-i18n-podkop-plus-ru" "$asset_ext")" - [ -n "$UPDATES_PODKOP_I18N_NAME" ] || return 1 - UPDATES_PODKOP_I18N_URL="$(updates_select_release_asset_url "$release_json" "$UPDATES_PODKOP_I18N_NAME")" - [ -n "$UPDATES_PODKOP_I18N_URL" ] || return 1 - fi -} - -updates_download_podkop_plus_packages() { - UPDATES_PODKOP_BACKEND_FILE="$UPDATES_TMP_DIR/$UPDATES_PODKOP_BACKEND_NAME" - UPDATES_PODKOP_APP_FILE="$UPDATES_TMP_DIR/$UPDATES_PODKOP_APP_NAME" - UPDATES_PODKOP_I18N_FILE="" - - updates_download_with_retry "$UPDATES_PODKOP_BACKEND_URL" "$UPDATES_PODKOP_BACKEND_FILE" "$UPDATES_PODKOP_BACKEND_NAME" || return 1 - updates_download_with_retry "$UPDATES_PODKOP_APP_URL" "$UPDATES_PODKOP_APP_FILE" "$UPDATES_PODKOP_APP_NAME" || return 1 - - if [ -n "$UPDATES_PODKOP_I18N_URL" ]; then - UPDATES_PODKOP_I18N_FILE="$UPDATES_TMP_DIR/$UPDATES_PODKOP_I18N_NAME" - updates_download_with_retry "$UPDATES_PODKOP_I18N_URL" "$UPDATES_PODKOP_I18N_FILE" "$UPDATES_PODKOP_I18N_NAME" || return 1 - fi -} - -updates_refresh_luci_after_app_update() { - rm -f /var/luci-indexcache* /tmp/luci-indexcache* 2>/dev/null || true - rm -rf /tmp/luci-modulecache/ 2>/dev/null || true - [ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd reload >/dev/null 2>&1 && return 0 - [ -x /etc/init.d/rpcd ] && /etc/init.d/rpcd restart >/dev/null 2>&1 && return 0 - killall -HUP rpcd 2>/dev/null || true -} - -updates_get_openwrt_release_series() { - updates_ucode openwrt-release-series /etc/openwrt_release -} - -updates_resolve_zapret_release() { - local release_json candidate_name arch url - - UPDATES_ZAPRET_ARCH="" - UPDATES_ZAPRET_BUNDLE_URL="" - UPDATES_ZAPRET_BUNDLE_NAME="" - UPDATES_ZAPRET_PACKAGE_VERSION="" - UPDATES_ZAPRET_RELEASE_URL="" - - release_json="$(updates_fetch_github_release_json "remittor" "zapret-openwrt")" || return 1 - UPDATES_ZAPRET_RELEASE_URL="$(printf '%s' "$release_json" | updates_ucode object-get-default html_url "" 2>/dev/null)" - - for arch in $UPDATES_ARCH_CANDIDATES; do - candidate_name="$(printf '%s' "$release_json" | updates_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" - break - fi - done - - [ -n "$UPDATES_ZAPRET_BUNDLE_NAME" ] || return 1 - - url="$(printf '%s' "$release_json" | updates_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")" - [ -n "$UPDATES_ZAPRET_PACKAGE_VERSION" ] || UPDATES_ZAPRET_PACKAGE_VERSION="$(updates_ucode string-remove-suffix "$UPDATES_ZAPRET_BUNDLE_NAME" ".zip")" -} - -updates_download_and_extract_zapret_package() { - local bundle_file inner_package_path - - UPDATES_ZAPRET_PACKAGE_FILE="" - UPDATES_ZAPRET_PACKAGE_NAME="" - UPDATES_ZAPRET_PACKAGE_VERSION="" - - bundle_file="$UPDATES_TMP_DIR/$UPDATES_ZAPRET_BUNDLE_NAME" - updates_download_with_retry "$UPDATES_ZAPRET_BUNDLE_URL" "$bundle_file" "$UPDATES_ZAPRET_BUNDLE_NAME" || return 1 - - if updates_is_apk; then - inner_package_path="$(updates_select_inner_package_path "$bundle_file" "zapret" "" "apk")" - else - inner_package_path="$(updates_select_inner_package_path "$bundle_file" "zapret" "$UPDATES_ZAPRET_ARCH" "ipk")" - fi - - [ -n "$inner_package_path" ] || return 1 - - UPDATES_ZAPRET_PACKAGE_NAME="$(basename "$inner_package_path")" - UPDATES_ZAPRET_PACKAGE_FILE="$UPDATES_TMP_DIR/$UPDATES_ZAPRET_PACKAGE_NAME" - - unzip -p "$bundle_file" "$inner_package_path" >"$UPDATES_ZAPRET_PACKAGE_FILE" || return 1 - [ -s "$UPDATES_ZAPRET_PACKAGE_FILE" ] || return 1 - - [ -n "$UPDATES_ZAPRET_PACKAGE_VERSION" ] || UPDATES_ZAPRET_PACKAGE_VERSION="$(updates_extract_zapret_bundle_version "$UPDATES_ZAPRET_BUNDLE_NAME")" - [ -n "$UPDATES_ZAPRET_PACKAGE_VERSION" ] || UPDATES_ZAPRET_PACKAGE_VERSION="$(updates_extract_arch_package_version "$UPDATES_ZAPRET_PACKAGE_NAME" "$UPDATES_ZAPRET_ARCH")" -} - -updates_resolve_zapret2_release() { - local releases_json resolved resolved_arch resolved_name resolved_url resolved_release_url resolved_tag - - UPDATES_ZAPRET2_ARCH="" - UPDATES_ZAPRET2_BUNDLE_URL="" - UPDATES_ZAPRET2_BUNDLE_NAME="" - UPDATES_ZAPRET2_PACKAGE_VERSION="" - UPDATES_ZAPRET2_RELEASE_URL="" - - releases_json="$(updates_fetch_github_releases_json "remittor" "zapret-openwrt" 30)" || return 1 - - resolved="$( - printf '%s' "$releases_json" | - updates_ucode named-release-select-asset "zapret2 " "zapret2" "zip" "$UPDATES_ARCH_CANDIDATES" 2>/dev/null - )" - [ -n "$resolved" ] || return 1 - - IFS="$(printf '\t')" read -r resolved_arch resolved_name resolved_url resolved_release_url resolved_tag <"$UPDATES_ZAPRET2_PACKAGE_FILE" || return 1 - [ -s "$UPDATES_ZAPRET2_PACKAGE_FILE" ] || return 1 - - [ -n "$UPDATES_ZAPRET2_PACKAGE_VERSION" ] || UPDATES_ZAPRET2_PACKAGE_VERSION="$(updates_extract_zapret2_bundle_version "$UPDATES_ZAPRET2_BUNDLE_NAME")" - [ -n "$UPDATES_ZAPRET2_PACKAGE_VERSION" ] || UPDATES_ZAPRET2_PACKAGE_VERSION="$(updates_extract_arch_package_version "$UPDATES_ZAPRET2_PACKAGE_NAME" "$UPDATES_ZAPRET2_ARCH")" -} - -updates_resolve_byedpi_release() { - local response release_series asset_ext resolved resolved_arch resolved_name resolved_url resolved_release_url - - UPDATES_BYEDPI_ARCH="" - UPDATES_BYEDPI_PACKAGE_URL="" - UPDATES_BYEDPI_PACKAGE_NAME="" - UPDATES_BYEDPI_PACKAGE_VERSION="" - UPDATES_BYEDPI_RELEASE_URL="" - - asset_ext="ipk" - updates_is_apk && asset_ext="apk" - release_series="$(updates_get_openwrt_release_series)" - - response="$(updates_fetch_github_releases_json "DPITrickster" "ByeDPI-OpenWrt" 30)" || return 1 - - resolved="$(printf '%s' "$response" | updates_ucode byedpi-select-asset "$release_series" "$asset_ext" "$UPDATES_ARCH_CANDIDATES" 2>/dev/null)" - - [ -n "$resolved" ] || return 1 - - IFS="$(printf '\t')" read -r resolved_arch resolved_name resolved_url resolved_release_url </dev/null -} - -updates_select_sing_box_extended_package_asset_url() { - local release_json="$1" - local distrib_arch asset_ext - - distrib_arch="$(updates_read_openwrt_release_value "DISTRIB_ARCH")" - [ -n "$distrib_arch" ] || return 1 - - asset_ext="ipk" - updates_is_apk && asset_ext="apk" - - printf '%s' "$release_json" | updates_ucode sing-box-extended-package-asset-url "$distrib_arch" "$asset_ext" 2>/dev/null -} - -updates_write_sing_box_variant_marker() { - local variant="$1" - local marker="${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" - local marker_dir - - marker_dir="$(dirname "$marker")" - mkdir -p "$marker_dir" || return 1 - printf '%s\n' "$variant" >"$marker" -} - -updates_read_sing_box_variant_marker() { - local marker="${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" - - [ -r "$marker" ] || return 1 - sed -n '1p' "$marker" 2>/dev/null -} - -updates_write_sing_box_version_state() { - local version="$1" - local state_file="${SB_VERSION_STATE_FILE:-/etc/podkop-plus/sing-box-version}" - local state_dir - - [ -n "$version" ] || return 1 - state_dir="$(dirname "$state_file")" - mkdir -p "$state_dir" || return 1 - printf '%s\n' "$version" >"$state_file" -} - -updates_read_sing_box_version_state() { - read_sing_box_version_state -} - -updates_clear_sing_box_version_state() { - rm -f "${SB_VERSION_STATE_FILE:-/etc/podkop-plus/sing-box-version}" 2>/dev/null || true -} - -updates_restore_sing_box_version_state() { - local previous_version="$1" - - if [ -n "$previous_version" ]; then - updates_write_sing_box_version_state "$previous_version" - else - updates_clear_sing_box_version_state - fi -} - -updates_restore_sing_box_variant_marker() { - local previous_marker="$1" - - if [ -n "$previous_marker" ]; then - updates_write_sing_box_variant_marker "$previous_marker" - else - updates_clear_sing_box_variant_marker - fi -} - -updates_clear_sing_box_variant_marker() { - rm -f "${SB_VARIANT_STATE_FILE:-/etc/podkop-plus/sing-box-variant}" 2>/dev/null || true -} - -updates_read_sing_box_binary_version() { - local binary="$1" - local library_dir="${2:-}" - - [ -n "$binary" ] || return 1 - [ -x "$binary" ] || return 1 - - if [ -n "$library_dir" ]; then - LD_LIBRARY_PATH="$library_dir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$binary" version 2>/dev/null | - helpers_ucode stdin-first-line-last-field - return $? - fi - - "$binary" version 2>/dev/null | helpers_ucode stdin-first-line-last-field -} - -updates_validate_sing_box_extended_binary() { - local binary="$1" - local library_dir="${2:-}" - local version - - version="$(updates_read_sing_box_binary_version "$binary" "$library_dir")" - case "$version" in - *extended*) - printf '%s\n' "$version" - return 0 - ;; - esac - - return 1 -} - -updates_restore_sing_box_backup() { - local backup_binary="$1" - - if [ -n "$backup_binary" ] && [ -s "$backup_binary" ]; then - mv -f "$backup_binary" /usr/bin/sing-box && chmod 0755 /usr/bin/sing-box - return $? - fi - - rm -f /usr/bin/sing-box -} - -updates_restore_file_backup() { - local target_path="$1" - local backup_path="$2" - - if [ -n "$backup_path" ] && [ -s "$backup_path" ]; then - mv -f "$backup_path" "$target_path" - return $? - fi - - rm -f "$target_path" -} - -updates_move_file_to_backup() { - local target_path="$1" - local backup_path="$2" - - [ -e "$target_path" ] || return 0 - rm -f "$backup_path" - mv -f "$target_path" "$backup_path" -} - -updates_restore_sing_box_service_from_marker() { - local marker="$1" - - if [ "$marker" = "extended-compressed" ]; then - updates_install_managed_sing_box_service_script >/dev/null 2>&1 || return 1 - return 0 - fi - - updates_remove_managed_sing_box_service_script >/dev/null 2>&1 || true -} - -updates_restore_sing_box_extended_package_variant() { - local package_file new_version - - updates_init_tmp_dir || return 1 - updates_resolve_sing_box_extended_release 0 || return 1 - package_file="$UPDATES_TMP_DIR/$UPDATES_SING_BOX_EXTENDED_ASSET_NAME" - - updates_download_with_retry "$UPDATES_SING_BOX_EXTENDED_ASSET_URL" "$package_file" "$UPDATES_SING_BOX_EXTENDED_ASSET_NAME" || return 1 - updates_prepare_sing_box_package_service_install - updates_pkg_remove_sing_box_conflict "sing-box-tiny" >/dev/null 2>&1 || true - updates_pkg_remove_sing_box_conflict "sing-box" >/dev/null 2>&1 || true - updates_pkg_install_files "$package_file" || { - rm -f "$package_file" - return 1 - } - rm -f "$package_file" - - new_version="$(updates_validate_sing_box_extended_binary /usr/bin/sing-box /usr/lib)" || return 1 - updates_write_sing_box_variant_marker "extended" >/dev/null 2>&1 || true - updates_write_sing_box_version_state "$new_version" >/dev/null 2>&1 || true -} - -updates_restore_sing_box_package_variant() { - local previous_variant="$1" - - case "$previous_variant" in - tiny) - updates_replace_sing_box_package_variant "sing-box-tiny" "sing-box" - ;; - stable) - updates_replace_sing_box_package_variant "sing-box" "sing-box-tiny" - ;; - extended) - updates_restore_sing_box_extended_package_variant - ;; - not-installed) - updates_pkg_remove_sing_box_conflict "sing-box-extended" >/dev/null 2>&1 || true - updates_pkg_remove_sing_box_conflict "sing-box-tiny" >/dev/null 2>&1 || true - updates_pkg_remove_sing_box_conflict "sing-box" >/dev/null 2>&1 || true - updates_remove_managed_sing_box_service_script >/dev/null 2>&1 || true - rm -f /usr/bin/sing-box - ;; - *) - return 1 - ;; - esac -} - -updates_sing_box_variant_is_package_managed() { - case "$1" in - stable | tiny | extended) - return 0 - ;; - esac - - return 1 -} - -updates_restore_sing_box_install_backup() { - local previous_variant="$1" - local backup_binary="$2" - - if updates_sing_box_variant_is_package_managed "$previous_variant"; then - if updates_restore_sing_box_package_variant "$previous_variant"; then - return 0 - fi - - [ -n "$backup_binary" ] && updates_restore_sing_box_backup "$backup_binary" >/dev/null 2>&1 || true - return 1 - fi - - if [ -n "$backup_binary" ]; then - updates_restore_sing_box_backup "$backup_binary" - return $? - fi - - updates_restore_sing_box_package_variant "$previous_variant" -} - -updates_restore_sing_box_after_failed_extended_install() { - local previous_variant="$1" - local backup_binary="$2" - local backup_cronet="$3" - local previous_marker="$4" - local previous_version_state="$5" - local archive_file="${6:-}" - local cronet_touched="${7:-0}" - local restore_status - - [ -n "$archive_file" ] && rm -f "$archive_file" - restore_status=0 - - if [ "$cronet_touched" -eq 1 ]; then - updates_restore_file_backup /usr/lib/libcronet.so "$backup_cronet" >/dev/null 2>&1 || true - fi - - updates_restore_sing_box_install_backup "$previous_variant" "$backup_binary" || restore_status=1 - - updates_restore_sing_box_variant_marker "$previous_marker" >/dev/null 2>&1 || true - updates_restore_sing_box_version_state "$previous_version_state" >/dev/null 2>&1 || true - updates_restore_sing_box_service_from_marker "$previous_marker" >/dev/null 2>&1 || true - updates_clear_version_caches - return "$restore_status" -} - -updates_restore_sing_box_after_failed_extended_package_install() { - local previous_variant="$1" - local backup_binary="$2" - local backup_cronet="$3" - local previous_marker="$4" - local previous_version_state="$5" - local package_file="${6:-}" - local cronet_touched="${7:-0}" - local restore_status - - [ -n "$package_file" ] && rm -f "$package_file" - updates_pkg_remove_sing_box_conflict "sing-box-extended" >/dev/null 2>&1 || true - restore_status=0 - - updates_restore_sing_box_install_backup "$previous_variant" "$backup_binary" >/dev/null 2>&1 || restore_status=1 - - if [ "$cronet_touched" -eq 1 ]; then - updates_restore_file_backup /usr/lib/libcronet.so "$backup_cronet" >/dev/null 2>&1 || true - [ -s /usr/lib/libcronet.so ] && chmod 0644 /usr/lib/libcronet.so >/dev/null 2>&1 || true - fi - - updates_restore_sing_box_variant_marker "$previous_marker" >/dev/null 2>&1 || true - updates_restore_sing_box_version_state "$previous_version_state" >/dev/null 2>&1 || true - updates_restore_sing_box_service_from_marker "$previous_marker" >/dev/null 2>&1 || true - updates_clear_version_caches - return "$restore_status" -} - -updates_extract_zapret_bundle_version() { - updates_ucode updates-zapret-bundle-version "$1" -} - -updates_extract_zapret2_bundle_version() { - updates_ucode updates-zapret2-bundle-version "$1" -} - -updates_normalize_zapret_version() { - updates_ucode updates-normalize-zapret-version "$1" -} - -updates_resolve_sing_box_extended_arch_suffix() { - local host_arch distrib_arch - - host_arch="$(uname -m 2>/dev/null || true)" - distrib_arch="$(updates_read_openwrt_release_value "DISTRIB_ARCH")" - - UPDATES_SING_BOX_EXTENDED_ARCH_SUFFIX="$(updates_ucode sing-box-extended-arch-suffix "$host_arch" "$distrib_arch" 2>/dev/null)" || return 1 - [ -n "$UPDATES_SING_BOX_EXTENDED_ARCH_SUFFIX" ] || return 1 -} - -updates_sing_box_extended_tag_is_stable() { - local tag="$1" - local lowered - - [ -n "$tag" ] || return 1 - lowered="$(printf '%s' "$tag" | tr '[:upper:]' '[:lower:]')" - case "$lowered" in - *alpha* | *beta* | *rc*) - return 1 - ;; - esac - - return 0 -} - -updates_set_sing_box_extended_release_from_json() { - local release_json="$1" - local compressed="${2:-0}" - local tag asset_url - - [ -n "$release_json" ] || return 1 - - tag="$(printf '%s' "$release_json" | updates_ucode object-get-default tag_name "" 2>/dev/null)" - updates_sing_box_extended_tag_is_stable "$tag" || return 1 - - if [ "$compressed" -eq 1 ]; then - asset_url="$(updates_select_sing_box_extended_asset_url "$release_json" "$compressed")" || return 1 - else - asset_url="$(updates_select_sing_box_extended_package_asset_url "$release_json")" || return 1 - fi - [ -n "$asset_url" ] || return 1 - - UPDATES_SING_BOX_EXTENDED_RELEASE_TAG="$tag" - UPDATES_SING_BOX_EXTENDED_RELEASE_URL="$(printf '%s' "$release_json" | updates_ucode object-get-default html_url "" 2>/dev/null)" - UPDATES_SING_BOX_EXTENDED_ASSET_URL="$asset_url" - UPDATES_SING_BOX_EXTENDED_ASSET_NAME="$(basename "$asset_url")" -} - -updates_resolve_sing_box_extended_release() { - local compressed="${1:-0}" - local response tag release_json - - UPDATES_SING_BOX_EXTENDED_RELEASE_TAG="" - UPDATES_SING_BOX_EXTENDED_RELEASE_URL="" - UPDATES_SING_BOX_EXTENDED_ASSET_URL="" - UPDATES_SING_BOX_EXTENDED_ASSET_NAME="" - - if [ "$compressed" -eq 1 ]; then - updates_resolve_sing_box_extended_arch_suffix || return 1 - fi - - release_json="$(updates_fetch_github_release_json "shtorm-7" "sing-box-extended" 2>/dev/null || true)" - if updates_set_sing_box_extended_release_from_json "$release_json" "$compressed"; then - return 0 - fi - - response="$(updates_fetch_github_releases_json "shtorm-7" "sing-box-extended" 30)" || return 1 - - tag="$(printf '%s' "$response" | updates_ucode sing-box-extended-release-tag 2>/dev/null)" - - [ -n "$tag" ] || return 1 - release_json="$(printf '%s' "$response" | updates_ucode release-by-tag "$tag" 2>/dev/null)" - updates_set_sing_box_extended_release_from_json "$release_json" "$compressed" -} - -updates_replace_sing_box_package_variant() { - local target_package="$1" - local conflict_package="$2" - - updates_prepare_sing_box_package_service_install - - if { [ -z "$conflict_package" ] || ! updates_pkg_is_installed "$conflict_package"; } && - { [ "$target_package" = "sing-box-extended" ] || ! updates_pkg_is_installed "sing-box-extended"; }; then - updates_pkg_install_name_downgrade "$target_package" && return 0 - fi - - if [ "$target_package" != "sing-box-extended" ]; then - updates_pkg_remove_sing_box_conflict "sing-box-extended" || return 1 - fi - if [ -n "$conflict_package" ]; then - updates_pkg_remove_sing_box_conflict "$conflict_package" || return 1 - fi - - updates_pkg_install_name_downgrade "$target_package" -} - -updates_install_sing_box_extended_package() { - local action="$1" - local current_version latest_version normalized_current normalized_latest package_file new_version backup_binary backup_cronet previous_marker previous_version_state current_variant cronet_touched changed - - updates_init_tmp_dir || updates_fail "sing_box" "$action" "Failed to create temporary directory" - current_version="$(get_sing_box_version)" - current_variant="$(get_sing_box_variant)" - previous_marker="$(updates_read_sing_box_variant_marker 2>/dev/null || true)" - previous_version_state="$(updates_read_sing_box_version_state 2>/dev/null || true)" - - updates_resolve_sing_box_extended_release 0 || updates_fail "sing_box" "$action" "Failed to resolve sing-box-extended package release" "$current_version" - latest_version="$(updates_normalize_sing_box_version "$UPDATES_SING_BOX_EXTENDED_RELEASE_TAG")" - normalized_current="$(updates_normalize_sing_box_version "$current_version")" - normalized_latest="$(updates_normalize_sing_box_version "$latest_version")" - - if [ "$action" = "check_update" ]; then - is_sing_box_extended "$current_version" || updates_fail "sing_box" "$action" "sing-box-extended is not installed" "$current_version" "$latest_version" - updates_check_success "sing_box" "$normalized_current" "$normalized_latest" "$UPDATES_SING_BOX_EXTENDED_RELEASE_URL" - fi - - package_file="$UPDATES_TMP_DIR/$UPDATES_SING_BOX_EXTENDED_ASSET_NAME" - if ! updates_download_with_retry "$UPDATES_SING_BOX_EXTENDED_ASSET_URL" "$package_file" "$UPDATES_SING_BOX_EXTENDED_ASSET_NAME"; then - updates_fail "sing_box" "$action" "Failed to download sing-box-extended package" "$current_version" "$latest_version" - fi - - updates_log_command "Updating package lists before sing-box-extended package installation" updates_pkg_list_update || - updates_fail "sing_box" "$action" "Failed to update package lists" "$current_version" "$latest_version" - - updates_stop_podkop_before_sing_box_change - updates_prepare_sing_box_package_service_install - - backup_binary="" - backup_cronet="" - cronet_touched=0 - - case "$current_variant" in - extended | extended-compressed) - if [ -e /usr/bin/sing-box ]; then - backup_binary="/usr/bin/sing-box.podkop-backup.$$" - if ! updates_move_file_to_backup /usr/bin/sing-box "$backup_binary"; then - rm -f "$backup_binary" - rm -f "$package_file" - updates_fail "sing_box" "$action" "Failed to backup current sing-box binary" "$current_version" "$latest_version" - fi - fi - if [ -e /usr/lib/libcronet.so ]; then - cronet_touched=1 - backup_cronet="/usr/lib/libcronet.so.podkop-backup.$$" - if ! updates_move_file_to_backup /usr/lib/libcronet.so "$backup_cronet"; then - rm -f "$backup_cronet" - backup_cronet="" - cronet_touched=0 - updates_restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$package_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to backup current libcronet.so" "$current_version" "$latest_version" - fi - fi - ;; - esac - - if ! updates_log_command "Removing sing-box-tiny before sing-box-extended package installation" updates_pkg_remove_sing_box_conflict "sing-box-tiny"; then - updates_restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$package_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to remove sing-box-tiny before sing-box-extended package installation" "$current_version" "$latest_version" - fi - if ! updates_log_command "Removing sing-box before sing-box-extended package installation" updates_pkg_remove_sing_box_conflict "sing-box"; then - updates_restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$package_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to remove sing-box before sing-box-extended package installation" "$current_version" "$latest_version" - fi - - if [ -z "$backup_binary" ] && [ -e /usr/bin/sing-box ]; then - backup_binary="/usr/bin/sing-box.podkop-backup.$$" - if ! updates_move_file_to_backup /usr/bin/sing-box "$backup_binary"; then - rm -f "$backup_binary" - backup_binary="" - updates_restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$package_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to backup existing sing-box binary" "$current_version" "$latest_version" - fi - fi - - if [ "$cronet_touched" -eq 0 ] && [ -e /usr/lib/libcronet.so ]; then - cronet_touched=1 - backup_cronet="/usr/lib/libcronet.so.podkop-backup.$$" - if ! updates_move_file_to_backup /usr/lib/libcronet.so "$backup_cronet"; then - rm -f "$backup_cronet" - backup_cronet="" - cronet_touched=0 - updates_restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$package_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to backup current libcronet.so" "$current_version" "$latest_version" - fi - fi - - if ! updates_log_command "Installing sing-box-extended package $UPDATES_SING_BOX_EXTENDED_ASSET_NAME" updates_pkg_install_files "$package_file"; then - updates_restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$package_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to install sing-box-extended package" "$current_version" "$latest_version" - fi - - rm -f "$package_file" - - new_version="$(updates_validate_sing_box_extended_binary /usr/bin/sing-box /usr/lib)" || { - if updates_restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$package_file" "$cronet_touched"; then - updates_fail "sing_box" "$action" "Installed sing-box-extended package failed validation; previous sing-box variant was restored" "$current_version" "$latest_version" - fi - updates_fail "sing_box" "$action" "Installed sing-box-extended package failed validation and previous sing-box variant could not be restored" "$current_version" "$latest_version" - } - - updates_write_sing_box_variant_marker "extended" || updates_log "Failed to write sing-box variant marker" "warn" - updates_write_sing_box_version_state "$new_version" || updates_log "Failed to write sing-box version state" "warn" - updates_restart_podkop_after_successful_change - if ! updates_wait_podkop_running_after_sing_box_change; then - updates_log "sing-box-extended package did not start cleanly; restoring previous sing-box variant" "error" - [ -x "$PODKOP_SERVICE_INIT" ] && "$PODKOP_SERVICE_INIT" stop >/dev/null 2>&1 || true - if updates_restore_sing_box_after_failed_extended_package_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$package_file" "$cronet_touched"; then - rm -f "$backup_binary" "$backup_cronet" - updates_fail "sing_box" "$action" "sing-box-extended package was installed but Podkop Plus did not start cleanly; previous sing-box variant was restored" "$current_version" "$latest_version" - fi - - updates_fail "sing_box" "$action" "sing-box-extended package was installed but Podkop Plus did not start cleanly and previous sing-box variant could not be restored" "$current_version" "$latest_version" - fi - - rm -f "$backup_binary" "$backup_cronet" - updates_clear_version_caches - updates_log "Installed sing-box-extended ${new_version:-unknown} from package" - changed=1 - [ "$new_version" = "$current_version" ] && changed=0 - updates_success "sing_box" "$action" "sing-box-extended has been installed" "$new_version" "$latest_version" "$changed" "latest" "$UPDATES_SING_BOX_EXTENDED_RELEASE_URL" -} - -updates_install_sing_box_extended() { - local action="$1" - local compressed="${2:-0}" - local current_version latest_version normalized_current normalized_latest archive_file binary_path cronet_path extract_error new_version backup_binary backup_cronet label marker_variant previous_marker previous_version_state current_variant cronet_touched tmp_binary tmp_cronet - - if [ "$compressed" -ne 1 ]; then - updates_install_sing_box_extended_package "$action" - return 0 - fi - - label="sing-box-extended compressed" - marker_variant="extended-compressed" - - updates_init_tmp_dir || updates_fail "sing_box" "$action" "Failed to create temporary directory" - current_version="$(get_sing_box_version)" - current_variant="$(get_sing_box_variant)" - previous_marker="$(updates_read_sing_box_variant_marker 2>/dev/null || true)" - previous_version_state="$(updates_read_sing_box_version_state 2>/dev/null || true)" - updates_resolve_sing_box_extended_release "$compressed" || updates_fail "sing_box" "$action" "Failed to resolve $label release" "$current_version" - latest_version="$(updates_normalize_sing_box_version "$UPDATES_SING_BOX_EXTENDED_RELEASE_TAG")" - normalized_current="$(updates_normalize_sing_box_version "$current_version")" - normalized_latest="$(updates_normalize_sing_box_version "$latest_version")" - - if [ "$action" = "check_update" ]; then - is_sing_box_extended "$current_version" || updates_fail "sing_box" "$action" "sing-box-extended is not installed" "$current_version" "$latest_version" - if [ "$compressed" -eq 1 ] && ! is_sing_box_compressed_marker_set; then - updates_fail "sing_box" "$action" "sing-box-extended compressed is not installed" "$current_version" "$latest_version" - fi - updates_check_success "sing_box" "$normalized_current" "$normalized_latest" "$UPDATES_SING_BOX_EXTENDED_RELEASE_URL" - fi - - backup_binary="" - backup_cronet="" - cronet_touched=0 - tmp_binary="$UPDATES_TMP_DIR/sing-box.compressed.$$" - tmp_cronet="" - - archive_file="$UPDATES_TMP_DIR/$UPDATES_SING_BOX_EXTENDED_ASSET_NAME" - if ! updates_download_with_retry "$UPDATES_SING_BOX_EXTENDED_ASSET_URL" "$archive_file" "$UPDATES_SING_BOX_EXTENDED_ASSET_NAME"; then - updates_fail "sing_box" "$action" "Failed to download $label" "$current_version" "$latest_version" - fi - - binary_path="$(updates_select_archive_member_path "$archive_file" "sing-box")" - if [ -z "$binary_path" ]; then - rm -f "$archive_file" - updates_fail "sing_box" "$action" "sing-box binary was not found in the downloaded archive" "$current_version" "$latest_version" - fi - cronet_path="$(updates_select_archive_member_path "$archive_file" "libcronet.so")" - - extract_error="$UPDATES_TMP_DIR/sing-box-extract.err" - - if ! tar -xzf "$archive_file" -O "$binary_path" >"$tmp_binary" 2>"$extract_error"; then - while IFS= read -r line; do - [ -n "$line" ] && updates_log "$line" - done <"$extract_error" - rm -f "$tmp_binary" "$archive_file" - updates_fail "sing_box" "$action" "Failed to extract $label" "$current_version" "$latest_version" - fi - - if [ ! -s "$tmp_binary" ]; then - rm -f "$tmp_binary" "$archive_file" - updates_fail "sing_box" "$action" "sing-box binary was empty after extraction" "$current_version" "$latest_version" - fi - - if ! chmod 0755 "$tmp_binary"; then - rm -f "$tmp_binary" "$archive_file" - updates_fail "sing_box" "$action" "Failed to prepare sing-box-extended binary" "$current_version" "$latest_version" - fi - - if [ -n "$cronet_path" ]; then - tmp_cronet="$UPDATES_TMP_DIR/libcronet.so" - if ! tar -xzf "$archive_file" -O "$cronet_path" >"$tmp_cronet" 2>"$extract_error"; then - while IFS= read -r line; do - [ -n "$line" ] && updates_log "$line" - done <"$extract_error" - rm -f "$tmp_binary" "$tmp_cronet" "$archive_file" - updates_fail "sing_box" "$action" "Failed to extract libcronet.so from sing-box-extended archive" "$current_version" "$latest_version" - fi - - if [ ! -s "$tmp_cronet" ]; then - rm -f "$tmp_binary" "$tmp_cronet" "$archive_file" - updates_fail "sing_box" "$action" "libcronet.so was empty after extraction" "$current_version" "$latest_version" - fi - - if ! chmod 0644 "$tmp_cronet"; then - rm -f "$tmp_binary" "$tmp_cronet" "$archive_file" - updates_fail "sing_box" "$action" "Failed to prepare libcronet.so" "$current_version" "$latest_version" - fi - fi - - new_version="$(updates_validate_sing_box_extended_binary "$tmp_binary" "$UPDATES_TMP_DIR")" || { - rm -f "$tmp_binary" "$tmp_cronet" "$archive_file" - updates_fail "sing_box" "$action" "Downloaded $label failed validation" "$current_version" "$latest_version" - } - - updates_stop_podkop_before_sing_box_change - - if [ -e /usr/bin/sing-box ]; then - backup_binary="/usr/bin/sing-box.podkop-backup.$$" - if ! updates_move_file_to_backup /usr/bin/sing-box "$backup_binary"; then - rm -f "$backup_binary" "$tmp_binary" "$tmp_cronet" "$archive_file" - updates_fail "sing_box" "$action" "Failed to backup current sing-box binary" "$current_version" "$latest_version" - fi - fi - - if [ -n "$cronet_path" ]; then - cronet_touched=1 - if [ -e /usr/lib/libcronet.so ]; then - backup_cronet="/usr/lib/libcronet.so.podkop-backup.$$" - if ! updates_move_file_to_backup /usr/lib/libcronet.so "$backup_cronet"; then - rm -f "$backup_cronet" "$tmp_binary" "$tmp_cronet" "$archive_file" - updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to backup current libcronet.so" "$current_version" "$latest_version" - fi - fi - fi - - if ! updates_log_command "Removing sing-box-extended package before $label installation" updates_pkg_remove_sing_box_conflict "sing-box-extended"; then - updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched" >/dev/null 2>&1 || true - rm -f "$tmp_binary" "$tmp_cronet" - updates_fail "sing_box" "$action" "Failed to remove sing-box-extended before $label installation" "$current_version" "$latest_version" - fi - if ! updates_log_command "Removing sing-box-tiny package before $label installation" updates_pkg_remove_sing_box_conflict "sing-box-tiny"; then - updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched" >/dev/null 2>&1 || true - rm -f "$tmp_binary" "$tmp_cronet" - updates_fail "sing_box" "$action" "Failed to remove sing-box-tiny before $label installation" "$current_version" "$latest_version" - fi - if ! updates_log_command "Removing sing-box package before $label installation" updates_pkg_remove_sing_box_conflict "sing-box"; then - updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched" >/dev/null 2>&1 || true - rm -f "$tmp_binary" "$tmp_cronet" - updates_fail "sing_box" "$action" "Failed to remove sing-box before $label installation" "$current_version" "$latest_version" - fi - - updates_remove_managed_sing_box_service_script >/dev/null 2>&1 || true - if ! updates_install_managed_sing_box_service_script; then - updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched" >/dev/null 2>&1 || true - rm -f "$tmp_binary" "$tmp_cronet" - updates_fail "sing_box" "$action" "Failed to install managed sing-box service for $label" "$current_version" "$latest_version" - fi - - rm -f /usr/bin/sing-box - if ! mv -f "$tmp_binary" /usr/bin/sing-box || ! chmod 0755 /usr/bin/sing-box; then - rm -f /usr/bin/sing-box "$tmp_binary" - updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to install $label binary" "$current_version" "$latest_version" - fi - - if [ -n "$tmp_cronet" ]; then - rm -f /usr/lib/libcronet.so - if ! mv -f "$tmp_cronet" /usr/lib/libcronet.so || ! chmod 0644 /usr/lib/libcronet.so; then - rm -f /usr/lib/libcronet.so "$tmp_cronet" - updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched" >/dev/null 2>&1 || true - updates_fail "sing_box" "$action" "Failed to install libcronet.so for $label" "$current_version" "$latest_version" - fi - fi - - rm -f "$archive_file" - - new_version="$(updates_validate_sing_box_extended_binary /usr/bin/sing-box /usr/lib)" || { - if updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched"; then - updates_fail "sing_box" "$action" "Installed $label failed validation; previous sing-box variant was restored" "$current_version" "$latest_version" - fi - updates_fail "sing_box" "$action" "Installed $label failed validation and previous sing-box variant could not be restored" "$current_version" "$latest_version" - } - - updates_write_sing_box_variant_marker "$marker_variant" || updates_log "Failed to write sing-box variant marker" "warn" - updates_write_sing_box_version_state "$new_version" || updates_log "Failed to write sing-box version state" "warn" - updates_restart_podkop_after_successful_change - if ! updates_wait_podkop_running_after_sing_box_change; then - updates_log "$label did not start cleanly; restoring previous sing-box binary" "error" - [ -x "$PODKOP_SERVICE_INIT" ] && "$PODKOP_SERVICE_INIT" stop >/dev/null 2>&1 || true - if updates_restore_sing_box_after_failed_extended_install "$current_variant" "$backup_binary" "$backup_cronet" "$previous_marker" "$previous_version_state" "$archive_file" "$cronet_touched"; then - rm -f "$backup_binary" "$backup_cronet" - updates_fail "sing_box" "$action" "$label was installed but Podkop Plus did not start cleanly; previous sing-box variant was restored" "$current_version" "$latest_version" - fi - - updates_fail "sing_box" "$action" "$label was installed but Podkop Plus did not start cleanly and previous sing-box variant could not be restored" "$current_version" "$latest_version" - fi - - rm -f "$backup_binary" "$backup_cronet" - updates_clear_version_caches - updates_log "Installed $label ${new_version:-unknown}" - updates_success "sing_box" "$action" "$label has been installed" "$new_version" "$latest_version" 1 "latest" -} - -updates_install_stable_sing_box() { - local action="$1" - local current_version latest_version new_version changed package_version binary_version - - package_version="$(updates_get_installed_package_version "sing-box")" - binary_version="$(get_sing_box_version)" - current_version="$package_version" - if is_sing_box_extended "$binary_version"; then - current_version="$binary_version" - fi - [ -n "$current_version" ] || current_version="$binary_version" - latest_version="$(updates_get_available_package_version "sing-box")" - [ -n "$latest_version" ] || latest_version="$(updates_get_installed_package_version "sing-box")" - [ -n "$latest_version" ] || updates_fail "sing_box" "$action" "Failed to resolve stable sing-box package version" "$current_version" - - if [ "$action" = "check_update" ]; then - updates_check_success "sing_box" "$current_version" "$latest_version" - fi - - updates_log_command "Updating package lists before sing-box installation" updates_pkg_list_update || - updates_fail "sing_box" "$action" "Failed to update package lists" "$current_version" "$latest_version" - - latest_version="$(updates_get_available_package_version "sing-box")" - [ -n "$latest_version" ] || latest_version="$(updates_get_installed_package_version "sing-box")" - [ -n "$latest_version" ] || updates_fail "sing_box" "$action" "Failed to resolve stable sing-box package version" "$current_version" - - updates_stop_podkop_before_sing_box_change - - if ! updates_log_command "Installing stable sing-box package" updates_replace_sing_box_package_variant "sing-box" "sing-box-tiny"; then - updates_fail "sing_box" "$action" "Failed to install stable sing-box" "$current_version" "$latest_version" - fi - - new_version="$(updates_read_sing_box_binary_version /usr/bin/sing-box)" - if [ -z "$new_version" ]; then - updates_clear_version_caches - updates_fail "sing_box" "$action" "Stable sing-box package was installed, but sing-box binary is not available" "$current_version" "$latest_version" - fi - - if is_sing_box_extended "$new_version"; then - updates_clear_version_caches - updates_fail "sing_box" "$action" "Stable sing-box package was installed, but the active binary is still sing-box-extended" "$new_version" "$latest_version" - fi - - updates_write_sing_box_variant_marker "stable" || updates_log "Failed to write sing-box variant marker" "warn" - updates_write_sing_box_version_state "$new_version" || updates_log "Failed to write sing-box version state" "warn" - updates_restart_podkop_after_successful_change - if ! updates_wait_podkop_running_after_sing_box_change; then - updates_clear_version_caches - updates_fail "sing_box" "$action" "Stable sing-box was installed, but Podkop Plus did not start cleanly" "$new_version" "$latest_version" - fi - updates_clear_version_caches - - changed=1 - [ "$new_version" = "$current_version" ] && changed=0 - - updates_success "sing_box" "$action" "stable sing-box has been installed" "$new_version" "$latest_version" "$changed" "latest" -} - -updates_install_tiny_sing_box() { - local action="$1" - local current_version latest_version new_version changed package_version binary_version - - package_version="$(updates_get_installed_package_version "sing-box-tiny")" - binary_version="$(get_sing_box_version)" - current_version="$package_version" - if is_sing_box_extended "$binary_version"; then - current_version="$binary_version" - fi - [ -n "$current_version" ] || current_version="$binary_version" - - latest_version="$(updates_get_available_package_version "sing-box-tiny")" - [ -n "$latest_version" ] || latest_version="$(updates_get_installed_package_version "sing-box-tiny")" - [ -n "$latest_version" ] || updates_fail "sing_box" "$action" "Failed to resolve tiny sing-box package version" "$current_version" - - if [ "$action" = "check_update" ]; then - is_sing_box_tiny "$binary_version" || updates_fail "sing_box" "$action" "sing-box-tiny is not installed" "$current_version" "$latest_version" - updates_check_success "sing_box" "$current_version" "$latest_version" - fi - - updates_log_command "Updating package lists before sing-box-tiny installation" updates_pkg_list_update || - updates_fail "sing_box" "$action" "Failed to update package lists" "$current_version" "$latest_version" - - latest_version="$(updates_get_available_package_version "sing-box-tiny")" - [ -n "$latest_version" ] || latest_version="$(updates_get_installed_package_version "sing-box-tiny")" - [ -n "$latest_version" ] || updates_fail "sing_box" "$action" "Failed to resolve tiny sing-box package version" "$current_version" - - updates_stop_podkop_before_sing_box_change - - if ! updates_log_command "Installing tiny sing-box package" updates_replace_sing_box_package_variant "sing-box-tiny" "sing-box"; then - updates_fail "sing_box" "$action" "Failed to install sing-box-tiny" "$current_version" "$latest_version" - fi - - new_version="$(updates_read_sing_box_binary_version /usr/bin/sing-box)" - if [ -z "$new_version" ]; then - updates_clear_version_caches - updates_fail "sing_box" "$action" "sing-box-tiny package was installed, but sing-box binary is not available" "$current_version" "$latest_version" - fi - - if is_sing_box_extended "$new_version"; then - updates_clear_version_caches - updates_fail "sing_box" "$action" "sing-box-tiny package was installed, but the active binary is still sing-box-extended" "$new_version" "$latest_version" - fi - - updates_write_sing_box_variant_marker "tiny" || updates_log "Failed to write sing-box variant marker" "warn" - updates_write_sing_box_version_state "$new_version" || updates_log "Failed to write sing-box version state" "warn" - updates_restart_podkop_after_successful_change - if ! updates_wait_podkop_running_after_sing_box_change; then - updates_clear_version_caches - updates_fail "sing_box" "$action" "Tiny sing-box was installed, but Podkop Plus did not start cleanly" "$new_version" "$latest_version" - fi - updates_clear_version_caches - - changed=1 - [ "$new_version" = "$current_version" ] && changed=0 - - updates_success "sing_box" "$action" "tiny sing-box has been installed" "$new_version" "$latest_version" "$changed" "latest" -} - -updates_check_podkop_plus() { - local release_metadata latest_version release_url compare_result status message now - - release_metadata="$(updates_fetch_podkop_latest_release_metadata 2>/dev/null || true)" - IFS="$(printf '\t')" read -r latest_version release_url </dev/null || true)" - if [ -z "$compare_result" ]; then - updates_fail "podkop" "check_update" "Failed to compare Podkop Plus versions" "$PODKOP_VERSION" "$latest_version" - fi - - status="$(updates_status_from_compare "$compare_result")" || updates_fail "podkop" "check_update" "Failed to compare Podkop Plus versions" "$PODKOP_VERSION" "$latest_version" - case "$status" in - latest) - message="Latest version is installed" - updates_log "Podkop Plus is already up to date ($PODKOP_VERSION)" - ;; - outdated) - message="Update is available" - updates_log "Podkop Plus update found: $PODKOP_VERSION -> $latest_version" - ;; - dev) - message="Installed version is newer than release" - updates_log "Podkop Plus installed version is newer than upstream release: $PODKOP_VERSION -> $latest_version" - ;; - esac - - updates_success "podkop" "check_update" "$message" "$PODKOP_VERSION" "$latest_version" 0 "$status" "$release_url" -} - -updates_install_podkop_plus() { - local latest_version now new_version - - latest_version="$(fetch_latest_podkop_version)" - [ -n "$latest_version" ] || latest_version="unknown" - - if [ "$latest_version" = "unknown" ]; then - updates_fail "podkop" "install" "Failed to resolve Podkop Plus release" "$PODKOP_VERSION" "$latest_version" - fi - - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) now=0 ;; - esac - write_podkop_latest_version_cache "$latest_version" "$now" - - updates_init_tmp_dir || updates_fail "podkop" "install" "Failed to create temporary directory" "$PODKOP_VERSION" "$latest_version" - updates_log "Resolving Podkop Plus release $latest_version packages" - updates_resolve_podkop_plus_release "$latest_version" || - updates_fail "podkop" "install" "Failed to resolve Podkop Plus release packages" "$PODKOP_VERSION" "$latest_version" - updates_download_podkop_plus_packages || - updates_fail "podkop" "install" "Failed to download Podkop Plus release packages" "$PODKOP_VERSION" "$latest_version" - - if ! updates_log_command "Installing LuCI app package $UPDATES_PODKOP_APP_NAME" updates_pkg_install_files "$UPDATES_PODKOP_APP_FILE"; then - updates_fail "podkop" "install" "Failed to install LuCI app package" "$PODKOP_VERSION" "$latest_version" - fi - - if [ -n "$UPDATES_PODKOP_I18N_FILE" ]; then - if ! updates_log_command "Installing LuCI Russian i18n package $UPDATES_PODKOP_I18N_NAME" updates_pkg_install_files "$UPDATES_PODKOP_I18N_FILE"; then - updates_fail "podkop" "install" "Failed to install LuCI Russian i18n package" "$PODKOP_VERSION" "$latest_version" - fi - fi - - if ! updates_log_command "Installing Podkop Plus package $UPDATES_PODKOP_BACKEND_NAME" updates_pkg_install_files "$UPDATES_PODKOP_BACKEND_FILE"; then - updates_fail "podkop" "install" "Failed to install Podkop Plus package" "$PODKOP_VERSION" "$latest_version" - fi - - updates_refresh_luci_after_app_update - updates_restart_podkop_after_successful_change - updates_clear_version_caches - - new_version="$(updates_get_installed_package_version "podkop-plus")" - [ -n "$new_version" ] || new_version="$latest_version" - updates_log "Podkop Plus updated to $new_version" - updates_success "podkop" "install" "Podkop Plus has been installed" "$new_version" "$latest_version" 1 "latest" "$UPDATES_PODKOP_RELEASE_URL" -} - -component_action() { - local component="$1" - local action="$2" - - trap updates_component_action_cleanup EXIT HUP INT TERM - - updates_acquire_component_lock || updates_fail "${component:-unknown}" "${action:-unknown}" "Another component action is already running" - updates_begin_direct_component_job_state "$component" "$action" - updates_init_tmp_dir || updates_fail "${component:-unknown}" "${action:-unknown}" "Failed to create temporary directory" - updates_capture_podkop_running_state - - case "$component:$action" in - podkop:check_update) - updates_check_podkop_plus - ;; - podkop:install) - updates_install_podkop_plus - ;; - sing_box:check_update) - case "$(get_sing_box_variant)" in - extended-compressed) - updates_install_sing_box_extended "$action" 1 - ;; - extended) - updates_install_sing_box_extended "$action" 0 - ;; - tiny) - updates_install_tiny_sing_box "$action" - ;; - *) - updates_install_stable_sing_box "$action" - ;; - esac - ;; - sing_box:install) - case "$(get_sing_box_variant)" in - extended-compressed) - updates_install_sing_box_extended "$action" 1 - ;; - extended) - updates_install_sing_box_extended "$action" 0 - ;; - tiny) - updates_install_tiny_sing_box "$action" - ;; - *) - updates_install_stable_sing_box "$action" - ;; - esac - ;; - sing_box:install_extended) - updates_install_sing_box_extended "$action" 0 - ;; - sing_box:install_extended_compressed) - updates_install_sing_box_extended "$action" 1 - ;; - sing_box:install_tiny) - updates_install_tiny_sing_box "$action" - ;; - sing_box:install_stable) - updates_install_stable_sing_box "$action" - ;; - zapret:check_update | zapret:install) - updates_install_zapret "$action" - ;; - zapret:remove) - updates_remove_optional_component "zapret" "zapret" "zapret" is_zapret_installed get_zapret_package_version - ;; - zapret2:check_update | zapret2:install) - updates_install_zapret2 "$action" - ;; - zapret2:remove) - updates_remove_optional_component "zapret2" "zapret2" "zapret2" is_zapret2_installed get_zapret2_package_version - ;; - byedpi:check_update | byedpi:install) - updates_install_byedpi "$action" - ;; - byedpi:remove) - updates_remove_optional_component "byedpi" "byedpi" "ByeDPI" is_byedpi_installed get_byedpi_package_version - ;; - *) - updates_fail "${component:-unknown}" "${action:-unknown}" "Unknown component action" - ;; - esac -} diff --git a/podkop/files/usr/lib/updates_runtime.sh b/podkop/files/usr/lib/updates_runtime.sh deleted file mode 100644 index bc011716..00000000 --- a/podkop/files/usr/lib/updates_runtime.sh +++ /dev/null @@ -1,973 +0,0 @@ -# shellcheck shell=ash - -updates_runtime_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/updates_runtime.uc" "$@" -} - -build_list_update_cron_job() { - local update_interval - local interval_seconds - local cron_schedule - - update_interval="$(get_settings_update_interval)" - [ -n "$update_interval" ] || return 1 - - interval_seconds="$(duration_to_seconds "$update_interval")" || { - log "Invalid update_interval value: $update_interval" "error" - return 1 - } - - cron_schedule="$(seconds_to_due_check_cron_schedule "$interval_seconds")" - printf '%s %s list_update_if_due %s\n' "$cron_schedule" "$PODKOP_BIN" "$PODKOP_LIST_UPDATE_CRON_MARKER" -} - -build_subscription_update_cron_job() { - local min_interval_seconds cron_schedule - - SUBSCRIPTION_MIN_UPDATE_INTERVAL_SECONDS=0 - config_foreach collect_subscription_update_interval_seconds "section" - - [ "$SUBSCRIPTION_MIN_UPDATE_INTERVAL_SECONDS" -gt 0 ] || return 1 - - min_interval_seconds="$SUBSCRIPTION_MIN_UPDATE_INTERVAL_SECONDS" - cron_schedule="$(seconds_to_subscription_cron_schedule "$min_interval_seconds")" - printf '%s %s subscription_update_if_due %s\n' "$cron_schedule" "$PODKOP_BIN" "$PODKOP_SUBSCRIPTION_UPDATE_CRON_MARKER" -} - -collect_subscription_update_interval_seconds() { - local section="$1" - local subscription_update_interval interval_seconds - - rule_is_subscription_proxy "$section" || return 0 - - subscription_update_interval="$(get_subscription_update_interval_for_rule "$section")" - [ -n "$subscription_update_interval" ] || return 0 - - interval_seconds="$(duration_to_seconds "$subscription_update_interval")" || { - log "Invalid subscription_update_interval value for rule '$section': $subscription_update_interval" "error" - return 0 - } - - if [ "$SUBSCRIPTION_MIN_UPDATE_INTERVAL_SECONDS" -eq 0 ] || - [ "$interval_seconds" -lt "$SUBSCRIPTION_MIN_UPDATE_INTERVAL_SECONDS" ]; then - SUBSCRIPTION_MIN_UPDATE_INTERVAL_SECONDS="$interval_seconds" - fi -} - -seconds_to_due_check_cron_schedule() { - updates_ucode due-check-cron-schedule "$1" -} - -seconds_to_subscription_cron_schedule() { - seconds_to_due_check_cron_schedule "$1" -} - -remove_cron_job() { - crontab -l 2>/dev/null | - updates_runtime_ucode filter-cron-markers \ - "$PODKOP_LIST_UPDATE_CRON_MARKER" \ - "$PODKOP_SUBSCRIPTION_UPDATE_CRON_MARKER" | - crontab - - log "The cron job removed" -} - -duration_to_seconds() { - updates_ucode duration-to-seconds "$1" -} - -read_list_update_timestamp() { - updates_ucode file-first-line "$PODKOP_LIST_UPDATE_STATE_FILE" 2>/dev/null -} - -write_list_update_timestamp() { - local timestamp="$1" - - mkdir -p "$PODKOP_RUNTIME_STATE_DIR" - printf '%s\n' "$timestamp" > "$PODKOP_LIST_UPDATE_STATE_FILE" -} - -list_update_if_due() { - local update_interval interval_seconds now last_run - - config_load "$PODKOP_CONFIG_NAME" - update_interval="$(get_settings_update_interval)" - [ -n "$update_interval" ] || return 0 - - interval_seconds="$(duration_to_seconds "$update_interval")" || { - log "Invalid update_interval value: $update_interval" "error" - return 1 - } - now="$(date +%s 2>/dev/null)" - last_run="$(read_list_update_timestamp)" - - case "$now" in - '' | *[!0-9]*) return 1 ;; - esac - - case "$last_run" in - '' | *[!0-9]*) last_run=0 ;; - esac - - if [ "$last_run" -gt 0 ] && [ $((now - last_run)) -lt "$interval_seconds" ]; then - return 0 - fi - - list_update -} - -list_update_section_handler() { - local section="$1" - local callback="$2" - - "$callback" "$section" || LIST_UPDATE_FOREACH_STATUS=1 -} - -list_update_for_each_section() { - local callback="$1" - - LIST_UPDATE_FOREACH_STATUS=0 - config_foreach list_update_section_handler "section" "$callback" - [ "$LIST_UPDATE_FOREACH_STATUS" -eq 0 ] -} - -list_update() { - echolog "Starting lists update..." - - local pidfile="/var/run/podkop_list_update.pid" - local existing_pid - if [ -f "$pidfile" ]; then - existing_pid=$(cat "$pidfile" 2> /dev/null) - if [ -n "$existing_pid" ] && [ "$existing_pid" != "$$" ] && kill -0 "$existing_pid" 2> /dev/null; then - echolog "Another lists update is already running, skipping" - return 0 - fi - fi - - echo $$ > "$pidfile" - trap 'rm -f "$pidfile"' EXIT INT TERM - - local dns_probe_timeout=3 - local dns_probe_attempts=10 - local curl_timeout=5 - local curl_attempts=10 - local curl_max_timeout=10 - local delay=3 - local i service_proxy_address dns_probe_answer dns_probe_ok curl_ok - - # DNS Check - service_proxy_address="$(get_service_proxy_address lists)" - if [ -n "$service_proxy_address" ]; then - echolog "DNS check skipped because list downloads use service proxy" - else - dns_probe_ok=0 - i=1 - while [ "$i" -le "$dns_probe_attempts" ]; do - dns_probe_answer="$( - dig +short openwrt.org A +timeout=$dns_probe_timeout +tries=1 2> /dev/null | - updates_runtime_ucode stdin-first-ipv4-line 2>/dev/null - )" - if [ -n "$dns_probe_answer" ]; then - echolog "DNS check passed" - dns_probe_ok=1 - break - fi - echolog "DNS is unavailable [$i/$dns_probe_attempts]" - sleep $delay - i=$((i + 1)) - done - - if [ "$dns_probe_ok" -ne 1 ]; then - echolog "DNS check failed after $dns_probe_attempts attempts; skipping remote lists update until the next attempt" - return 0 - fi - fi - - # Github Check - curl_ok=0 - i=1 - while [ "$i" -le "$curl_attempts" ]; do - if [ -n "$service_proxy_address" ]; then - if curl -s -x "http://$service_proxy_address" -m $curl_timeout https://github.com > /dev/null; then - echolog "GitHub connection check passed (via proxy)" - curl_ok=1 - break - fi - else - if curl -s -m $curl_timeout https://github.com > /dev/null; then - echolog "GitHub connection check passed" - curl_ok=1 - break - fi - fi - - echolog "GitHub is unavailable [$i/$curl_attempts] (max-timeout=$curl_timeout)" - if [ "$curl_timeout" -lt $curl_max_timeout ]; then - curl_timeout=$((curl_timeout + 1)) - fi - sleep $delay - i=$((i + 1)) - done - - if [ "$curl_ok" -ne 1 ]; then - echolog "GitHub connection check failed after $curl_attempts attempts; trying configured list downloads individually" - fi - - echolog "Downloading and processing lists..." - - local update_status now - update_status=0 - - list_update_for_each_section rebuild_domain_ip_lists_from_rule || update_status=1 - list_update_for_each_section import_builtin_subnets_from_rule || update_status=1 - list_update_for_each_section import_domains_from_remote_domain_lists || update_status=1 - list_update_for_each_section import_subnets_from_remote_subnet_lists || update_status=1 - list_update_for_each_section import_rule_sets_with_subnets_from_rule || update_status=1 - # Keep legacy rule_set references inside sing-box only. - # Mirroring them into nft via decompile/import regressed compared to the - # original Podkop behavior and causes long-running list updates plus - # unstable routing when third-party SRS files are large or incompatible. - - if [ "$update_status" -eq 0 ]; then - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) ;; - *) - write_list_update_timestamp "$now" - ;; - esac - echolog "Lists update completed successfully" - else - echolog "Lists update failed" - fi - - [ "$update_status" -eq 0 ] || return 1 -} - -read_subscription_update_timestamp() { - local section="$1" - - updates_ucode file-first-line "$(get_subscription_update_timestamp_path "$section")" 2>/dev/null -} - -write_subscription_update_timestamp() { - local section="$1" - local timestamp="$2" - - mkdir -p "$PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR" - printf '%s\n' "$timestamp" > "$(get_subscription_update_timestamp_path "$section")" -} - -subscription_update_section_is_due() { - local section="$1" - local update_interval interval_seconds now last_run - - update_interval="$(get_subscription_update_interval_for_rule "$section")" - [ -n "$update_interval" ] || return 1 - - interval_seconds="$(duration_to_seconds "$update_interval")" || { - log "Invalid subscription_update_interval value for rule '$section': $update_interval" "error" - return 2 - } - - now="$(date +%s 2>/dev/null)" - last_run="$(read_subscription_update_timestamp "$section")" - - case "$now" in - '' | *[!0-9]*) return 2 ;; - esac - - case "$last_run" in - '' | *[!0-9]*) last_run=0 ;; - esac - - [ "$last_run" -le 0 ] || [ $((now - last_run)) -ge "$interval_seconds" ] -} - -subscription_update_section() { - local section="$1" - local force="$2" - local update_result now metadata_tmpfile metadata_count - - rule_is_subscription_proxy "$section" || return 3 - - if [ "$force" -eq 0 ]; then - subscription_update_section_is_due "$section" - case "$?" in - 0) ;; - 1) return 3 ;; - *) return 1 ;; - esac - fi - - mkdir -p "$TMP_SUBSCRIPTION_FOLDER" - - echolog "Updating subscriptions for rule '$section'..." - - metadata_tmpfile="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${section}.metadata-section.XXXXXX" 2>/dev/null || true)" - if [ -n "$metadata_tmpfile" ]; then - printf '[]\n' > "$metadata_tmpfile" - SUBSCRIPTION_SECTION_METADATA_TMP="$metadata_tmpfile" - else - SUBSCRIPTION_SECTION_METADATA_TMP="" - fi - - SUBSCRIPTION_SECTION_UPDATE_CHANGED=0 - SUBSCRIPTION_SECTION_UPDATE_UNCHANGED=0 - SUBSCRIPTION_SECTION_UPDATE_FAILED=0 - SUBSCRIPTION_SECTION_UPDATE_SUPERSEDED=0 - SUBSCRIPTION_SECTION_UPDATE_TOTAL=0 - for_each_subscription_source "$section" subscription_update_source_handler - SUBSCRIPTION_SECTION_METADATA_TMP="" - - if [ "$SUBSCRIPTION_SECTION_UPDATE_TOTAL" -eq 0 ]; then - rm -f "$metadata_tmpfile" - echolog "Subscription URL is not set for rule '$section'" - return 1 - fi - - if [ "$SUBSCRIPTION_SECTION_UPDATE_CHANGED" -gt 0 ]; then - update_result=0 - elif [ "$SUBSCRIPTION_SECTION_UPDATE_FAILED" -gt 0 ]; then - update_result=1 - elif [ "$SUBSCRIPTION_SECTION_UPDATE_SUPERSEDED" -gt 0 ]; then - update_result=4 - else - update_result=2 - fi - - if [ -n "$metadata_tmpfile" ]; then - if [ "$SUBSCRIPTION_SECTION_UPDATE_SUPERSEDED" -eq 0 ]; then - metadata_count="$(updates_runtime_ucode json-length "$metadata_tmpfile" 2>/dev/null)" - if [ -n "$metadata_count" ] && [ "$metadata_count" -gt 0 ]; then - write_subscription_metadata_json "$section" "$metadata_tmpfile" - else - write_subscription_metadata_json "$section" "" - fi - fi - rm -f "$metadata_tmpfile" - fi - - case "$update_result" in - 0) - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) ;; - *) write_subscription_update_timestamp "$section" "$now" ;; - esac - - echolog "Subscriptions updated for rule '$section'" - return 0 - ;; - 2) - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) ;; - *) write_subscription_update_timestamp "$section" "$now" ;; - esac - - echolog "Subscriptions for rule '$section' are unchanged" - return 2 - ;; - 4) - echolog "Subscription update for rule '$section' was superseded by newer URLs" - return 4 - ;; - *) - echolog "Failed to download subscriptions for rule '$section'" - return 1 - ;; - esac -} - -subscription_update_source_handler() { - local section="$1" - local index="$2" - local entry="$3" - local source_section subscription_json_path subscription_url_cache_path metadata_output_path \ - had_usable_cache service_proxy_address update_result - - SUBSCRIPTION_SECTION_UPDATE_TOTAL=$((SUBSCRIPTION_SECTION_UPDATE_TOTAL + 1)) - - if ! parse_subscription_source_entry "$entry"; then - echolog "Invalid subscription URL in rule '$section': $SUBSCRIPTION_SOURCE_PARSE_ERROR" - SUBSCRIPTION_SECTION_UPDATE_FAILED=$((SUBSCRIPTION_SECTION_UPDATE_FAILED + 1)) - return 0 - fi - - source_section="$(subscription_source_id "$section" "$index")" - subscription_json_path="$(get_subscription_json_path "$source_section")" - subscription_url_cache_path="$(get_subscription_url_cache_path "$source_section")" - restore_persistent_subscription_cache \ - "$source_section" "$subscription_json_path" "$subscription_url_cache_path" "$(get_subscription_user_agent_cache_path "$source_section")" \ - "$SUBSCRIPTION_SOURCE_URL" "$SUBSCRIPTION_SOURCE_USER_AGENT" || true - had_usable_cache=0 - subscription_cache_is_usable "$subscription_json_path" && had_usable_cache=1 - service_proxy_address="$(get_subscription_download_proxy_address "$section" "$had_usable_cache" "runtime")" - metadata_output_path="" - if [ -n "$SUBSCRIPTION_SECTION_METADATA_TMP" ]; then - metadata_output_path="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${source_section}.metadata-output.XXXXXX" 2>/dev/null || true)" - fi - - download_subscription_into_cache \ - "$section" "$SUBSCRIPTION_SOURCE_URL" "$subscription_json_path" "$subscription_url_cache_path" "$service_proxy_address" \ - "$SUBSCRIPTION_SOURCE_USER_AGENT" "$source_section" "$metadata_output_path" - update_result=$? - - case "$update_result" in - 0) - subscription_metadata_append_file "$SUBSCRIPTION_SECTION_METADATA_TMP" "$metadata_output_path" "$index" "$source_section" - SUBSCRIPTION_SECTION_UPDATE_CHANGED=$((SUBSCRIPTION_SECTION_UPDATE_CHANGED + 1)) - ;; - 2) - subscription_metadata_append_file "$SUBSCRIPTION_SECTION_METADATA_TMP" "$metadata_output_path" "$index" "$source_section" - SUBSCRIPTION_SECTION_UPDATE_UNCHANGED=$((SUBSCRIPTION_SECTION_UPDATE_UNCHANGED + 1)) - ;; - 4) - SUBSCRIPTION_SECTION_UPDATE_SUPERSEDED=$((SUBSCRIPTION_SECTION_UPDATE_SUPERSEDED + 1)) - ;; - *) - subscription_metadata_append_cached_source "$SUBSCRIPTION_SECTION_METADATA_TMP" "$section" "$index" "$source_section" || true - SUBSCRIPTION_SECTION_UPDATE_FAILED=$((SUBSCRIPTION_SECTION_UPDATE_FAILED + 1)) - ;; - esac - rm -f "$metadata_output_path" -} - -subscription_update_selected_source_handler() { - local section="$1" - local index="$2" - local entry="$3" - local source_section subscription_json_path subscription_url_cache_path metadata_output_path \ - had_usable_cache service_proxy_address update_result - - [ "$index" = "$SUBSCRIPTION_SELECTED_SOURCE_INDEX" ] || return 0 - - SUBSCRIPTION_SELECTED_SOURCE_FOUND=1 - if ! parse_subscription_source_entry "$entry"; then - echolog "Invalid subscription URL in rule '$section': $SUBSCRIPTION_SOURCE_PARSE_ERROR" - SUBSCRIPTION_SELECTED_SOURCE_RESULT=1 - return 0 - fi - - source_section="$(subscription_source_id "$section" "$index")" - subscription_json_path="$(get_subscription_json_path "$source_section")" - subscription_url_cache_path="$(get_subscription_url_cache_path "$source_section")" - restore_persistent_subscription_cache \ - "$source_section" "$subscription_json_path" "$subscription_url_cache_path" "$(get_subscription_user_agent_cache_path "$source_section")" \ - "$SUBSCRIPTION_SOURCE_URL" "$SUBSCRIPTION_SOURCE_USER_AGENT" || true - had_usable_cache=0 - subscription_cache_is_usable "$subscription_json_path" && had_usable_cache=1 - service_proxy_address="$(get_subscription_download_proxy_address "$section" "$had_usable_cache" "runtime")" - metadata_output_path="$(mktemp "$TMP_SUBSCRIPTION_FOLDER/${source_section}.metadata-output.XXXXXX" 2>/dev/null || true)" - - download_subscription_into_cache \ - "$section" "$SUBSCRIPTION_SOURCE_URL" "$subscription_json_path" "$subscription_url_cache_path" "$service_proxy_address" \ - "$SUBSCRIPTION_SOURCE_USER_AGENT" "$source_section" "$metadata_output_path" - update_result=$? - - case "$update_result" in - 0 | 2) - write_subscription_source_metadata_json "$section" "$index" "$source_section" "$metadata_output_path" - ;; - esac - - rm -f "$metadata_output_path" - SUBSCRIPTION_SELECTED_SOURCE_RESULT="$update_result" -} - -subscription_update_selected_source() { - local section="$1" - local source_index="$2" - local force="$3" - local now - - case "$section" in - "" | */* | *..*) - echolog "Invalid subscription rule name" - return 1 - ;; - esac - - case "$source_index" in - "" | *[!0-9]*) - echolog "Invalid subscription source index for rule '$section'" - return 1 - ;; - esac - - [ "$source_index" -gt 0 ] || { - echolog "Invalid subscription source index for rule '$section'" - return 1 - } - - rule_is_subscription_proxy "$section" || { - echolog "Rule '$section' has no subscription sources" - return 1 - } - - if [ "$force" -eq 0 ]; then - subscription_update_section_is_due "$section" - case "$?" in - 0) ;; - 1) return 3 ;; - *) return 1 ;; - esac - fi - - mkdir -p "$TMP_SUBSCRIPTION_FOLDER" - - echolog "Updating subscription source '$source_index' for rule '$section'..." - - SUBSCRIPTION_SELECTED_SOURCE_INDEX="$source_index" - SUBSCRIPTION_SELECTED_SOURCE_FOUND=0 - SUBSCRIPTION_SELECTED_SOURCE_RESULT=3 - for_each_subscription_source "$section" subscription_update_selected_source_handler - - if [ "$SUBSCRIPTION_SELECTED_SOURCE_FOUND" -eq 0 ]; then - echolog "Subscription source '$source_index' was not found for rule '$section'" - return 1 - fi - - case "$SUBSCRIPTION_SELECTED_SOURCE_RESULT" in - 0) - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) ;; - *) write_subscription_update_timestamp "$section" "$now" ;; - esac - - echolog "Subscription source '$source_index' updated for rule '$section'" - return 0 - ;; - 2) - now="$(date +%s 2>/dev/null)" - case "$now" in - '' | *[!0-9]*) ;; - *) write_subscription_update_timestamp "$section" "$now" ;; - esac - - echolog "Subscription source '$source_index' for rule '$section' is unchanged" - return 2 - ;; - 4) - echolog "Subscription source '$source_index' update for rule '$section' was superseded by newer URLs" - return 4 - ;; - *) - echolog "Failed to download subscription source '$source_index' for rule '$section'" - return 1 - ;; - esac -} - -subscription_update_handler() { - local section="$1" - local result - - subscription_update_section "$section" "$SUBSCRIPTION_UPDATE_FORCE" - result=$? - - case "$result" in - 0) SUBSCRIPTION_UPDATED_SECTIONS=$((SUBSCRIPTION_UPDATED_SECTIONS + 1)) ;; - 1) SUBSCRIPTION_FAILED_SECTIONS=$((SUBSCRIPTION_FAILED_SECTIONS + 1)) ;; - 2) SUBSCRIPTION_UNCHANGED_SECTIONS=$((SUBSCRIPTION_UNCHANGED_SECTIONS + 1)) ;; - 4) SUBSCRIPTION_SUPERSEDED_SECTIONS=$((SUBSCRIPTION_SUPERSEDED_SECTIONS + 1)) ;; - 3) ;; - esac -} - -subscription_update_abort_cleanup() { - local status="${1:-1}" - - trap - EXIT INT TERM - release_runtime_dir_lock "$PODKOP_RELOAD_LOCK_DIR" - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - exit "$status" -} - -subscription_update_common_locked() { - local force="$1" - local target_section="${2:-}" - local target_source_index="${3:-}" - - SUBSCRIPTION_UPDATE_FORCE="$force" - SUBSCRIPTION_UPDATED_SECTIONS=0 - SUBSCRIPTION_FAILED_SECTIONS=0 - SUBSCRIPTION_UNCHANGED_SECTIONS=0 - SUBSCRIPTION_SUPERSEDED_SECTIONS=0 - - if [ -n "$target_section" ]; then - local result - if [ -n "$target_source_index" ]; then - subscription_update_selected_source "$target_section" "$target_source_index" "$force" - else - subscription_update_section "$target_section" "$force" - fi - result=$? - - case "$result" in - 0) SUBSCRIPTION_UPDATED_SECTIONS=$((SUBSCRIPTION_UPDATED_SECTIONS + 1)) ;; - 1) SUBSCRIPTION_FAILED_SECTIONS=$((SUBSCRIPTION_FAILED_SECTIONS + 1)) ;; - 2) SUBSCRIPTION_UNCHANGED_SECTIONS=$((SUBSCRIPTION_UNCHANGED_SECTIONS + 1)) ;; - 4) SUBSCRIPTION_SUPERSEDED_SECTIONS=$((SUBSCRIPTION_SUPERSEDED_SECTIONS + 1)) ;; - 3) ;; - esac - else - config_foreach subscription_update_handler "section" - fi - - if [ "$SUBSCRIPTION_UPDATED_SECTIONS" -eq 0 ]; then - if [ "$SUBSCRIPTION_SUPERSEDED_SECTIONS" -gt 0 ]; then - echolog "Subscription update was superseded by newer configuration" - return 0 - fi - - if [ "$SUBSCRIPTION_FAILED_SECTIONS" -gt 0 ]; then - echolog "Subscription update finished with errors; keeping the last working cache" - return 1 - fi - - if [ "$SUBSCRIPTION_UNCHANGED_SECTIONS" -gt 0 ]; then - echolog "Subscription update completed: no changes detected" - else - echolog "No subscription rules are due for update" - fi - return 0 - fi - - echolog "Reloading sing-box to apply updated subscriptions..." - config_load "$PODKOP_CONFIG_NAME" - prepare_all_server_defaults - validate_runtime_settings - sing_box_configure_service - PODKOP_NFT_POPULATE_ENABLED=0 - sing_box_init_config - PODKOP_NFT_POPULATE_ENABLED=1 - reload_sing_box_runtime - capture_reload_state - write_reload_state - - if [ "$SUBSCRIPTION_FAILED_SECTIONS" -gt 0 ]; then - echolog "Subscription update applied for changed rules; failed rules kept their previous cache" - else - echolog "Subscription update completed" - fi -} - -subscription_update_common() { - local force="$1" - local target_section="${2:-}" - local target_source_index="${3:-}" - local result - - config_load "$PODKOP_CONFIG_NAME" - migration - ensure_runtime_dirs - - if [ "$force" -eq 1 ]; then - if ! acquire_runtime_dir_lock_wait "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" 300; then - echolog "Subscription update is already running" - mark_pending_reload "subscription_update_busy" - return 1 - fi - elif ! acquire_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR"; then - echolog "Subscription update is already running" - return 0 - fi - - if [ "$force" -eq 1 ]; then - if ! acquire_runtime_dir_lock_wait "$PODKOP_RELOAD_LOCK_DIR" 300; then - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - echolog "Podkop Plus reload is already running; skipping subscription update" - mark_pending_reload "reload_busy" - return 1 - fi - elif ! acquire_runtime_dir_lock "$PODKOP_RELOAD_LOCK_DIR"; then - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - echolog "Podkop Plus reload is already running; skipping subscription update" - return 0 - fi - - trap 'subscription_update_abort_cleanup "$?"' EXIT INT TERM - - subscription_update_common_locked "$force" "$target_section" "$target_source_index" - result=$? - - trap - EXIT INT TERM - - release_runtime_dir_lock "$PODKOP_RELOAD_LOCK_DIR" - release_runtime_dir_lock "$PODKOP_SUBSCRIPTION_UPDATE_LOCK_DIR" - run_pending_reload_if_requested - return "$result" -} - -subscription_update_if_due() { - echolog "Starting due subscription update..." - subscription_update_common 0 -} - -subscription_update() { - local target_section="${1:-}" - local target_source_index="${2:-}" - - if [ -n "$target_section" ]; then - echolog "Starting subscription update for rule '$target_section'..." - else - echolog "Starting subscription update..." - fi - subscription_update_common 1 "$target_section" "$target_source_index" -} - -subscription_update_job_json_response() { - local success="$1" - local job_id="$2" - local message="${3:-}" - - updates_runtime_ucode subscription-job-json-response "$success" "$job_id" "$message" -} - -subscription_update_job_state_path() { - local job_id="$1" - - case "$job_id" in - *[!A-Za-z0-9._-]* | "" | "." | "..") - return 1 - ;; - esac - - printf '%s/%s.json\n' "$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR" "$job_id" -} - -subscription_update_job_tmp_file() { - local target_file="$1" - local tmp_file - - tmp_file="$(mktemp "${target_file}.XXXXXX" 2>/dev/null || true)" - if [ -z "$tmp_file" ]; then - tmp_file="${target_file}.$$.$(date +%s 2>/dev/null).tmp" - : >"$tmp_file" || return 1 - fi - - printf '%s\n' "$tmp_file" -} - -subscription_update_write_running_job_state() { - local state_file="$1" - local section="${2:-}" - local source_index="${3:-}" - local tmp_file started_at - - started_at="$(date +%s 2>/dev/null)" - case "$started_at" in - "" | *[!0-9]*) started_at=0 ;; - esac - - mkdir -p "$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR" || return 1 - tmp_file="$(subscription_update_job_tmp_file "$state_file")" || return 1 - - updates_runtime_ucode subscription-running-job-state "$section" "$source_index" "$started_at" >"$tmp_file" && mv "$tmp_file" "$state_file" - - local rc=$? - rm -f "$tmp_file" 2>/dev/null - return "$rc" -} - -subscription_update_update_running_job_pid() { - local state_file="$1" - local pid="$2" - local tmp_file - - case "$pid" in - "" | *[!0-9]*) return 1 ;; - esac - - tmp_file="$(subscription_update_job_tmp_file "$state_file")" || return 1 - updates_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 - return "$rc" -} - -subscription_update_started_at_is_within_stale_grace() { - local started_at="$1" - local now age - - case "$started_at" in - "" | *[!0-9]*) return 1 ;; - esac - [ "$started_at" -gt 0 ] || return 1 - - now="$(date +%s 2>/dev/null)" - case "$now" in - "" | *[!0-9]*) return 1 ;; - esac - - age=$((now - started_at)) - [ "$age" -lt "${PODKOP_UI_ACTION_STALE_GRACE_SECONDS:-15}" ] -} - -subscription_update_cleanup_jobs() { - local state_file - - [ -d "$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR" ] || return 0 - - find "$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR" -type f -name '*.out' -mmin "+$PODKOP_SUBSCRIPTION_UPDATE_JOB_ORPHAN_OUTPUT_TTL_MINUTES" -delete 2>/dev/null || true - - find "$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR" -type f -name '*.json' -mmin "+$PODKOP_SUBSCRIPTION_UPDATE_JOB_FINISHED_TTL_MINUTES" 2>/dev/null | - while IFS= read -r state_file; do - [ -f "$state_file" ] || continue - if updates_ucode job-running-is "$state_file" false >/dev/null 2>&1; then - rm -f "$state_file" 2>/dev/null || true - fi - done -} - -subscription_update_output_message() { - local output_file="$1" - local fallback="$2" - - updates_ucode file-last-nonblank-line "$output_file" "$fallback" 240 -} - -subscription_update_write_finished_job_state() { - local state_file="$1" - local exit_code="$2" - local output_file="$3" - local tmp_file updated_at success message section source_index started_at - - updated_at="$(date +%s 2>/dev/null)" - case "$updated_at" in - "" | *[!0-9]*) updated_at=0 ;; - esac - - success=false - message="$(subscription_update_output_message "$output_file" "Subscription update failed")" - if [ "$exit_code" -eq 0 ]; then - success=true - message="$(subscription_update_output_message "$output_file" "Subscription update completed")" - fi - - section="$(updates_ucode json-file-field "$state_file" section "" 2>/dev/null)" - source_index="$(updates_ucode json-file-field "$state_file" source_index "" 2>/dev/null)" - started_at="$(updates_ucode json-file-field "$state_file" started_at 0 2>/dev/null)" - - tmp_file="$(subscription_update_job_tmp_file "$state_file")" || return 1 - updates_runtime_ucode subscription-finished-job-state "$success" "$message" "$exit_code" "$updated_at" "$section" "$source_index" "$started_at" >"$tmp_file" && mv "$tmp_file" "$state_file" - - local rc=$? - rm -f "$tmp_file" "$output_file" 2>/dev/null - return "$rc" -} - -subscription_update_mark_stale_job_state() { - local state_file="$1" - local tmp_file updated_at section source_index started_at - - updated_at="$(date +%s 2>/dev/null)" - case "$updated_at" in - "" | *[!0-9]*) updated_at=0 ;; - esac - - section="$(updates_ucode json-file-field "$state_file" section "" 2>/dev/null)" - source_index="$(updates_ucode json-file-field "$state_file" source_index "" 2>/dev/null)" - started_at="$(updates_ucode json-file-field "$state_file" started_at 0 2>/dev/null)" - - tmp_file="$(subscription_update_job_tmp_file "$state_file")" || return 1 - updates_runtime_ucode subscription-stale-job-state "$updated_at" "$section" "$source_index" "$started_at" >"$tmp_file" && mv "$tmp_file" "$state_file" - - local rc=$? - rm -f "$tmp_file" 2>/dev/null - return "$rc" -} - -subscription_update_refresh_running_job_state() { - local state_file="$1" - local pid started_at - - updates_ucode job-running-is "$state_file" true >/dev/null 2>&1 || return 0 - - pid="$(updates_runtime_ucode job-pid "$state_file" 2>/dev/null)" - case "$pid" in - "" | *[!0-9]*) - started_at="$(updates_ucode json-file-field "$state_file" started_at 0 2>/dev/null)" - if subscription_update_started_at_is_within_stale_grace "$started_at"; then - return 0 - fi - updates_ucode job-running-is "$state_file" true >/dev/null 2>&1 || return 0 - subscription_update_mark_stale_job_state "$state_file" - return 0 - ;; - esac - - kill -0 "$pid" 2>/dev/null && return 0 - sleep 1 - updates_ucode job-running-is "$state_file" true >/dev/null 2>&1 || return 0 - kill -0 "$pid" 2>/dev/null && return 0 - updates_ucode job-running-is "$state_file" true >/dev/null 2>&1 || return 0 - subscription_update_mark_stale_job_state "$state_file" -} - -subscription_update_async() { - local target_section="${1:-}" - local target_source_index="${2:-}" - local job_id state_file output_file job_pid - - ensure_runtime_dirs - mkdir -p "$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR" || { - subscription_update_job_json_response false "" "Failed to create subscription update state directory" - exit 1 - } - subscription_update_cleanup_jobs - - job_id="$(date +%s 2>/dev/null)-$$" - state_file="$(subscription_update_job_state_path "$job_id")" || { - subscription_update_job_json_response false "" "Failed to prepare subscription update job" - exit 1 - } - output_file="$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR/$job_id.out" - - subscription_update_write_running_job_state "$state_file" "$target_section" "$target_source_index" || { - subscription_update_job_json_response false "" "Failed to write subscription update state" - exit 1 - } - - ( - trap '' HUP - /usr/bin/podkop-plus subscription_update "$target_section" "$target_source_index" >"$output_file" 2>&1 - subscription_update_write_finished_job_state "$state_file" "$?" "$output_file" - ) >/dev/null 2>&1 & - job_pid="$!" - - subscription_update_update_running_job_pid "$state_file" "$job_pid" || { - kill "$job_pid" 2>/dev/null || true - subscription_update_job_json_response false "" "Failed to write subscription update worker pid" - exit 1 - } - - subscription_update_job_json_response true "$job_id" "Subscription update started" -} - -subscription_update_status() { - local job_id="$1" - local state_file - - mkdir -p "$PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR" 2>/dev/null || true - subscription_update_cleanup_jobs - - state_file="$(subscription_update_job_state_path "$job_id")" || { - updates_runtime_ucode subscription-status-error "Invalid subscription update job id" - exit 1 - } - - if [ ! -f "$state_file" ]; then - updates_runtime_ucode subscription-status-error "Subscription update job was not found" - exit 1 - fi - - subscription_update_refresh_running_job_state "$state_file" - - cat "$state_file" -} - -# sing-box funcs diff --git a/podkop/files/usr/lib/updates_runtime.uc b/podkop/files/usr/lib/updates_runtime.uc deleted file mode 100644 index 7b008208..00000000 --- a/podkop/files/usr/lib/updates_runtime.uc +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env ucode - -let fs = require("fs"); - -function as_string(value) { - return value == null ? "" : "" + value; -} - -function read_stdin() { - let data = fs.readfile("/dev/stdin"); - 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 write_json(value) { - print(sprintf("%J", value), "\n"); -} - -function file_first_line(path) { - let data = fs.readfile(path); - if (data == null) - exit(1); - - let newline = index(data, "\n"); - print(newline >= 0 ? substr(data, 0, newline) : data, "\n"); -} - -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 stdin_first_ipv4_line() { - for (let line in split(read_stdin(), "\n")) { - line = as_string(line); - if (match(line, /^[0-9]+\./) != null) { - print(line, "\n"); - return; - } - } -} - -function line_contains_any_marker(line, markers) { - line = as_string(line); - for (let marker in markers) { - marker = as_string(marker); - if (marker != "" && index(line, marker) >= 0) - return true; - } - - return false; -} - -function filter_cron_markers(markers) { - let data = read_stdin(); - if (data == "") - return; - - let lines = split(data, "\n"); - let has_trailing_newline = substr(data, length(data) - 1) == "\n"; - - for (let i = 0; i < length(lines); i++) { - let line = as_string(lines[i]); - if (i == length(lines) - 1 && has_trailing_newline && line == "") - continue; - if (line_contains_any_marker(line, markers)) - continue; - print(line, "\n"); - } -} - -function json_length(path) { - let value = read_json_file(path); - if (type(value) == "array" || type(value) == "object") - print(length(value), "\n"); - else - print("0\n"); -} - -function job_pid(path) { - let value = read_json_file(path); - if (type(value) == "object" && value.pid != null) - print(as_string(value.pid), "\n"); -} - -function subscription_job_json_response(success, job_id, message) { - write_json({ - success: arg_bool(success), - job_id: as_string(job_id), - message: as_string(message) - }); -} - -function subscription_running_job_state(section, source_index, started_at) { - write_json({ - success: true, - running: true, - kind: "subscription", - message: "Subscription update is running", - section: as_string(section), - source_index: as_string(source_index), - pid: null, - started_at: arg_number(started_at), - updated_at: null, - exit_code: null - }); -} - -function subscription_finished_job_state(success, message, exit_code, updated_at, section, source_index, started_at) { - write_json({ - success: arg_bool(success), - running: false, - kind: "subscription", - message: as_string(message), - section: as_string(section), - source_index: as_string(source_index), - pid: null, - started_at: arg_number(started_at), - exit_code: arg_number(exit_code), - updated_at: arg_number(updated_at) - }); -} - -function subscription_stale_job_state(updated_at, section, source_index, started_at) { - write_json({ - success: false, - running: false, - kind: "subscription", - message: "Subscription update worker exited unexpectedly", - section: as_string(section), - source_index: as_string(source_index), - pid: null, - started_at: arg_number(started_at), - exit_code: null, - updated_at: arg_number(updated_at) - }); -} - -function subscription_status_error(message) { - write_json({ - success: false, - running: false, - message: as_string(message), - exit_code: null - }); -} - -let mode = ARGV[0] || ""; - -if (mode == "json-length") - json_length(ARGV[1]); -else if (mode == "file-first-line") - file_first_line(ARGV[1]); -else if (mode == "stdin-first-ipv4-line") - stdin_first_ipv4_line(); -else if (mode == "filter-cron-markers") - filter_cron_markers([ARGV[1], ARGV[2]]); -else if (mode == "job-pid") - job_pid(ARGV[1]); -else if (mode == "subscription-job-json-response") - subscription_job_json_response(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "subscription-running-job-state") - subscription_running_job_state(ARGV[1], ARGV[2], ARGV[3]); -else if (mode == "subscription-finished-job-state") - subscription_finished_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7]); -else if (mode == "subscription-stale-job-state") - subscription_stale_job_state(ARGV[1], ARGV[2], ARGV[3], ARGV[4]); -else if (mode == "subscription-status-error") - subscription_status_error(ARGV[1]); -else { - warn("Usage: updates_runtime.uc ...\n"); - exit(1); -} diff --git a/podkop/files/usr/lib/zapret.sh b/podkop/files/usr/lib/zapret.sh deleted file mode 100644 index 771f6665..00000000 --- a/podkop/files/usr/lib/zapret.sh +++ /dev/null @@ -1,958 +0,0 @@ -has_enabled_zapret_rules() { - local count - - count="$(get_zapret_rule_count)" - [ "${count:-0}" -gt 0 ] -} - -_count_zapret_rule_handler() { - local section="$1" - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret" ] || return 0 - - ZAPRET_RULE_COUNT=$((ZAPRET_RULE_COUNT + 1)) -} - -get_zapret_rule_count() { - ZAPRET_RULE_COUNT=0 - config_foreach _count_zapret_rule_handler "section" - echo "$ZAPRET_RULE_COUNT" -} - -_find_zapret_rule_index_handler() { - local section="$1" - local target_section="$2" - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret" ] || return 0 - - ZAPRET_RULE_INDEX_WALK=$((ZAPRET_RULE_INDEX_WALK + 1)) - if [ "$section" = "$target_section" ]; then - ZAPRET_RULE_INDEX_RESULT="$ZAPRET_RULE_INDEX_WALK" - fi -} - -get_zapret_rule_index() { - local section="$1" - - ZAPRET_RULE_INDEX_WALK=0 - ZAPRET_RULE_INDEX_RESULT=0 - config_foreach _find_zapret_rule_index_handler "section" "$section" - - echo "$ZAPRET_RULE_INDEX_RESULT" -} - -format_mark_hex() { - local mark_value="$1" - - printf '0x%08x\n' "$mark_value" -} - -get_zapret_rule_mark_value() { - local index="$1" - - echo $((ZAPRET_ROUTE_MARK_BASE + index)) -} - -get_zapret_rule_mark_hex() { - local index="$1" - - format_mark_hex "$(get_zapret_rule_mark_value "$index")" -} - -get_zapret_rule_queue_number() { - local index="$1" - - echo $((ZAPRET_QUEUE_BASE + index - 1)) -} - -get_rule_nfqws_opt() { - local section="$1" - local nfqws_opt - - config_get nfqws_opt "$section" "nfqws_opt" - if [ -n "$nfqws_opt" ]; then - normalize_nfqws_strategy_whitespace "$nfqws_opt" - else - normalize_nfqws_strategy_whitespace "$ZAPRET_DEFAULT_NFQWS_OPT" - fi -} - -normalize_nfqws_strategy_whitespace() { - normalize_strategy_whitespace "$1" -} - -nfqws_option_argument_mode() { - case "$1" in - --debug | --comment | --synack-split | --ctrack-disable | --ipcache-hostname | \ - --dup-autottl | --dup-autottl6 | --dup-tcp-flags-set | --dup-tcp-flags-unset | \ - --dup-replace | --orig-autottl | --orig-autottl6 | --orig-tcp-flags-set | \ - --orig-tcp-flags-unset | --dpi-desync-autottl | --dpi-desync-autottl6 | \ - --dpi-desync-tcp-flags-set | --dpi-desync-tcp-flags-unset | \ - --dpi-desync-skip-nosni | --dpi-desync-any-protocol) - echo "optional" - ;; - --dry-run | --version | --daemon | --hostcase | --hostnospace | --domcase | \ - --methodeol | --new | --skip | --bind-fix4 | --bind-fix6) - echo "none" - ;; - --qnum | --pidfile | --user | --uid | --wsize | --wssize | --wssize-cutoff | \ - --wssize-forced-cutoff | --ctrack-timeouts | --ipcache-lifetime | \ - --hostspell | --ip-id | --dpi-desync | --dpi-desync-fwmark | --dup | \ - --dup-ttl | --dup-ttl6 | --dup-fooling | --dup-ts-increment | \ - --dup-badseq-increment | --dup-badack-increment | --dup-ip-id | \ - --dup-start | --dup-cutoff | --orig-ttl | --orig-ttl6 | \ - --orig-mod-start | --orig-mod-cutoff | --dpi-desync-ttl | \ - --dpi-desync-ttl6 | --dpi-desync-fooling | --dpi-desync-repeats | \ - --dpi-desync-split-pos | --dpi-desync-split-http-req | \ - --dpi-desync-split-tls | --dpi-desync-split-seqovl | \ - --dpi-desync-split-seqovl-pattern | --dpi-desync-fakedsplit-pattern | \ - --dpi-desync-fakedsplit-mod | --dpi-desync-hostfakesplit-midhost | \ - --dpi-desync-hostfakesplit-mod | --dpi-desync-ipfrag-pos-tcp | \ - --dpi-desync-ipfrag-pos-udp | --dpi-desync-ts-increment | \ - --dpi-desync-badseq-increment | --dpi-desync-badack-increment | \ - --dpi-desync-fake-tcp-mod | --dpi-desync-fake-http | \ - --dpi-desync-fake-tls | --dpi-desync-fake-tls-mod | \ - --dpi-desync-fake-unknown | --dpi-desync-fake-syndata | \ - --dpi-desync-fake-quic | --dpi-desync-fake-wireguard | \ - --dpi-desync-fake-dht | --dpi-desync-fake-discord | \ - --dpi-desync-fake-stun | --dpi-desync-fake-unknown-udp | \ - --dpi-desync-udplen-increment | --dpi-desync-udplen-pattern | \ - --dpi-desync-cutoff | --dpi-desync-start | --hostlist | \ - --hostlist-domains | --hostlist-exclude | --hostlist-exclude-domains | \ - --hostlist-auto | --hostlist-auto-fail-threshold | \ - --hostlist-auto-fail-time | --hostlist-auto-retrans-threshold | \ - --hostlist-auto-debug | --filter-l3 | --filter-tcp | --filter-udp | \ - --filter-l7 | --ipset | --ipset-ip | --ipset-exclude | \ - --ipset-exclude-ip) - echo "required" - ;; - *) - echo "unknown" - ;; - esac -} - -clear_nfqws_validation_state() { - NFQWS_VALIDATE_ERROR="" - NFQWS_VALIDATE_NEEDLE="" - NFQWS_VALIDATE_NEEDLES="" -} - -append_nfqws_validation_needle() { - local needle="$1" - - [ -n "$needle" ] || return 0 - - case " -$NFQWS_VALIDATE_NEEDLES -" in - *" -$needle -"*) - return 0 - ;; - esac - - if [ -n "$NFQWS_VALIDATE_NEEDLES" ]; then - NFQWS_VALIDATE_NEEDLES="${NFQWS_VALIDATE_NEEDLES} -$needle" - else - NFQWS_VALIDATE_NEEDLES="$needle" - fi - - [ -n "$NFQWS_VALIDATE_NEEDLE" ] || NFQWS_VALIDATE_NEEDLE="$needle" -} - -set_nfqws_validation_failure() { - NFQWS_VALIDATE_ERROR="$1" - NFQWS_VALIDATE_NEEDLE="" - NFQWS_VALIDATE_NEEDLES="" - shift - - while [ "$#" -gt 0 ]; do - append_nfqws_validation_needle "$1" - shift - done - - return 1 -} - -get_nfqws_unsupported_token_reason() { - local token="$1" - - case "$token" in - "" | "") - echo "Podkop Plus does not expand zapret hostlist templates in per-rule strategies because sing-box already selects the resources before NFQUEUE." - return 0 - ;; - --hostlist | --hostlist=* | --hostlist-domains | --hostlist-domains=* | \ - --hostlist-exclude | --hostlist-exclude=* | --hostlist-exclude-domains | --hostlist-exclude-domains=* | \ - --hostlist-auto | --hostlist-auto=* | --hostlist-auto-fail-threshold | --hostlist-auto-fail-threshold=* | \ - --hostlist-auto-fail-time | --hostlist-auto-fail-time=* | \ - --hostlist-auto-retrans-threshold | --hostlist-auto-retrans-threshold=* | \ - --hostlist-auto-debug | --hostlist-auto-debug=*) - echo "Hostname-based selection inside nfqws is incompatible with the Podkop Plus architecture because sing-box already chooses which resources enter action=zapret." - return 0 - ;; - --ipset | --ipset=* | --ipset-ip | --ipset-ip=* | --ipset-exclude | --ipset-exclude=* | --ipset-exclude-ip | --ipset-exclude-ip=*) - echo "IP or CIDR selection inside nfqws is incompatible with the Podkop Plus architecture because sing-box already chooses which resources enter action=zapret." - return 0 - ;; - --qnum | --qnum=*) - echo "The NFQUEUE number is assigned by Podkop Plus per rule and must not be overridden in the strategy." - return 0 - ;; - --dpi-desync-fwmark | --dpi-desync-fwmark=*) - echo "The desync fwmark is managed by Podkop Plus for loop prevention and must not be overridden in the strategy." - return 0 - ;; - --daemon) - echo "Podkop Plus manages the nfqws process lifecycle itself. The strategy must not daemonize nfqws." - return 0 - ;; - --dry-run) - echo "The strategy must launch a working nfqws process. --dry-run exits immediately and is not allowed." - return 0 - ;; - --version) - echo "The strategy must launch a working nfqws process. --version exits immediately and is not allowed." - return 0 - ;; - esac - - return 1 -} - -zapret_validation_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/zapret_validation.uc" "$@" -} - -extract_nfqws_validation_summary() { - printf '%s' "$1" | zapret_validation_ucode summary nfqws 2>/dev/null -} - -collect_nfqws_validation_needles() { - zapret_validation_ucode needles "$1" 2>/dev/null -} - -run_nfqws_dry_run_validation() { - local raw_opt="$1" - local old_ifs output_file output summary needles rc - - if ! is_zapret_provider_available; then - return 0 - fi - - raw_opt="$(expand_zapret_nfqws_opt "$raw_opt")" - raw_opt="$(normalize_nfqws_strategy_whitespace "$raw_opt")" - [ -n "$raw_opt" ] || return 0 - - output_file="$(mktemp)" - - set -f - old_ifs="$IFS" - IFS=' ' - set -- $raw_opt - IFS="$old_ifs" - - "$ZAPRET_NFQWS_BIN" --dry-run --qnum="$ZAPRET_QUEUE_BASE" --dpi-desync-fwmark="$ZAPRET_DESYNC_MARK" "$@" >"$output_file" 2>&1 - rc=$? - output="$(cat "$output_file")" - rm -f "$output_file" - set +f - - [ "$rc" -eq 0 ] && return 0 - - summary="$(extract_nfqws_validation_summary "$output")" - [ -n "$summary" ] || summary="nfqws rejected the strategy during syntax validation." - - needles="$(collect_nfqws_validation_needles "$summary")" - if [ -n "$needles" ]; then - # shellcheck disable=SC2086 - set_nfqws_validation_failure "nfqws syntax check failed: $summary" $needles - else - set_nfqws_validation_failure "nfqws syntax check failed: $summary" - fi -} - -check_nfqws_strategy() { - local raw_opt="$1" - local old_ifs token base_token mode reason display next_token token_count=0 - - clear_nfqws_validation_state - raw_opt="$(normalize_nfqws_strategy_whitespace "$raw_opt")" - - if [ "$raw_opt" = "$ZAPRET_LEGACY_DEFAULT_NFQWS_OPT" ]; then - return 0 - fi - - set -f - old_ifs="$IFS" - IFS=' ' - set -- $raw_opt - IFS="$old_ifs" - - while [ "$#" -gt 0 ]; do - token="$1" - next_token="$2" - base_token="${token%%=*}" - - if [ "$token_count" -eq 0 ]; then - case "$token" in - @* | \$*) - set_nfqws_validation_failure \ - "Unsupported NFQWS token '$token': External nfqws config files bypass Podkop Plus validation and queue management." \ - "$token" - return 1 - ;; - esac - fi - - if reason="$(get_nfqws_unsupported_token_reason "$token")"; then - display="$token" - mode="$(nfqws_option_argument_mode "$base_token")" - if [ "$base_token" = "$token" ] && [ "$mode" = "required" ] && [ "$#" -gt 1 ] && [ "${next_token#--}" = "$next_token" ]; then - display="$display $next_token" - set_nfqws_validation_failure "Unsupported NFQWS token '$display': $reason" "$base_token" "$next_token" - return 1 - fi - - set_nfqws_validation_failure "Unsupported NFQWS token '$display': $reason" "$base_token" - return 1 - fi - - case "$token" in - --*) - mode="$(nfqws_option_argument_mode "$base_token")" - case "$mode" in - unknown) - set_nfqws_validation_failure \ - "Unknown NFQWS flag '$token'." \ - "$base_token" - return 1 - ;; - none) - if [ "$base_token" != "$token" ]; then - set_nfqws_validation_failure \ - "NFQWS flag '$base_token' does not accept a value." \ - "$base_token" - return 1 - fi - shift - token_count=$((token_count + 1)) - ;; - optional) - if [ "$base_token" = "$token" ] && [ "$#" -gt 1 ] && [ "${next_token#--}" = "$next_token" ]; then - set_nfqws_validation_failure \ - "Optional value for '$base_token' must be attached as '$base_token=value'. Separate tokens are ignored by nfqws here." \ - "$base_token" "$next_token" - return 1 - fi - shift - token_count=$((token_count + 1)) - ;; - required) - if [ "$base_token" != "$token" ]; then - shift - token_count=$((token_count + 1)) - continue - fi - - if [ "$#" -lt 2 ] || [ "${next_token#--}" != "$next_token" ]; then - set_nfqws_validation_failure \ - "NFQWS option '$base_token' requires a value." \ - "$base_token" - return 1 - fi - - shift 2 - token_count=$((token_count + 2)) - ;; - esac - ;; - *) - set_nfqws_validation_failure \ - "Unexpected standalone NFQWS token '$token'. Use explicit flags such as --name or --name=value." \ - "$token" - return 1 - ;; - esac - done - - set +f - - run_nfqws_dry_run_validation "$raw_opt" -} - -validate_nfqws_strategy() { - local raw_opt="$1" - local context="$2" - - if ! check_nfqws_strategy "$raw_opt"; then - log "$context uses invalid NFQWS strategy: $NFQWS_VALIDATE_ERROR Aborted." "fatal" - exit 1 - fi -} - -validate_rule_nfqws_opt() { - local section="$1" - local raw_opt - - raw_opt="$(get_rule_nfqws_opt "$section")" - validate_nfqws_strategy "$raw_opt" "Zapret rule '$section'" -} - -prepare_zapret_runtime() { - mkdir -p "$ZAPRET_STATE_DIR" "$ZAPRET_PID_DIR" "$ZAPRET_CHILD_PID_DIR" "$ZAPRET_LOG_DIR" -} - -is_zapret_provider_available() { - [ -x "$ZAPRET_PROVIDER_NFQWS_BIN" ] -} - -is_zapret_installed() { - is_zapret_provider_available -} - -zapret_package_installed() { - if command -v apk >/dev/null 2>&1 && apk info -e zapret >/dev/null 2>&1; then - return 0 - fi - - if opkg_package_is_installed "zapret"; then - return 0 - fi - - return 1 -} - -luci_app_zapret_installed() { - if command -v apk >/dev/null 2>&1 && apk info -e luci-app-zapret >/dev/null 2>&1; then - return 0 - fi - - if opkg_package_is_installed "luci-app-zapret"; then - return 0 - fi - - [ -f /usr/share/luci/menu.d/luci-app-zapret.json ] || - [ -f /usr/share/rpcd/acl.d/luci-app-zapret.json ] -} - -zapret_legacy_runtime_path_present() { - uci -q show "$PODKOP_CONFIG_NAME" 2>/dev/null | - zapret_validation_ucode stdin-contains "$ZAPRET_LEGACY_RUNTIME_BASE_DIR" >/dev/null 2>&1 && return 0 - [ -d "$ZAPRET_LEGACY_RUNTIME_BASE_DIR" ] -} - -rewrite_legacy_zapret_runtime_paths() { - printf '%s' "$1" | zapret_validation_ucode rewrite-legacy-runtime-path "$ZAPRET_LEGACY_RUNTIME_BASE_DIR" "$ZAPRET_PROVIDER_BASE_DIR" -} - -expand_zapret_nfqws_opt() { - rewrite_legacy_zapret_runtime_paths "$1" -} - -check_zapret_requirements() { - cleanup_legacy_zapret_runtime - has_enabled_zapret_rules || return 0 - - if ! is_zapret_provider_available; then - log "Zapret provider is not available at $ZAPRET_PROVIDER_NFQWS_BIN. Rules with action 'zapret' will be skipped until the zapret provider is installed." "error" - return 0 - fi - - if ! prepare_zapret_runtime; then - log "Failed to prepare the Podkop Plus zapret state directory in $ZAPRET_STATE_DIR. Aborted." "fatal" - exit 1 - fi -} - -get_zapret_package_version() { - local version="" - - if command -v apk >/dev/null 2>&1 && apk info -e zapret >/dev/null 2>&1; then - version="$(get_apk_installed_package_version "zapret")" - elif command -v opkg >/dev/null 2>&1; then - version="$(get_opkg_installed_package_version "zapret")" - fi - - if [ -z "$version" ] && [ -x "$ZAPRET_PROVIDER_NFQWS_BIN" ]; then - version="$("$ZAPRET_PROVIDER_NFQWS_BIN" --version 2>/dev/null | provider_status_ucode stdin-first-line-version-field)" - fi - - echo "$version" -} - -is_zapret_standalone_service_enabled() { - [ -x /etc/init.d/zapret ] || return 1 - /etc/init.d/zapret enabled >/dev/null 2>&1 -} - -is_zapret_standalone_service_running() { - [ -x /etc/init.d/zapret ] || return 1 - /etc/init.d/zapret status >/dev/null 2>&1 -} - -zapret_standalone_uci_config_present() { - [ -f /etc/config/zapret ] || return 1 - uci -q get zapret.config >/dev/null 2>&1 -} - -stop_legacy_zapret_runtime_processes() { - local pid - - ps w 2>/dev/null | - zapret_validation_ucode ps-matching-pids "$ZAPRET_LEGACY_RUNTIME_BASE_DIR/nfq/nfqws" 2>/dev/null | - while read -r pid; do - [ -n "$pid" ] || continue - kill "$pid" 2>/dev/null || true - done -} - -cleanup_legacy_zapret_runtime() { - stop_legacy_zapret_runtime_processes - rm -rf "$ZAPRET_LEGACY_RUNTIME_BASE_DIR" -} - -get_zapret_nfqws_process_count() { - local count=0 pidfile pid - - [ -d "$ZAPRET_CHILD_PID_DIR" ] || { - echo 0 - return 0 - } - - for pidfile in "$ZAPRET_CHILD_PID_DIR"/*.pid; do - [ -f "$pidfile" ] || continue - pid="$(cat "$pidfile" 2>/dev/null)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - count=$((count + 1)) - else - rm -f "$pidfile" - fi - done - - echo "$count" -} - -get_zapret_supervisor_process_count() { - local count=0 pidfile pid - - [ -d "$ZAPRET_PID_DIR" ] || { - echo 0 - return 0 - } - - for pidfile in "$ZAPRET_PID_DIR"/*.pid; do - [ -f "$pidfile" ] || continue - pid="$(cat "$pidfile" 2>/dev/null)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - count=$((count + 1)) - else - rm -f "$pidfile" - fi - done - - echo "$count" -} - -get_zapret_queue_range_end() { - echo $((ZAPRET_QUEUE_BASE + ZAPRET_QUEUE_RANGE_SIZE - 1)) -} - -zapret_external_queue_overlap_present() { - local range_end - - range_end="$(get_zapret_queue_range_end)" - nft list ruleset 2>/dev/null | - zapret_validation_ucode nft-queue-overlap "$NFT_TABLE_NAME" "$ZAPRET_QUEUE_BASE" "$range_end" >/dev/null 2>&1 -} - -zapret_standalone_conflict_present() { - has_enabled_zapret_rules || return 1 - is_zapret_standalone_service_running -} - -zapret_rule_outbound_present() { - local section="$1" - local routing_mark="$2" - local outbound_tag - - [ -f "$ZAPRET_SINGBOX_CONFIG_PATH" ] || return 1 - - outbound_tag="$(get_outbound_tag_by_section "$section")" - - provider_status_ucode has-direct-mark-outbound "$ZAPRET_SINGBOX_CONFIG_PATH" "$outbound_tag" "$routing_mark" >/dev/null 2>&1 -} - -zapret_rule_route_rule_present() { - local section="$1" - local outbound_tag - - [ -f "$ZAPRET_SINGBOX_CONFIG_PATH" ] || return 1 - - outbound_tag="$(get_outbound_tag_by_section "$section")" - - 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() { - local section="$1" - local index mark_value - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret" ] || return 0 - - ZAPRET_RUNTIME_RULES_CONFIGURED=1 - index="$(get_zapret_rule_index "$section")" - mark_value="$(get_zapret_rule_mark_value "$index")" - - if ! zapret_rule_outbound_present "$section" "$mark_value"; then - ZAPRET_RUNTIME_OUTBOUNDS_CONFIGURED=0 - fi - - if ! zapret_rule_route_rule_present "$section"; then - ZAPRET_RUNTIME_ROUTES_CONFIGURED=0 - fi -} - -collect_zapret_runtime_status() { - local sing_box_config_path - - ZAPRET_RUNTIME_RULES_CONFIGURED=0 - ZAPRET_RUNTIME_OUTBOUNDS_CONFIGURED=1 - ZAPRET_RUNTIME_ROUTES_CONFIGURED=1 - - config_get sing_box_config_path "settings" "config_path" - ZAPRET_SINGBOX_CONFIG_PATH="$sing_box_config_path" - config_foreach _collect_zapret_runtime_status_handler "section" - - if [ "$ZAPRET_RUNTIME_RULES_CONFIGURED" -eq 0 ]; then - ZAPRET_RUNTIME_OUTBOUNDS_CONFIGURED=0 - ZAPRET_RUNTIME_ROUTES_CONFIGURED=0 - fi -} - -get_zapret_status_json() { - local installed=0 package_installed=0 provider_available=0 files_available=0 ipset_available=0 configured=0 - local standalone_service_enabled=0 standalone_service_running=0 standalone_config_present=0 ready=0 conflict=0 - local queue_overlap=0 standalone_conflict=0 legacy_runtime_present=0 luci_app_installed=0 - local enabled_rule_count expected_process_count running_process_count supervisor_process_count version queue_range_end status_message - local outbounds_configured=0 routes_configured=0 - - enabled_rule_count="$(get_zapret_rule_count)" - expected_process_count="${enabled_rule_count:-0}" - running_process_count="$(get_zapret_nfqws_process_count)" - supervisor_process_count="$(get_zapret_supervisor_process_count)" - queue_range_end="$(get_zapret_queue_range_end)" - version="not installed" - status_message="" - - if [ "${enabled_rule_count:-0}" -gt 0 ]; then - configured=1 - fi - - if zapret_package_installed; then - package_installed=1 - version="$(get_zapret_package_version)" - [ -n "$version" ] || version="unknown" - fi - - if [ -d "$ZAPRET_PROVIDER_FILES_DIR" ]; then - files_available=1 - fi - - if [ -d "$ZAPRET_PROVIDER_IPSET_DIR" ]; then - ipset_available=1 - fi - - if is_zapret_provider_available; then - provider_available=1 - installed=1 - if [ "$package_installed" -eq 0 ]; then - version="$(get_zapret_package_version)" - [ -n "$version" ] || version="unknown" - fi - fi - - if is_zapret_standalone_service_enabled; then - standalone_service_enabled=1 - fi - - if is_zapret_standalone_service_running; then - standalone_service_running=1 - fi - - if zapret_standalone_uci_config_present; then - standalone_config_present=1 - fi - - if zapret_external_queue_overlap_present; then - queue_overlap=1 - fi - - if zapret_standalone_conflict_present; then - standalone_conflict=1 - fi - - if zapret_legacy_runtime_path_present; then - legacy_runtime_present=1 - fi - - if luci_app_zapret_installed; then - luci_app_installed=1 - fi - - collect_zapret_runtime_status - outbounds_configured="${ZAPRET_RUNTIME_OUTBOUNDS_CONFIGURED:-0}" - routes_configured="${ZAPRET_RUNTIME_ROUTES_CONFIGURED:-0}" - - if [ "${running_process_count:-0}" -gt "${expected_process_count:-0}" ] || - [ "$queue_overlap" -eq 1 ] || - [ "$legacy_runtime_present" -eq 1 ]; then - conflict=1 - fi - - if [ "$configured" -eq 1 ] && - [ "$provider_available" -eq 1 ] && - [ "$conflict" -eq 0 ] && - [ "$outbounds_configured" -eq 1 ] && - [ "$routes_configured" -eq 1 ] && - [ "${expected_process_count:-0}" -gt 0 ] && - [ "${running_process_count:-0}" -eq "${expected_process_count:-0}" ]; then - ready=1 - fi - - if [ "$configured" -eq 1 ] && [ "$provider_available" -eq 0 ]; then - status_message="action=zapret is configured, but zapret provider is not available at $ZAPRET_PROVIDER_NFQWS_BIN" - elif [ "$queue_overlap" -eq 1 ]; then - status_message="external NFQUEUE rules overlap with the Podkop Plus zapret range $ZAPRET_QUEUE_BASE-$queue_range_end" - elif [ "$legacy_runtime_present" -eq 1 ]; then - status_message="legacy zapret runtime paths are still present and should be migrated" - elif [ "${running_process_count:-0}" -gt "${expected_process_count:-0}" ] || - [ "${supervisor_process_count:-0}" -gt "${expected_process_count:-0}" ]; then - status_message="unexpected Podkop Plus-managed nfqws processes are running without matching action=zapret rules" - elif [ "$configured" -eq 1 ] && [ "$ready" -eq 0 ]; then - status_message="action=zapret is configured, but the Podkop Plus-managed nfqws runtime is not ready" - elif [ "$standalone_conflict" -eq 1 ]; then - status_message="standalone zapret is active together with Podkop Plus action=zapret; queues are separate, but packet-level policy overlap is possible" - elif [ "$configured" -eq 0 ] && [ "$provider_available" -eq 0 ] && [ "$package_installed" -eq 1 ]; then - status_message="zapret package is installed, but the provider binary is not available at $ZAPRET_PROVIDER_NFQWS_BIN" - elif [ "$configured" -eq 0 ] && [ "$provider_available" -eq 0 ]; then - status_message="zapret provider is not installed; action=zapret is unavailable" - else - status_message="zapret provider status is normal" - fi - - 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() { - local installed=0 package_installed=0 - - if is_zapret_provider_available; then - installed=1 - fi - - if zapret_package_installed; then - package_installed=1 - fi - - provider_status_ucode zapret-check "$installed" "$package_installed" "$ZAPRET_PROVIDER_NFQWS_BIN" -} - -_create_zapret_nft_rule_handler() { - local section="$1" - local index mark_hex queue_number - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret" ] || return 0 - - index="$(get_zapret_rule_index "$section")" - mark_hex="$(get_zapret_rule_mark_hex "$index")" - queue_number="$(get_zapret_rule_queue_number "$index")" - - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark "$mark_hex" meta l4proto tcp \ - counter queue num "$queue_number" bypass - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark "$mark_hex" meta l4proto udp \ - counter queue num "$queue_number" bypass -} - -create_zapret_nft_rules() { - has_enabled_zapret_rules || return 0 - is_zapret_installed || return 0 - - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark \& "$ZAPRET_DESYNC_MARK" == "$ZAPRET_DESYNC_MARK" return - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark \& "$ZAPRET_DESYNC_MARK_POSTNAT" == "$ZAPRET_DESYNC_MARK_POSTNAT" return - config_foreach _create_zapret_nft_rule_handler "section" -} - -stop_zapret_pidfile_process() { - local pidfile="$1" - local pid - - [ -f "$pidfile" ] || return 0 - pid="$(cat "$pidfile" 2>/dev/null)" - [ -n "$pid" ] || return 0 - kill "$pid" 2>/dev/null || true -} - -kill_zapret_pidfile_process() { - local pidfile="$1" - local pid - - [ -f "$pidfile" ] || return 0 - pid="$(cat "$pidfile" 2>/dev/null)" - [ -n "$pid" ] || return 0 - kill -0 "$pid" 2>/dev/null || return 0 - kill -9 "$pid" 2>/dev/null || true -} - -stop_zapret_runtime() { - local pidfile - - if [ -d "$ZAPRET_PID_DIR" ]; then - for pidfile in "$ZAPRET_PID_DIR"/*.pid; do - stop_zapret_pidfile_process "$pidfile" - done - fi - - if [ -d "$ZAPRET_CHILD_PID_DIR" ]; then - for pidfile in "$ZAPRET_CHILD_PID_DIR"/*.pid; do - stop_zapret_pidfile_process "$pidfile" - done - fi - - sleep 1 - - if [ -d "$ZAPRET_PID_DIR" ]; then - for pidfile in "$ZAPRET_PID_DIR"/*.pid; do - kill_zapret_pidfile_process "$pidfile" - done - fi - - if [ -d "$ZAPRET_CHILD_PID_DIR" ]; then - for pidfile in "$ZAPRET_CHILD_PID_DIR"/*.pid; do - kill_zapret_pidfile_process "$pidfile" - done - fi - - rm -rf "$ZAPRET_PID_DIR" "$ZAPRET_CHILD_PID_DIR" "$ZAPRET_LOG_DIR" "$ZAPRET_HOSTLIST_DIR" "$ZAPRET_LEGACY_RUNTIME_BASE_DIR" -} - -run_zapret_nfqws_supervisor() { - local section="$1" - local queue_number="$2" - local expanded_opt="$3" - local child_pidfile="$4" - local child_pid="" old_ifs rc - - trap 'rm -f "$child_pidfile"; [ -n "$child_pid" ] && kill "$child_pid" 2>/dev/null; [ -n "$child_pid" ] && wait "$child_pid" 2>/dev/null; exit 0' TERM INT - - while :; do - if [ ! -x "$ZAPRET_NFQWS_BIN" ]; then - printf '%s Provider %s is not executable; retrying in %s seconds\n' \ - "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null)" "$ZAPRET_NFQWS_BIN" "$ZAPRET_NFQWS_RESPAWN_DELAY" - sleep "$ZAPRET_NFQWS_RESPAWN_DELAY" & - child_pid="$!" - wait "$child_pid" - child_pid="" - continue - fi - - set -f - old_ifs="$IFS" - IFS=' ' - set -- $expanded_opt - IFS="$old_ifs" - - "$ZAPRET_NFQWS_BIN" --qnum="$queue_number" --dpi-desync-fwmark="$ZAPRET_DESYNC_MARK" "$@" & - child_pid="$!" - echo "$child_pid" > "$child_pidfile" - wait "$child_pid" - rc="$?" - rm -f "$child_pidfile" - child_pid="" - set +f - - printf '%s nfqws for rule %s exited with code %s; respawning in %s seconds\n' \ - "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null)" "$section" "$rc" "$ZAPRET_NFQWS_RESPAWN_DELAY" - sleep "$ZAPRET_NFQWS_RESPAWN_DELAY" & - child_pid="$!" - wait "$child_pid" - child_pid="" - done -} - -_start_zapret_runtime_handler() { - local section="$1" - local index queue_number mark_hex raw_opt expanded_opt pidfile child_pidfile logfile pid child_pid - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret" ] || return 0 - - index="$(get_zapret_rule_index "$section")" - queue_number="$(get_zapret_rule_queue_number "$index")" - mark_hex="$(get_zapret_rule_mark_hex "$index")" - raw_opt="$(get_rule_nfqws_opt "$section")" - expanded_opt="$(expand_zapret_nfqws_opt "$raw_opt" "$section")" - pidfile="$ZAPRET_PID_DIR/$section.pid" - child_pidfile="$ZAPRET_CHILD_PID_DIR/$section.pid" - logfile="$ZAPRET_LOG_DIR/$section.log" - - log "Starting nfqws for rule '$section' on queue $queue_number with mark $mark_hex" - (close_inherited_service_lock_fd; run_zapret_nfqws_supervisor "$section" "$queue_number" "$expanded_opt" "$child_pidfile") >>"$logfile" 2>&1 & - pid="$!" - echo "$pid" > "$pidfile" - sleep 1 - - if ! kill -0 "$pid" 2>/dev/null; then - log "nfqws failed to start for rule '$section'. Check $logfile. Aborted." "fatal" - exit 1 - fi - - child_pid="$(cat "$child_pidfile" 2>/dev/null)" - if [ -z "$child_pid" ] || ! kill -0 "$child_pid" 2>/dev/null; then - log "nfqws supervisor started for rule '$section', but nfqws is not running yet. Check $logfile." "warn" - fi -} - -start_zapret_runtime() { - stop_zapret_runtime - - has_enabled_zapret_rules || return 0 - is_zapret_provider_available || return 0 - - check_zapret_requirements - mkdir -p "$ZAPRET_PID_DIR" "$ZAPRET_CHILD_PID_DIR" "$ZAPRET_LOG_DIR" - config_foreach _start_zapret_runtime_handler "section" -} diff --git a/podkop/files/usr/lib/zapret2.sh b/podkop/files/usr/lib/zapret2.sh deleted file mode 100644 index 1f08d58b..00000000 --- a/podkop/files/usr/lib/zapret2.sh +++ /dev/null @@ -1,918 +0,0 @@ -# shellcheck shell=ash - -has_enabled_zapret2_rules() { - local count - - count="$(get_zapret2_rule_count)" - [ "${count:-0}" -gt 0 ] -} - -_count_zapret2_rule_handler() { - local section="$1" - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret2" ] || return 0 - - ZAPRET2_RULE_COUNT=$((ZAPRET2_RULE_COUNT + 1)) -} - -get_zapret2_rule_count() { - ZAPRET2_RULE_COUNT=0 - config_foreach _count_zapret2_rule_handler "section" - echo "$ZAPRET2_RULE_COUNT" -} - -_find_zapret2_rule_index_handler() { - local section="$1" - local target_section="$2" - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret2" ] || return 0 - - ZAPRET2_RULE_INDEX_WALK=$((ZAPRET2_RULE_INDEX_WALK + 1)) - if [ "$section" = "$target_section" ]; then - ZAPRET2_RULE_INDEX_RESULT="$ZAPRET2_RULE_INDEX_WALK" - fi -} - -get_zapret2_rule_index() { - local section="$1" - - ZAPRET2_RULE_INDEX_WALK=0 - ZAPRET2_RULE_INDEX_RESULT=0 - config_foreach _find_zapret2_rule_index_handler "section" "$section" - - echo "$ZAPRET2_RULE_INDEX_RESULT" -} - -format_zapret2_mark_hex() { - local mark_value="$1" - - printf '0x%08x\n' "$mark_value" -} - -get_zapret2_rule_mark_value() { - local index="$1" - - echo $((ZAPRET2_ROUTE_MARK_BASE + index)) -} - -get_zapret2_rule_mark_hex() { - local index="$1" - - format_zapret2_mark_hex "$(get_zapret2_rule_mark_value "$index")" -} - -get_zapret2_rule_queue_number() { - local index="$1" - - echo $((ZAPRET2_QUEUE_BASE + index - 1)) -} - -get_rule_nfqws2_opt() { - local section="$1" - local nfqws2_opt - - config_get nfqws2_opt "$section" "nfqws2_opt" - if [ -n "$nfqws2_opt" ]; then - normalize_nfqws2_strategy_whitespace "$nfqws2_opt" - else - normalize_nfqws2_strategy_whitespace "$ZAPRET2_DEFAULT_NFQWS2_OPT" - fi -} - -normalize_nfqws2_strategy_whitespace() { - normalize_strategy_whitespace "$1" -} - -nfqws2_option_argument_mode() { - case "$1" in - --debug | --comment | --intercept | --chdir | --ctrack-disable | --payload-disable | \ - --server | --ipcache-hostname | --reasm-disable | --writeable | --new | --template | \ - --hostlist-auto-retrans-reset) - echo "optional" - ;; - --dry-run | --version | --daemon | --skip | --bind-fix4 | --bind-fix6) - echo "none" - ;; - --qnum | --pidfile | --user | --uid | --ctrack-timeouts | --ipcache-lifetime | \ - --fwmark | --fuzz | --blob | --lua-init | --lua-gc | --hostlist | \ - --hostlist-domains | --hostlist-exclude | --hostlist-exclude-domains | \ - --hostlist-auto | --hostlist-auto-fail-threshold | --hostlist-auto-fail-time | \ - --hostlist-auto-retrans-threshold | --hostlist-auto-retrans-maxseq | \ - --hostlist-auto-incoming-maxseq | --hostlist-auto-udp-in | --hostlist-auto-udp-out | \ - --hostlist-auto-debug | --name | --import | --cookie | --filter-l3 | --filter-tcp | \ - --filter-udp | --filter-icmp | --filter-ipp | --filter-l7 | --filter-ssid | \ - --ipset | --ipset-ip | --ipset-exclude | --ipset-exclude-ip | \ - --payload | --in-range | --out-range | --lua-desync) - echo "required" - ;; - *) - echo "unknown" - ;; - esac -} - -clear_nfqws2_validation_state() { - NFQWS2_VALIDATE_ERROR="" - NFQWS2_VALIDATE_NEEDLE="" - NFQWS2_VALIDATE_NEEDLES="" -} - -append_nfqws2_validation_needle() { - local needle="$1" - - [ -n "$needle" ] || return 0 - - case " -$NFQWS2_VALIDATE_NEEDLES -" in - *" -$needle -"*) - return 0 - ;; - esac - - if [ -n "$NFQWS2_VALIDATE_NEEDLES" ]; then - NFQWS2_VALIDATE_NEEDLES="${NFQWS2_VALIDATE_NEEDLES} -$needle" - else - NFQWS2_VALIDATE_NEEDLES="$needle" - fi - - [ -n "$NFQWS2_VALIDATE_NEEDLE" ] || NFQWS2_VALIDATE_NEEDLE="$needle" -} - -set_nfqws2_validation_failure() { - NFQWS2_VALIDATE_ERROR="$1" - NFQWS2_VALIDATE_NEEDLE="" - NFQWS2_VALIDATE_NEEDLES="" - shift - - while [ "$#" -gt 0 ]; do - append_nfqws2_validation_needle "$1" - shift - done - - return 1 -} - -get_nfqws2_unsupported_token_reason() { - local token="$1" - - case "$token" in - "" | "") - echo "Podkop Plus does not expand zapret2 hostlist templates in per-rule strategies because sing-box already selects the resources before NFQUEUE." - return 0 - ;; - --hostlist | --hostlist=* | --hostlist-domains | --hostlist-domains=* | \ - --hostlist-exclude | --hostlist-exclude=* | --hostlist-exclude-domains | --hostlist-exclude-domains=* | \ - --hostlist-auto | --hostlist-auto=* | --hostlist-auto-fail-threshold | --hostlist-auto-fail-threshold=* | \ - --hostlist-auto-fail-time | --hostlist-auto-fail-time=* | \ - --hostlist-auto-retrans-threshold | --hostlist-auto-retrans-threshold=* | \ - --hostlist-auto-retrans-maxseq | --hostlist-auto-retrans-maxseq=* | \ - --hostlist-auto-retrans-reset | --hostlist-auto-retrans-reset=* | \ - --hostlist-auto-incoming-maxseq | --hostlist-auto-incoming-maxseq=* | \ - --hostlist-auto-udp-in | --hostlist-auto-udp-in=* | --hostlist-auto-udp-out | --hostlist-auto-udp-out=* | \ - --hostlist-auto-debug | --hostlist-auto-debug=*) - echo "Hostname-based selection inside nfqws2 is incompatible with the Podkop Plus architecture because sing-box already chooses which resources enter action=zapret2." - return 0 - ;; - --ipset | --ipset=* | --ipset-ip | --ipset-ip=* | --ipset-exclude | --ipset-exclude=* | --ipset-exclude-ip | --ipset-exclude-ip=*) - echo "IP or CIDR selection inside nfqws2 is incompatible with the Podkop Plus architecture because sing-box already chooses which resources enter action=zapret2." - return 0 - ;; - --qnum | --qnum=*) - echo "The NFQUEUE number is assigned by Podkop Plus per rule and must not be overridden in the strategy." - return 0 - ;; - --fwmark | --fwmark=* | --dpi-desync-fwmark | --dpi-desync-fwmark=*) - echo "The nfqws2 fwmark is managed by Podkop Plus for loop prevention and must not be overridden in the strategy." - return 0 - ;; - --fuzz | --fuzz=*) - echo "nfqws2 fuzz mode disables normal interception and is incompatible with Podkop Plus-managed action=zapret2 rules." - return 0 - ;; - --intercept=0) - echo "nfqws2 interception must stay enabled for Podkop Plus-managed action=zapret2 rules." - return 0 - ;; - --daemon) - echo "Podkop Plus manages the nfqws2 process lifecycle itself. The strategy must not daemonize nfqws2." - return 0 - ;; - --dry-run) - echo "The strategy must launch a working nfqws2 process. --dry-run exits immediately and is not allowed." - return 0 - ;; - --version) - echo "The strategy must launch a working nfqws2 process. --version exits immediately and is not allowed." - return 0 - ;; - esac - - return 1 -} - -zapret_validation_ucode() { - ucode "${PODKOP_LIB:-/usr/lib/podkop-plus}/zapret_validation.uc" "$@" -} - -extract_nfqws2_validation_summary() { - printf '%s' "$1" | zapret_validation_ucode summary nfqws2 2>/dev/null -} - -collect_nfqws2_validation_needles() { - zapret_validation_ucode needles "$1" 2>/dev/null -} - -run_nfqws2_dry_run_validation() { - local raw_opt="$1" - local old_ifs output_file output summary needles rc - - if ! is_zapret2_provider_available; then - return 0 - fi - - raw_opt="$(expand_zapret2_nfqws2_opt "$raw_opt")" - raw_opt="$(normalize_nfqws2_strategy_whitespace "$raw_opt")" - [ -n "$raw_opt" ] || return 0 - - output_file="$(mktemp)" - - set -f - old_ifs="$IFS" - IFS=' ' - set -- $raw_opt - IFS="$old_ifs" - - "$ZAPRET2_NFQWS2_BIN" --dry-run --qnum="$ZAPRET2_QUEUE_BASE" $(get_zapret2_base_args) "$@" >"$output_file" 2>&1 - rc=$? - output="$(cat "$output_file")" - rm -f "$output_file" - set +f - - [ "$rc" -eq 0 ] && return 0 - - summary="$(extract_nfqws2_validation_summary "$output")" - [ -n "$summary" ] || summary="nfqws2 rejected the strategy during syntax validation." - - needles="$(collect_nfqws2_validation_needles "$summary")" - if [ -n "$needles" ]; then - # shellcheck disable=SC2086 - set_nfqws2_validation_failure "nfqws2 syntax check failed: $summary" $needles - else - set_nfqws2_validation_failure "nfqws2 syntax check failed: $summary" - fi -} - -check_nfqws2_strategy() { - local raw_opt="$1" - local old_ifs token base_token mode reason display next_token token_count=0 - - clear_nfqws2_validation_state - raw_opt="$(normalize_nfqws2_strategy_whitespace "$raw_opt")" - - set -f - old_ifs="$IFS" - IFS=' ' - set -- $raw_opt - IFS="$old_ifs" - - while [ "$#" -gt 0 ]; do - token="$1" - next_token="$2" - base_token="${token%%=*}" - - if [ "$token_count" -eq 0 ]; then - case "$token" in - @* | \$*) - set_nfqws2_validation_failure \ - "Unsupported NFQWS2 token '$token': External nfqws2 config files bypass Podkop Plus validation and queue management." \ - "$token" - return 1 - ;; - esac - fi - - if reason="$(get_nfqws2_unsupported_token_reason "$token")"; then - display="$token" - mode="$(nfqws2_option_argument_mode "$base_token")" - if [ "$base_token" = "$token" ] && [ "$mode" = "required" ] && [ "$#" -gt 1 ] && [ "${next_token#--}" = "$next_token" ]; then - display="$display $next_token" - set_nfqws2_validation_failure "Unsupported NFQWS2 token '$display': $reason" "$base_token" "$next_token" - return 1 - fi - - set_nfqws2_validation_failure "Unsupported NFQWS2 token '$display': $reason" "$base_token" - return 1 - fi - - case "$token" in - --*) - mode="$(nfqws2_option_argument_mode "$base_token")" - case "$mode" in - unknown) - set_nfqws2_validation_failure \ - "Unknown NFQWS2 flag '$token'." \ - "$base_token" - return 1 - ;; - none) - if [ "$base_token" != "$token" ]; then - set_nfqws2_validation_failure \ - "NFQWS2 flag '$base_token' does not accept a value." \ - "$base_token" - return 1 - fi - shift - token_count=$((token_count + 1)) - ;; - optional) - if [ "$base_token" = "$token" ] && [ "$#" -gt 1 ] && [ "${next_token#--}" = "$next_token" ]; then - set_nfqws2_validation_failure \ - "Optional value for '$base_token' must be attached as '$base_token=value'. Separate tokens are ignored by nfqws2 here." \ - "$base_token" "$next_token" - return 1 - fi - shift - token_count=$((token_count + 1)) - ;; - required) - if [ "$base_token" != "$token" ]; then - shift - token_count=$((token_count + 1)) - continue - fi - - if [ "$#" -lt 2 ] || [ "${next_token#--}" != "$next_token" ]; then - set_nfqws2_validation_failure \ - "NFQWS2 option '$base_token' requires a value." \ - "$base_token" - return 1 - fi - - shift 2 - token_count=$((token_count + 2)) - ;; - esac - ;; - *) - set_nfqws2_validation_failure \ - "Unexpected standalone NFQWS2 token '$token'. Use explicit flags such as --name or --name=value." \ - "$token" - return 1 - ;; - esac - done - - set +f - - run_nfqws2_dry_run_validation "$raw_opt" -} - -validate_nfqws2_strategy() { - local raw_opt="$1" - local context="$2" - - if ! check_nfqws2_strategy "$raw_opt"; then - log "$context uses invalid NFQWS2 strategy: $NFQWS2_VALIDATE_ERROR Aborted." "fatal" - exit 1 - fi -} - -validate_rule_nfqws2_opt() { - local section="$1" - local raw_opt - - raw_opt="$(get_rule_nfqws2_opt "$section")" - validate_nfqws2_strategy "$raw_opt" "Zapret2 rule '$section'" -} - -prepare_zapret2_runtime() { - mkdir -p "$ZAPRET2_STATE_DIR" "$ZAPRET2_PID_DIR" "$ZAPRET2_CHILD_PID_DIR" "$ZAPRET2_LOG_DIR" -} - -get_zapret2_base_args() { - printf '%s\n' \ - "--fwmark=$ZAPRET2_DESYNC_MARK" \ - "--lua-init=@$ZAPRET2_PROVIDER_LUA_DIR/zapret-lib.lua" \ - "--lua-init=@$ZAPRET2_PROVIDER_LUA_DIR/zapret-antidpi.lua" \ - "--lua-init=@$ZAPRET2_PROVIDER_LUA_DIR/zapret-auto.lua" -} - -is_zapret2_provider_available() { - [ -x "$ZAPRET2_PROVIDER_NFQWS2_BIN" ] -} - -is_zapret2_installed() { - is_zapret2_provider_available -} - -zapret2_package_installed() { - if command -v apk >/dev/null 2>&1 && apk info -e zapret2 >/dev/null 2>&1; then - return 0 - fi - - if opkg_package_is_installed "zapret2"; then - return 0 - fi - - return 1 -} - -luci_app_zapret2_installed() { - if command -v apk >/dev/null 2>&1 && apk info -e luci-app-zapret2 >/dev/null 2>&1; then - return 0 - fi - - if opkg_package_is_installed "luci-app-zapret2"; then - return 0 - fi - - [ -f /usr/share/luci/menu.d/luci-app-zapret2.json ] || - [ -f /usr/share/rpcd/acl.d/luci-app-zapret2.json ] -} - -expand_zapret2_nfqws2_opt() { - printf '%s' "$1" -} - -check_zapret2_requirements() { - has_enabled_zapret2_rules || return 0 - - if ! is_zapret2_provider_available; then - log "Zapret2 provider is not available at $ZAPRET2_PROVIDER_NFQWS2_BIN. Rules with action 'zapret2' will be skipped until the zapret2 provider is installed." "error" - return 0 - fi - - if ! prepare_zapret2_runtime; then - log "Failed to prepare the Podkop Plus zapret2 state directory in $ZAPRET2_STATE_DIR. Aborted." "fatal" - exit 1 - fi -} - -get_zapret2_package_version() { - local version="" - - if command -v apk >/dev/null 2>&1 && apk info -e zapret2 >/dev/null 2>&1; then - version="$(get_apk_installed_package_version "zapret2")" - elif command -v opkg >/dev/null 2>&1; then - version="$(get_opkg_installed_package_version "zapret2")" - fi - - if [ -z "$version" ] && [ -x "$ZAPRET2_PROVIDER_NFQWS2_BIN" ]; then - version="$("$ZAPRET2_PROVIDER_NFQWS2_BIN" --version 2>/dev/null | provider_status_ucode stdin-first-line-version-field)" - fi - - echo "$version" -} - -is_zapret2_standalone_service_enabled() { - [ -x /etc/init.d/zapret2 ] || return 1 - /etc/init.d/zapret2 enabled >/dev/null 2>&1 -} - -is_zapret2_standalone_service_running() { - [ -x /etc/init.d/zapret2 ] || return 1 - /etc/init.d/zapret2 status >/dev/null 2>&1 -} - -zapret2_standalone_uci_config_present() { - [ -f /etc/config/zapret2 ] || return 1 - uci -q get zapret2.config >/dev/null 2>&1 -} - -get_zapret2_nfqws2_process_count() { - local count=0 pidfile pid - - [ -d "$ZAPRET2_CHILD_PID_DIR" ] || { - echo 0 - return 0 - } - - for pidfile in "$ZAPRET2_CHILD_PID_DIR"/*.pid; do - [ -f "$pidfile" ] || continue - pid="$(cat "$pidfile" 2>/dev/null)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - count=$((count + 1)) - else - rm -f "$pidfile" - fi - done - - echo "$count" -} - -get_zapret2_supervisor_process_count() { - local count=0 pidfile pid - - [ -d "$ZAPRET2_PID_DIR" ] || { - echo 0 - return 0 - } - - for pidfile in "$ZAPRET2_PID_DIR"/*.pid; do - [ -f "$pidfile" ] || continue - pid="$(cat "$pidfile" 2>/dev/null)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - count=$((count + 1)) - else - rm -f "$pidfile" - fi - done - - echo "$count" -} - -get_zapret2_queue_range_end() { - echo $((ZAPRET2_QUEUE_BASE + ZAPRET2_QUEUE_RANGE_SIZE - 1)) -} - -zapret2_external_queue_overlap_present() { - local range_end - - range_end="$(get_zapret2_queue_range_end)" - nft list ruleset 2>/dev/null | - zapret_validation_ucode nft-queue-overlap "$NFT_TABLE_NAME" "$ZAPRET2_QUEUE_BASE" "$range_end" >/dev/null 2>&1 -} - -zapret2_standalone_conflict_present() { - has_enabled_zapret2_rules || return 1 - is_zapret2_standalone_service_running -} - -zapret2_rule_outbound_present() { - local section="$1" - local routing_mark="$2" - local outbound_tag - - [ -f "$ZAPRET2_SINGBOX_CONFIG_PATH" ] || return 1 - - outbound_tag="$(get_outbound_tag_by_section "$section")" - - provider_status_ucode has-direct-mark-outbound "$ZAPRET2_SINGBOX_CONFIG_PATH" "$outbound_tag" "$routing_mark" >/dev/null 2>&1 -} - -zapret2_rule_route_rule_present() { - local section="$1" - local outbound_tag - - [ -f "$ZAPRET2_SINGBOX_CONFIG_PATH" ] || return 1 - - outbound_tag="$(get_outbound_tag_by_section "$section")" - - provider_status_ucode has-route-rule "$ZAPRET2_SINGBOX_CONFIG_PATH" "$SB_TPROXY_INBOUND_TAG" "$outbound_tag" >/dev/null 2>&1 -} - -_collect_zapret2_runtime_status_handler() { - local section="$1" - local index mark_value - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret2" ] || return 0 - - ZAPRET2_RUNTIME_RULES_CONFIGURED=1 - index="$(get_zapret2_rule_index "$section")" - mark_value="$(get_zapret2_rule_mark_value "$index")" - - if ! zapret2_rule_outbound_present "$section" "$mark_value"; then - ZAPRET2_RUNTIME_OUTBOUNDS_CONFIGURED=0 - fi - - if ! zapret2_rule_route_rule_present "$section"; then - ZAPRET2_RUNTIME_ROUTES_CONFIGURED=0 - fi -} - -collect_zapret2_runtime_status() { - local sing_box_config_path - - ZAPRET2_RUNTIME_RULES_CONFIGURED=0 - ZAPRET2_RUNTIME_OUTBOUNDS_CONFIGURED=1 - ZAPRET2_RUNTIME_ROUTES_CONFIGURED=1 - - config_get sing_box_config_path "settings" "config_path" - ZAPRET2_SINGBOX_CONFIG_PATH="$sing_box_config_path" - config_foreach _collect_zapret2_runtime_status_handler "section" - - if [ "$ZAPRET2_RUNTIME_RULES_CONFIGURED" -eq 0 ]; then - ZAPRET2_RUNTIME_OUTBOUNDS_CONFIGURED=0 - ZAPRET2_RUNTIME_ROUTES_CONFIGURED=0 - fi -} - -get_zapret2_status_json() { - local installed=0 package_installed=0 provider_available=0 files_available=0 ipset_available=0 configured=0 - local standalone_service_enabled=0 standalone_service_running=0 standalone_config_present=0 ready=0 conflict=0 - local queue_overlap=0 standalone_conflict=0 luci_app_installed=0 - local enabled_rule_count expected_process_count running_process_count supervisor_process_count version queue_range_end status_message - local outbounds_configured=0 routes_configured=0 - - enabled_rule_count="$(get_zapret2_rule_count)" - expected_process_count="${enabled_rule_count:-0}" - running_process_count="$(get_zapret2_nfqws2_process_count)" - supervisor_process_count="$(get_zapret2_supervisor_process_count)" - queue_range_end="$(get_zapret2_queue_range_end)" - version="not installed" - status_message="" - - if [ "${enabled_rule_count:-0}" -gt 0 ]; then - configured=1 - fi - - if zapret2_package_installed; then - package_installed=1 - version="$(get_zapret2_package_version)" - [ -n "$version" ] || version="unknown" - fi - - if [ -d "$ZAPRET2_PROVIDER_FILES_DIR" ]; then - files_available=1 - fi - - if [ -d "$ZAPRET2_PROVIDER_IPSET_DIR" ]; then - ipset_available=1 - fi - - if is_zapret2_provider_available; then - provider_available=1 - installed=1 - if [ "$package_installed" -eq 0 ]; then - version="$(get_zapret2_package_version)" - [ -n "$version" ] || version="unknown" - fi - fi - - if is_zapret2_standalone_service_enabled; then - standalone_service_enabled=1 - fi - - if is_zapret2_standalone_service_running; then - standalone_service_running=1 - fi - - if zapret2_standalone_uci_config_present; then - standalone_config_present=1 - fi - - if zapret2_external_queue_overlap_present; then - queue_overlap=1 - fi - - if zapret2_standalone_conflict_present; then - standalone_conflict=1 - fi - - if luci_app_zapret2_installed; then - luci_app_installed=1 - fi - - collect_zapret2_runtime_status - outbounds_configured="${ZAPRET2_RUNTIME_OUTBOUNDS_CONFIGURED:-0}" - routes_configured="${ZAPRET2_RUNTIME_ROUTES_CONFIGURED:-0}" - - if [ "${running_process_count:-0}" -gt "${expected_process_count:-0}" ] || - [ "$queue_overlap" -eq 1 ]; then - conflict=1 - fi - - if [ "$configured" -eq 1 ] && - [ "$provider_available" -eq 1 ] && - [ "$conflict" -eq 0 ] && - [ "$outbounds_configured" -eq 1 ] && - [ "$routes_configured" -eq 1 ] && - [ "${expected_process_count:-0}" -gt 0 ] && - [ "${running_process_count:-0}" -eq "${expected_process_count:-0}" ]; then - ready=1 - fi - - if [ "$configured" -eq 1 ] && [ "$provider_available" -eq 0 ]; then - status_message="action=zapret2 is configured, but zapret2 provider is not available at $ZAPRET2_PROVIDER_NFQWS2_BIN" - elif [ "$queue_overlap" -eq 1 ]; then - status_message="external NFQUEUE rules overlap with the Podkop Plus zapret2 range $ZAPRET2_QUEUE_BASE-$queue_range_end" - elif [ "${running_process_count:-0}" -gt "${expected_process_count:-0}" ] || - [ "${supervisor_process_count:-0}" -gt "${expected_process_count:-0}" ]; then - status_message="unexpected Podkop Plus-managed nfqws2 processes are running without matching action=zapret2 rules" - elif [ "$configured" -eq 1 ] && [ "$ready" -eq 0 ]; then - status_message="action=zapret2 is configured, but the Podkop Plus-managed nfqws2 runtime is not ready" - elif [ "$standalone_conflict" -eq 1 ]; then - status_message="standalone zapret2 is active together with Podkop Plus action=zapret2; queues are separate, but packet-level policy overlap is possible" - elif [ "$configured" -eq 0 ] && [ "$provider_available" -eq 0 ] && [ "$package_installed" -eq 1 ]; then - status_message="zapret2 package is installed, but the provider binary is not available at $ZAPRET2_PROVIDER_NFQWS2_BIN" - elif [ "$configured" -eq 0 ] && [ "$provider_available" -eq 0 ]; then - status_message="zapret2 provider is not installed; action=zapret2 is unavailable" - else - status_message="zapret2 provider status is normal" - fi - - provider_status_ucode zapret2-status \ - "$installed" \ - "$package_installed" \ - "$provider_available" \ - "$ZAPRET2_PROVIDER_NFQWS2_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" \ - "$ZAPRET2_QUEUE_BASE" \ - "$queue_range_end" \ - "$queue_overlap" \ - "$ready" \ - "$conflict" \ - "$outbounds_configured" \ - "$routes_configured" \ - "$status_message" -} - -check_zapret2_runtime_json() { - local installed=0 package_installed=0 - - if is_zapret2_provider_available; then - installed=1 - fi - - if zapret2_package_installed; then - package_installed=1 - fi - - provider_status_ucode zapret2-check "$installed" "$package_installed" "$ZAPRET2_PROVIDER_NFQWS2_BIN" -} - -_create_zapret2_nft_rule_handler() { - local section="$1" - local index mark_hex queue_number - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret2" ] || return 0 - - index="$(get_zapret2_rule_index "$section")" - mark_hex="$(get_zapret2_rule_mark_hex "$index")" - queue_number="$(get_zapret2_rule_queue_number "$index")" - - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark "$mark_hex" meta l4proto tcp \ - counter queue num "$queue_number" bypass - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark "$mark_hex" meta l4proto udp \ - counter queue num "$queue_number" bypass -} - -create_zapret2_nft_rules() { - has_enabled_zapret2_rules || return 0 - is_zapret2_installed || return 0 - - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark \& "$ZAPRET2_DESYNC_MARK" == "$ZAPRET2_DESYNC_MARK" return - nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark \& "$ZAPRET2_DESYNC_MARK_POSTNAT" == "$ZAPRET2_DESYNC_MARK_POSTNAT" return - config_foreach _create_zapret2_nft_rule_handler "section" -} - -stop_zapret2_pidfile_process() { - local pidfile="$1" - local pid - - [ -f "$pidfile" ] || return 0 - pid="$(cat "$pidfile" 2>/dev/null)" - [ -n "$pid" ] || return 0 - kill "$pid" 2>/dev/null || true -} - -kill_zapret2_pidfile_process() { - local pidfile="$1" - local pid - - [ -f "$pidfile" ] || return 0 - pid="$(cat "$pidfile" 2>/dev/null)" - [ -n "$pid" ] || return 0 - kill -0 "$pid" 2>/dev/null || return 0 - kill -9 "$pid" 2>/dev/null || true -} - -stop_zapret2_runtime() { - local pidfile - - if [ -d "$ZAPRET2_PID_DIR" ]; then - for pidfile in "$ZAPRET2_PID_DIR"/*.pid; do - stop_zapret2_pidfile_process "$pidfile" - done - fi - - if [ -d "$ZAPRET2_CHILD_PID_DIR" ]; then - for pidfile in "$ZAPRET2_CHILD_PID_DIR"/*.pid; do - stop_zapret2_pidfile_process "$pidfile" - done - fi - - sleep 1 - - if [ -d "$ZAPRET2_PID_DIR" ]; then - for pidfile in "$ZAPRET2_PID_DIR"/*.pid; do - kill_zapret2_pidfile_process "$pidfile" - done - fi - - if [ -d "$ZAPRET2_CHILD_PID_DIR" ]; then - for pidfile in "$ZAPRET2_CHILD_PID_DIR"/*.pid; do - kill_zapret2_pidfile_process "$pidfile" - done - fi - - rm -rf "$ZAPRET2_PID_DIR" "$ZAPRET2_CHILD_PID_DIR" "$ZAPRET2_LOG_DIR" -} - -run_zapret2_nfqws2_supervisor() { - local section="$1" - local queue_number="$2" - local expanded_opt="$3" - local child_pidfile="$4" - local child_pid="" old_ifs rc - - trap 'rm -f "$child_pidfile"; [ -n "$child_pid" ] && kill "$child_pid" 2>/dev/null; [ -n "$child_pid" ] && wait "$child_pid" 2>/dev/null; exit 0' TERM INT - - while :; do - if [ ! -x "$ZAPRET2_NFQWS2_BIN" ]; then - printf '%s Provider %s is not executable; retrying in %s seconds\n' \ - "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null)" "$ZAPRET2_NFQWS2_BIN" "$ZAPRET2_NFQWS2_RESPAWN_DELAY" - sleep "$ZAPRET2_NFQWS2_RESPAWN_DELAY" & - child_pid="$!" - wait "$child_pid" - child_pid="" - continue - fi - - set -f - old_ifs="$IFS" - IFS=' ' - set -- $expanded_opt - IFS="$old_ifs" - - "$ZAPRET2_NFQWS2_BIN" --qnum="$queue_number" $(get_zapret2_base_args) "$@" & - child_pid="$!" - echo "$child_pid" > "$child_pidfile" - wait "$child_pid" - rc="$?" - rm -f "$child_pidfile" - child_pid="" - set +f - - printf '%s nfqws2 for rule %s exited with code %s; respawning in %s seconds\n' \ - "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null)" "$section" "$rc" "$ZAPRET2_NFQWS2_RESPAWN_DELAY" - sleep "$ZAPRET2_NFQWS2_RESPAWN_DELAY" & - child_pid="$!" - wait "$child_pid" - child_pid="" - done -} - -_start_zapret2_runtime_handler() { - local section="$1" - local index queue_number mark_hex raw_opt expanded_opt pidfile child_pidfile logfile pid child_pid - - rule_is_enabled "$section" || return 0 - [ "$(get_rule_action "$section")" = "zapret2" ] || return 0 - - index="$(get_zapret2_rule_index "$section")" - queue_number="$(get_zapret2_rule_queue_number "$index")" - mark_hex="$(get_zapret2_rule_mark_hex "$index")" - raw_opt="$(get_rule_nfqws2_opt "$section")" - expanded_opt="$(expand_zapret2_nfqws2_opt "$raw_opt" "$section")" - pidfile="$ZAPRET2_PID_DIR/$section.pid" - child_pidfile="$ZAPRET2_CHILD_PID_DIR/$section.pid" - logfile="$ZAPRET2_LOG_DIR/$section.log" - - log "Starting nfqws2 for rule '$section' on queue $queue_number with mark $mark_hex" - (close_inherited_service_lock_fd; run_zapret2_nfqws2_supervisor "$section" "$queue_number" "$expanded_opt" "$child_pidfile") >>"$logfile" 2>&1 & - pid="$!" - echo "$pid" > "$pidfile" - sleep 1 - - if ! kill -0 "$pid" 2>/dev/null; then - log "nfqws2 failed to start for rule '$section'. Check $logfile. Aborted." "fatal" - exit 1 - fi - - child_pid="$(cat "$child_pidfile" 2>/dev/null)" - if [ -z "$child_pid" ] || ! kill -0 "$child_pid" 2>/dev/null; then - log "nfqws2 supervisor started for rule '$section', but nfqws2 is not running yet. Check $logfile." "warn" - fi -} - -start_zapret2_runtime() { - stop_zapret2_runtime - - has_enabled_zapret2_rules || return 0 - is_zapret2_provider_available || return 0 - - check_zapret2_requirements - mkdir -p "$ZAPRET2_PID_DIR" "$ZAPRET2_CHILD_PID_DIR" "$ZAPRET2_LOG_DIR" - config_foreach _start_zapret2_runtime_handler "section" -} diff --git a/scripts/build-openwrt-packages-wsl.sh b/scripts/build.sh similarity index 81% rename from scripts/build-openwrt-packages-wsl.sh rename to scripts/build.sh index e821dd87..ba48ac7d 100644 --- a/scripts/build-openwrt-packages-wsl.sh +++ b/scripts/build.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, ca-bundle, kmod-inet-diag, kmod-netlink-diag, kmod-tun, curl, ucode, ucode-mod-fs, kmod-nft-tproxy, coreutils-base64, coreutils-sort, bind-dig, nftables, kmod-nft-nat" -BACKEND_DEPENDS_APK="bind-dig ca-bundle coreutils-base64 coreutils-sort curl kmod-inet-diag kmod-netlink-diag kmod-nft-nat kmod-nft-tproxy kmod-tun libc nftables ucode ucode-mod-fs" +BACKEND_DEPENDS_IPK="libc, ca-bundle, kmod-inet-diag, kmod-netlink-diag, kmod-tun, curl, ucode, ucode-mod-fs, ucode-mod-uci, kmod-nft-tproxy, coreutils-base64, coreutils-sort, bind-dig, nftables, kmod-nft-nat" +BACKEND_DEPENDS_APK="bind-dig ca-bundle coreutils-base64 coreutils-sort curl kmod-inet-diag kmod-netlink-diag kmod-nft-nat kmod-nft-tproxy kmod-tun libc nftables ucode ucode-mod-fs ucode-mod-uci" APP_DEPENDS_IPK="libc, luci-base, podkop-plus" APP_DEPENDS_APK="libc luci-base podkop-plus" @@ -95,7 +95,7 @@ copy_to_native_root() { echo "Synced repository to native WSL path: $target_root" >&2 export SOURCE_ROOT_DIR="$source_root" export WINDOWS_ARTIFACTS_DIR="$windows_output" - exec bash "$target_root/scripts/build-openwrt-packages-wsl.sh" "$RELEASE_VERSION" "$target_output" + exec bash "$target_root/scripts/build.sh" "$RELEASE_VERSION" "$target_output" } ensure_native_root() { @@ -250,7 +250,7 @@ build_backend_root() { cp -a "$ROOT_DIR/podkop/files/usr/lib/." "$output_root/usr/lib/podkop-plus/" sed -i -e "s/__COMPILED_VERSION_VARIABLE__/${RELEASE_VERSION}/g" \ - "$output_root/usr/lib/podkop-plus/constants.sh" + "$output_root/usr/lib/podkop-plus/core/constants.uc" normalize_package_root_modes "$output_root" chmod 0755 "$output_root/etc/init.d/podkop-plus" "$output_root/usr/bin/podkop-plus" @@ -356,74 +356,30 @@ EOF EOF cat > "$control_dir/preinst" <<'EOF' -#!/bin/sh -[ -n "${IPKG_INSTROOT}" ] && exit 0 +#!/usr/bin/ucode -if [ ! -f /etc/config/podkop-plus ] && [ -f /etc/config/podkop_plus ]; then - cp /etc/config/podkop_plus /etc/config/podkop-plus || exit 1 - chmod 0644 /etc/config/podkop-plus 2>/dev/null || true -fi +let fs = require("fs"); -exit 0 +if (getenv("IPKG_INSTROOT") != null && getenv("IPKG_INSTROOT") != "") + exit(0); + +if (fs.stat("/etc/config/podkop-plus") == null && fs.stat("/etc/config/podkop_plus") != null) { + let data = fs.readfile("/etc/config/podkop_plus"); + if (data == null || fs.writefile("/etc/config/podkop-plus", data) == null) + exit(1); + system("chmod 0644 /etc/config/podkop-plus >/dev/null 2>&1"); +} + +exit(0); EOF cat > "$control_dir/prerm" <<'EOF' -#!/bin/sh +#!/usr/bin/ucode -grep -q "105 podkopplus" /etc/iproute2/rt_tables && sed -i "/105 podkopplus/d" /etc/iproute2/rt_tables +if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "") + system("/usr/bin/podkop-plus package_prerm >/dev/null 2>&1"); -/etc/init.d/podkop-plus stop >/dev/null 2>&1 || true -podkop_dont_touch_dhcp="$(uci -q get 'podkop-plus.settings.dont_touch_dhcp' 2>/dev/null)" -case "$podkop_dont_touch_dhcp" in - 1|true|yes|on) - ;; - *) - /usr/bin/podkop-plus restore_dnsmasq >/dev/null 2>&1 || true -if [ -r /usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh ]; then - sh /usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh >/dev/null 2>&1 || true -else - podkop_backup_notinterfaces="$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" - uci -q delete dhcp.podkop_plus >/dev/null 2>&1 || true - if [ -n "$podkop_backup_notinterfaces" ]; then - uci -q delete 'dhcp.@dnsmasq[0].notinterface' >/dev/null 2>&1 || true - for podkop_value in $podkop_backup_notinterfaces; do - uci -q add_list "dhcp.@dnsmasq[0].notinterface=$podkop_value" >/dev/null 2>&1 || true - done - else - uci -q del_list 'dhcp.@dnsmasq[0].notinterface=br-lan' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - podkop_backup_servers="$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" - if [ -n "$podkop_backup_servers" ]; then - uci -q delete 'dhcp.@dnsmasq[0].server' >/dev/null 2>&1 || true - for podkop_value in $podkop_backup_servers; do - uci -q add_list "dhcp.@dnsmasq[0].server=$podkop_value" >/dev/null 2>&1 || true - done - else - uci -q del_list 'dhcp.@dnsmasq[0].server=127.0.0.42' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - podkop_noresolv="$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" - if [ -n "$podkop_noresolv" ]; then - uci -q set "dhcp.@dnsmasq[0].noresolv=$podkop_noresolv" >/dev/null 2>&1 || true - else - uci -q set 'dhcp.@dnsmasq[0].noresolv=0' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_noresolv' >/dev/null 2>&1 || true - podkop_cachesize="$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" - if [ -n "$podkop_cachesize" ]; then - uci -q set "dhcp.@dnsmasq[0].cachesize=$podkop_cachesize" >/dev/null 2>&1 || true - else - uci -q set 'dhcp.@dnsmasq[0].cachesize=150' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_cachesize' >/dev/null 2>&1 || true - uci -q commit dhcp >/dev/null 2>&1 || true - [ -x /etc/init.d/dnsmasq ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true -fi - ;; -esac - -exit 0 +exit(0); EOF chmod 0755 "$control_dir/preinst" "$control_dir/prerm" @@ -458,11 +414,7 @@ default_postinst $0 $@ EOF cat > "$control_dir/postinst-pkg" <<'EOF' -[ -n "${IPKG_INSTROOT}" ] || { rm -f /tmp/luci-indexcache.* - rm -rf /tmp/luci-modulecache/ - killall -HUP rpcd 2>/dev/null - exit 0 -} +[ -n "${IPKG_INSTROOT}" ] || /usr/bin/podkop-plus luci_postinst >/dev/null 2>&1 || true EOF cat > "$control_dir/prerm" <<'EOF' @@ -560,93 +512,58 @@ write_backend_apk_scripts() { make_dir "$scripts_dir" cat > "$scripts_dir/backend-pre-install.sh" <<'EOF' -#!/bin/sh -[ -n "${IPKG_INSTROOT}" ] && exit 0 +#!/usr/bin/ucode -if [ ! -f /etc/config/podkop-plus ] && [ -f /etc/config/podkop_plus ]; then - cp /etc/config/podkop_plus /etc/config/podkop-plus || exit 1 - chmod 0644 /etc/config/podkop-plus 2>/dev/null || true -fi +let fs = require("fs"); -exit 0 +if (getenv("IPKG_INSTROOT") != null && getenv("IPKG_INSTROOT") != "") + exit(0); + +if (fs.stat("/etc/config/podkop-plus") == null && fs.stat("/etc/config/podkop_plus") != null) { + let data = fs.readfile("/etc/config/podkop_plus"); + if (data == null || fs.writefile("/etc/config/podkop-plus", data) == null) + exit(1); + system("chmod 0644 /etc/config/podkop-plus >/dev/null 2>&1"); +} + +exit(0); EOF cat > "$scripts_dir/backend-post-install.sh" <<'EOF' -#!/bin/sh -exit 0 +#!/usr/bin/ucode +exit(0); EOF cat > "$scripts_dir/backend-pre-deinstall.sh" <<'EOF' -#!/bin/sh -grep -q "105 podkopplus" /etc/iproute2/rt_tables && sed -i "/105 podkopplus/d" /etc/iproute2/rt_tables -/etc/init.d/podkop-plus stop >/dev/null 2>&1 || true -podkop_dont_touch_dhcp="$(uci -q get 'podkop-plus.settings.dont_touch_dhcp' 2>/dev/null)" -case "$podkop_dont_touch_dhcp" in - 1|true|yes|on) - ;; - *) - /usr/bin/podkop-plus restore_dnsmasq >/dev/null 2>&1 || true -if [ -r /usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh ]; then - sh /usr/lib/podkop-plus/dnsmasq_failsafe_restore.sh >/dev/null 2>&1 || true -else - podkop_backup_notinterfaces="$(uci -q get 'dhcp.@dnsmasq[0].podkop_notinterface' 2>/dev/null)" - uci -q delete dhcp.podkop_plus >/dev/null 2>&1 || true - if [ -n "$podkop_backup_notinterfaces" ]; then - uci -q delete 'dhcp.@dnsmasq[0].notinterface' >/dev/null 2>&1 || true - for podkop_value in $podkop_backup_notinterfaces; do - uci -q add_list "dhcp.@dnsmasq[0].notinterface=$podkop_value" >/dev/null 2>&1 || true - done - else - uci -q del_list 'dhcp.@dnsmasq[0].notinterface=br-lan' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_notinterface' >/dev/null 2>&1 || true - podkop_backup_servers="$(uci -q get 'dhcp.@dnsmasq[0].podkop_server' 2>/dev/null)" - if [ -n "$podkop_backup_servers" ]; then - uci -q delete 'dhcp.@dnsmasq[0].server' >/dev/null 2>&1 || true - for podkop_value in $podkop_backup_servers; do - uci -q add_list "dhcp.@dnsmasq[0].server=$podkop_value" >/dev/null 2>&1 || true - done - else - uci -q del_list 'dhcp.@dnsmasq[0].server=127.0.0.42' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_server' >/dev/null 2>&1 || true - podkop_noresolv="$(uci -q get 'dhcp.@dnsmasq[0].podkop_noresolv' 2>/dev/null)" - if [ -n "$podkop_noresolv" ]; then - uci -q set "dhcp.@dnsmasq[0].noresolv=$podkop_noresolv" >/dev/null 2>&1 || true - else - uci -q set 'dhcp.@dnsmasq[0].noresolv=0' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_noresolv' >/dev/null 2>&1 || true - podkop_cachesize="$(uci -q get 'dhcp.@dnsmasq[0].podkop_cachesize' 2>/dev/null)" - if [ -n "$podkop_cachesize" ]; then - uci -q set "dhcp.@dnsmasq[0].cachesize=$podkop_cachesize" >/dev/null 2>&1 || true - else - uci -q set 'dhcp.@dnsmasq[0].cachesize=150' >/dev/null 2>&1 || true - fi - uci -q delete 'dhcp.@dnsmasq[0].podkop_cachesize' >/dev/null 2>&1 || true - uci -q commit dhcp >/dev/null 2>&1 || true - [ -x /etc/init.d/dnsmasq ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true -fi - ;; -esac -exit 0 +#!/usr/bin/ucode + +if (getenv("IPKG_INSTROOT") == null || getenv("IPKG_INSTROOT") == "") + system("/usr/bin/podkop-plus package_prerm >/dev/null 2>&1"); + +exit(0); EOF cat > "$scripts_dir/backend-pre-upgrade.sh" <<'EOF' -#!/bin/sh -[ -n "${IPKG_INSTROOT}" ] && exit 0 +#!/usr/bin/ucode -if [ ! -f /etc/config/podkop-plus ] && [ -f /etc/config/podkop_plus ]; then - cp /etc/config/podkop_plus /etc/config/podkop-plus || exit 1 - chmod 0644 /etc/config/podkop-plus 2>/dev/null || true -fi +let fs = require("fs"); -exit 0 +if (getenv("IPKG_INSTROOT") != null && getenv("IPKG_INSTROOT") != "") + exit(0); + +if (fs.stat("/etc/config/podkop-plus") == null && fs.stat("/etc/config/podkop_plus") != null) { + let data = fs.readfile("/etc/config/podkop_plus"); + if (data == null || fs.writefile("/etc/config/podkop-plus", data) == null) + exit(1); + system("chmod 0644 /etc/config/podkop-plus >/dev/null 2>&1"); +} + +exit(0); EOF cat > "$scripts_dir/backend-post-upgrade.sh" <<'EOF' -#!/bin/sh -exit 0 +#!/usr/bin/ucode +exit(0); EOF chmod 0755 "$scripts_dir"/backend-*.sh @@ -671,11 +588,7 @@ export root="${IPKG_INSTROOT}" export pkgname="luci-app-podkop-plus" add_group_and_user default_postinst -[ -n "${IPKG_INSTROOT}" ] || { rm -f /tmp/luci-indexcache.* - rm -rf /tmp/luci-modulecache/ - killall -HUP rpcd 2>/dev/null - exit 0 -} +[ -n "${IPKG_INSTROOT}" ] || /usr/bin/podkop-plus luci_postinst >/dev/null 2>&1 || true EOF cat > "$scripts_dir/app-pre-deinstall.sh" <<'EOF' @@ -703,11 +616,7 @@ export root="${IPKG_INSTROOT}" export pkgname="luci-app-podkop-plus" add_group_and_user default_postinst -[ -n "${IPKG_INSTROOT}" ] || { rm -f /tmp/luci-indexcache.* - rm -rf /tmp/luci-modulecache/ - killall -HUP rpcd 2>/dev/null - exit 0 -} +[ -n "${IPKG_INSTROOT}" ] || /usr/bin/podkop-plus luci_postinst >/dev/null 2>&1 || true EOF chmod 0755 "$scripts_dir"/app-*.sh diff --git a/scripts/deploy-openwrt-ipk.ps1 b/scripts/deploy-openwrt-ipk.ps1 deleted file mode 100644 index 1e6892f0..00000000 --- a/scripts/deploy-openwrt-ipk.ps1 +++ /dev/null @@ -1,133 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [ValidatePattern('^([0-9]+(\.[0-9]+)*-[0-9]+|[0-9]+(\.[0-9]+){3})$')] - [string]$ReleaseVersion, - - [string]$RouterHost = "192.168.1.1", - [string]$RouterUser = "root", - [int]$SshPort = 22, - - # Включи, если opkg ругается на зависимости и ты точно хочешь форсить их - [switch]$ForceDepends -) - -$ErrorActionPreference = "Stop" - -function Assert-LastExitCode { - param([string]$Step) - - if ($LASTEXITCODE -ne 0) { - throw "$Step failed with exit code $LASTEXITCODE" - } -} - -function Convert-ToWslPath { - param([Parameter(Mandatory = $true)][string]$WindowsPath) - - $ResolvedPath = (Resolve-Path -LiteralPath $WindowsPath).Path - - # Важно: wslpath плохо получает Windows-пути с обратными слэшами через wsl.exe. - # Поэтому передаем путь в виде C:/Users/... - $SafeWindowsPath = $ResolvedPath -replace '\\', '/' - - $result = & wsl.exe wslpath -a -u "$SafeWindowsPath" - Assert-LastExitCode "wslpath" - - return $result.Trim() -} - -function Quote-Bash { - param([Parameter(Mandatory = $true)][string]$Value) - - return "'" + ($Value -replace "'", "'\''") + "'" -} - -$CurrentDir = Split-Path -Parent $MyInvocation.MyCommand.Path - -# Скрипт можно положить в корень репозитория или в папку scripts -if (Test-Path (Join-Path $CurrentDir "scripts\build-openwrt-packages-wsl.sh")) { - $RepoRoot = $CurrentDir -} elseif (Test-Path (Join-Path $CurrentDir "build-openwrt-packages-wsl.sh")) { - $RepoRoot = Split-Path -Parent $CurrentDir -} elseif (Test-Path (Join-Path (Split-Path -Parent $CurrentDir) "scripts\build-openwrt-packages-wsl.sh")) { - $RepoRoot = Split-Path -Parent $CurrentDir -} else { - throw "Не найден scripts\build-openwrt-packages-wsl.sh. Запусти скрипт из корня репозитория или положи его в корень/scripts." -} - -$BuildScriptWin = Join-Path $RepoRoot "scripts\build-openwrt-packages-wsl.sh" -$OutputDirWin = Join-Path $RepoRoot "dist\release-final" - -New-Item -ItemType Directory -Force -Path $OutputDirWin | Out-Null - -$BuildScriptWsl = Convert-ToWslPath $BuildScriptWin -$OutputDirWsl = Convert-ToWslPath $OutputDirWin - -Write-Host "Building OpenWrt packages..." -Write-Host "Release version: $ReleaseVersion" -Write-Host "Output dir: $OutputDirWin" - -$BuildCommand = "bash $(Quote-Bash $BuildScriptWsl) $(Quote-Bash $ReleaseVersion) $(Quote-Bash $OutputDirWsl)" -& wsl.exe bash -lc $BuildCommand -Assert-LastExitCode "Package build" - -$Ipks = Get-ChildItem -Path $OutputDirWin -Filter "*.ipk" -File | Sort-Object Name - -if (-not $Ipks -or $Ipks.Count -eq 0) { - throw "В $OutputDirWin не найдены .ipk пакеты." -} - -Write-Host "Found IPK packages:" -$Ipks | ForEach-Object { Write-Host " - $($_.Name)" } - -$RemoteDir = "/tmp/openwrt-ipk-deploy" -$Remote = "${RouterUser}@${RouterHost}" - -$SshArgs = @() -if ($SshPort -ne 22) { - $SshArgs += @("-p", "$SshPort") -} - -$ScpArgs = @("-O") -if ($SshPort -ne 22) { - $ScpArgs += @("-P", "$SshPort") -} - -Write-Host "Preparing router temp dir: $RemoteDir" -& ssh.exe @SshArgs $Remote "rm -rf '$RemoteDir' && mkdir -p '$RemoteDir'" -Assert-LastExitCode "Router temp dir preparation" - -Write-Host "Copying IPK packages to router..." -$Ipks.FullName | ForEach-Object { - & scp.exe @ScpArgs "$_" "${Remote}:$RemoteDir/" - Assert-LastExitCode "SCP upload: $_" -} - -$ForceOptions = @( - "--force-reinstall", - "--force-overwrite", - "--force-downgrade" -) - -if ($ForceDepends) { - $ForceOptions += "--force-depends" -} - -$ForceOptionsText = $ForceOptions -join " " - -$InstallParts = @( - "set -e", - "echo 'Installing IPK packages from $RemoteDir...'", - "for pkg in $RemoteDir/podkop-plus_*.ipk $RemoteDir/luci-app-podkop-plus_*.ipk $RemoteDir/luci-i18n-podkop-plus-ru_*.ipk; do [ -e `"`$pkg`" ] || continue; echo `"Installing `$pkg`"; opkg $ForceOptionsText install `"`$pkg`"; done", - "rm -rf /tmp/luci-indexcache.* /tmp/luci-modulecache/ 2>/dev/null || true", - "/etc/init.d/rpcd restart 2>/dev/null || killall -HUP rpcd 2>/dev/null || true", - "echo 'Done.'" -) - -$InstallCommand = $InstallParts -join "; " - -Write-Host "Installing packages on router..." -& ssh.exe @SshArgs $Remote $InstallCommand -Assert-LastExitCode "Remote opkg install" - -Write-Host "Deployment completed successfully." diff --git a/tests/backend/byedpi_runtime_owner_regression.sh b/tests/backend/byedpi_runtime_owner_regression.sh new file mode 100644 index 00000000..a391e45c --- /dev/null +++ b/tests/backend/byedpi_runtime_owner_regression.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +CLI_UC="$PODKOP_BIN" +BYEDPI_RUNTIME_SH="$PODKOP_LIB/byedpi.sh" +LIFECYCLE_UC="$PODKOP_LIB/service/lifecycle.uc" +BYEDPI_RUNTIME_UC="$PODKOP_LIB/providers/byedpi/runtime.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ ! -e "$BYEDPI_RUNTIME_SH" ] || + fail "byedpi.sh shell owner must be removed" + +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +grep -Fq 'service/lifecycle.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" +grep -Fq 'providers/byedpi/runtime.uc' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc must call providers/byedpi/runtime.uc for ByeDPI runtime operations" + +if grep -R -n -E 'start_byedpi_runtime|stop_byedpi_runtime|get_byedpi_status_json|check_byedpi_runtime_json|is_byedpi_installed|get_byedpi_package_version|get_byedpi_rule_|run_byedpi_supervisor' \ + "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "ByeDPI runtime shell symbols must not remain" +fi +if grep -n -E 'require\("uci"\)\.cursor|uci -q|"uci", "-q"' "$BYEDPI_RUNTIME_UC" >/dev/null 2>&1; then + fail "providers/byedpi/runtime.uc must use core.uci instead of direct UCI cursor or CLI access" +fi +grep -Fq 'require("core.uci")' "$BYEDPI_RUNTIME_UC" || + fail "providers/byedpi/runtime.uc must use core.uci for runtime UCI access" + +grep -Fq 'mode == "start-runtime"' "$BYEDPI_RUNTIME_UC" || + fail "providers/byedpi/runtime.uc must own ByeDPI start" +grep -Fq 'mode == "stop-runtime"' "$BYEDPI_RUNTIME_UC" || + fail "providers/byedpi/runtime.uc must own ByeDPI stop" +grep -Fq 'mode == "status"' "$BYEDPI_RUNTIME_UC" || + fail "providers/byedpi/runtime.uc must own ByeDPI status" +grep -Fq 'mode == "check"' "$BYEDPI_RUNTIME_UC" || + fail "providers/byedpi/runtime.uc must own ByeDPI check" + +BYEDPI_BIN="$ROOT_DIR/tests/missing-ciadpi" \ + ucode -L "$PODKOP_LIB" "$BYEDPI_RUNTIME_UC" check | + node -e ' +const fs = require("fs"); +const value = JSON.parse(fs.readFileSync(0, "utf8")); +if (value.byedpi_installed !== false || value.byedpi_package_installed !== false) { + console.error("unexpected ByeDPI check JSON"); + process.exit(1); +} +' + +printf 'ByeDPI runtime ownership regression checks passed\n' diff --git a/tests/backend/byedpi_validator_regression.sh b/tests/backend/byedpi_validator_regression.sh new file mode 100644 index 00000000..af6b55e5 --- /dev/null +++ b/tests/backend/byedpi_validator_regression.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +VALIDATOR="$ROOT_DIR/podkop/files/usr/lib/providers/byedpi/validator.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_json_field() { + local json="$1" + local field="$2" + local expected="$3" + + JSON_VALUE="$json" node - "$field" "$expected" <<'NODE' +const field = process.argv[2]; +const expected = process.argv[3]; +const value = JSON.parse(process.env.JSON_VALUE); +const actual = String(value[field]); +if (actual !== expected) { + console.error(`expected ${field}=${expected}, got ${actual}`); + process.exit(1); +} +NODE +} + +valid_json="$(ucode -- "$VALIDATOR" validate-json '--disorder 3 --fake-sni example.org -N')" +assert_json_field "$valid_json" valid true + +configured="$(ucode -- "$VALIDATOR" strategy-or-default "$(printf -- '--disorder\t3\n--fake-sni example.org')" '--default 1')" +[ "$configured" = "--disorder 3 --fake-sni example.org" ] || + fail "configured strategy should be normalized, got '$configured'" + +defaulted="$(ucode -- "$VALIDATOR" strategy-or-default "" "$(printf -- '--default\t1')")" +[ "$defaulted" = "--default 1" ] || + fail "empty strategy should use normalized default, got '$defaulted'" + +invalid_json="$(ucode -- "$VALIDATOR" validate-json '--port 1080 --disorder 3')" +assert_json_field "$invalid_json" valid false +assert_json_field "$invalid_json" needle --port + +if ucode -- "$VALIDATOR" validate '--transparent' >/tmp/byedpi-validator.out 2>/dev/null; then + fail "controlled transparent mode should be rejected" +fi +grep -q 'Transparent proxy mode is incompatible' /tmp/byedpi-validator.out || + fail "reject message should explain transparent mode" + +if ucode -- "$VALIDATOR" validate '--disorder --fake example.org' >/dev/null 2>&1; then + fail "missing value should be rejected" +fi + +cat >"$WORK_DIR/require-byedpi-validator.uc" <<'UCODE' +let validator = require("providers.byedpi.validator"); + +let valid = validator.validate_byedpi_strategy("--disorder 3 --fake-sni example.org -N"); +if (!valid.valid) + exit(1); + +let invalid = validator.validate_byedpi_strategy("--port 1080 --disorder 3"); +if (invalid.valid || invalid.needle != "--port") + exit(1); + +if (validator.strategy_or_default("", "--default\t1") != "--default 1") + exit(1); +UCODE + +ucode -L "$PODKOP_LIB" "$WORK_DIR/require-byedpi-validator.uc" + +printf 'ByeDPI validator regression checks passed\n' diff --git a/tests/backend/cli_entrypoint_regression.sh b/tests/backend/cli_entrypoint_regression.sh new file mode 100644 index 00000000..e7dd9e4e --- /dev/null +++ b/tests/backend/cli_entrypoint_regression.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ ! -e "$PODKOP_LIB/service/cli.uc" ] || + fail "service/cli.uc must be removed after /usr/bin ucode entrypoint takeover" +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +if grep -Fq '#!/bin/sh' "$PODKOP_BIN" || + grep -Fq '#!/bin/ash' "$PODKOP_BIN" || + grep -Fq 'exec ucode' "$PODKOP_BIN"; then + fail "podkop entrypoint must not keep a shell loader" +fi +if grep -Fq 'PODKOP_COMMAND' "$PODKOP_BIN" || + grep -Fq 'run_module()' "$PODKOP_BIN"; then + fail "podkop entrypoint must not keep legacy shell routing symbols" +fi +grep -Fq 'function command_spec(command)' "$PODKOP_BIN" || + fail "podkop ucode entrypoint must own command routing" +grep -Fq 'function show_help()' "$PODKOP_BIN" || + fail "podkop ucode entrypoint must own help text" + +fake_lib="$WORK_DIR/lib" +mkdir -p "$fake_lib/service" "$fake_lib/diagnostics" "$fake_lib/components" "$fake_lib/dns" + +cat >"$fake_lib/diagnostics/runtime.uc" <<'UCODE' +#!/usr/bin/env ucode +print("diagnostics\t", ARGV[0], "\t", ARGV[1] || "", "\t", ARGV[2] || "", "\t", ARGV[3] || "", "\n"); +UCODE + +cat >"$fake_lib/components/updates.uc" <<'UCODE' +#!/usr/bin/env ucode +print("updates\t", ARGV[0], "\t", ARGV[1] || "", "\t", ARGV[2] || "", "\n"); +UCODE + +cat >"$fake_lib/components/action.uc" <<'UCODE' +#!/usr/bin/env ucode +print("action\t", ARGV[0], "\t", ARGV[1] || "", "\t", ARGV[2] || "", "\n"); +UCODE + +cat >"$fake_lib/dns/apply.uc" <<'UCODE' +#!/usr/bin/env ucode +let fs = require("fs"); +let marker = getenv("PODKOP_TEST_DNS_RESTORE_MARKER"); +if (marker != null && marker != "") + fs.writefile(marker, ARGV[0] || ""); +UCODE + +show_version_out="$(PODKOP_LIB="$fake_lib" ucode "$PODKOP_BIN" show_version)" +[ "$show_version_out" = $'diagnostics\tshow-version\t\t\t' ] || + fail "show_version must dispatch through diagnostics/runtime.uc" + +subscription_out="$(PODKOP_LIB="$fake_lib" ucode "$PODKOP_BIN" subscription_update proxy 2)" +[ "$subscription_out" = $'updates\tsubscription-update\tproxy\t2' ] || + fail "subscription_update must dispatch through components/updates.uc with arguments" + +component_out="$(PODKOP_LIB="$fake_lib" ucode "$PODKOP_BIN" component_action sing_box update)" +[ "$component_out" = $'action\tcomponent-action\tsing_box\tupdate' ] || + fail "component_action must dispatch through components/action.uc with arguments" + +rm -f "$fake_lib/service/lifecycle.uc" +set +e +PODKOP_TEST_DNS_RESTORE_MARKER="$WORK_DIR/dns-restore.marker" PODKOP_LIB="$fake_lib" \ + ucode "$PODKOP_BIN" start >/dev/null 2>"$WORK_DIR/missing.err" +status="$?" +set -e +[ "$status" -ne 0 ] || + fail "missing lifecycle module must fail" +[ "$(cat "$WORK_DIR/dns-restore.marker")" = "failsafe-restore" ] || + fail "missing lifecycle module must trigger dnsmasq failsafe restore" + +printf 'CLI entrypoint regression checks passed\n' diff --git a/tests/backend/components_updater_job_regression.sh b/tests/backend/components_updater_job_regression.sh new file mode 100644 index 00000000..2b4d91d3 --- /dev/null +++ b/tests/backend/components_updater_job_regression.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +CLI_UC="$PODKOP_BIN" +UPDATER="$ROOT_DIR/podkop/files/usr/lib/components/updater.uc" +UPDATES_UC="$ROOT_DIR/podkop/files/usr/lib/components/updates.uc" +ACTION_UC="$ROOT_DIR/podkop/files/usr/lib/components/action.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +write_state() { + local name="$1" + local content="$2" + + printf '%s\n' "$content" >"$WORK_DIR/$name.json" + printf '%s\n' "$WORK_DIR/$name.json" +} + +assert_eq "/tmp/jobs/abc-1_2.json" \ + "$(ucode "$UPDATER" updates-job-state-path /tmp/jobs abc-1_2)" \ + "valid job state path" + +if ucode "$UPDATER" updates-job-state-path /tmp/jobs '../bad' >/dev/null 2>&1; then + fail "invalid job id should be rejected" +fi + +assert_eq outdated \ + "$(ucode "$UPDATER" updates-status-from-compare -1)" \ + "outdated compare status" +assert_eq latest \ + "$(ucode "$UPDATER" updates-status-from-compare 0)" \ + "latest compare status" +assert_eq dev \ + "$(ucode "$UPDATER" updates-status-from-compare 1)" \ + "dev compare status" +if ucode "$UPDATER" updates-status-from-compare invalid >/dev/null 2>&1; then + fail "invalid compare status should be rejected" +fi + +assert_eq "$(printf 'Latest version is installed\tcomponent is up to date (1.0)')" \ + "$(ucode "$UPDATER" updates-check-result-row component 1.0 1.0 latest)" \ + "latest check result" +assert_eq "$(printf 'Update is available\tcomponent update is available: 1.0 -> 2.0')" \ + "$(ucode "$UPDATER" updates-check-result-row component 1.0 2.0 outdated)" \ + "outdated check result" +assert_eq "$(printf 'Installed version is newer than release\tcomponent installed version is newer than upstream release: 2.0 -> 1.0')" \ + "$(ucode "$UPDATER" updates-check-result-row component 2.0 1.0 dev)" \ + "dev check result" +if ucode "$UPDATER" updates-check-result-row component 1.0 2.0 invalid >/dev/null 2>&1; then + fail "invalid check result status should be rejected" +fi + +[ ! -e "$ROOT_DIR/podkop/files/usr/lib/updater.sh" ] || + fail "updater.sh shell owner must be removed" +grep -Fq 'component_action: [ "components/action.uc", "component-action", 2 ]' "$CLI_UC" || + fail "service/cli.uc must dispatch direct component_action through components/action.uc" +grep -Fq 'component-action-async' "$UPDATES_UC" || + fail "components/updates.uc must own component action async" +grep -Fq 'component-action-status' "$UPDATES_UC" || + fail "components/updates.uc must own component action status" +grep -Fq 'components/action.uc' "$UPDATES_UC" || + fail "component action worker must execute components/action.uc directly" +grep -Fq 'singbox/runtime.uc' "$UPDATES_UC" || + fail "components/updates.uc must delegate sing-box service/config work to singbox/runtime.uc" +if grep -n -E 'function (configure_sing_box_service|rebuild_sing_box_config|save_sing_box_config_file|service_listen_address|managed_sing_box_service_text)' "$UPDATES_UC" >/dev/null 2>&1; then + fail "components/updates.uc must not duplicate singbox/runtime.uc service/config ownership" +fi +if grep -Fq 'save-sing-box-config-file-fixture' "$UPDATES_UC"; then + fail "components/updates.uc must not expose sing-box config save fixtures owned by singbox/runtime.uc" +fi +grep -Fq 'component_action_async: [ "components/updates.uc", "component-action-async", 2 ]' "$CLI_UC" || + fail "service/cli.uc must dispatch component_action_async through components/updates.uc" +grep -Fq 'component_action_status: [ "components/updates.uc", "component-action-status", 1 ]' "$CLI_UC" || + fail "service/cli.uc must dispatch component_action_status through components/updates.uc" +grep -Fq 'require("core.uci")' "$ACTION_UC" || + fail "components/action.uc must use core.uci for component UCI mutations" +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"|command_exists\("uci"\)' "$ACTION_UC" >/dev/null 2>&1; then + fail "components/action.uc must not own direct UCI cursor or CLI calls" +fi +init_line="$(grep -n '^function init_tmp_dir()' "$ACTION_UC" | cut -d: -f1)" +tmp_file_line="$(grep -n '^function make_tmp_file' "$ACTION_UC" | cut -d: -f1)" +[ -n "$init_line" ] && [ -n "$tmp_file_line" ] && [ "$init_line" -lt "$tmp_file_line" ] || + fail "components/action.uc must declare init_tmp_dir before make_tmp_file for OpenWrt ucode" + +release_json="$(cat <<'JSON' +{ + "tag_name": "v1.2.3", + "html_url": "https://example.com/release", + "assets": [ + {"name": "podkop-plus_1.2.3_all.ipk", "browser_download_url": "https://example.com/backend.ipk"}, + {"name": "luci-app-podkop-plus_1.2.3_all.ipk", "browser_download_url": "https://example.com/app.ipk"}, + {"name": "luci-i18n-podkop-plus-ru_1.2.3_all.ipk", "browser_download_url": "https://example.com/i18n.ipk"} + ] +} +JSON +)" + +assert_eq "$(printf 'https://example.com/release\tpodkop-plus_1.2.3_all.ipk\thttps://example.com/backend.ipk\tluci-app-podkop-plus_1.2.3_all.ipk\thttps://example.com/app.ipk\tluci-i18n-podkop-plus-ru_1.2.3_all.ipk\thttps://example.com/i18n.ipk')" \ + "$(printf '%s' "$release_json" | ucode "$UPDATER" podkop-plus-release-plan v1.2.3 ipk 1)" \ + "podkop release plan with i18n" +assert_eq "$(printf 'https://example.com/release\tpodkop-plus_1.2.3_all.ipk\thttps://example.com/backend.ipk\tluci-app-podkop-plus_1.2.3_all.ipk\thttps://example.com/app.ipk\t\t')" \ + "$(printf '%s' "$release_json" | ucode "$UPDATER" podkop-plus-release-plan v1.2.3 ipk 0)" \ + "podkop release plan without i18n" +if printf '%s' "$release_json" | ucode "$UPDATER" podkop-plus-release-plan v9.9.9 ipk 0 >/dev/null 2>&1; then + fail "podkop release plan should reject tag mismatch" +fi +missing_i18n_json="$(cat <<'JSON' +{ + "tag_name": "v1.2.3", + "html_url": "https://example.com/release", + "assets": [ + {"name": "podkop-plus_1.2.3_all.ipk", "browser_download_url": "https://example.com/backend.ipk"}, + {"name": "luci-app-podkop-plus_1.2.3_all.ipk", "browser_download_url": "https://example.com/app.ipk"} + ] +} +JSON +)" +if printf '%s' "$missing_i18n_json" | ucode "$UPDATER" podkop-plus-release-plan v1.2.3 ipk 1 >/dev/null 2>&1; then + fail "podkop release plan should require i18n asset when requested" +fi + +component_actions_dir="$WORK_DIR/component-actions" +fake_lib="$WORK_DIR/lib" +mkdir -p "$fake_lib/components" "$fake_lib/core" +cp "$UPDATES_UC" "$fake_lib/components/updates.uc" +cp "$PODKOP_LIB/core/uci.uc" "$fake_lib/core/uci.uc" +cp "$PODKOP_LIB/core/common.uc" "$fake_lib/core/common.uc" +cat >"$fake_lib/components/action.uc" <<'UCODE' +#!/usr/bin/env ucode +if ((ARGV[0] || "") == "component-action") { + printf('{"success":true,"kind":"component","component":"%s","action":"%s","message":"done","current_version":"1.0","latest_version":"1.0","changed":0,"status":"latest"}\n', ARGV[1], ARGV[2]); + exit(0); +} +exit(1); +UCODE + +start_json="$(PODKOP_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ + ucode -L "$PODKOP_LIB" "$UPDATES_UC" component-action-async sing_box check_update)" +job_id="$(JSON_VALUE="$start_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (!value.success || !value.job_id) { + process.exit(1); +} +process.stdout.write(value.job_id); +NODE +)" +[ -n "$job_id" ] || fail "component action async should return a job id" + +status_json="" +for _ in 1 2 3 4 5; do + status_json="$(PODKOP_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ + ucode -L "$PODKOP_LIB" "$UPDATES_UC" component-action-status "$job_id")" + JSON_VALUE="$status_json" node - <<'NODE' && break || true +const value = JSON.parse(process.env.JSON_VALUE); +process.exit(value.running === false ? 0 : 1); +NODE + sleep 1 +done + +JSON_VALUE="$status_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.running !== false || value.success !== true || value.component !== "sing_box" || + value.action !== "check_update" || value.message !== "done" || value.exit_code !== 0) { + console.error("component action async/status state mismatch"); + process.exit(1); +} +NODE + +start_json="$(PODKOP_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ + ucode -L "$PODKOP_LIB" "$UPDATES_UC" component-action-async sing-box check_update)" +job_id="$(JSON_VALUE="$start_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (!value.success || !value.job_id) { + process.exit(1); +} +process.stdout.write(value.job_id); +NODE +)" +[ -n "$job_id" ] || fail "component action async should accept sing-box public name" + +status_json="" +for _ in 1 2 3 4 5; do + status_json="$(PODKOP_LIB="$fake_lib" UPDATES_JOB_DIR="$component_actions_dir" \ + ucode -L "$PODKOP_LIB" "$UPDATES_UC" component-action-status "$job_id")" + JSON_VALUE="$status_json" node - <<'NODE' && break || true +const value = JSON.parse(process.env.JSON_VALUE); +process.exit(value.running === false ? 0 : 1); +NODE + sleep 1 +done + +JSON_VALUE="$status_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.running !== false || value.success !== true || value.component !== "sing_box" || + value.action !== "check_update" || value.message !== "done" || value.exit_code !== 0) { + console.error("component action public sing-box alias state mismatch"); + process.exit(1); +} +NODE + +zapret_release_json="$(cat <<'JSON' +{ + "tag_name": "v70.1", + "html_url": "https://example.com/zapret-release", + "assets": [ + {"name": "zapret_v70.1_aarch64_generic.zip", "browser_download_url": "https://example.com/aarch64.zip"}, + {"name": "zapret_v70.1_mipsel_24kc.zip", "browser_download_url": "https://example.com/mipsel.zip"} + ] +} +JSON +)" + +assert_eq "$(printf 'mipsel_24kc\tzapret_v70.1_mipsel_24kc.zip\thttps://example.com/mipsel.zip\thttps://example.com/zapret-release\tv70.1')" \ + "$(printf '%s' "$zapret_release_json" | ucode "$UPDATER" release-select-arch-suffix-asset zip 'mipsel_24kc aarch64_generic')" \ + "release arch suffix selector" +[ -z "$(printf '%s' "$zapret_release_json" | ucode "$UPDATER" release-select-arch-suffix-asset zip 'arm_cortex-a7')" ] || + fail "release arch suffix selector should be empty for missing arch" + +running="$(write_state running '{"running":true,"pid":"123","started_at":100}')" +assert_eq "$(printf 'pid\t123\t0')" \ + "$(ucode "$UPDATER" updates-job-refresh-plan "$running" 105 15)" \ + "alive candidate within grace" +assert_eq "$(printf 'pid\t123\t1')" \ + "$(ucode "$UPDATER" updates-job-refresh-plan "$running" 120 15)" \ + "alive candidate after grace" + +invalid_pid="$(write_state invalid-pid '{"running":true,"pid":"","started_at":100}')" +assert_eq "skip" \ + "$(ucode "$UPDATER" updates-job-refresh-plan "$invalid_pid" 105 15)" \ + "invalid pid within grace" +assert_eq "stale" \ + "$(ucode "$UPDATER" updates-job-refresh-plan "$invalid_pid" 120 15)" \ + "invalid pid after grace" + +finished="$(write_state finished '{"running":false,"pid":"123","started_at":100}')" +assert_eq "skip" \ + "$(ucode "$UPDATER" updates-job-refresh-plan "$finished" 120 15)" \ + "finished job refresh" + +stale_json="$(ucode "$UPDATER" updates-mark-stale-job-state "$invalid_pid")" +JSON_VALUE="$stale_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.running !== false || value.success !== false || value.exit_code !== null) { + console.error("stale state shape mismatch"); + process.exit(1); +} +NODE + +printf 'component updater job regression checks passed\n' diff --git a/tests/backend/config_contract_matrix_regression.sh b/tests/backend/config_contract_matrix_regression.sh new file mode 100644 index 00000000..b09b3587 --- /dev/null +++ b/tests/backend/config_contract_matrix_regression.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +STABLE_REF="${PODKOP_STABLE_REF:-0.7.19.9}" +STABLE_REPO="${PODKOP_STABLE_REPO:-}" +MATRIX_SCRIPT="$ROOT_DIR/tests/differential/config_contract_matrix.js" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +ensure_stable_ref() { + if git -C "$ROOT_DIR" rev-parse --verify "$STABLE_REF^{commit}" >/dev/null 2>&1; then + return 0 + fi + + git -C "$ROOT_DIR" fetch --force --depth=1 origin "refs/tags/$STABLE_REF:refs/tags/$STABLE_REF" >/dev/null 2>&1 || + fail "stable ref is unavailable and could not be fetched: $STABLE_REF" +} + +prepare_stable_repo() { + if [ -n "$STABLE_REPO" ]; then + [ -r "$STABLE_REPO/podkop/files/etc/config/podkop" ] || + fail "stable repo is missing Podkop config template: $STABLE_REPO" + return 0 + fi + + ensure_stable_ref + STABLE_REPO="$WORK_DIR/stable-$STABLE_REF" + mkdir -p "$STABLE_REPO" + git -C "$ROOT_DIR" archive "$STABLE_REF" | tar -x -C "$STABLE_REPO" || + fail "failed to materialize stable baseline: $STABLE_REF" +} + +prepare_stable_repo + +node "$MATRIX_SCRIPT" --current "$ROOT_DIR" --stable "$STABLE_REPO" --check >"$WORK_DIR/matrix.json" + +node - "$WORK_DIR/matrix.json" <<'NODE' +const fs = require("fs"); +const matrix = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +function fail(message) { + console.error(message); + process.exit(1); +} + +const missing = matrix.fields.filter((field) => field.status === "missing_current"); +if (missing.length) { + fail(`stable config fields missing in current contract: ${missing.map((field) => field.name).join(", ")}`); +} + +for (const name of ["dns_type", "subscription_urls", "selector_proxy_links", "action", "protocol", "transport", "security", "user_domains", "user_domains_text", "user_domain_list_type", "local_domain_lists", "remote_domain_lists", "remote_subnet_lists", "domain_ip_lists", "fully_routed_ips", "server_uuid", "tailscale_auth_key"]) { + const field = matrix.fields.find((item) => item.name === name); + if (!field) fail(`expected config field is absent from matrix: ${name}`); + if (field.status !== "supported" && field.status !== "migrated") { + fail(`expected config field ${name} to be supported or migrated, got ${field.status}`); + } +} + +if ((matrix.summary.supported || 0) < 140) { + fail(`unexpectedly small supported config surface: ${matrix.summary.supported || 0}`); +} + +if (matrix.stable.version !== "0.7.19.9") { + fail(`unexpected stable baseline: ${matrix.stable.version}`); +} + +function uiValues(field) { + const result = new Set(); + for (const entry of field?.ui || []) { + for (const value of entry.values || []) { + result.add(value); + } + } + return [...result].sort(); +} + +function assertCurrentKeepsStableValues(name, required = []) { + const field = matrix.fields.find((item) => item.name === name); + if (!field) fail(`expected enum field is absent from matrix: ${name}`); + + const stableValues = uiValues(field.stable); + const currentValues = uiValues(field.current); + const missingStable = stableValues.filter((value) => !currentValues.includes(value)); + if (missingStable.length) { + fail(`current UI contract for ${name} is missing stable values: ${missingStable.join(", ")}`); + } + + const missingRequired = required.filter((value) => !currentValues.includes(value)); + if (missingRequired.length) { + fail(`current UI contract for ${name} is missing required values: ${missingRequired.join(", ")}`); + } +} + +assertCurrentKeepsStableValues("action", ["proxy", "vpn", "direct", "block", "zapret", "zapret2", "byedpi", "outbound"]); +assertCurrentKeepsStableValues("protocol", ["tailscale", "vless", "vmess", "trojan", "shadowsocks", "hysteria2", "socks", "mtproto", "json_inbound"]); +assertCurrentKeepsStableValues("security", ["reality", "tls", "none"]); +assertCurrentKeepsStableValues("transport", ["tcp", "ws", "grpc", "http", "httpupgrade", "xhttp"]); +assertCurrentKeepsStableValues("urltest_filter_mode", ["disabled", "exclude", "include", "mixed"]); +assertCurrentKeepsStableValues("dns_type", ["doh", "dot", "udp"]); +assertCurrentKeepsStableValues("routing_mode", ["rules", "direct", "section"]); +assertCurrentKeepsStableValues("shadowsocks_method", ["aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305"]); +assertCurrentKeepsStableValues("vless_flow", ["none", "xtls-rprx-vision"]); +assertCurrentKeepsStableValues("transport_xhttp_mode", ["auto", "packet-up", "stream-up", "stream-one"]); +NODE + +printf 'config contract matrix regression checks passed\n' diff --git a/tests/backend/config_migration_regression.sh b/tests/backend/config_migration_regression.sh new file mode 100644 index 00000000..ce8c7126 --- /dev/null +++ b/tests/backend/config_migration_regression.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +MIGRATION="$PODKOP_LIB/config/migration.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$MIGRATION" >/dev/null 2>&1; then + fail "config/migration.uc must use core.uci instead of direct UCI cursor or CLI access" +fi +grep -Fq 'require("core.uci")' "$MIGRATION" || + fail "config/migration.uc must import core.uci" + +eval "$(ucode -L "$PODKOP_LIB" "$PODKOP_LIB/core/constants.uc" shell-env)" +export ZAPRET_LEGACY_DEFAULT_NFQWS_OPT ZAPRET_DEFAULT_NFQWS_OPT + +node >"$WORK_DIR/fixture.json" <<'NODE' +const fixture = { + settings: { + '.name': 'settings', + '.type': 'settings', + download_lists_via_proxy: '1' + }, + rule: [ + { + '.name': 'legacy-url', + '.type': 'rule', + enabled: '1', + connection_type: 'proxy', + proxy_config_type: 'url', + proxy_string: 'vless://one\n//commented\n ss://two ', + urltest_check_interval_disabled: '1', + domain: [ 'Example.COM' ], + domain_keyword_text_mode: '1', + domain_keyword_text: 'Video, Stream # comment', + domain_regex_text: '^api[.]example$, ^cdn[.]example$' + } + ], + section: [ + { + '.name': 'legacy-sub', + '.type': 'section', + action: 'proxy', + proxy_config_type: 'subscription', + subscription_url: 'https://example.com/sub.txt', + subscription_user_agent: 'Agent/1.0', + subscription_update_interval_disabled: '1', + urltest_enabled: '1', + urltest_filter_mode: 'include', + detect_server_country: '1' + }, + { + '.name': 'legacy-urltest', + '.type': 'section', + connection_type: 'proxy', + proxy_config_type: 'urltest', + urltest_proxy_links: [ 'vmess://a', 'vmess://a', 'trojan://b' ], + urltest_exclude_regex: [ 'bad.*' ], + urltest_enabled: '1', + detect_server_country: '0' + }, + { + '.name': 'legacy-zap', + '.type': 'section', + action: 'zapret', + nfqws_opt: process.env.ZAPRET_LEGACY_DEFAULT_NFQWS_OPT, + cmd_opts: '--legacy-bye' + }, + { + '.name': 'legacy-vpn', + '.type': 'section', + connection_type: 'vpn', + proxy_config_type: 'subscription', + proxy_string: 'ss://drop', + subscription_url: 'https://drop.example/sub.txt', + subscription_user_agent: 'DropUA', + urltest_check_interval_disabled: '1', + subscription_update_interval_disabled: '1' + } + ] +}; + +process.stdout.write(`${JSON.stringify(fixture, null, 2)}\n`); +NODE + +PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/fixture.json" >"$WORK_DIR/output.json" + +node - "$WORK_DIR/output.json" <<'NODE' +const fs = require('fs'); +const out = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const config = out.config; +const sections = Object.fromEntries(config.section.map(section => [section['.name'], section])); + +function assert(condition, message) { + if (!condition) { + console.error(message); + process.exit(1); + } +} + +function absent(object, key, label) { + assert(!Object.prototype.hasOwnProperty.call(object, key), `${label}: ${key} should be absent`); +} + +assert(out.changed === true, 'migration should report changes'); +assert(!config.rule, 'legacy rule sections should be converted'); + +assert(config.settings.list_update_enabled === '0', 'missing list_update_enabled should become disabled'); +assert(config.settings.update_interval === '1d', 'missing update_interval should get 1d default'); +assert(config.settings.download_lists_via_proxy === '1', 'download_lists_via_proxy should be preserved'); +assert(config.settings.download_subscriptions_via_proxy === '1', 'download_subscriptions_via_proxy should be copied'); +assert(config.settings.download_components_via_proxy === '1', 'download_components_via_proxy should be copied'); + +const legacyUrl = sections['legacy-url']; +assert(legacyUrl['.type'] === 'section', 'legacy rule should become section'); +assert(legacyUrl.action === 'proxy', 'legacy-url action'); +assert(JSON.stringify(legacyUrl.selector_proxy_links) === JSON.stringify(['vless://one', 'ss://two']), 'proxy_string links'); +assert(legacyUrl.urltest_enabled === '0', 'urltest disabled flag'); +assert(legacyUrl.domain_suffix.includes('full:Example.COM'), 'full domain list value migrated'); +assert(legacyUrl.domain_suffix.includes('keyword:Video'), 'keyword migrated'); +assert(legacyUrl.domain_suffix.includes('keyword:Stream'), 'keyword comment stripped'); +assert(legacyUrl.domain_suffix.includes('regex:^api[.]example$'), 'regex migrated'); +assert(legacyUrl.domain_suffix.includes('regex:^cdn[.]example$'), 'second regex migrated'); +absent(legacyUrl, 'proxy_string', 'legacy-url'); +absent(legacyUrl, 'proxy_config_type', 'legacy-url'); +absent(legacyUrl, 'connection_type', 'legacy-url'); +absent(legacyUrl, 'domain', 'legacy-url'); +absent(legacyUrl, 'domain_keyword_text', 'legacy-url'); +absent(legacyUrl, 'domain_regex_text', 'legacy-url'); + +const legacySub = sections['legacy-sub']; +assert(JSON.stringify(legacySub.subscription_urls) === JSON.stringify(['https://example.com/sub.txt | Agent/1.0']), 'subscription entry'); +assert(legacySub.subscription_update_enabled === '0', 'subscription update disabled flag'); +assert(legacySub.detect_server_country === 'flag_emoji', 'detect server country normalized'); +absent(legacySub, 'subscription_url', 'legacy-sub'); +absent(legacySub, 'subscription_user_agent', 'legacy-sub'); +absent(legacySub, 'proxy_config_type', 'legacy-sub'); + +const legacyUrltest = sections['legacy-urltest']; +assert(legacyUrltest.action === 'proxy', 'legacy-urltest action'); +assert(legacyUrltest.urltest_filter_mode === 'exclude', 'urltest filter mode migration'); +assert(legacyUrltest.detect_server_country === 'flag_emoji', 'detect server country kept when urltest filter enabled'); +assert(JSON.stringify(legacyUrltest.selector_proxy_links) === JSON.stringify(['vmess://a', 'trojan://b']), 'urltest links deduped'); +absent(legacyUrltest, 'urltest_proxy_links', 'legacy-urltest'); + +const legacyZap = sections['legacy-zap']; +assert(legacyZap.nfqws_opt === process.env.ZAPRET_DEFAULT_NFQWS_OPT, 'zapret legacy default migrated'); +assert(legacyZap.byedpi_cmd_opts === '--legacy-bye', 'cmd_opts copied to byedpi_cmd_opts'); +absent(legacyZap, 'cmd_opts', 'legacy-zap'); + +const legacyVpn = sections['legacy-vpn']; +assert(legacyVpn.action === 'vpn', 'vpn action inferred'); +absent(legacyVpn, 'proxy_config_type', 'legacy-vpn'); +absent(legacyVpn, 'proxy_string', 'legacy-vpn'); +absent(legacyVpn, 'subscription_url', 'legacy-vpn'); +absent(legacyVpn, 'subscription_user_agent', 'legacy-vpn'); + +assert(out.removed_caches.includes('/tmp/sing-box/subscriptions/legacy-sub.json'), 'subscription runtime cache removal'); +assert(out.removed_caches.includes('/var/run/podkop-plus/section-cache/legacy-sub.json'), 'section cache removal'); +NODE + +cat >"$WORK_DIR/runtime-migrate.state" <<'EOF_UCI' +podkop-plus.settings=settings +podkop-plus.legacy=rule +podkop-plus.legacy.enabled=1 +podkop-plus.legacy.connection_type=proxy +podkop-plus.legacy.proxy_config_type=url +podkop-plus.legacy.proxy_string=vless://one +EOF_UCI +mkdir -p "$WORK_DIR/runtime" "$WORK_DIR/persistent-cache" +: >"$WORK_DIR/runtime-migrate.log" +PODKOP_UCI_STATE_FILE="$WORK_DIR/runtime-migrate.state" \ +PODKOP_UCI_LOG_FILE="$WORK_DIR/runtime-migrate.log" \ +PODKOP_CONFIG_NAME="podkop-plus" \ +TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/tmp-subscriptions" \ +PODKOP_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \ +PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent-cache" \ +PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ +ucode -L "$PODKOP_LIB" "$MIGRATION" migrate + +grep -Fxq 'podkop-plus.legacy=section' "$WORK_DIR/runtime-migrate.state" || + fail "runtime migration must convert legacy rule type through core.uci" +grep -Fxq 'podkop-plus.legacy.action=proxy' "$WORK_DIR/runtime-migrate.state" || + fail "runtime migration must write migrated action through core.uci" +grep -Fxq 'podkop-plus.legacy.selector_proxy_links=vless://one' "$WORK_DIR/runtime-migrate.state" || + fail "runtime migration must write migrated list option through core.uci" +if grep -Fq 'podkop-plus.legacy.proxy_string=' "$WORK_DIR/runtime-migrate.state"; then + fail "runtime migration must delete legacy proxy_string through core.uci" +fi +grep -Fxq 'commit podkop-plus' "$WORK_DIR/runtime-migrate.log" || + fail "runtime migration must commit through core.uci" + +: >"$WORK_DIR/uci-commit.log" +: >"$WORK_DIR/uci-commit.state" +PODKOP_UCI_STATE_FILE="$WORK_DIR/uci-commit.state" \ +PODKOP_UCI_LOG_FILE="$WORK_DIR/uci-commit.log" \ +PODKOP_CONFIG_NAME="podkop-plus" \ +PODKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ +ucode -L "$PODKOP_LIB" "$MIGRATION" commit + +grep -Fxq 'commit podkop-plus' "$WORK_DIR/uci-commit.log" || + fail "commit mode must commit podkop-plus through core.uci" + +printf 'config migration regression checks passed\n' diff --git a/tests/backend/config_validation_owner_regression.sh b/tests/backend/config_validation_owner_regression.sh new file mode 100644 index 00000000..e7fb0395 --- /dev/null +++ b/tests/backend/config_validation_owner_regression.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_FILES="$ROOT_DIR/podkop/files" +PODKOP_BIN="$PODKOP_FILES/usr/bin/podkop" +PODKOP_LIB="$PODKOP_FILES/usr/lib" +CLI_UC="$PODKOP_BIN" +VALIDATOR="$PODKOP_LIB/config/validator.uc" +LIFECYCLE="$PODKOP_LIB/service/lifecycle.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ ! -e "$PODKOP_LIB/config_validation.sh" ] || + fail "config_validation.sh shell owner must be removed" + +if grep -R -n "config_validation.sh" "$PODKOP_FILES" >/dev/null 2>&1; then + fail "runtime files must not reference config_validation.sh" +fi + +legacy_symbols='(^|[^A-Za-z0-9_])(config_validate_runtime|check_requirements|commit_podkop_config|mwan3_is_active|get_inline_remote_ruleset_format|detect_inline_ruleset_reference_kind)([^A-Za-z0-9_]|$)' +if grep -R -n -E "$legacy_symbols" "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "runtime shell must not keep config_validation.sh symbols" +fi + +grep -Fq 'mode == "check-requirements"' "$VALIDATOR" || + fail "config validator must own requirement checks" +grep -Fq 'mode == "mwan3-is-active"' "$VALIDATOR" || + fail "config validator must own mwan3 runtime predicate" +grep -Fq 'require("core.uci")' "$VALIDATOR" || + fail "config validator must use core.uci for runtime UCI access" +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"|command_output\("uci' "$VALIDATOR" >/dev/null 2>&1; then + fail "config validator must not own direct UCI cursor or CLI access" +fi +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +grep -Fq 'service/lifecycle.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch service lifecycle through service/lifecycle.uc" +grep -Fq 'MIGRATION_UC' "$LIFECYCLE" && +grep -Fq '"migrate"' "$LIFECYCLE" || + fail "service/lifecycle.uc must run config migration directly through ucode" +grep -Fq 'VALIDATOR_UC' "$LIFECYCLE" && +grep -Fq '"validate-runtime"' "$LIFECYCLE" || + fail "service/lifecycle.uc must run runtime validation directly through ucode" + +printf 'config validation ownership regression checks passed\n' diff --git a/tests/backend/config_validator_detour_regression.sh b/tests/backend/config_validator_detour_regression.sh new file mode 100644 index 00000000..0ff20737 --- /dev/null +++ b/tests/backend/config_validator_detour_regression.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +VALIDATOR="$ROOT_DIR/podkop/files/usr/lib/config/validator.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +rows() { + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$@" +} + +assert_rejects() { + local label="$1" + local expected="$2" + shift 2 + local output + + if output="$(rows "$@" | ucode "$VALIDATOR" validate-outbound-detours 2>/dev/null)"; then + fail "$label should be rejected" + fi + + printf '%s\n' "$output" | grep -Fq "$expected" || + fail "$label: expected message containing '$expected', got '$output'" +} + +rows \ + source 1 proxy 1 target '' \ + target 1 vpn 0 '' '' | + ucode "$VALIDATOR" validate-outbound-detours + +rows \ + source 1 outbound 1 target '{"type":"direct"}' \ + target 1 proxy 0 '' '' | + ucode "$VALIDATOR" validate-outbound-detours + +assert_rejects "unsupported source action" "supported only for proxy and JSON outbound rules" \ + source 1 vpn 1 target '' \ + target 1 proxy 0 '' '' + +assert_rejects "missing target" "references missing rule 'missing'" \ + source 1 proxy 1 missing '' + +assert_rejects "disabled target" "references disabled rule 'target'" \ + source 1 proxy 1 target '' \ + target 0 proxy 0 '' '' + +assert_rejects "wrong target action" "but it is not a proxy/VPN/JSON outbound rule" \ + source 1 proxy 1 target '' \ + target 1 zapret 0 '' '' + +assert_rejects "self target" "cannot point to itself" \ + source 1 proxy 1 source '' + +assert_rejects "cycle" "creates a cycle through 'second'" \ + first 1 proxy 1 second '' \ + second 1 proxy 1 first '' + +assert_rejects "unsupported json outbound" "does not support Dial Fields" \ + source 1 outbound 1 target '{"type":"selector"}' \ + target 1 proxy 0 '' '' + +printf 'config validator detour regression checks passed\n' diff --git a/tests/backend/config_validator_download_section_regression.sh b/tests/backend/config_validator_download_section_regression.sh new file mode 100644 index 00000000..bef4fe47 --- /dev/null +++ b/tests/backend/config_validator_download_section_regression.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +VALIDATOR="$ROOT_DIR/podkop/files/usr/lib/config/validator.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +rows() { + printf '%s\t%s\t%s\n' "$@" +} + +assert_accepts() { + local target="$1" + local byedpi="$2" + local zapret="$3" + local zapret2="$4" + shift 4 + + rows "$@" | ucode "$VALIDATOR" validate-download-section "$target" "$byedpi" "$zapret" "$zapret2" +} + +assert_rejects() { + local label="$1" + local expected="$2" + local target="$3" + local byedpi="$4" + local zapret="$5" + local zapret2="$6" + shift 6 + local output + + if output="$(rows "$@" | ucode "$VALIDATOR" validate-download-section "$target" "$byedpi" "$zapret" "$zapret2" 2>/dev/null)"; then + fail "$label should be rejected" + fi + + printf '%s\n' "$output" | grep -Fq "$expected" || + fail "$label: expected message containing '$expected', got '$output'" +} + +assert_accepts proxy 0 0 0 proxy 1 proxy +assert_accepts outbound 0 0 0 outbound 1 outbound +assert_accepts vpn 0 0 0 vpn 1 vpn +assert_accepts zap 0 1 0 zap 1 zapret +assert_accepts zap2 0 0 1 zap2 1 zapret2 +assert_accepts bye 1 0 0 bye 1 byedpi + +assert_rejects "empty target" "no download section is selected" "" 0 0 0 proxy 1 proxy +assert_rejects "missing target" "references missing rule 'missing'" missing 0 0 0 proxy 1 proxy +assert_rejects "disabled target" "references disabled rule 'proxy'" proxy 0 0 0 proxy 0 proxy +assert_rejects "provider missing" "cannot provide an outbound" zap 0 0 0 zap 1 zapret +assert_rejects "unsupported action" "cannot provide an outbound" direct 0 0 0 direct 1 direct + +printf 'config validator download section regression checks passed\n' diff --git a/tests/backend/config_validator_reference_regression.sh b/tests/backend/config_validator_reference_regression.sh new file mode 100644 index 00000000..bb478898 --- /dev/null +++ b/tests/backend/config_validator_reference_regression.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +VALIDATOR="$ROOT_DIR/podkop/files/usr/lib/config/validator.uc" +COMMUNITY_SERVICES="youtube twitter telegram" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_accepts() { + local label="$1" + shift + + ucode "$VALIDATOR" "$@" >/dev/null || + fail "$label should be accepted" +} + +assert_rejects() { + local label="$1" + shift + + if ucode "$VALIDATOR" "$@" >/dev/null 2>&1; then + fail "$label should be rejected" + fi +} + +assert_accepts "known community service" community-service-valid twitter "$COMMUNITY_SERVICES" +assert_rejects "unknown community service" community-service-valid unknown "$COMMUNITY_SERVICES" + +assert_accepts "empty ruleset reference" ruleset-reference-valid "" "$COMMUNITY_SERVICES" +assert_accepts "community ruleset reference" ruleset-reference-valid telegram "$COMMUNITY_SERVICES" +assert_accepts "https ruleset reference" ruleset-reference-valid https://example.com/rule.srs "$COMMUNITY_SERVICES" +assert_accepts "absolute srs ruleset reference" ruleset-reference-valid /tmp/rule.srs "$COMMUNITY_SERVICES" +assert_accepts "absolute json ruleset reference" ruleset-reference-valid /tmp/rule.json "$COMMUNITY_SERVICES" +assert_rejects "relative ruleset reference" ruleset-reference-valid relative/rule.srs "$COMMUNITY_SERVICES" +assert_rejects "plain list as ruleset reference" ruleset-reference-valid /tmp/rule.lst "$COMMUNITY_SERVICES" + +assert_accepts "empty plain list reference" plain-domain-ip-list-reference-valid "" +assert_accepts "https plain list reference" plain-domain-ip-list-reference-valid https://example.com/list.lst +assert_accepts "absolute plain list reference" plain-domain-ip-list-reference-valid /tmp/list.lst +assert_rejects "ruleset as plain list reference" plain-domain-ip-list-reference-valid /tmp/rule.srs +assert_rejects "relative plain list reference" plain-domain-ip-list-reference-valid relative/list.lst + +printf 'config validator reference regression checks passed\n' diff --git a/tests/backend/config_validator_runtime_regression.sh b/tests/backend/config_validator_runtime_regression.sh new file mode 100644 index 00000000..0ceac1d0 --- /dev/null +++ b/tests/backend/config_validator_runtime_regression.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +VALIDATOR="$PODKOP_LIB/config/validator.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +cat >"$WORK_DIR/context.json" <<'JSON' +{} +JSON + +context="$(cat "$WORK_DIR/context.json")" + +validate_fixture() { + PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$1" "$context" +} + +assert_rejects() { + local label="$1" + local fixture="$2" + local expected="$3" + local output + + if output="$(validate_fixture "$fixture" 2>/dev/null)"; then + fail "$label should be rejected" + fi + + printf '%s\n' "$output" | grep -Fq "$expected" || + fail "$label: expected message containing '$expected', got '$output'" +} + +cat >"$WORK_DIR/valid.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "list_update_enabled": "1", + "update_interval": "1d", + "download_lists_via_proxy": "1", + "download_lists_via_proxy_section": "proxy" + }, + "section": [ + { + ".name": "detour", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "domain_suffix": [ "detour.example" ] + }, + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "proxy", + "ports": [ "80", "1000-2000" ], + "subscription_urls": [ "https://example.com/sub.txt | Agent/1.0" ], + "subscription_update_enabled": "1", + "subscription_update_interval": "12h", + "urltest_enabled": "1", + "urltest_check_interval": "3m", + "urltest_filter_mode": "mixed", + "urltest_include_countries": [ "US" ], + "urltest_exclude_regex": [ "bad.*" ], + "detect_server_country": "flag_emoji", + "domain_suffix": [ "example.org", "full:exact.example", "keyword:video", "regex:^api[.]example$" ], + "domain_suffix_text": "text.example\nkeyword:stream", + "community_lists": [ "discord" ], + "rule_set": [ "https://example.com/rules.srs" ], + "rule_set_with_subnets": [ "/tmp/local.json" ], + "domain_ip_lists": [ "https://example.com/mixed.lst" ], + "outbound_detour_enabled": "1", + "outbound_detour_section": "detour" + }, + { + ".name": "zap", + ".type": "section", + "enabled": "1", + "action": "zapret", + "domain_ip_lists": [ "https://example.com/provider.lst" ] + } + ] +} +JSON + +validate_fixture "$WORK_DIR/valid.json" + +cat >"$WORK_DIR/bad-port.json" <<'JSON' +{ + "settings": { ".name": "settings", ".type": "settings" }, + "section": [ + { ".name": "proxy", ".type": "section", "enabled": "1", "action": "proxy", "ports": [ "70000" ] } + ] +} +JSON +assert_rejects "bad port" "$WORK_DIR/bad-port.json" "Invalid port condition '70000'" + +cat >"$WORK_DIR/bad-subscription.json" <<'JSON' +{ + "settings": { ".name": "settings", ".type": "settings" }, + "section": [ + { ".name": "proxy", ".type": "section", "enabled": "1", "action": "proxy", "subscription_urls": [ "https://example.com/a| Agent" ] } + ] +} +JSON +assert_rejects "bad subscription" "$WORK_DIR/bad-subscription.json" "Use 'URL | User-Agent'" + +cat >"$WORK_DIR/bad-detour.json" <<'JSON' +{ + "settings": { ".name": "settings", ".type": "settings" }, + "section": [ + { ".name": "first", ".type": "section", "enabled": "1", "action": "proxy", "outbound_detour_enabled": "1", "outbound_detour_section": "second" }, + { ".name": "second", ".type": "section", "enabled": "1", "action": "proxy", "outbound_detour_enabled": "1", "outbound_detour_section": "first" } + ] +} +JSON +assert_rejects "bad detour" "$WORK_DIR/bad-detour.json" "creates a cycle" + +cat >"$WORK_DIR/bad-list-reference.json" <<'JSON' +{ + "settings": { ".name": "settings", ".type": "settings" }, + "section": [ + { ".name": "proxy", ".type": "section", "enabled": "1", "action": "proxy", "domain_ip_lists": [ "ftp://example.com/list.lst" ] } + ] +} +JSON +assert_rejects "bad list reference" "$WORK_DIR/bad-list-reference.json" "Unknown plain list reference" + +cat >"$WORK_DIR/bad-country.json" <<'JSON' +{ + "settings": { ".name": "settings", ".type": "settings" }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "proxy", + "urltest_enabled": "1", + "urltest_filter_mode": "include", + "urltest_include_countries": [ "USA" ] + } + ] +} +JSON +assert_rejects "bad country" "$WORK_DIR/bad-country.json" "Invalid country code 'USA'" + +provider_context="$(node -e 'const fs=require("fs"); const c=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); c.byedpi_installed=true; process.stdout.write(JSON.stringify(c));' "$WORK_DIR/context.json")" +cat >"$WORK_DIR/bad-byedpi.json" <<'JSON' +{ + "settings": { ".name": "settings", ".type": "settings" }, + "section": [ + { ".name": "bye", ".type": "section", "enabled": "1", "action": "byedpi", "byedpi_cmd_opts": "--port 1080 --disorder 3" } + ] +} +JSON +if output="$(PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/bad-byedpi.json" "$provider_context" 2>/dev/null)"; then + fail "bad byedpi should be rejected" +fi +printf '%s\n' "$output" | grep -Fq "ByeDPI listen address and port are assigned" || + fail "bad byedpi: unexpected message '$output'" + +runtime_lib="$WORK_DIR/runtime-lib" +mkdir -p "$runtime_lib" +ln -s "$PODKOP_LIB/core" "$runtime_lib/core" +ln -s "$PODKOP_LIB/subscription" "$runtime_lib/subscription" +ln -s "$PODKOP_LIB/providers" "$runtime_lib/providers" +touch "$WORK_DIR/ciadpi-provider" +cat >"$WORK_DIR/bad-byedpi-runtime-state.json" <<'JSON' +{ + "settings": { ".name": "settings", ".type": "settings" }, + "section": [ + { ".name": "bye", ".type": "section", "enabled": "1", "action": "byedpi", "byedpi_cmd_opts": "--port 1080 --disorder 3" } + ] +} +JSON + +env \ + COMMUNITY_SERVICES="discord" \ + BYEDPI_DEFAULT_CMD_OPTS="" \ + ZAPRET_DEFAULT_NFQWS_OPT="" \ + ZAPRET_LEGACY_DEFAULT_NFQWS_OPT="" \ + ZAPRET2_DEFAULT_NFQWS2_OPT="" \ + BYEDPI_BIN="$WORK_DIR/ciadpi-provider" \ + ZAPRET_PROVIDER_NFQWS_BIN="$WORK_DIR/missing-nfqws" \ + ZAPRET2_PROVIDER_NFQWS2_BIN="$WORK_DIR/missing-nfqws2" \ + ZAPRET_ROUTE_MARK_BASE="0x01000000" \ + ZAPRET_QUEUE_RANGE_SIZE="16" \ + ZAPRET2_ROUTE_MARK_BASE="0x02000000" \ + ZAPRET2_QUEUE_RANGE_SIZE="16" \ + NFT_FAKEIP_MARK="0x00000800" \ + NFT_OUTBOUND_MARK="0x00000400" \ + PODKOP_LIB="$runtime_lib" \ + ucode -L "$runtime_lib" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/bad-byedpi-runtime-state.json" "{}" +chmod 755 "$WORK_DIR/ciadpi-provider" +if output="$(env \ + COMMUNITY_SERVICES="discord" \ + BYEDPI_DEFAULT_CMD_OPTS="" \ + ZAPRET_DEFAULT_NFQWS_OPT="" \ + ZAPRET_LEGACY_DEFAULT_NFQWS_OPT="" \ + ZAPRET2_DEFAULT_NFQWS2_OPT="" \ + BYEDPI_BIN="$WORK_DIR/ciadpi-provider" \ + ZAPRET_PROVIDER_NFQWS_BIN="$WORK_DIR/missing-nfqws" \ + ZAPRET2_PROVIDER_NFQWS2_BIN="$WORK_DIR/missing-nfqws2" \ + ZAPRET_ROUTE_MARK_BASE="0x01000000" \ + ZAPRET_QUEUE_RANGE_SIZE="16" \ + ZAPRET2_ROUTE_MARK_BASE="0x02000000" \ + ZAPRET2_QUEUE_RANGE_SIZE="16" \ + NFT_FAKEIP_MARK="0x00000800" \ + NFT_OUTBOUND_MARK="0x00000400" \ + PODKOP_LIB="$runtime_lib" \ + ucode -L "$runtime_lib" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/bad-byedpi-runtime-state.json" "{}" 2>/dev/null)"; then + fail "executable byedpi provider should enable strategy validation" +fi +printf '%s\n' "$output" | grep -Fq "ByeDPI listen address and port are assigned" || + fail "executable byedpi provider: unexpected message '$output'" + +printf 'config validator runtime regression checks passed\n' diff --git a/tests/backend/constants_owner_regression.sh b/tests/backend/constants_owner_regression.sh new file mode 100644 index 00000000..1d6babcf --- /dev/null +++ b/tests/backend/constants_owner_regression.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +CLI_UC="$PODKOP_BIN" +PODKOP_MAKEFILE="$ROOT_DIR/podkop/Makefile" +BUILD_SCRIPT="$ROOT_DIR/scripts/build.sh" +CONSTANTS_SH="$PODKOP_LIB/constants.sh" +LIFECYCLE_UC="$PODKOP_LIB/service/lifecycle.uc" +CONSTANTS_UC="$PODKOP_LIB/core/constants.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ ! -e "$CONSTANTS_SH" ] || + fail "constants.sh shell owner must be removed" + +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +grep -Fq 'service/lifecycle.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" +grep -Fq 'core.constants' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc must load constants from core/constants.uc" + +if grep -R -n -E 'constants\.sh|read_shell_constants|expand_shell_constants|unquote_shell_value' \ + "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' --include='*.uc' >/dev/null 2>&1; then + fail "shell constants owner or parser references must not remain" +fi + +if grep -n 'constants\.sh' "$PODKOP_MAKEFILE" "$BUILD_SCRIPT" >/dev/null 2>&1; then + fail "package build must not patch removed constants.sh" +fi +grep -Fq 'core/constants.uc' "$PODKOP_MAKEFILE" || + fail "podkop/Makefile must patch core/constants.uc" +grep -Fq 'core/constants.uc' "$BUILD_SCRIPT" || + fail "manual WSL build must patch core/constants.uc" + +config_name="$(ucode -L "$PODKOP_LIB" "$CONSTANTS_UC" get PODKOP_CONFIG_NAME)" +[ "$config_name" = "podkop-plus" ] || + fail "core/constants.uc get returned unexpected PODKOP_CONFIG_NAME" + +eval "$(ucode -L "$PODKOP_LIB" "$CONSTANTS_UC" shell-env)" +[ "$PODKOP_CONFIG" = "/etc/config/podkop-plus" ] || + fail "core/constants.uc shell-env did not derive PODKOP_CONFIG" +[ "$TMP_RULESET_FOLDER" = "/tmp/sing-box/rulesets" ] || + fail "core/constants.uc shell-env did not derive TMP_RULESET_FOLDER" +[ "$BYEDPI_PID_DIR" = "/var/run/podkop-plus/byedpi/pid" ] || + fail "core/constants.uc shell-env did not derive BYEDPI_PID_DIR" + +printf 'constants ownership regression checks passed\n' diff --git a/tests/backend/core_helpers_regression.sh b/tests/backend/core_helpers_regression.sh new file mode 100644 index 00000000..03730027 --- /dev/null +++ b/tests/backend/core_helpers_regression.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HELPERS_UC="$ROOT_DIR/podkop/files/usr/lib/core/helpers.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +assert_eq download_lists_via_proxy \ + "$(ucode "$HELPERS_UC" download-via-proxy-option-for-purpose lists)" \ + "lists download option" +assert_eq download_subscriptions_via_proxy \ + "$(ucode "$HELPERS_UC" download-via-proxy-option-for-purpose subscriptions)" \ + "subscriptions download option" +assert_eq download_components_via_proxy \ + "$(ucode "$HELPERS_UC" download-via-proxy-option-for-purpose components)" \ + "components download option" + +if ucode "$HELPERS_UC" download-via-proxy-option-for-purpose unknown >/dev/null 2>&1; then + fail "unknown download purpose should fail" +fi + +assert_eq proxy-out \ + "$(ucode "$HELPERS_UC" outbound-tag proxy)" \ + "outbound tag" +assert_eq server-edge-in \ + "$(ucode "$HELPERS_UC" server-inbound-tag edge)" \ + "server inbound tag" +assert_eq server-edge-tailscale-dns \ + "$(ucode "$HELPERS_UC" tailscale-dns-tag edge)" \ + "tailscale DNS tag" +assert_eq dns-server-1-out \ + "$(SB_DIRECT_OUTBOUND_TAG=dns-server-out ucode "$HELPERS_UC" outbound-tag dns-server)" \ + "reserved outbound tag" + +printf 'core helpers regression checks passed\n' diff --git a/tests/backend/diagnostics_status_regression.sh b/tests/backend/diagnostics_status_regression.sh new file mode 100644 index 00000000..1786362e --- /dev/null +++ b/tests/backend/diagnostics_status_regression.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DIAGNOSTICS="$ROOT_DIR/podkop/files/usr/lib/diagnostics/status.uc" +DIAGNOSTICS_RUNTIME="$ROOT_DIR/podkop/files/usr/lib/diagnostics/runtime.uc" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +CLI_UC="$PODKOP_BIN" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_status() { + local running="$1" + local enabled="$2" + local dns="$3" + local expected="$4" + local json + + json="$(ucode "$DIAGNOSTICS" service-status-json "$running" "$enabled" "$dns")" + JSON_VALUE="$json" node - "$expected" "$dns" <<'NODE' +const expected = process.argv[2]; +const expectedDns = Number(process.argv[3]); +const value = JSON.parse(process.env.JSON_VALUE); +if (value.status !== expected || value.dns_configured !== expectedDns) { + console.error(`expected ${expected}/${expectedDns}, got ${value.status}/${value.dns_configured}`); + process.exit(1); +} +NODE +} + +assert_status 1 1 1 "running & enabled" +assert_status 1 0 0 "running but disabled" +assert_status 0 1 1 "stopped but enabled" +assert_status 0 0 0 "stopped & disabled" + +[ ! -e "$PODKOP_LIB/status_diagnostics.sh" ] || + fail "status_diagnostics.sh shell owner must be removed" +grep -Fq 'get_system_info: [ "diagnostics/runtime.uc", "get-system-info", 0 ]' "$CLI_UC" || + fail "service/cli.uc must dispatch get_system_info through diagnostics/runtime.uc" +[ "$(PODKOP_VERSION=runtime-test ucode -L "$PODKOP_LIB" "$DIAGNOSTICS_RUNTIME" show-version)" = "runtime-test" ] || + fail "diagnostics/runtime.uc show-version mode failed" +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "show"|uci", "-q"' "$DIAGNOSTICS_RUNTIME" >/dev/null 2>&1; then + fail "diagnostics/runtime.uc must use core.uci instead of owning direct UCI cursor or CLI calls" +fi + +legacy_json="$(ucode "$DIAGNOSTICS" service-status-json 1 0 ignored 1)" +JSON_VALUE="$legacy_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.status !== "running but disabled" || value.dns_configured !== 1) { + console.error("legacy service-status-json call shape changed"); + process.exit(1); +} +NODE + +{ + printf 'Tue Jun 30 11:00:00 2026 user.notice podkop-plus: [info] Starting Podkop Plus\n' + for i in $(seq 1 4500); do + printf 'Tue Jun 30 11:00:%02d 2026 daemon.info unrelated[%04d]: filler filler filler filler filler filler filler filler filler filler\n' "$((i % 60))" "$i" + done + printf 'Tue Jun 30 11:01:00 2026 user.notice podkop-plus: [info] large logread marker survived stdin transport\n' +} >"$WORK_DIR/large-logread.txt" +large_logs="$(PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$DIAGNOSTICS_RUNTIME" podkop-logs-fixture <"$WORK_DIR/large-logread.txt")" || + fail "diagnostics/runtime.uc must process large logread payloads through stdin without shell argument limits" +case "$large_logs" in + *"large logread marker survived stdin transport"*) ;; + *) fail "large logread marker missing from rendered logs" ;; +esac + +firewall_rules="$(cat <<'EOF' +firewall.@rule[0]=rule +firewall.@rule[0].enabled='1' +firewall.@rule[0].target='ACCEPT' +firewall.@rule[0].src='wan' +firewall.@rule[0].proto='tcp udp' +firewall.@rule[0].dest_port='443' +EOF +)" + +printf '%s\n' "$firewall_rules" | + ucode "$DIAGNOSTICS" firewall-required-protocols-open 443 "tcp udp" >/dev/null || + fail "tcp+udp firewall rule should satisfy required protocols" +if printf '%s\n' "$firewall_rules" | + ucode "$DIAGNOSTICS" firewall-required-protocols-open 8443 "tcp" >/dev/null 2>&1; then + fail "wrong firewall port should not satisfy required protocols" +fi + +ucode "$DIAGNOSTICS" server-listen-requires-firewall 0.0.0.0 "" 0 >/dev/null || + fail "wildcard listen should require firewall" +ucode "$DIAGNOSTICS" server-listen-requires-firewall 198.51.100.2 198.51.100.2 0 >/dev/null || + fail "WAN listen address should require firewall" +ucode "$DIAGNOSTICS" server-listen-requires-firewall 203.0.113.2 "" 1 >/dev/null || + fail "public listen address should require firewall" +if ucode "$DIAGNOSTICS" server-listen-requires-firewall 192.168.1.2 198.51.100.2 0 >/dev/null 2>&1; then + fail "private non-WAN listen address should not require firewall" +fi + +[ "$(ucode "$DIAGNOSTICS" public-host-flags '' '' 8.8.8.8 1)" = "-1 -1 -1" ] || + fail "empty public host flags changed" +[ "$(ucode "$DIAGNOSTICS" public-host-flags example.com '' 8.8.8.8 1)" = "0 -1 -1" ] || + fail "unresolved public host flags changed" +[ "$(ucode "$DIAGNOSTICS" public-host-flags example.com '1.1.1.1 8.8.8.8' 8.8.8.8 1)" = "1 1 1" ] || + fail "public host WAN match flags changed" +[ "$(ucode "$DIAGNOSTICS" public-host-flags example.com '192.168.1.10' 8.8.8.8 1)" = "1 0 0" ] || + fail "private public host flags changed" +[ "$(ucode "$DIAGNOSTICS" public-host-flags example.com '8.8.8.8' 8.8.8.8 0)" = "1 1 -1" ] || + fail "non-public WAN host match flags changed" + +netstat_listening="$(cat <<'EOF' +Active Internet connections (only servers) +Proto Recv-Q Send-Q Local Address Foreign Address State +tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN +udp 0 0 0.0.0.0:443 0.0.0.0:* +EOF +)" + +printf '%s\n' "$netstat_listening" | + ucode "$DIAGNOSTICS" server-required-ports-listening 0.0.0.0 443 "tcp udp" >/dev/null || + fail "tcp+udp netstat listeners should satisfy required protocols" +if printf '%s\n' "$netstat_listening" | + ucode "$DIAGNOSTICS" server-required-ports-listening 0.0.0.0 8443 "tcp" >/dev/null 2>&1; then + fail "missing netstat listener should fail" +fi + +sing_box_netstat="$(cat <<'EOF' +Active Internet connections (only servers) +Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name +tcp 0 0 127.0.0.42:53 0.0.0.0:* LISTEN 16244/sing-box +tcp 0 0 0.0.0.0:1602 0.0.0.0:* LISTEN 16244/sing-box +udp 0 0 127.0.0.42:53 0.0.0.0:* 16244/sing-box +udp 0 0 0.0.0.0:1602 0.0.0.0:* 16244/sing-box +EOF +)" + +printf '%s\n' "$sing_box_netstat" | + PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$DIAGNOSTICS_RUNTIME" sing-box-standard-ports-listening-fixture >/dev/null || + fail "sing-box standard listeners should satisfy diagnostics" +if printf '%s\n' "$sing_box_netstat" | sed '/0.0.0.0:1602/d' | + PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$DIAGNOSTICS_RUNTIME" sing-box-standard-ports-listening-fixture >/dev/null 2>&1; then + fail "missing sing-box tproxy listener should fail diagnostics" +fi + +netstat_owners="$(cat <<'EOF' +Active Internet connections (only servers) +Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name +tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 111/nginx +udp 0 0 0.0.0.0:443 0.0.0.0:* 222/dnsmasq +tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 333/sing-box +EOF +)" + +owners="$(printf '%s\n' "$netstat_owners" | + ucode "$DIAGNOSTICS" server-required-port-conflict-owners 0.0.0.0 443 "tcp udp")" +[ "$owners" = "111/nginx 222/dnsmasq" ] || + fail "unexpected conflict owners: $owners" + +printf 'diagnostics status regression checks passed\n' diff --git a/tests/backend/dns_apply_regression.sh b/tests/backend/dns_apply_regression.sh new file mode 100644 index 00000000..041b33b9 --- /dev/null +++ b/tests/backend/dns_apply_regression.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +APPLY="$ROOT_DIR/podkop/files/usr/lib/dns/apply.uc" +UCODE_LIB="$ROOT_DIR/podkop/files/usr/lib" +WORK_DIR="$(mktemp -d)" +STATE="$WORK_DIR/uci.state" +LOG="$WORK_DIR/uci.log" +DNSMASQ_LOG="$WORK_DIR/dnsmasq.log" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + printf 'UCI state:\n' >&2 + cat "$STATE" >&2 2>/dev/null || true + printf 'UCI log:\n' >&2 + cat "$LOG" >&2 2>/dev/null || true + exit 1 +} + +mkdir -p "$WORK_DIR/bin" +cat >"$WORK_DIR/bin/uci" <<'UCI' +#!/usr/bin/env bash +set -eo pipefail + +while [ "${1:-}" = "-q" ]; do + shift +done + +cmd="${1:-}" +shift || true + +state="${UCI_STATE:?}" +log="${UCI_LOG:?}" + +get_value() { + awk -F= -v key="$1" '$1 == key { print substr($0, length($1) + 2); found = 1 } END { exit found ? 0 : 1 }' "$state" +} + +key_exists() { + awk -F= -v key="$1" -v prefix="$1." '$1 == key || index($1, prefix) == 1 { found = 1 } END { exit found ? 0 : 1 }' "$state" +} + +delete_key() { + local key="$1" + local tmp + tmp="$(mktemp)" + awk -F= -v key="$key" -v prefix="$key." '$1 != key && index($1, prefix) != 1' "$state" > "$tmp" + mv "$tmp" "$state" +} + +set_key() { + local key="$1" + local value="$2" + delete_key "$key" + printf '%s=%s\n' "$key" "$value" >> "$state" +} + +case "$cmd" in + get) + get_value "$1" + ;; + show) + key_exists "$1" + ;; + delete) + delete_key "$1" + ;; + set) + item="$1" + set_key "${item%%=*}" "${item#*=}" + ;; + add_list) + item="$1" + key="${item%%=*}" + value="${item#*=}" + current="$(get_value "$key" 2>/dev/null || true)" + if [ -n "$current" ]; then + set_key "$key" "$current $value" + else + set_key "$key" "$value" + fi + ;; + del_list) + item="$1" + key="${item%%=*}" + value="${item#*=}" + current="$(get_value "$key" 2>/dev/null || true)" + [ -n "$current" ] || exit 1 + new="" + removed=0 + for entry in $current; do + if [ "$entry" = "$value" ]; then + removed=1 + continue + fi + new="${new:+$new }$entry" + done + [ "$removed" -eq 1 ] || exit 1 + if [ -n "$new" ]; then + set_key "$key" "$new" + else + delete_key "$key" + fi + ;; + commit) + printf 'commit %s\n' "$1" >> "$log" + ;; + *) + printf 'unsupported uci command: %s\n' "$cmd" >&2 + exit 2 + ;; +esac +UCI +chmod 0755 "$WORK_DIR/bin/uci" + +cat >"$WORK_DIR/dnsmasq-init" <<'DNSMASQ' +#!/usr/bin/env bash +set -eo pipefail +printf '%s\n' "$*" >> "${DNSMASQ_LOG:?}" +DNSMASQ +chmod 0755 "$WORK_DIR/dnsmasq-init" + +export PATH="$WORK_DIR/bin:$PATH" +export UCI_STATE="$STATE" +export UCI_LOG="$LOG" +export DNSMASQ_LOG +export DNSMASQ_INIT="$WORK_DIR/dnsmasq-init" +export PODKOP_CONFIG_NAME="podkop-plus" +export SB_DNS_INBOUND_ADDRESS="127.0.0.42" + +if grep -E 'uci -q|command -v uci' "$APPLY" >/dev/null; then + fail "dns/apply.uc must use ucode UCI access instead of shelling out to uci" +fi + +run_restore() { + : > "$LOG" + ucode -L "$UCODE_LIB" "$APPLY" failsafe-restore +} + +uci_get() { + "$WORK_DIR/bin/uci" -q get "$1" +} + +assert_value() { + local path="$1" + local expected="$2" + local actual + + actual="$(uci_get "$path" 2>/dev/null || true)" + [ "$actual" = "$expected" ] || fail "$path: expected '$expected', got '$actual'" +} + +assert_absent() { + local path="$1" + + if uci_get "$path" >/dev/null 2>&1; then + fail "$path: expected option to be absent" + fi +} + +assert_log_contains() { + local expected="$1" + + grep -Fxq "$expected" "$LOG" || fail "expected log entry '$expected'" +} + +assert_log_empty() { + [ ! -s "$LOG" ] || fail "expected empty log" +} + +assert_dnsmasq_restarted() { + grep -Fxq 'restart' "$DNSMASQ_LOG" || fail "expected dnsmasq restart" +} + +cat >"$STATE" <<'EOF_STATE' +dhcp.@dnsmasq[0].server=1.1.1.1 8.8.8.8 +dhcp.@dnsmasq[0].noresolv=0 +dhcp.@dnsmasq[0].cachesize=150 +podkop-plus.settings.shutdown_correctly=1 +EOF_STATE + +: > "$DNSMASQ_LOG" +: > "$LOG" +ucode -L "$UCODE_LIB" "$APPLY" configure force +assert_value 'dhcp.@dnsmasq[0].server' '127.0.0.42' +assert_value 'dhcp.@dnsmasq[0].podkop_server' '1.1.1.1 8.8.8.8' +assert_value 'dhcp.@dnsmasq[0].noresolv' '1' +assert_value 'dhcp.@dnsmasq[0].podkop_noresolv' '0' +assert_value 'dhcp.@dnsmasq[0].cachesize' '0' +assert_value 'dhcp.@dnsmasq[0].podkop_cachesize' '150' +assert_log_contains 'commit dhcp' +assert_dnsmasq_restarted + +: > "$DNSMASQ_LOG" +: > "$LOG" +ucode -L "$UCODE_LIB" "$APPLY" restore force +assert_value 'dhcp.@dnsmasq[0].server' '1.1.1.1 8.8.8.8' +assert_value 'dhcp.@dnsmasq[0].noresolv' '0' +assert_value 'dhcp.@dnsmasq[0].cachesize' '150' +assert_absent 'dhcp.@dnsmasq[0].podkop_server' +assert_absent 'dhcp.@dnsmasq[0].podkop_noresolv' +assert_absent 'dhcp.@dnsmasq[0].podkop_cachesize' +assert_log_contains 'commit dhcp' +assert_dnsmasq_restarted + +cat >"$STATE" <<'EOF_STATE' +dhcp.@dnsmasq[0].server=127.0.0.42 +dhcp.@dnsmasq[0].notinterface=br-lan guest +dhcp.@dnsmasq[0].podkop_server=1.1.1.1 8.8.8.8 +dhcp.@dnsmasq[0].podkop_notinterface=wan docker +dhcp.@dnsmasq[0].podkop_noresolv=1 +dhcp.@dnsmasq[0].podkop_cachesize=0 +dhcp.podkop_plus.interface=br-lan guest +podkop-plus.settings.dont_touch_dhcp=1 +EOF_STATE + +run_restore +assert_value 'dhcp.@dnsmasq[0].server' '1.1.1.1 8.8.8.8' +assert_value 'dhcp.@dnsmasq[0].notinterface' 'wan docker' +assert_value 'dhcp.@dnsmasq[0].noresolv' '1' +assert_value 'dhcp.@dnsmasq[0].cachesize' '0' +assert_absent 'dhcp.@dnsmasq[0].podkop_server' +assert_absent 'dhcp.@dnsmasq[0].podkop_notinterface' +assert_absent 'dhcp.@dnsmasq[0].podkop_noresolv' +assert_absent 'dhcp.@dnsmasq[0].podkop_cachesize' +assert_absent 'dhcp.podkop_plus.interface' +assert_log_contains 'commit dhcp' + +cat >"$STATE" <<'EOF_STATE' +dhcp.@dnsmasq[0].server=9.9.9.9 +podkop-plus.settings.dont_touch_dhcp=1 +EOF_STATE + +run_restore +assert_value 'dhcp.@dnsmasq[0].server' '9.9.9.9' +assert_log_empty + +cat >"$STATE" <<'EOF_STATE' +dhcp.@dnsmasq[0].server=127.0.0.42 +dhcp.@dnsmasq[0].podkop_noresolv=1 +dhcp.@dnsmasq[0].podkop_cachesize=0 +podkop-plus.settings.dont_touch_dhcp=0 +EOF_STATE + +run_restore +assert_absent 'dhcp.@dnsmasq[0].server' +assert_value 'dhcp.@dnsmasq[0].noresolv' '1' +assert_value 'dhcp.@dnsmasq[0].cachesize' '0' +assert_absent 'dhcp.@dnsmasq[0].podkop_noresolv' +assert_absent 'dhcp.@dnsmasq[0].podkop_cachesize' +assert_log_contains 'commit dhcp' + +printf 'DNS apply regression checks passed\n' diff --git a/tests/backend/helpers_owner_regression.sh b/tests/backend/helpers_owner_regression.sh new file mode 100644 index 00000000..9ef52608 --- /dev/null +++ b/tests/backend/helpers_owner_regression.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +CLI_UC="$PODKOP_BIN" +HELPERS_SH="$PODKOP_LIB/helpers.sh" +LIFECYCLE_UC="$PODKOP_LIB/service/lifecycle.uc" +PACKAGES_UC="$PODKOP_LIB/core/packages.uc" +RULES_UC="$PODKOP_LIB/providers/rules.uc" +SINGBOX_RUNTIME_UC="$PODKOP_LIB/singbox/runtime.uc" +COMPONENT_ACTION_UC="$PODKOP_LIB/components/action.uc" +DIAGNOSTICS_RUNTIME_UC="$PODKOP_LIB/diagnostics/runtime.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ ! -e "$HELPERS_SH" ] || + fail "helpers.sh shell owner must be removed" +if grep -R -n -F 'helpers.sh' "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "runtime shell must not reference helpers.sh" +fi +if grep -R -n -E 'helpers_ucode\(|get_(inbound|server_inbound|tailscale_dns_server|outbound)_tag_by_section\(|get_domain_resolver_tag\(|provider_status_ucode\(|is_ipv4\(|is_min_package_version\(|url_get_(scheme|userinfo|host|port|path|query_param)\(' \ + "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "helpers.sh wrapper symbols must not remain in runtime shell" +fi + +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +grep -Fq 'service/lifecycle.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch lifecycle through service/lifecycle.uc" +grep -Fq 'core/packages.uc' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc must use core/packages.uc directly" +for shell_owner_pattern in \ + 'config_load' \ + 'config_get' \ + 'uci_set' +do + if grep -Fq "$shell_owner_pattern" "$PODKOP_BIN"; then + fail "podkop shell entrypoint must not own $shell_owner_pattern logic" + fi +done +if grep -E -n '(^|[;&|[:space:]])nft[[:space:]]+(list|add|delete|flush)' "$PODKOP_BIN" >/dev/null 2>&1; then + fail "podkop shell entrypoint must not own nft command logic" +fi +if grep -E -n '(^|[;&|[:space:]])ip[[:space:]]+(rule|route)' "$PODKOP_BIN" >/dev/null 2>&1; then + fail "podkop shell entrypoint must not own ip rule/route logic" +fi +[ ! -e "$PODKOP_LIB/updater.sh" ] || + fail "updater.sh shell owner must be removed" +[ ! -e "$PODKOP_LIB/status_diagnostics.sh" ] || + fail "status_diagnostics.sh shell owner must be removed" +grep -Fq 'diagnostics/runtime.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch diagnostics through diagnostics/runtime.uc" +grep -Fq 'mode == "get-system-info"' "$DIAGNOSTICS_RUNTIME_UC" || + fail "diagnostics/runtime.uc must own system info" +grep -Fq 'core/packages.uc' "$COMPONENT_ACTION_UC" || + fail "component action owner must use core/packages.uc directly" +grep -Fq 'count-uci' "$RULES_UC" || + fail "providers/rules.uc must own UCI rule counting" +for mode in \ + 'mode == "version"' \ + 'mode == "version-output"' \ + 'mode == "version-from-output"' \ + 'mode == "read-version-state"' \ + 'mode == "write-version-state"' \ + 'mode == "restore-version-state"' \ + 'mode == "read-variant-marker"' \ + 'mode == "write-variant-marker"' \ + 'mode == "restore-variant-marker"' \ + 'mode == "is-extended"' \ + 'mode == "is-tiny"' \ + 'mode == "supports-tailscale"' \ + 'mode == "variant"' +do + grep -Fq "$mode" "$SINGBOX_RUNTIME_UC" || + fail "singbox/runtime.uc missing $mode" +done +if grep -R -n -E 'get_sing_box_version\(|sing_box_version_from_output\(|sing_box_version_output\(|sing_box_output_has_build_tag\(|sing_box_has_build_tag\(|is_sing_box_extended\(|is_sing_box_tiny_package_installed\(|is_sing_box_full_package_installed\(|is_sing_box_compressed_marker_set\(|is_sing_box_extended_marker_set\(|read_sing_box_version_state\(|is_sing_box_tiny_marker_set\(|is_sing_box_tiny\(|sing_box_supports_tailscale\(|get_sing_box_variant\(|updates_(write|read|clear|restore)_sing_box_(variant_marker|version_state)\(' \ + "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "sing-box helper/state shell symbols must not remain" +fi + +if ucode -L "$PODKOP_LIB" "$PACKAGES_UC" installed podkop-plus-definitely-missing >/dev/null 2>&1; then + fail "missing package must not be reported installed" +fi + +mark_hex="$(ucode -L "$PODKOP_LIB" "$RULES_UC" mark-hex 0x01000000 2)" +[ "$mark_hex" = "0x01000002" ] || + fail "providers/rules.uc mark math changed" + +version="$(printf 'sing-box version 1.12.4-extended\nEnvironment: test\n' | + ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" version-from-output)" +[ "$version" = "1.12.4-extended" ] || + fail "singbox/runtime.uc version-from-output changed" + +ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" is-extended "1.12.4-extended" >/dev/null || + fail "extended sing-box version should be detected" +if ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" is-extended "1.12.4" >/dev/null 2>&1; then + fail "stable sing-box version must not be detected as extended" +fi +ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" supports-tailscale "" "$(printf 'Tags: with_quic,with_tailscale\n')" >/dev/null || + fail "with_tailscale build tag should be detected" + +SB_VERSION_STATE_FILE="$WORK_DIR/version" \ + ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" write-version-state 1.2.3 >/dev/null || + fail "version state write failed" +[ "$(SB_VERSION_STATE_FILE="$WORK_DIR/version" ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" read-version-state)" = "1.2.3" ] || + fail "version state read failed" +SB_VERSION_STATE_FILE="$WORK_DIR/version" \ + ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" restore-version-state "" >/dev/null || + fail "version state restore-clear failed" +[ ! -e "$WORK_DIR/version" ] || + fail "empty version state restore must clear the state file" + +SB_VARIANT_STATE_FILE="$WORK_DIR/variant" \ + ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" write-variant-marker extended-compressed >/dev/null || + fail "variant marker write failed" +SB_VARIANT_STATE_FILE="$WORK_DIR/variant" \ + ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" marker-is extended-compressed >/dev/null || + fail "variant marker check failed" +[ "$(SB_VARIANT_STATE_FILE="$WORK_DIR/variant" ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" read-variant-marker)" = "extended-compressed" ] || + fail "variant marker read failed" +SB_VARIANT_STATE_FILE="$WORK_DIR/variant" \ + ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" restore-variant-marker "" >/dev/null || + fail "variant marker restore-clear failed" +[ ! -e "$WORK_DIR/variant" ] || + fail "empty variant marker restore must clear the marker file" + +printf 'helpers ownership regression checks passed\n' diff --git a/tests/backend/initd_state_regression.sh b/tests/backend/initd_state_regression.sh new file mode 100755 index 00000000..7cf1f2b1 --- /dev/null +++ b/tests/backend/initd_state_regression.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +INITD_UC="$PODKOP_LIB/service/initd.uc" +STATE_UC="$PODKOP_LIB/service/state.uc" +INITD="$ROOT_DIR/podkop/files/etc/init.d/podkop" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +initd_ucode() { + ucode -L "$PODKOP_LIB" "$INITD_UC" "$@" +} + +config_file="$WORK_DIR/podkop-plus" +guard_file="$WORK_DIR/internal-config-change" +sync_file="$WORK_DIR/service-triggers.sync" + +printf '%s\n' "config-version=1" >"$config_file" +config_hash="$(md5sum "$config_file" | awk '{print $1}')" + +printf '%s\n%s\n' 1000 "$config_hash" >"$guard_file" +initd_ucode initd-guard-matches-current-config "$guard_file" "$config_file" 1010 >/dev/null || + fail "valid internal config guard should match current config" +if initd_ucode initd-guard-matches-current-config "$guard_file" "$config_file" 1031 >/dev/null 2>&1; then + fail "stale internal config guard should not match" +fi + +printf '%s\n%s\n' 1000 "$config_hash" >"$guard_file" +initd_ucode initd-should-skip-internal-config-reload on_config_change "$guard_file" "$config_file" on_config_change 1010 >/dev/null || + fail "matching internal config guard should skip config-change reload" +[ ! -e "$guard_file" ] || + fail "matching internal config guard should be consumed" + +printf '%s\n%s\n' 1000 "$config_hash" >"$guard_file" +if initd_ucode initd-should-skip-internal-config-reload manual "$guard_file" "$config_file" on_config_change 1010 >/dev/null 2>&1; then + fail "manual reload should not be skipped by internal config guard" +fi +[ -e "$guard_file" ] || + fail "non-config-change reason should not consume internal config guard" + +printf '%s\n%s\n' 1000 "bad-hash" >"$guard_file" +if initd_ucode initd-should-skip-internal-config-reload on_config_change "$guard_file" "$config_file" on_config_change 1010 >/dev/null 2>&1; then + fail "mismatched internal config guard should not skip reload" +fi +[ ! -e "$guard_file" ] || + fail "mismatched internal config guard should be consumed" + +printf '%s\n' 1 >"$sync_file" +initd_ucode initd-service-trigger-sync-requested "$sync_file" >/dev/null || + fail "service trigger sync marker should request sync" +[ ! -e "$sync_file" ] || + fail "service trigger sync marker should be consumed" + +printf '%s\n' 0 >"$sync_file" +if initd_ucode initd-service-trigger-sync-requested "$sync_file" >/dev/null 2>&1; then + fail "service trigger sync marker with value 0 should not request sync" +fi +[ ! -e "$sync_file" ] || + fail "service trigger sync marker value 0 should be consumed" + +initd_ucode initd-should-ignore-config-change-reload on_config_change on_config_change 0 0 >/dev/null || + fail "disabled stopped service should ignore config-change reload" +if initd_ucode initd-should-ignore-config-change-reload manual on_config_change 0 0 >/dev/null 2>&1; then + fail "manual reload should not be ignored" +fi +if initd_ucode initd-should-ignore-config-change-reload on_config_change on_config_change 1 0 >/dev/null 2>&1; then + fail "running service should not ignore config-change reload" +fi +if initd_ucode initd-should-ignore-config-change-reload on_config_change on_config_change 0 1 >/dev/null 2>&1; then + fail "enabled service should not ignore config-change reload" +fi + +initd_ucode initd-should-restore-dnsmasq-on-start-fixture "" 0 >/dev/null || + fail "unclean shutdown should request dnsmasq restore on start" +if initd_ucode initd-should-restore-dnsmasq-on-start-fixture triggered 0 >/dev/null 2>&1; then + fail "triggered start should not restore dnsmasq" +fi +if initd_ucode initd-should-restore-dnsmasq-on-start-fixture "" 1 >/dev/null 2>&1; then + fail "clean shutdown should not restore dnsmasq" +fi + +start_plan="$(initd_ucode start-plan-fixture "" 0 1 "wan vpn0" 123 1)" +case "$start_plan" in + *"INITD_BIN_OK='1'"* ) ;; + *) fail "start-plan must expose executable binary state" ;; +esac +case "$start_plan" in + *"INITD_BADWAN_NETDEV='wan vpn0'"* ) ;; + *) fail "start-plan must derive Bad WAN netdev from UCI settings" ;; +esac + +start_plan_triggered="$(initd_ucode start-plan-fixture triggered 0 1 "wan vpn0" 123 1)" +case "$start_plan_triggered" in + *"INITD_BADWAN_NETDEV='wan vpn0'"* ) ;; + *) fail "triggered start should keep Bad WAN netdev plan" ;; +esac + +cat >"$WORK_DIR/settings.json" <<'JSON' +{ + "settings": { + "enable_badwan_interface_monitoring": "1", + "badwan_monitored_interfaces": [ "wan", "vpn0", "wwan" ], + "badwan_reload_delay": "3500" + } +} +JSON +trigger_plan="$(initd_ucode trigger-plan-fixture "$WORK_DIR/settings.json")" +case "$trigger_plan" in + *"delay 3500"* ) ;; + *) fail "trigger-plan must emit Bad WAN reload delay" ;; +esac +case "$trigger_plan" in + *"interface interface.*.up vpn0"* ) ;; + *) fail "trigger-plan must include non-wan Bad WAN interface triggers" ;; +esac +case "$trigger_plan" in + *"interface interface.*.up wan "*"retry_start_on_wan_up"* ) ;; + *) fail "trigger-plan must keep WAN cold-start retry trigger" ;; +esac + +for legacy in \ + service_trigger_sync_requested \ + current_config_hash \ + internal_config_guard_matches_current_config \ + should_skip_internal_config_reload \ + should_restore_dnsmasq_on_start; do + if grep -Fq "$legacy" "$INITD"; then + fail "init.d must not keep shell decision function $legacy" + fi +done + +grep -Fq 'service/initd.uc' "$INITD" || + fail "init.d must delegate init.d decisions to service/initd.uc" +grep -Fq 'start-service' "$INITD" || + fail "init.d must start through a complete ucode start entrypoint" +grep -Fq 'mode == "start-service"' "$INITD_UC" || + fail "service/initd.uc must expose the complete start entrypoint" +grep -Fq 'stop-service' "$INITD" || + fail "init.d must stop through a complete ucode stop entrypoint" +grep -Fq 'mode == "stop-service"' "$INITD_UC" || + fail "service/initd.uc must expose the complete stop entrypoint" +grep -Fq 'reload-service' "$INITD" || + fail "init.d must reload through a complete ucode reload entrypoint" +grep -Fq 'mode == "reload-service"' "$INITD_UC" || + fail "service/initd.uc must expose the complete reload entrypoint" +grep -Fq 'status-service' "$INITD" || + fail "init.d status must delegate to service/initd.uc" +grep -Fq 'mode == "status-service"' "$INITD_UC" || + fail "service/initd.uc must expose the complete status entrypoint" +grep -Fq 'retry-start-on-wan-up' "$INITD" || + fail "init.d WAN retry must delegate to service/initd.uc" +grep -Fq 'mode == "retry-start-on-wan-up"' "$INITD_UC" || + fail "service/initd.uc must expose the complete WAN retry entrypoint" +grep -Fq 'trigger-plan' "$INITD" || + fail "init.d must get procd trigger plan from service/initd.uc" +if grep -Fq 'eval ' "$INITD"; then + fail "init.d must not eval ucode-generated shell plans" +fi +if grep -Fq 'runtime_is_running' "$INITD"; then + fail "init.d must not keep shell runtime status decisions" +fi +if grep -E -n 'PODKOP_(CONFIG_FILE|RELOAD_LOCK_DIR|RUNTIME_STATE_DIR|PENDING_RELOAD_FILE|SERVICE_TRIGGER_SYNC_FILE|INTERNAL_CONFIG_TRIGGER_GUARD|CONFIG_CHANGE_REASON|BIN|SERVICE_INIT)=' "$INITD" >/dev/null 2>&1; then + fail "init.d must not pass internal orchestration paths through shell env" +fi +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$INITD_UC" >/dev/null 2>&1; then + fail "service/initd.uc must use core.uci instead of direct UCI cursor/CLI access" +fi +if grep -E -n '(^|[[:space:]])config_(load|get|get_bool|set)[[:space:]]' "$INITD" >/dev/null 2>&1; then + fail "init.d must not read UCI directly" +fi +if grep -Fq 'PODKOP_STATE_UC' "$INITD" || grep -Fq 'PODKOP_UI_UC' "$INITD" || grep -Fq 'PODKOP_STATUS_UC' "$INITD"; then + fail "init.d must not orchestrate state/UI/status modules directly" +fi +if grep -Fq 'procd_set_param command "$PODKOP_BIN" start' "$INITD"; then + fail "init.d must not hand the one-shot start operation to procd as a daemon" +fi +if grep -Fq 'initd-should-' "$STATE_UC"; then + fail "service/state.uc must not keep init.d decision owner modes" +fi + +printf 'init.d state regression checks passed\n' diff --git a/tests/backend/installer_owner_regression.sh b/tests/backend/installer_owner_regression.sh new file mode 100644 index 00000000..6d4eca66 --- /dev/null +++ b/tests/backend/installer_owner_regression.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +INSTALLER="$ROOT_DIR/install.sh" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ -r "$INSTALLER" ] || fail "install.sh is missing" + +if grep -n -E '(^|[;&|[:space:]])uci[[:space:]]+-q|command_exists[[:space:]]+uci|/usr/bin/uci' "$INSTALLER" >/dev/null; then + fail "install.sh must not own UCI reads/writes through shell" +fi + +shell_sing_box_owner_symbols='REQUIRED_SING_BOX_VERSION|SING_BOX_EXTENDED_|SING_BOX_MANAGED_SERVICE_MARKER|remove_old_sing_box_if_needed|install_managed_sing_box_service_script|remove_managed_sing_box_service_script|disable_sing_box_service_config|prepare_sing_box_service_disabled|prepare_sing_box_package_service_install|stop_podkop_for_sing_box_install|pkg_install_sing_box_variant|pkg_install_name_downgrade|pkg_remove_sing_box_conflict|resolve_sing_box_extended_release|install_sing_box_extended_(package|binary)|restore_sing_box_after_failed|move_file_to_backup|validate_extended_sing_box_binary|archive-member-path|sing-box-extended-arch-suffix|sing-box-extended-asset-url|sing-box-extended-package-asset-url' +if grep -n -E "$shell_sing_box_owner_symbols" "$INSTALLER" >/dev/null; then + fail "install.sh must not contain shell sing-box install/runtime ownership" +fi + +grep -Fq 'ensure_bootstrap_package "ucode-mod-fs"' "$INSTALLER" || + fail "install.sh must bootstrap ucode-mod-fs before embedded ucode helper use" +grep -Fq 'ensure_bootstrap_package "ucode-mod-uci"' "$INSTALLER" || + fail "install.sh must bootstrap ucode-mod-uci before embedded UCI helper use" + +grep -Fq '/usr/bin/podkop-plus component_action sing_box "$action"' "$INSTALLER" || + fail "selected sing-box install must delegate to podkop-plus component_action" +for action in install_stable install_tiny install_extended install_extended_compressed; do + grep -Fq "action=\"$action\"" "$INSTALLER" || + fail "selected sing-box installer is missing action mapping: $action" +done + +grep -Fq 'run_args([ INSTALLER_PODKOP_PLUS_BIN, "restore_dnsmasq" ])' "$INSTALLER" || + fail "installer dnsmasq restore must prefer installed podkop-plus ucode entrypoint" +grep -Fq 'else if (mode == "dnsmasq-failsafe-restore")' "$INSTALLER" || + fail "dnsmasq restore fallback mode must remain available in embedded ucode helper" +grep -Fq 'else if (mode == "installer-cleanup-legacy")' "$INSTALLER" || + fail "installer cleanup must be exposed as an embedded ucode mode" +grep -Fq 'else if (mode == "installer-post-install")' "$INSTALLER" || + fail "installer post-install must be exposed as an embedded ucode mode" +grep -Fq 'install_json_ucode installer-cleanup-legacy' "$INSTALLER" || + fail "install.sh cleanup must delegate to embedded ucode" +grep -Fq 'install_json_ucode installer-post-install' "$INSTALLER" || + fail "install.sh post-install must delegate to embedded ucode" + +if grep -n -E 'restore_podkop_dnsmasq_failsafe|remember_service_state|stop_conflicting_services|deactivate_original_podkop_if_present|remove_conflicting_dns_proxy|pkg_remove_if_installed|pkg_remove_matching_prefix|pkg_list_installed_names' "$INSTALLER" >/dev/null 2>&1; then + fail "install.sh must not keep shell cleanup/remove service owners" +fi +if grep -n -E 'rm -f /var/luci-indexcache|rm -f /tmp/luci-indexcache|/etc/init\.d/rpcd[[:space:]]+reload|/etc/init\.d/podkop-plus[[:space:]]+(start|stop|disable|enable|restart)|/etc/init\.d/podkop[[:space:]]+(stop|disable)' "$INSTALLER" >/dev/null 2>&1; then + fail "install.sh shell must not own service/cache lifecycle actions" +fi + +awk ' + /^[[:space:]]*main\(\)[[:space:]]*\{/ { in_main = 1 } + in_main && /ensure_bootstrap_ucode_runtime/ { ensure = NR } + in_main && /decide_i18n_installation/ { i18n = NR } + in_main && /install_packages/ { install_packages = NR } + in_main && /install_selected_sing_box/ { sing_box = NR } + in_main && /^[[:space:]]*\}/ { in_main = 0 } + END { + if (ensure > 0 && i18n > 0 && ensure < i18n && + install_packages > 0 && sing_box > 0 && install_packages < sing_box) + exit 0 + exit 1 + } +' "$INSTALLER" || fail "install.sh main order must bootstrap ucode before UCI reads and install backend before sing-box component_action" + +helper="$WORK_DIR/install-json.uc" +awk ' + /cat > "\$helper_path" <<'\''EOF'\''/ { capture = 1; next } + capture && /^EOF$/ { exit } + capture { print } +' "$INSTALLER" > "$helper" +[ -s "$helper" ] || fail "failed to extract embedded installer ucode helper" + +printf '%s\n' '{"tag_name":"v-test"}' | ucode "$helper" release-tag | grep -Fxq 'v-test' || + fail "embedded helper release-tag mode must parse release JSON" + +cat >"$WORK_DIR/opkg" <<'SH' +#!/usr/bin/env sh +case "$1" in + list-installed) + printf '%s\n' \ + 'https-dns-proxy - 1.0' \ + 'luci-app-https-dns-proxy - 1.0' \ + 'luci-i18n-https-dns-proxy-ru - 1.0' + ;; + remove) + shift + [ "$1" = "--force-depends" ] && shift + printf '%s\n' "$1" >> "$PODKOP_INSTALLER_OPKG_LOG" + ;; +esac +exit 0 +SH +chmod 0755 "$WORK_DIR/opkg" + +: >"$WORK_DIR/opkg.log" +printf '%s\n' '1' | + PATH="$WORK_DIR:$PATH" \ + PODKOP_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ + PODKOP_INSTALLER_PODKOP_PLUS_INIT="$WORK_DIR/missing-podkop-plus-init" \ + PODKOP_INSTALLER_PODKOP_PLUS_BIN="$WORK_DIR/missing-podkop-bin" \ + PODKOP_INSTALLER_PODKOP_PLUS_LIB="$WORK_DIR/missing-podkop-lib" \ + PODKOP_INSTALLER_PODKOP_PLUS_UCI_DEFAULTS="$WORK_DIR/missing-uci-defaults" \ + PODKOP_INSTALLER_PODKOP_PLUS_LUCI_VIEW="$WORK_DIR/missing-luci-view" \ + PODKOP_INSTALLER_MENU_JSON="$WORK_DIR/missing-menu.json" \ + PODKOP_INSTALLER_ACL_JSON="$WORK_DIR/missing-acl.json" \ + PODKOP_INSTALLER_RU_LMO="$WORK_DIR/missing-ru.lmo" \ + PODKOP_INSTALLER_EN_LMO="$WORK_DIR/missing-en.lmo" \ + PODKOP_INSTALLER_RU_LUA="$WORK_DIR/missing-ru.lua" \ + PODKOP_INSTALLER_EN_LUA="$WORK_DIR/missing-en.lua" \ + PODKOP_INSTALLER_ORIGINAL_PODKOP_INIT="$WORK_DIR/missing-original-init" \ + PODKOP_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ + ucode "$helper" installer-cleanup-legacy >"$WORK_DIR/conflict-state.env" 2>"$WORK_DIR/conflict.err" + +grep -Fxq 'https-dns-proxy' "$WORK_DIR/opkg.log" || + fail "installer cleanup must remove confirmed https-dns-proxy conflict" +grep -Fxq 'luci-app-https-dns-proxy' "$WORK_DIR/opkg.log" || + fail "installer cleanup must remove confirmed https-dns-proxy LuCI package" +grep -Fxq 'luci-i18n-https-dns-proxy-ru' "$WORK_DIR/opkg.log" || + fail "installer cleanup must remove confirmed https-dns-proxy i18n packages" + +write_fake_podkop_init() { + cat >"$WORK_DIR/podkop-plus-init" <<'SH' +#!/usr/bin/env sh +case "$1" in + enabled) exit 0 ;; + status) printf '%s\n' running; exit 0 ;; + stop|disable|enable|start|restart) printf '%s\n' "$1" >> "$PODKOP_INSTALLER_INIT_LOG"; exit 0 ;; +esac +exit 0 +SH + chmod 0755 "$WORK_DIR/podkop-plus-init" +} + +write_fake_podkop_init + +cat >"$WORK_DIR/podkop-bin" <<'SH' +#!/usr/bin/env sh +printf '%s\n' "$*" >> "$PODKOP_INSTALLER_BIN_LOG" +case "$1" in + get_status) printf '%s\n' '{"running":1}' ;; +esac +exit 0 +SH +chmod 0755 "$WORK_DIR/podkop-bin" + +: >"$WORK_DIR/init.log" +: >"$WORK_DIR/bin.log" +state="$WORK_DIR/state.env" +PODKOP_INSTALLER_PODKOP_PLUS_INIT="$WORK_DIR/podkop-plus-init" \ +PODKOP_INSTALLER_PODKOP_PLUS_BIN="$WORK_DIR/podkop-bin" \ +PODKOP_INSTALLER_PODKOP_PLUS_LIB="$WORK_DIR/podkop-lib" \ +PODKOP_INSTALLER_PODKOP_PLUS_UCI_DEFAULTS="$WORK_DIR/uci-defaults" \ +PODKOP_INSTALLER_PODKOP_PLUS_LUCI_VIEW="$WORK_DIR/luci-view" \ +PODKOP_INSTALLER_MENU_JSON="$WORK_DIR/menu.json" \ +PODKOP_INSTALLER_ACL_JSON="$WORK_DIR/acl.json" \ +PODKOP_INSTALLER_RU_LMO="$WORK_DIR/ru.lmo" \ +PODKOP_INSTALLER_EN_LMO="$WORK_DIR/en.lmo" \ +PODKOP_INSTALLER_RU_LUA="$WORK_DIR/ru.lua" \ +PODKOP_INSTALLER_EN_LUA="$WORK_DIR/en.lua" \ +PODKOP_INSTALLER_ORIGINAL_PODKOP_INIT="$WORK_DIR/missing-original-init" \ +PODKOP_INSTALLER_INIT_LOG="$WORK_DIR/init.log" \ +PODKOP_INSTALLER_BIN_LOG="$WORK_DIR/bin.log" \ +PODKOP_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ + ucode "$helper" installer-cleanup-legacy >"$state" + +grep -Fxq 'PODKOP_WAS_ENABLED=1' "$state" || + fail "installer cleanup must export previous enabled state" +grep -Fxq 'PODKOP_WAS_RUNNING=1' "$state" || + fail "installer cleanup must export previous running state" +grep -Fxq 'stop' "$WORK_DIR/init.log" || + fail "installer cleanup must stop old Podkop Plus through ucode owner" +grep -Fxq 'disable' "$WORK_DIR/init.log" || + fail "installer cleanup must disable old Podkop Plus through ucode owner" +grep -Fxq 'restore_dnsmasq' "$WORK_DIR/bin.log" || + fail "installer cleanup must prefer backend restore_dnsmasq" + +write_fake_podkop_init +touch "$WORK_DIR/luci-indexcache.one" "$WORK_DIR/luci-indexcache.two" +: >"$WORK_DIR/init.log" +PODKOP_INSTALLER_PODKOP_PLUS_INIT="$WORK_DIR/podkop-plus-init" \ +PODKOP_INSTALLER_RPCD_INIT="$WORK_DIR/missing-rpcd" \ +PODKOP_INSTALLER_LUCI_CACHE_GLOBS="$WORK_DIR/luci-indexcache*" \ +PODKOP_INSTALLER_LATEST_VERSION_CACHE="$WORK_DIR/latest.cache" \ +PODKOP_INSTALLER_SYSTEM_INFO_CACHE="$WORK_DIR/system-info.json" \ +PODKOP_INSTALLER_SERVER_COUNTRY_CACHE="$WORK_DIR/server-country.json" \ +PODKOP_INSTALLER_SING_BOX_VERSION_CACHE="$WORK_DIR/sing-box-version" \ +PODKOP_INSTALLER_TMP_SYSTEM_INFO_CACHE="$WORK_DIR/tmp-system-info.json" \ +PODKOP_WAS_ENABLED=1 \ +PODKOP_WAS_RUNNING=1 \ +PODKOP_INSTALLER_INIT_LOG="$WORK_DIR/init.log" \ + ucode "$helper" installer-post-install + +if compgen -G "$WORK_DIR/luci-indexcache*" >/dev/null; then + fail "installer post-install must clear LuCI caches through ucode owner" +fi +grep -Fxq 'enable' "$WORK_DIR/init.log" || + fail "installer post-install must restore enabled state through ucode owner" +grep -Fxq 'start' "$WORK_DIR/init.log" || + fail "installer post-install must restore running state through ucode owner" + +printf 'installer ownership regression checks passed\n' diff --git a/tests/backend/mark_ranges_regression.sh b/tests/backend/mark_ranges_regression.sh index 4644efbc..d55a602c 100644 --- a/tests/backend/mark_ranges_regression.sh +++ b/tests/backend/mark_ranges_regression.sh @@ -3,6 +3,13 @@ set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +VALIDATOR="$PODKOP_LIB/config/validator.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -35,23 +42,45 @@ assert_mark_range_no_overlap() { done } -# shellcheck source=/dev/null -. "$PODKOP_LIB/constants.sh" -# shellcheck source=/dev/null -. "$PODKOP_LIB/config_validation.sh" - -log() { - : -} +eval "$(ucode -L "$PODKOP_LIB" "$PODKOP_LIB/core/constants.uc" shell-env)" assert_mark_range_no_overlap "Zapret" "$((ZAPRET_ROUTE_MARK_BASE))" "$ZAPRET_QUEUE_RANGE_SIZE" "$((NFT_FAKEIP_MARK))" "FakeIP" assert_mark_range_no_overlap "Zapret" "$((ZAPRET_ROUTE_MARK_BASE))" "$ZAPRET_QUEUE_RANGE_SIZE" "$((NFT_OUTBOUND_MARK))" "outbound" assert_mark_range_no_overlap "Zapret2" "$((ZAPRET2_ROUTE_MARK_BASE))" "$ZAPRET2_QUEUE_RANGE_SIZE" "$((NFT_FAKEIP_MARK))" "FakeIP" assert_mark_range_no_overlap "Zapret2" "$((ZAPRET2_ROUTE_MARK_BASE))" "$ZAPRET2_QUEUE_RANGE_SIZE" "$((NFT_OUTBOUND_MARK))" "outbound" -validate_runtime_mark_ranges +cat >"$WORK_DIR/fixture.json" <<'JSON' +{ + "settings": { ".name": "settings", ".type": "settings" } +} +JSON -if (ZAPRET2_ROUTE_MARK_BASE="0x01100000"; validate_runtime_mark_ranges); then +context_json() { + local zapret2_base="${1:-$ZAPRET2_ROUTE_MARK_BASE}" + + cat </dev/null 2>&1; then fail "legacy Zapret2 route mark base should overlap FakeIP mark" fi diff --git a/tests/backend/nft_apply_regression.sh b/tests/backend/nft_apply_regression.sh new file mode 100644 index 00000000..4aaa8bfb --- /dev/null +++ b/tests/backend/nft_apply_regression.sh @@ -0,0 +1,599 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +NFT_RUNTIME="$ROOT_DIR/podkop/files/usr/lib/nft/apply.uc" +WORK_DIR="$(mktemp -d)" +NFT_LOG="$WORK_DIR/nft.log" +LOGGER_LOG="$WORK_DIR/logger.log" +IP_LOG="$WORK_DIR/ip.log" +SYSCTL_LOG="$WORK_DIR/sysctl.log" + +nft_ucode() { + ucode -L "$PODKOP_LIB" "$NFT_RUNTIME" "$@" +} + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + printf 'nft log:\n' >&2 + cat "$NFT_LOG" >&2 2>/dev/null || true + printf 'logger log:\n' >&2 + cat "$LOGGER_LOG" >&2 2>/dev/null || true + exit 1 +} + +assert_contains() { + local file="$1" + local expected="$2" + local label="$3" + + grep -Fq "$expected" "$file" || fail "$label: expected '$expected'" +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +if grep -n 'require("uci").cursor' "$NFT_RUNTIME" >/dev/null 2>&1 || + grep -n 'uci -q' "$NFT_RUNTIME" >/dev/null 2>&1 || + grep -n '"uci", "-q"' "$NFT_RUNTIME" >/dev/null 2>&1 || + grep -n 'command_from_args(\[ "uci"' "$NFT_RUNTIME" >/dev/null 2>&1; then + fail "nft/apply.uc must use core.uci instead of direct UCI cursor or CLI access" +fi +grep -Fq 'require("core.uci")' "$NFT_RUNTIME" || + fail "nft/apply.uc must import core.uci" + +mkdir -p "$WORK_DIR/bin" +cat >"$WORK_DIR/bin/nft" <<'NFT' +#!/usr/bin/env bash +set -eo pipefail +{ + printf 'nft' + for arg in "$@"; do + printf '\t%s' "$arg" + done + printf '\n' +} >> "${NFT_LOG:?}" + +if [ "$#" -eq 5 ] && [ "$1" = "list" ] && [ "$2" = "chain" ] && + [ "$3" = "inet" ] && [ "$5" = "mangle" ] && [ -n "${NFT_MANGLE_CHAIN_OUTPUT:-}" ]; then + printf '%s\n' "$NFT_MANGLE_CHAIN_OUTPUT" +fi + +if [ "$#" -eq 4 ] && [ "$1" = "list" ] && [ "$2" = "table" ] && + [ "$3" = "inet" ]; then + if [ "${NFT_LIST_TABLE_FAIL:-0}" = "1" ]; then + printf 'table %s is missing\n' "$4" >&2 + exit 1 + fi +fi +NFT +chmod 0755 "$WORK_DIR/bin/nft" + +cat >"$WORK_DIR/bin/ip" <<'IP' +#!/usr/bin/env bash +set -eo pipefail +{ + printf 'ip' + for arg in "$@"; do + printf '\t%s' "$arg" + done + printf '\n' +} >> "${IP_LOG:?}" + +if [ "$#" -eq 4 ] && [ "$1" = "route" ] && [ "$2" = "list" ] && [ "$3" = "table" ]; then + printf '%s\n' "${IP_ROUTE_OUTPUT:-}" + exit 0 +fi + +if [ "$#" -eq 3 ] && [ "$1" = "-4" ] && [ "$2" = "rule" ] && [ "$3" = "list" ]; then + printf '%s\n' "${IP_RULE_OUTPUT:-}" + exit 0 +fi + +[ "${IP_FAIL_ADD:-0}" = "1" ] && exit 1 +exit 0 +IP +chmod 0755 "$WORK_DIR/bin/ip" + +cat >"$WORK_DIR/bin/lsmod" <<'LSMOD' +#!/usr/bin/env bash +set -eo pipefail +printf '%s\n' "${LSMOD_OUTPUT:-}" +LSMOD +chmod 0755 "$WORK_DIR/bin/lsmod" + +cat >"$WORK_DIR/bin/sysctl" <<'SYSCTL' +#!/usr/bin/env bash +set -eo pipefail +{ + printf 'sysctl' + for arg in "$@"; do + printf '\t%s' "$arg" + done + printf '\n' +} >> "${SYSCTL_LOG:?}" + +if [ "$#" -eq 2 ] && [ "$1" = "-n" ] && [ "$2" = "net.bridge.bridge-nf-call-iptables" ]; then + printf '%s\n' "${SYSCTL_BRIDGE_NF_CALL_IPTABLES:-0}" + exit 0 +fi + +exit 0 +SYSCTL +chmod 0755 "$WORK_DIR/bin/sysctl" + +cat >"$WORK_DIR/bin/logger" <<'LOGGER' +#!/usr/bin/env bash +set -eo pipefail +printf '%s\n' "$*" >> "${LOGGER_LOG:?}" +LOGGER +chmod 0755 "$WORK_DIR/bin/logger" + +export PATH="$WORK_DIR/bin:$PATH" +export NFT_LOG +export LOGGER_LOG +export IP_LOG +export SYSCTL_LOG + +assert_eq "/tmp/podkop-cache/condition_rule_1_domain_domains" \ + "$(nft_ucode cache-path 1 /tmp/podkop-cache condition rule_1 domain domains)" \ + "cache path" +if nft_ucode cache-path 1 /tmp/podkop-cache condition 'bad-name' domain domains >/dev/null 2>&1; then + fail "unsafe cache key should fail" +fi + +assert_eq "198.51.100.1,203.0.113.0/24" \ + "$(nft_ucode legacy-condition-csv subnets 1 0 $'198.51.100.1\nbad-value\n203.0.113.0/24' '10.0.0.0/8')" \ + "legacy text-mode subnets" +assert_eq "10.0.0.0/8,192.0.2.1" \ + "$(nft_ucode legacy-condition-csv subnets 0 0 '198.51.100.1' '10.0.0.0/8 192.0.2.1')" \ + "legacy list priority" +assert_eq "full:example.com,keyword:ads,regex:^foo" \ + "$(nft_ucode list-value-to-csv 'full:example.com keyword:ads regex:^foo')" \ + "list value csv" +assert_eq "one.example,list.example,legacy.example" \ + "$(nft_ucode rule-condition-csv domain domains 0 0 legacy.example '' 'full:one.example keyword:skip regex:^skip suffix.example' 'full:list.example keyword:list-skip')" \ + "combined domain plus legacy" +assert_eq "suffix.example,list.example" \ + "$(nft_ucode rule-condition-csv domain_suffix domains 0 0 legacy.example legacy-list.example 'full:one.example suffix.example keyword:skip' 'full:list-full.example list.example')" \ + "combined suffix ignores legacy" + +nft_ucode nft-create-runtime-base PodkopPlusTable localv4 podkop_plus_subnets podkop_plus_ports podkop_plus_ip_ports podkop_plus_interfaces "br-lan tun0" 0x00100000 0x00200000 198.18.0.0/15 1602 1 +assert_contains "$NFT_LOG" $'nft\tadd\ttable\tinet\tPodkopPlusTable' "runtime table" +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tPodkopPlusTable\tlocalv4\t{ type ipv4_addr; flags interval; auto-merge; }' "runtime localv4 set" +assert_contains "$NFT_LOG" '0.0.0.0/8,10.0.0.0/8,127.0.0.0/8' "runtime localv4 elements" +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tPodkopPlusTable\tpodkop_plus_interfaces\t{ type ifname; flags interval; }' "runtime interface set" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_interfaces\t{ br-lan }' "runtime br-lan interface" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_interfaces\t{ tun0 }' "runtime tun0 interface" +assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tPodkopPlusTable\tmangle\t{ type filter hook prerouting priority -150; policy accept; }' "runtime mangle chain" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle\tiifname\t@podkop_plus_interfaces\tip\tdaddr\t@podkop_plus_subnets\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime common tcp rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tproxy\tmeta\tmark\t&\t0x00100000\t==\t0x00100000\tmeta\tl4proto\ttcp\ttproxy\tip\tto\t:1602\tcounter' "runtime proxy tcp rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle_output\tmeta\tmark\t0x00200000\tcounter\treturn' "runtime outbound return" +assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tPodkopPlusTable\tmangle\tudp\tdport\t123\treturn' "runtime ntp exclusion" + +cat >"$WORK_DIR/runtime-base-uci.state" <<'EOF_UCI' +podkop-plus.settings=settings +podkop-plus.settings.source_network_interfaces=br-lan tun0 +podkop-plus.settings.exclude_ntp=1 +EOF_UCI +: > "$NFT_LOG" +PODKOP_UCI_STATE_FILE="$WORK_DIR/runtime-base-uci.state" \ + nft_ucode nft-create-runtime-base-from-uci PodkopPlusTable localv4 podkop_plus_subnets podkop_plus_ports podkop_plus_ip_ports podkop_plus_interfaces 0x00100000 0x00200000 198.18.0.0/15 1602 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_interfaces\t{ br-lan }' "runtime base from UCI br-lan interface" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_interfaces\t{ tun0 }' "runtime base from UCI tun0 interface" +assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tPodkopPlusTable\tmangle\tudp\tdport\t123\treturn' "runtime base from UCI ntp exclusion" + +: > "$NFT_LOG" +nft_ucode nft-create-runtime-output-rules PodkopPlusTable localv4 podkop_plus_subnets podkop_plus_ports podkop_plus_ip_ports 0x00100000 198.18.0.0/15 +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle_output\tip\tdaddr\t@podkop_plus_subnets\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output common tcp" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle_output\tip\tdaddr\t.\ttcp\tdport\t@podkop_plus_ip_ports\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output ip-port tcp" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle_output\ttcp\tdport\t@podkop_plus_ports\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output port tcp" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle_output\tip\tdaddr\t198.18.0.0/15\tmeta\tl4proto\tudp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime output fakeip udp" + +: > "$NFT_LOG" +if NFT_LIST_TABLE_FAIL=1 nft_ucode nft-table-present-fixture PodkopPlusTable 2>"$WORK_DIR/nft-table-present.err"; then + fail "missing nft table should return false" +fi +[ ! -s "$WORK_DIR/nft-table-present.err" ] || + fail "missing nft table predicate must suppress nft stderr" +assert_contains "$NFT_LOG" $'nft\tlist\ttable\tinet\tPodkopPlusTable' "nft table presence check" + +cat >"$WORK_DIR/provider-rules.json" <<'JSON' +{ + "section": [ + { ".name": "direct", "enabled": "1", "action": "direct" }, + { ".name": "zapret_one", "enabled": "1", "action": "zapret" }, + { ".name": "zapret_disabled", "enabled": "0", "action": "zapret" }, + { ".name": "zapret_two", "enabled": "1", "action": "zapret" }, + { ".name": "zapret2_one", "enabled": "1", "action": "zapret2" } + ] +} +JSON +provider_bin="$WORK_DIR/bin/provider" +printf '#!/usr/bin/env sh\nexit 0\n' >"$provider_bin" +chmod 0755 "$provider_bin" + +: > "$NFT_LOG" +nft_ucode nft-create-provider-output-rules-fixture "$WORK_DIR/provider-rules.json" PodkopPlusTable zapret "$provider_bin" 0x01000000 4000 0x40000000 0x20000000 +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle_output\tmeta\tmark\t&\t0x40000000\t==\t0x40000000\treturn' "zapret desync return" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle_output\tmeta\tmark\t0x01000001\tmeta\tl4proto\ttcp\tcounter\tqueue\tnum\t4000\tbypass' "zapret first tcp queue rule" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle_output\tmeta\tmark\t0x01000002\tmeta\tl4proto\tudp\tcounter\tqueue\tnum\t4001\tbypass' "zapret second udp queue rule" +if grep -Fq 'zapret_disabled' "$NFT_LOG"; then + fail "disabled provider section should not create nft rule" +fi + +: > "$NFT_LOG" +nft_ucode nft-create-provider-output-rules-fixture "$WORK_DIR/provider-rules.json" PodkopPlusTable zapret "$WORK_DIR/bin/missing-provider" 0x01000000 4000 0x40000000 0x20000000 +if [ -s "$NFT_LOG" ]; then + fail "missing provider binary should skip provider nft output rules" +fi + +: > "$IP_LOG" +: > "$LOGGER_LOG" +rt_tables="$WORK_DIR/rt_tables" +IP_ROUTE_OUTPUT='' IP_RULE_OUTPUT='' nft_ucode ensure-tproxy-route-rule podkop 0x00100000 "$rt_tables" +assert_contains "$rt_tables" "105 podkop" "tproxy route table registry" +assert_contains "$IP_LOG" $'ip\troute\tadd\tlocal\t0.0.0.0/0\tdev\tlo\ttable\tpodkop' "tproxy route add" +assert_contains "$IP_LOG" $'ip\t-4\trule\tadd\tfwmark\t0x00100000/0x00100000\ttable\tpodkop\tpriority\t105' "tproxy marking rule add" +assert_contains "$LOGGER_LOG" "[debug] Added route for tproxy" "tproxy route creation log" +assert_contains "$LOGGER_LOG" "[debug] Create marking rule" "tproxy marking rule creation log" + +: > "$IP_LOG" +: > "$LOGGER_LOG" +printf '%s\n' '105 podkop' >"$rt_tables" +IP_ROUTE_OUTPUT='local default dev lo scope host' \ + IP_RULE_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup podkop' \ + nft_ucode ensure-tproxy-route-rule podkop 0x00100000 "$rt_tables" +if grep -Fq $'\tadd\t' "$IP_LOG"; then + fail "existing tproxy route/rule should not be added again" +fi +assert_contains "$LOGGER_LOG" "[debug] Route for tproxy exists" "existing tproxy route log" +assert_contains "$LOGGER_LOG" "[debug] Marking rule exist" "existing tproxy marking rule log" +IP_ROUTE_OUTPUT='local default dev lo scope host' \ + IP_RULE_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup podkop' \ + nft_ucode tproxy-route-rule-present podkop 0x00100000 +if IP_ROUTE_OUTPUT='local default dev lo scope host' \ + IP_RULE_OUTPUT='105: from all fwmark 0x100000/0x100000 lookup other' \ + nft_ucode tproxy-route-rule-present podkop 0x00100000 >/dev/null 2>&1; then + fail "tproxy route/rule presence should require matching lookup table" +fi + +: > "$SYSCTL_LOG" +: > "$LOGGER_LOG" +LSMOD_OUTPUT='br_netfilter 32768 0' SYSCTL_BRIDGE_NF_CALL_IPTABLES=1 nft_ucode ensure-bridge-netfilter-disabled +assert_contains "$SYSCTL_LOG" $'sysctl\t-n\tnet.bridge.bridge-nf-call-iptables' "bridge netfilter sysctl check" +assert_contains "$SYSCTL_LOG" $'sysctl\t-w\tnet.bridge.bridge-nf-call-iptables=0' "bridge netfilter ipv4 disable" +assert_contains "$SYSCTL_LOG" $'sysctl\t-w\tnet.bridge.bridge-nf-call-ip6tables=0' "bridge netfilter ipv6 disable" +assert_contains "$LOGGER_LOG" "[debug] br_netfilter enabled detected. Disabling" "bridge netfilter disable log" + +: > "$SYSCTL_LOG" +LSMOD_OUTPUT='' SYSCTL_BRIDGE_NF_CALL_IPTABLES=1 nft_ucode ensure-bridge-netfilter-disabled +if [ -s "$SYSCTL_LOG" ]; then + fail "bridge netfilter absent should not call sysctl" +fi + +input="$WORK_DIR/ips.txt" +cat >"$input" <<'EOF_INPUT' +198.51.100.1 +bad-value +203.0.113.0/24 +EOF_INPUT + +nft_ucode nft-add-file-chunks-to-set "$input" PodkopPlusTable podkop_plus_subnets ips "" 2 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_subnets\t{ 198.51.100.1,203.0.113.0/24 }' "nft chunked ips" +assert_contains "$LOGGER_LOG" "[debug] 'bad-value' is not IPv4 or IPv4 CIDR" "invalid element log" +assert_contains "$LOGGER_LOG" "[debug] Adding 2 elements to nft set podkop_plus_subnets" "chunk count log" + +: > "$NFT_LOG" +ports_input="$WORK_DIR/ip-ports.txt" +cat >"$ports_input" <<'EOF_INPUT' +198.51.100.1 +203.0.113.0/24 +EOF_INPUT + +nft_ucode nft-add-file-chunks-to-set "$ports_input" PodkopPlusTable podkop_plus_ip_ports ip-port-from-ip "80,443-444" 3 +assert_contains "$NFT_LOG" $'198.51.100.1 . 80,198.51.100.1 . 443-444,203.0.113.0/24 . 80' "ip-port chunk" +assert_contains "$NFT_LOG" $'203.0.113.0/24 . 443-444' "ip-port second chunk" + +cat >"$WORK_DIR/populate-fixture.json" <<'JSON' +{ + "section": [ + { + ".name": "disabled", + ".type": "section", + "enabled": "0", + "ip_cidr": [ "192.0.2.1" ], + "ports": [ "53" ], + "fully_routed_ips": [ "192.168.1.50/32" ] + }, + { + ".name": "deferred", + ".type": "section", + "enabled": "1", + "ip_cidr": [ "192.0.2.2" ], + "ports": [ "54" ], + "fully_routed_ips": [ "192.168.1.51/32" ] + }, + { + ".name": "inline", + ".type": "section", + "enabled": "1", + "ip_cidr": [ "198.51.100.1", "203.0.113.0/24" ], + "ports": [ "80", "443-444" ], + "fully_routed_ips": [ "192.168.1.20/32", "192.168.1.20/32" ] + }, + { + ".name": "inline_no_ports", + ".type": "section", + "enabled": "1", + "ip_cidr": [ "198.51.100.200" ] + }, + { + ".name": "ports_only", + ".type": "section", + "enabled": "1", + "ports": [ "53", "853" ], + "ports_text": "5353, 853" + }, + { + ".name": "ports_with_domain", + ".type": "section", + "enabled": "1", + "domain_suffix": [ "example.org" ], + "ports": [ "8443" ] + } + ] +} +JSON + +plain_subnets="$WORK_DIR/plain-subnets.txt" +cat >"$plain_subnets" <<'EOF_INPUT' +198.51.100.210 +203.0.113.0/24 +EOF_INPUT + +: > "$NFT_LOG" +nft_ucode nft-add-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports "$plain_subnets" PodkopPlusTable podkop_plus_subnets podkop_plus_ip_ports 5000 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_subnets\t{ 198.51.100.210,203.0.113.0/24 }' "plain subnet import without ports" + +: > "$NFT_LOG" +nft_ucode nft-add-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only "$plain_subnets" PodkopPlusTable podkop_plus_subnets podkop_plus_ip_ports 3 +assert_contains "$NFT_LOG" $'198.51.100.210 . 53,198.51.100.210 . 853,198.51.100.210 . 5353' "plain subnet import scoped first chunk" +assert_contains "$NFT_LOG" $'203.0.113.0/24 . 53,203.0.113.0/24 . 853,203.0.113.0/24 . 5353' "plain subnet import scoped second chunk" + +json_ruleset="$WORK_DIR/subnets-ruleset.json" +cat >"$json_ruleset" <<'JSON' +{ + "version": 3, + "rules": [ + { "ip_cidr": [ "198.51.100.220" ] }, + { "ip_cidr": [ "198.51.100.221" ], "port": [ 443 ] }, + { + "type": "logical", + "mode": "and", + "rules": [ + { "ip_cidr": [ "198.51.100.222" ] }, + { "port_range": [ "1000:1002" ] } + ] + } + ] +} +JSON + +: > "$NFT_LOG" +unscoped_json="$WORK_DIR/unscoped-json.txt" +scoped_json="$WORK_DIR/scoped-json.txt" +nft_ucode nft-add-json-ruleset-subnets-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports "$json_ruleset" "fixture json" PodkopPlusTable podkop_plus_subnets podkop_plus_ip_ports "$unscoped_json" "$scoped_json" 5000 +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_subnets\t{ 198.51.100.220 }' "json ruleset unscoped import" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_ip_ports\t{ 198.51.100.221 . 443,198.51.100.222 . 1000-1002 }' "json ruleset own port filters" + +: > "$NFT_LOG" +unscoped_json="$WORK_DIR/unscoped-json-ports.txt" +scoped_json="$WORK_DIR/scoped-json-ports.txt" +nft_ucode nft-add-json-ruleset-subnets-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only "$json_ruleset" "fixture json ports" PodkopPlusTable podkop_plus_subnets podkop_plus_ip_ports "$unscoped_json" "$scoped_json" 4 +assert_contains "$NFT_LOG" $'198.51.100.220 . 53,198.51.100.220 . 5353,198.51.100.220 . 853' "json ruleset scoped import" +if grep -Fq '198.51.100.221' "$NFT_LOG" || grep -Fq '198.51.100.222' "$NFT_LOG"; then + fail "json ruleset section ports should intersect rule-owned port filters" +fi + +: > "$NFT_LOG" +nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports discord "$plain_subnets" PodkopPlusTable podkop_plus_subnets podkop_plus_ip_ports podkop_plus_interfaces podkop_plus_discord_subnets 0x00100000 5000 +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tPodkopPlusTable\tpodkop_plus_discord_subnets\t{ type ipv4_addr; flags interval; auto-merge; }' "discord community set" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tPodkopPlusTable\tmangle\tiifname\t@podkop_plus_interfaces\tip\tdaddr\t@podkop_plus_discord_subnets\tudp\tdport\t{ 19000-20000, 50000-65535 }\tmeta\tmark\tset\t0x00100000\tcounter' "discord community udp rule" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_discord_subnets\t{ 198.51.100.210,203.0.113.0/24 }' "discord community subnet import" + +: > "$NFT_LOG" +NFT_MANGLE_CHAIN_OUTPUT='iifname @podkop_plus_interfaces ip daddr @podkop_plus_discord_subnets udp dport { 19000-20000, 50000-65535 } meta mark set 0x00100000 counter' \ + nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" inline_no_ports discord "$plain_subnets" PodkopPlusTable podkop_plus_subnets podkop_plus_ip_ports podkop_plus_interfaces podkop_plus_discord_subnets 0x00100000 5000 +assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tPodkopPlusTable\tpodkop_plus_discord_subnets\t{ type ipv4_addr; flags interval; auto-merge; }' "discord community existing set" +if grep -Fq $'nft\tadd\trule' "$NFT_LOG"; then + fail "discord community existing rule should not insert" +fi + +: > "$NFT_LOG" +nft_ucode nft-add-community-subnet-file-for-section-fixture "$WORK_DIR/populate-fixture.json" ports_only discord "$plain_subnets" PodkopPlusTable podkop_plus_subnets podkop_plus_ip_ports podkop_plus_interfaces podkop_plus_discord_subnets 0x00100000 3 +if grep -Fq 'podkop_plus_discord_subnets' "$NFT_LOG"; then + fail "discord community with section ports should not use the discord nft set" +fi +assert_contains "$NFT_LOG" $'198.51.100.210 . 53,198.51.100.210 . 853,198.51.100.210 . 5353' "discord community with ports uses scoped import" + +: > "$NFT_LOG" +nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 1 "deferred" PodkopPlusTable podkop_plus_subnets podkop_plus_ports podkop_plus_ip_ports podkop_plus_interfaces localv4 0x00100000 +assert_contains "$NFT_LOG" $'198.51.100.1 . 80,198.51.100.1 . 443-444,203.0.113.0/24 . 80' "populate inline ip-port first chunk" +assert_contains "$NFT_LOG" $'203.0.113.0/24 . 443-444' "populate inline ip-port second chunk" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_subnets\t{ 198.51.100.200 }' "populate inline ip without ports" +assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_ports\t{ 53,853,5353 }' "populate ports-only set" +assert_contains "$NFT_LOG" $'nft\tinsert\trule\tinet\tPodkopPlusTable\tmangle\tiifname\t@podkop_plus_interfaces\tip\tsaddr\t192.168.1.20/32\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "populate fully routed tcp" +if grep -Fq '192.0.2.1' "$NFT_LOG"; then + fail "disabled section should not populate nft" +fi +if grep -Fq '192.0.2.2' "$NFT_LOG"; then + fail "deferred section should not populate nft" +fi +if grep -Fq $'nft\tadd\telement\tinet\tPodkopPlusTable\tpodkop_plus_ports\t{ 8443 }' "$NFT_LOG"; then + fail "ports with destination matchers should not populate global port set" +fi +if [ "$(grep -F $'ip\tsaddr\t192.168.1.20/32' "$NFT_LOG" | wc -l | tr -d ' ')" != "3" ]; then + fail "duplicate fully routed source should insert exactly one tcp/udp/local rule set" +fi + +cat >"$WORK_DIR/signature-fixture.json" <<'JSON' +{ + "settings": { + "source_network_interfaces": [ "br-lan", "tun0" ], + "exclude_ntp": "1" + }, + "section": [ + { + ".name": "disabled", + ".type": "section", + "enabled": "0", + "ip_cidr": [ "192.0.2.10" ], + "ports": [ "1234" ], + "community_lists": [ "discord" ] + }, + { + ".name": "text_rule", + ".type": "section", + "enabled": "1", + "ip_cidr_text_mode": "1", + "ip_cidr_text": "198.51.100.1\nbad-value\n203.0.113.0/24", + "ip_cidr": [ "10.0.0.0/8" ], + "ports": [ "443", "bad-value", "443" ], + "ports_text": "80, 443-444\n0\n65536", + "fully_routed_ips": [ "192.168.1.10/32" ], + "community_lists": [ "geoblock", "meta", "telegram", "youtube", "discord" ], + "remote_subnet_lists": [ "https://example.com/subnets.lst" ], + "rule_set_with_subnets": [ "/tmp/local.json" ], + "domain_ip_lists": [ "https://example.com/mixed.lst" ] + }, + { + ".name": "default_enabled", + ".type": "section", + "ip_cidr": [ "10.0.0.0/8", "192.0.2.1" ], + "ports_text": "53 853", + "community_lists": [ "youtube", "cloudflare", "roblox" ] + } + ] +} +JSON + +cat >"$WORK_DIR/signature-expected.txt" <<'EOF_EXPECTED' +[settings.source_network_interfaces] +br-lan tun0 +[settings.exclude_ntp] +1 +[rule.text_rule.ip_cidr] +198.51.100.1,203.0.113.0/24 +[rule.text_rule.ports] +443,80,443-444 +[rule.text_rule.fully_routed_ips] +192.168.1.10/32 +[rule.text_rule.community_subnet_lists] +meta telegram discord +[rule.text_rule.remote_subnet_lists] +https://example.com/subnets.lst +[rule.text_rule.rule_set_with_subnets] +/tmp/local.json +[rule.text_rule.domain_ip_lists] +https://example.com/mixed.lst +[rule.default_enabled.ip_cidr] +10.0.0.0/8,192.0.2.1 +[rule.default_enabled.ports] +53,853 +[rule.default_enabled.fully_routed_ips] + +[rule.default_enabled.community_subnet_lists] +cloudflare roblox +[rule.default_enabled.remote_subnet_lists] + +[rule.default_enabled.rule_set_with_subnets] + +[rule.default_enabled.domain_ip_lists] + +EOF_EXPECTED +expected_signature="$(md5sum "$WORK_DIR/signature-expected.txt" | awk '{print $1}')" +assert_eq "$expected_signature" \ + "$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/signature-fixture.json")" \ + "nft runtime signature fixture" + +cat >"$WORK_DIR/signature-uci.state" <<'EOF_UCI' +podkop-plus.settings=settings +podkop-plus.settings.source_network_interfaces=br-lan tun0 +podkop-plus.settings.exclude_ntp=1 +podkop-plus.disabled=section +podkop-plus.disabled.enabled=0 +podkop-plus.disabled.ip_cidr=192.0.2.10 +podkop-plus.enabled=section +podkop-plus.enabled.enabled=1 +podkop-plus.enabled.ip_cidr=10.0.0.0/8 192.0.2.1 +podkop-plus.enabled.ports=443 bad-value +podkop-plus.enabled.ports_text=80 443-444 65536 +podkop-plus.enabled.fully_routed_ips=192.168.1.10/32 +podkop-plus.enabled.community_lists=geoblock meta telegram youtube discord +podkop-plus.enabled.remote_subnet_lists=https://example.com/subnets.lst +podkop-plus.enabled.rule_set_with_subnets=/tmp/local.json +podkop-plus.enabled.domain_ip_lists=https://example.com/mixed.lst +EOF_UCI +cat >"$WORK_DIR/signature-uci-expected.txt" <<'EOF_EXPECTED' +[settings.source_network_interfaces] +br-lan tun0 +[settings.exclude_ntp] +1 +[rule.enabled.ip_cidr] +10.0.0.0/8,192.0.2.1 +[rule.enabled.ports] +443,80,443-444 +[rule.enabled.fully_routed_ips] +192.168.1.10/32 +[rule.enabled.community_subnet_lists] +meta telegram discord +[rule.enabled.remote_subnet_lists] +https://example.com/subnets.lst +[rule.enabled.rule_set_with_subnets] +/tmp/local.json +[rule.enabled.domain_ip_lists] +https://example.com/mixed.lst +EOF_EXPECTED +expected_signature="$(md5sum "$WORK_DIR/signature-uci-expected.txt" | awk '{print $1}')" +assert_eq "$expected_signature" \ + "$(PODKOP_UCI_STATE_FILE="$WORK_DIR/signature-uci.state" nft_ucode nft-runtime-signature)" \ + "nft runtime signature from UCI state" + +cat >"$WORK_DIR/signature-defaults-fixture.json" <<'JSON' +{} +JSON +cat >"$WORK_DIR/signature-defaults-expected.txt" <<'EOF_EXPECTED' +[settings.source_network_interfaces] +br-lan +[settings.exclude_ntp] +0 +EOF_EXPECTED +expected_signature="$(md5sum "$WORK_DIR/signature-defaults-expected.txt" | awk '{print $1}')" +assert_eq "$expected_signature" \ + "$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/signature-defaults-fixture.json")" \ + "nft runtime signature defaults" + +: > "$NFT_LOG" +nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 0 "" PodkopPlusTable podkop_plus_subnets podkop_plus_ports podkop_plus_ip_ports podkop_plus_interfaces localv4 0x00100000 +[ ! -s "$NFT_LOG" ] || fail "disabled nft population should not call nft" + +: > "$NFT_LOG" +NFT_MANGLE_CHAIN_OUTPUT='ip saddr 192.168.1.20 meta l4proto tcp meta mark set 0x00100000 counter' \ + nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 1 "disabled deferred inline inline_no_ports ports_only ports_with_domain" PodkopPlusTable podkop_plus_subnets podkop_plus_ports podkop_plus_ip_ports podkop_plus_interfaces localv4 0x00100000 +if grep -Fq $'nft\tinsert\trule' "$NFT_LOG"; then + fail "existing nft-normalized /32 fully routed source should not insert" +fi + +printf 'NFT apply regression checks passed\n' diff --git a/tests/backend/package_contract_regression.sh b/tests/backend/package_contract_regression.sh new file mode 100644 index 00000000..7c52f6f6 --- /dev/null +++ b/tests/backend/package_contract_regression.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_MAKEFILE="$ROOT_DIR/podkop/Makefile" +BUILD_SCRIPT="$ROOT_DIR/scripts/build.sh" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +require_file() { + local file="$1" + + [ -r "$file" ] || fail "required file is missing: $file" +} + +require_make_dep() { + local package="$1" + + grep -Eq "DEPENDS:=.*(^|[[:space:]])\\+$package([[:space:]]|$)" "$PODKOP_MAKEFILE" || + fail "podkop/Makefile DEPENDS is missing +$package" +} + +require_build_dep() { + local variable="$1" + local package="$2" + + grep -Eq "^${variable}=.*(^|[[:space:],])${package}([[:space:],\"]|$)" "$BUILD_SCRIPT" || + fail "scripts/build.sh ${variable} is missing $package" +} + +require_package_dependency() { + local package="$1" + + require_make_dep "$package" + require_build_dep "BACKEND_DEPENDS_IPK" "$package" + require_build_dep "BACKEND_DEPENDS_APK" "$package" +} + +require_file "$PODKOP_MAKEFILE" +require_file "$BUILD_SCRIPT" +require_file "$PODKOP_LIB" + +if grep -Rqs 'require("uci")' "$PODKOP_LIB"; then + require_package_dependency "ucode-mod-uci" +fi + +if grep -Rqs 'require("fs")' "$PODKOP_LIB"; then + require_package_dependency "ucode-mod-fs" +fi + +if grep -Rqs 'podkop_dnsmasq_failsafe_restore_raw' \ + "$ROOT_DIR/podkop/files/usr/bin" \ + "$ROOT_DIR/podkop/files/usr/lib" \ + "$ROOT_DIR/podkop/files/etc/init.d"; then + fail "duplicated raw dnsmasq failsafe restore shell owner is present" +fi + +printf 'package contract regression checks passed\n' diff --git a/tests/backend/package_lifecycle_regression.sh b/tests/backend/package_lifecycle_regression.sh new file mode 100644 index 00000000..a7e35e6c --- /dev/null +++ b/tests/backend/package_lifecycle_regression.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +PACKAGE_UC="$PODKOP_LIB/service/package.uc" +PODKOP_MAKEFILE="$ROOT_DIR/podkop/Makefile" +LUCI_UCI_DEFAULTS="$ROOT_DIR/luci-app-podkop-plus/root/etc/uci-defaults/50_luci-podkop-plus" +BUILD_SCRIPT="$ROOT_DIR/scripts/build.sh" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ -r "$PACKAGE_UC" ] || + fail "service/package.uc must own package lifecycle logic" +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$PACKAGE_UC" >/dev/null 2>&1; then + fail "service/package.uc must use core.uci instead of direct UCI cursor or CLI access" +fi +grep -Fq 'require("core.uci")' "$PACKAGE_UC" || + fail "service/package.uc must import core.uci" +grep -Fq 'package_prerm: [ "service/package.uc", "prerm", 0 ]' "$PODKOP_BIN" || + fail "podkop entrypoint must dispatch package prerm cleanup through service/package.uc" +grep -Fq 'luci_postinst: [ "service/package.uc", "luci-postinst", 0 ]' "$PODKOP_BIN" || + fail "podkop entrypoint must dispatch LuCI postinstall cleanup through service/package.uc" +grep -Fq '#!/bin/sh' "$LUCI_UCI_DEFAULTS" || + fail "LuCI uci-defaults must remain a shell script because OpenWrt default_postinst runs it through shell" +grep -Fq '/usr/bin/podkop-plus luci_postinst' "$LUCI_UCI_DEFAULTS" || + fail "LuCI uci-defaults must delegate cache/rpcd handling to ucode" +if grep -E 'rm -f /var/luci-indexcache|rm -f /tmp/luci-indexcache|logger -t "podkop-plus"' "$LUCI_UCI_DEFAULTS" >/dev/null; then + fail "LuCI uci-defaults must not own cache/logger shell logic" +fi + +if grep -n -E 'grep -q "105 podkopplus"|sed -i "/105 podkopplus|podkop_dont_touch_dhcp=.*uci|cp /etc/config/podkop_plus|rm -f /tmp/luci-indexcache|killall -HUP rpcd' "$PODKOP_MAKEFILE" "$BUILD_SCRIPT" >/dev/null; then + fail "package scripts must not keep backend/LuCI lifecycle business logic in shell" +fi +grep -Fq '#!/usr/bin/ucode' "$PODKOP_MAKEFILE" || + fail "podkop Makefile package hooks must use ucode entrypoints" +grep -Fq '/usr/bin/podkop-plus package_prerm' "$PODKOP_MAKEFILE" || + fail "podkop Makefile prerm must delegate cleanup to package_prerm" +grep -Fq '/usr/bin/podkop-plus luci_postinst' "$BUILD_SCRIPT" || + fail "manual package builder must delegate LuCI cache/rpcd handling to luci_postinst" + +legacy_config="$WORK_DIR/podkop_plus" +new_config="$WORK_DIR/podkop-plus" +printf 'config podkop_plus legacy\n' >"$legacy_config" +PODKOP_CONFIG="$new_config" PODKOP_LEGACY_CONFIG="$legacy_config" \ + ucode -L "$PODKOP_LIB" "$PACKAGE_UC" preinst +[ "$(cat "$new_config")" = "config podkop_plus legacy" ] || + fail "package preinst must copy legacy config when new config is absent" + +printf 'existing config\n' >"$new_config" +PODKOP_CONFIG="$new_config" PODKOP_LEGACY_CONFIG="$legacy_config" \ + ucode -L "$PODKOP_LIB" "$PACKAGE_UC" preinst +[ "$(cat "$new_config")" = "existing config" ] || + fail "package preinst must not overwrite existing config" + +rt_tables="$WORK_DIR/rt_tables" +cat >"$rt_tables" <<'EOF' +100 main +105 podkopplus +200 custom +EOF +PODKOP_PACKAGE_TEST_MODE=1 PODKOP_RT_TABLES="$rt_tables" \ + ucode -L "$PODKOP_LIB" "$PACKAGE_UC" prerm +if grep -Fq '105 podkopplus' "$rt_tables"; then + fail "package prerm must remove the legacy podkopplus routing table entry" +fi +grep -Fq '200 custom' "$rt_tables" || + fail "package prerm must preserve unrelated rt_tables entries" + +touch "$WORK_DIR/luci-indexcache.one" "$WORK_DIR/luci-indexcache.two" +PODKOP_PACKAGE_TEST_MODE=1 PODKOP_LUCI_CACHE_GLOBS="$WORK_DIR/luci-indexcache*" \ + ucode -L "$PODKOP_LIB" "$PACKAGE_UC" luci-postinst +if compgen -G "$WORK_DIR/luci-indexcache*" >/dev/null; then + fail "luci-postinst must remove LuCI index cache files" +fi + +cat >"$WORK_DIR/podkop-bin" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${PODKOP_RESTORE_LOG:?}" +SH +chmod 0755 "$WORK_DIR/podkop-bin" + +cat >"$WORK_DIR/dont-touch.state" <<'EOF_UCI' +podkop-plus.settings=settings +podkop-plus.settings.dont_touch_dhcp=1 +EOF_UCI +printf '105 podkopplus\n' >"$WORK_DIR/rt_tables_dont_touch" +: >"$WORK_DIR/restore-dont-touch.log" +PODKOP_UCI_STATE_FILE="$WORK_DIR/dont-touch.state" \ +PODKOP_RESTORE_LOG="$WORK_DIR/restore-dont-touch.log" \ +PODKOP_BIN="$WORK_DIR/podkop-bin" \ +PODKOP_DNS_APPLY_UC="$WORK_DIR/missing-dns-apply.uc" \ +PODKOP_SING_BOX_INIT="$WORK_DIR/missing-sing-box-init" \ +PODKOP_SING_BOX_BIN="$WORK_DIR/missing-sing-box-bin" \ +PODKOP_SING_BOX_CRONET="$WORK_DIR/missing-cronet" \ +PODKOP_RT_TABLES="$WORK_DIR/rt_tables_dont_touch" \ + ucode -L "$PODKOP_LIB" "$PACKAGE_UC" prerm +[ ! -s "$WORK_DIR/restore-dont-touch.log" ] || + fail "package prerm must skip dnsmasq restore when dont_touch_dhcp is enabled" + +cat >"$WORK_DIR/restore.state" <<'EOF_UCI' +podkop-plus.settings=settings +podkop-plus.settings.dont_touch_dhcp=0 +EOF_UCI +printf '105 podkopplus\n' >"$WORK_DIR/rt_tables_restore" +: >"$WORK_DIR/restore.log" +PODKOP_UCI_STATE_FILE="$WORK_DIR/restore.state" \ +PODKOP_RESTORE_LOG="$WORK_DIR/restore.log" \ +PODKOP_BIN="$WORK_DIR/podkop-bin" \ +PODKOP_DNS_APPLY_UC="$WORK_DIR/missing-dns-apply.uc" \ +PODKOP_SING_BOX_INIT="$WORK_DIR/missing-sing-box-init" \ +PODKOP_SING_BOX_BIN="$WORK_DIR/missing-sing-box-bin" \ +PODKOP_SING_BOX_CRONET="$WORK_DIR/missing-cronet" \ +PODKOP_RT_TABLES="$WORK_DIR/rt_tables_restore" \ + ucode -L "$PODKOP_LIB" "$PACKAGE_UC" prerm +grep -Fxq 'restore_dnsmasq' "$WORK_DIR/restore.log" || + fail "package prerm must restore dnsmasq when dont_touch_dhcp is disabled" + +printf 'package lifecycle regression checks passed\n' diff --git a/tests/backend/provider_rules_regression.sh b/tests/backend/provider_rules_regression.sh new file mode 100644 index 00000000..2762dc07 --- /dev/null +++ b/tests/backend/provider_rules_regression.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RULES_UC="$ROOT_DIR/podkop/files/usr/lib/providers/rules.uc" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +rows() { + printf '%s\t%s\t%s\n' \ + "alpha" "1" "zapret" \ + "beta" "0" "zapret" \ + "gamma" "1" "byedpi" \ + "delta" "1" "zapret" \ + "zeta" "1" "zapret2" +} + +rules_ucode() { + ucode -L "$PODKOP_LIB" "$RULES_UC" "$@" +} + +if grep -n -E 'require\("uci"\)\.cursor|cursor\.foreach' "$RULES_UC" >/dev/null 2>&1; then + fail "providers/rules.uc must use core.uci instead of owning direct UCI cursor" +fi + +assert_eq "2" "$(rows | rules_ucode count zapret)" "enabled zapret count" +assert_eq "1" "$(rows | rules_ucode count byedpi)" "enabled byedpi count" +assert_eq "1" "$(rows | rules_ucode count zapret2)" "enabled zapret2 count" +assert_eq "0" "$(rows | rules_ucode count direct)" "missing action count" + +assert_eq "1" "$(rows | rules_ucode index zapret alpha)" "first zapret index" +assert_eq "2" "$(rows | rules_ucode index zapret delta)" "second zapret index skips disabled rows" +assert_eq "0" "$(rows | rules_ucode index zapret beta)" "disabled zapret section has no index" +assert_eq "1" "$(rows | rules_ucode index byedpi gamma)" "byedpi index" + +assert_eq "16777217" "$(rules_ucode mark-value 0x01000000 1)" "hex mark value" +assert_eq "0x01000002" "$(rules_ucode mark-hex 0x01000000 2)" "hex mark formatting" +assert_eq "4001" "$(rules_ucode queue-number 4000 2)" "queue number offset" +assert_eq "1081" "$(rules_ucode port-number 1080 2)" "port number offset" + +if rules_ucode mark-value invalid 1 >/dev/null 2>&1; then + fail "invalid base should be rejected" +fi + +printf 'Provider rule regression checks passed\n' diff --git a/tests/backend/runtime_state_owner_regression.sh b/tests/backend/runtime_state_owner_regression.sh new file mode 100644 index 00000000..1d043809 --- /dev/null +++ b/tests/backend/runtime_state_owner_regression.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +PODKOP_INIT="$ROOT_DIR/podkop/files/etc/init.d/podkop" +CLI_UC="$PODKOP_BIN" +RUNTIME_STATE="$ROOT_DIR/podkop/files/usr/lib/runtime_state.sh" +UPDATES_RUNTIME="$ROOT_DIR/podkop/files/usr/lib/updates_runtime.sh" +LIFECYCLE_UC="$ROOT_DIR/podkop/files/usr/lib/service/lifecycle.uc" +UPDATES_UC="$ROOT_DIR/podkop/files/usr/lib/components/updates.uc" +STATE_UC="$ROOT_DIR/podkop/files/usr/lib/service/state.uc" +NFT_UC="$ROOT_DIR/podkop/files/usr/lib/nft/apply.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ ! -e "$RUNTIME_STATE" ] || + fail "runtime_state.sh shell owner must be removed" +[ ! -e "$UPDATES_RUNTIME" ] || + fail "updates_runtime.sh shell owner must be removed" + +grep -Fq 'runtime_state.sh' "$PODKOP_BIN" && + fail "podkop must not source runtime_state.sh" + +service_predicates='process_age_seconds|sing_box_service_pid|pid_is_sing_box|sing_box_service_is_running|sing_box_service_is_stable|podkop_runtime_network_is_configured|podkop_is_running|podkop_is_stably_running|wait_for_podkop_stable_start' +if grep -R -n -E "$service_predicates" "$PODKOP_BIN" "$ROOT_DIR/podkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then + fail "service running/stability predicates must not remain in shell" +fi + +pending_state='mark_pending_reload|consume_pending_reload|run_pending_reload_if_requested|sync_time_if_needed' +if grep -R -n -E "$pending_state" "$PODKOP_BIN" "$PODKOP_INIT" "$ROOT_DIR/podkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then + fail "pending reload/time-sync ownership must not remain in shell" +fi + +runtime_state_shell_symbols='runtime_state_ucode|acquire_runtime_dir_lock|release_runtime_dir_lock|capture_reload_state|write_reload_state_file|write_reload_state|clear_reload_state|populate_nft_runtime_sets|rebuild_nft_runtime|reload_sing_box_runtime|apply_pending_urltest_selector_switches|close_inherited_service_lock_fd|PODKOP_URLTEST_SELECTOR_SWITCHES' +if grep -R -n -E "$runtime_state_shell_symbols" "$PODKOP_BIN" "$PODKOP_INIT" "$ROOT_DIR/podkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then + fail "runtime_state.sh symbols must not remain in shell" +fi + +if grep -Fq 'acquire_reload_lock' "$PODKOP_INIT" || grep -Fq 'release_reload_lock' "$PODKOP_INIT"; then + fail "init.d must not own reload lock decisions" +fi + +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +grep -Fq 'service/lifecycle.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" +grep -Fq 'refresh-cron-from-uci' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc start/reload must call the ucode cron refresh operation" +grep -Fq 'remove-cron-jobs' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc stop must call the ucode cron removal operation" +grep -Fq '"list-update"' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc start/reload must run list updates through components/updates.uc" +grep -Fq 'subscription_update: [ "components/updates.uc", "subscription-update", 2 ]' "$CLI_UC" || + fail "service/cli.uc must run subscription updates through components/updates.uc" +if grep -R -n -F 'updates_runtime.sh' "$PODKOP_BIN" "$ROOT_DIR/podkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then + fail "runtime shell must not reference updates_runtime.sh" +fi +grep -Fq 'mode == "refresh-cron-from-uci"' "$UPDATES_UC" || + fail "components/updates.uc must own cron refresh" +grep -Fq 'mode == "remove-cron-jobs"' "$UPDATES_UC" || + fail "components/updates.uc must own cron removal" +grep -Fq 'mode == "list-update"' "$UPDATES_UC" || + fail "components/updates.uc must own list update" +grep -Fq 'mode == "subscription-update"' "$UPDATES_UC" || + fail "components/updates.uc must own subscription update" +grep -Fq 'mode == "podkop-running"' "$STATE_UC" || + fail "service/state.uc must own Podkop running predicate" +grep -Fq 'mode == "wait-podkop-stable-start"' "$STATE_UC" || + fail "service/state.uc must own stable-start waiting" +grep -Fq 'mode == "mark-pending-reload"' "$STATE_UC" || + fail "service/state.uc must own pending reload writes" +grep -Fq 'mode == "sync-time-if-needed"' "$STATE_UC" || + fail "service/state.uc must own time-sync decisions" +grep -Fq 'mode == "acquire-runtime-dir-lock"' "$STATE_UC" || + fail "service/state.uc must own runtime lock acquisition" +grep -Fq 'mode == "write-captured-reload-state"' "$STATE_UC" || + fail "service/state.uc must own captured reload state writes" +grep -Fq 'mode == "reload-sing-box-runtime"' "$STATE_UC" || + fail "service/state.uc must own sing-box runtime reload" +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"|uci_get_cli|uci_exists_cli|mwan3_has_enabled_interface_from_uci_show' "$STATE_UC" >/dev/null 2>&1; then + fail "service/state.uc must not own direct UCI CLI/cursor access" +fi +grep -Fq 'nft-rebuild-runtime-from-uci' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc reload must call nft rebuild through ucode" +if grep -Fq 'nft-create-full-runtime-from-uci' "$LIFECYCLE_UC"; then + fail "podkop start/reload must rebuild nft runtime instead of appending to stale state" +fi +grep -Fq 'nft-populate-runtime-sets-from-uci' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc reload must call nft populate through ucode" +grep -Fq 'mode == "nft-rebuild-runtime-from-uci"' "$NFT_UC" || + fail "nft/apply.uc must own nft runtime rebuild" + +printf 'runtime state ownership regression checks passed\n' diff --git a/tests/backend/runtime_state_predicates_regression.sh b/tests/backend/runtime_state_predicates_regression.sh new file mode 100644 index 00000000..7c3e1d0b --- /dev/null +++ b/tests/backend/runtime_state_predicates_regression.sh @@ -0,0 +1,1251 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +STATE_UC="$ROOT_DIR/podkop/files/usr/lib/service/state.uc" +NFT_UC="$ROOT_DIR/podkop/files/usr/lib/nft/apply.uc" +WORK_DIR="$(mktemp -d)" +export ZAPRET_DEFAULT_NFQWS_OPT="--default-zapret" +export ZAPRET2_DEFAULT_NFQWS2_OPT="--default-zapret2" +export BYEDPI_DEFAULT_CMD_OPTS="--default-bye" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +md5_file() { + md5sum "$1" | awk '{print $1}' +} + +state_ucode() { + ucode -L "$PODKOP_LIB" "$STATE_UC" "$@" +} + +nft_ucode() { + ucode -L "$PODKOP_LIB" "$NFT_UC" "$@" +} + +state_ucode list-has-remote-references "local.lst https://example.com/list.txt" >/dev/null || + fail "remote reference should be detected" +if state_ucode list-has-remote-references "local.lst /tmp/list.txt" >/dev/null 2>&1; then + fail "local-only references should not be remote" +fi + +state_ucode list-has-remote-sing-box-rulesets "https://example.com/rules.srs" >/dev/null || + fail "remote sing-box ruleset should be detected" +if state_ucode list-has-remote-sing-box-rulesets "/tmp/rules.srs" >/dev/null 2>&1; then + fail "local sing-box ruleset should not be remote" +fi + +assert_eq "meta telegram discord" \ + "$(state_ucode filter-community-subnet-lists "geoblock meta telegram youtube discord")" \ + "community subnet list filter" + +state_ucode community-service-has-subnet-list roblox >/dev/null || + fail "roblox should have subnet list" +if state_ucode community-service-has-subnet-list youtube >/dev/null 2>&1; then + fail "youtube should not be a subnet-list community service" +fi + +state_ucode rule-has-list-update-source 1 "youtube telegram" "" "" "" "" >/dev/null || + fail "community subnet service should require list update" +state_ucode rule-has-list-update-source 1 "" "https://example.com/domains.lst" "" "" "" >/dev/null || + fail "remote domain list should require list update" +state_ucode rule-has-list-update-source 1 "" "" "" "" "local.lst https://example.com/mixed.lst" >/dev/null || + fail "remote domain_ip list should require list update" +if state_ucode rule-has-list-update-source 0 "telegram" "https://example.com/domains.lst" "" "" "" >/dev/null 2>&1; then + fail "disabled rule should not require list update" +fi + +state_ucode rule-has-nft-list-update-source 1 "telegram" "" "" "" >/dev/null || + fail "community subnet service should require nft list update" +state_ucode rule-has-nft-list-update-source 1 "" "https://example.com/subnets.lst" "" "" >/dev/null || + fail "remote subnet list should require nft list update" +if state_ucode rule-has-nft-list-update-source 1 "" "" "" "local.lst" >/dev/null 2>&1; then + fail "local-only domain_ip list should not require nft list update" +fi + +state_ucode rule-has-subscription-update-source 1 "1h" >/dev/null || + fail "subscription proxy with update interval should require subscription update" +if state_ucode rule-has-subscription-update-source 1 "" >/dev/null 2>&1; then + fail "empty subscription update interval should not require subscription update" +fi +if state_ucode rule-has-subscription-update-source 0 "1h" >/dev/null 2>&1; then + fail "non-subscription proxy should not require subscription update" +fi + +state_ucode time-sync-needed 2023 >/dev/null || + fail "time sync should be needed for invalid pre-2024 clock" +if state_ucode time-sync-needed 2024 >/dev/null 2>&1; then + fail "time sync should not be needed for current clock" +fi + +PENDING_RELOAD_FILE="$WORK_DIR/reload.pending" +state_ucode mark-pending-reload "$PENDING_RELOAD_FILE" "reload_busy" +grep -Fxq "reason=reload_busy" "$PENDING_RELOAD_FILE" || + fail "pending reload reason should be written by ucode" +updated_at="$(sed -n 's/^updated_at=//p' "$PENDING_RELOAD_FILE")" +case "$updated_at" in + ''|*[!0-9]*) fail "pending reload updated_at should be numeric, got '$updated_at'" ;; +esac +state_ucode consume-pending-reload "$PENDING_RELOAD_FILE" >/dev/null || + fail "pending reload should be consumed" +[ ! -e "$PENDING_RELOAD_FILE" ] || + fail "pending reload file should be removed after consume" +if state_ucode consume-pending-reload "$PENDING_RELOAD_FILE" >/dev/null 2>&1; then + fail "missing pending reload should not be consumed" +fi + +LOCK_DIR="$WORK_DIR/runtime.lock" +state_ucode acquire-runtime-dir-lock "$LOCK_DIR" "$$" || + fail "ucode should acquire runtime dir lock" +assert_eq "$$" "$(cat "$LOCK_DIR/pid")" "runtime lock owner pid" +if state_ucode acquire-runtime-dir-lock "$LOCK_DIR" "$$" >/dev/null 2>&1; then + fail "ucode should reject a lock held by a live pid" +fi +if state_ucode acquire-runtime-dir-lock-wait "$LOCK_DIR" "$$" 0 >/dev/null 2>&1; then + fail "ucode wait lock should time out for a live holder" +fi +state_ucode release-runtime-dir-lock "$LOCK_DIR" +[ ! -e "$LOCK_DIR" ] || + fail "ucode should remove runtime dir lock" + +mkdir -p "$LOCK_DIR" +printf '%s\n' 999999 >"$LOCK_DIR/pid" +state_ucode acquire-runtime-dir-lock "$LOCK_DIR" "$$" || + fail "ucode should replace a stale runtime dir lock" +assert_eq "$$" "$(cat "$LOCK_DIR/pid")" "stale runtime lock owner pid" +state_ucode release-runtime-dir-lock "$LOCK_DIR" + +SNAPSHOT_FILE="$WORK_DIR/reload-state.snapshot" +TARGET_RELOAD_STATE="$WORK_DIR/reload-state.target" +CACHE_DIR="$WORK_DIR/rule-cache" +mkdir -p "$CACHE_DIR" +printf 'format=1\nservice_trigger_signature=abc\n' >"$SNAPSHOT_FILE" +state_ucode write-captured-reload-state "$TARGET_RELOAD_STATE" "$SNAPSHOT_FILE" 1 "$CACHE_DIR" 1 1 +assert_eq "format=1 +service_trigger_signature=abc" "$(cat "$TARGET_RELOAD_STATE")" "captured reload state copy" +[ ! -e "$SNAPSHOT_FILE" ] || + fail "ucode should clear captured reload snapshot after final write" +[ ! -e "$CACHE_DIR" ] || + fail "ucode should clean rule condition cache after final reload state write" + +printf 'format=1\n' >"$SNAPSHOT_FILE" +printf 'old=1\n' >"$TARGET_RELOAD_STATE" +state_ucode clear-reload-state "$TARGET_RELOAD_STATE" "$SNAPSHOT_FILE" +[ ! -e "$TARGET_RELOAD_STATE" ] || + fail "ucode should clear reload state file" +[ ! -e "$SNAPSHOT_FILE" ] || + fail "ucode should clear reload state snapshot file" + +cat >"$WORK_DIR/service-dns-state.json" <<'JSON' +{ + "settings": { + "enable_badwan_interface_monitoring": "1", + "badwan_monitored_interfaces": [ "wan", "wwan" ], + "badwan_reload_delay": "3500", + "dont_touch_dhcp": "1" + }, + "dhcp_dnsmasq": { + "server": [ "1.1.1.1#53", "8.8.8.8" ], + "noresolv": "1", + "cachesize": "0", + "podkop_server": [ "127.0.0.42#53" ], + "podkop_noresolv": "0", + "podkop_cachesize": "1500" + }, + "legacy_dnsmasq_present": true +} +JSON + +cat >"$WORK_DIR/service-trigger.expected" <<'EOF_SERVICE_TRIGGER' +[settings.enable_badwan_interface_monitoring] +1 +[settings.badwan_monitored_interfaces] +wan wwan +[settings.badwan_reload_delay] +3500 +EOF_SERVICE_TRIGGER + +cat >"$WORK_DIR/dnsmasq-signature.expected" <<'EOF_DNSMASQ_SIG' +[settings.dont_touch_dhcp] +1 +[dhcp.@dnsmasq[0].server] +1.1.1.1#53 8.8.8.8 +[dhcp.@dnsmasq[0].noresolv] +1 +[dhcp.@dnsmasq[0].cachesize] +0 +[dhcp.@dnsmasq[0].podkop_server] +127.0.0.42#53 +[dhcp.@dnsmasq[0].podkop_noresolv] +0 +[dhcp.@dnsmasq[0].podkop_cachesize] +1500 +[dhcp.podkop_plus.present] +1 +EOF_DNSMASQ_SIG + +assert_eq "$(md5_file "$WORK_DIR/service-trigger.expected")" \ + "$(state_ucode service-trigger-signature-fixture "$WORK_DIR/service-dns-state.json")" \ + "service trigger signature hash" +assert_eq "$(md5_file "$WORK_DIR/dnsmasq-signature.expected")" \ + "$(state_ucode dnsmasq-signature-fixture "$WORK_DIR/service-dns-state.json")" \ + "dnsmasq signature hash" +assert_eq "1" \ + "$(state_ucode dont-touch-dhcp-fixture "$WORK_DIR/service-dns-state.json")" \ + "dont_touch_dhcp value" + +cat >"$WORK_DIR/service-trigger-disabled.json" <<'JSON' +{ + "settings": { + "enable_badwan_interface_monitoring": "0", + "badwan_monitored_interfaces": [ "wan" ], + "badwan_reload_delay": "3500" + } +} +JSON + +cat >"$WORK_DIR/service-trigger-disabled.expected" <<'EOF_SERVICE_TRIGGER_DISABLED' +[settings.enable_badwan_interface_monitoring] +0 +EOF_SERVICE_TRIGGER_DISABLED + +assert_eq "$(md5_file "$WORK_DIR/service-trigger-disabled.expected")" \ + "$(state_ucode service-trigger-signature-fixture "$WORK_DIR/service-trigger-disabled.json")" \ + "disabled service trigger signature hash" +assert_eq "0" \ + "$(state_ucode dont-touch-dhcp-fixture "$WORK_DIR/service-trigger-disabled.json")" \ + "default dont_touch_dhcp value" + +cat >"$WORK_DIR/service-trigger-default-delay.json" <<'JSON' +{ + "settings": { + "enable_badwan_interface_monitoring": "1", + "badwan_monitored_interfaces": [ "wan" ] + } +} +JSON + +cat >"$WORK_DIR/service-trigger-default-delay.expected" <<'EOF_SERVICE_TRIGGER_DEFAULT_DELAY' +[settings.enable_badwan_interface_monitoring] +1 +[settings.badwan_monitored_interfaces] +wan +[settings.badwan_reload_delay] +2000 +EOF_SERVICE_TRIGGER_DEFAULT_DELAY + +assert_eq "$(md5_file "$WORK_DIR/service-trigger-default-delay.expected")" \ + "$(state_ucode service-trigger-signature-fixture "$WORK_DIR/service-trigger-default-delay.json")" \ + "default badwan reload delay signature hash" + +cat >"$WORK_DIR/sing-box-signature.json" <<'JSON' +{ + "settings": { + "dns_type": "tcp", + "dns_server": "9.9.9.9", + "bootstrap_dns_server": "1.0.0.1", + "dns_rewrite_ttl": "120", + "output_network_interface": "wan", + "disable_quic": "on", + "routing_excluded_ips": [ "192.0.2.0/24" ], + "list_update_enabled": "1", + "update_interval": "2h", + "cache_path": "/tmp/cache.db", + "config_path": "/etc/sing-box/custom.json", + "log_level": "debug", + "service_listen_address": "127.0.0.1", + "enable_yacd": "yes", + "enable_yacd_wan_access": "1", + "yacd_secret_key": "secret", + "download_lists_via_proxy": "0", + "download_subscriptions_via_proxy": "on", + "download_components_via_proxy": "0", + "download_lists_via_proxy_section": "proxy1" + }, + "runtime": { + "mwan3_active": "1" + }, + "section": [ + { + ".name": "disabled_proxy", + ".type": "section", + "enabled": "0", + "action": "proxy", + "selector_proxy_links": [ "ignored" ] + }, + { + ".name": "proxy1", + ".type": "section", + "enabled": "1", + "action": "proxy", + "selector_proxy_links": [ "vless://one", "vless://two" ], + "subscription_urls": [ "https://example.com/sub.txt" ], + "enable_udp_over_tcp": "yes", + "urltest_enabled": "1", + "detect_server_country": "1", + "urltest_tolerance": "75", + "urltest_filter_mode": "exclude", + "urltest_exclude_countries": [ "RU" ], + "urltest_include_outbounds": [ "node-a" ], + "subscription_update_enabled": "1", + "outbound_detour_enabled": "1", + "outbound_detour_section": "out1", + "mixed_proxy_enabled": "1", + "mixed_proxy_port": "2080", + "mixed_proxy_auth_enabled": "1", + "mixed_proxy_username": "user", + "mixed_proxy_password": "pass", + "resolve_real_ip_for_routing": "1", + "domain": [ "legacy.example" ], + "domain_suffix": [ "full:full.example", "suffix.example" ], + "domain_keyword": [ "ads" ], + "domain_regex": [ "^foo" ], + "ip_cidr": [ "203.0.113.1", "bad" ], + "source_ip_cidr": [ "198.51.100.0/24" ], + "ports": [ "443" ], + "ports_text": "80,443-444", + "fully_routed_ips": [ "198.51.100.2" ], + "community_lists": [ "telegram" ], + "rule_set": [ "https://example.com/remote.srs" ], + "rule_set_with_subnets": [ "local.srs" ], + "domain_ip_lists": [ "local.lst" ] + }, + { + ".name": "out1", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "outbound_detour_enabled": "0", + "mixed_proxy_enabled": "0", + "resolve_real_ip_for_routing": "0" + }, + { + ".name": "bye1", + ".type": "section", + "enabled": "1", + "action": "byedpi", + "mixed_proxy_enabled": "0" + }, + { + ".name": "vpn1", + ".type": "section", + "enabled": "1", + "action": "vpn", + "interface": "wg0", + "domain_resolver_enabled": "1", + "domain_resolver_dns_type": "udp", + "domain_resolver_dns_server": "8.8.8.8", + "mixed_proxy_enabled": "0", + "resolve_real_ip_for_routing": "yes" + } + ], + "server": [ + { + ".name": "srv_disabled", + ".type": "server", + "enabled": "0", + "label": "ignored" + }, + { + ".name": "srv1", + ".type": "server", + "enabled": "1", + "listen_port": "8443", + "server_users": [ "alice", "bob" ], + "tls_server_name": "example.com", + "transport": "ws", + "transport_path": "/ws", + "tailscale_accept_routes": "1" + } + ] +} +JSON + +cat >"$WORK_DIR/sing-box-signature.expected" <<'EOF_SING_BOX_SIG' +[settings.dns_type] +tcp +[settings.dns_server] +9.9.9.9 +[settings.bootstrap_dns_server] +1.0.0.1 +[settings.dns_rewrite_ttl] +120 +[settings.output_network_interface] +wan +[settings.disable_quic] +1 +[settings.routing_excluded_ips] +192.0.2.0/24 +[settings.update_interval] +2h +[settings.cache_path] +/tmp/cache.db +[settings.config_path] +/etc/sing-box/custom.json +[settings.log_level] +debug +[settings.service_listen_address] +127.0.0.1 +[runtime.mwan3_active] +1 +[settings.enable_yacd] +1 +[settings.enable_yacd_wan_access] +1 +[settings.yacd_secret_key] +secret +[settings.download_lists_via_proxy] +0 +[settings.download_subscriptions_via_proxy] +1 +[settings.download_components_via_proxy] +0 +[settings.download_lists_via_proxy_section] +proxy1 +[rule.proxy1.action] +proxy +[rule.proxy1.selector_proxy_links] +vless://one vless://two +[rule.proxy1.subscription_urls] +https://example.com/sub.txt +[rule.proxy1.enable_udp_over_tcp] +1 +[rule.proxy1.urltest_enabled] +1 +[rule.proxy1.detect_server_country] +flag_emoji +[rule.proxy1.urltest_check_interval] +3m +[rule.proxy1.urltest_tolerance] +75 +[rule.proxy1.urltest_testing_url] +https://www.gstatic.com/generate_204 +[rule.proxy1.urltest_filter_mode] +exclude +[rule.proxy1.urltest_exclude_countries] +RU +[rule.proxy1.urltest_include_countries] + +[rule.proxy1.urltest_exclude_outbounds] + +[rule.proxy1.urltest_exclude_regex] + +[rule.proxy1.urltest_include_outbounds] +node-a +[rule.proxy1.urltest_include_regex] + +[rule.proxy1.subscription_update_enabled] +1 +[rule.proxy1.subscription_update_interval] +1h +[rule.proxy1.outbound_detour_enabled] +1 +[rule.proxy1.outbound_detour_section] +out1 +[rule.proxy1.mixed_proxy_enabled] +1 +[rule.proxy1.mixed_proxy_port] +2080 +[rule.proxy1.mixed_proxy_auth_enabled] +1 +[rule.proxy1.mixed_proxy_username] +user +[rule.proxy1.mixed_proxy_password] +pass +[rule.proxy1.resolve_real_ip_for_routing] +1 +[rule.proxy1.domain] +full.example,legacy.example +[rule.proxy1.domain_suffix] +suffix.example +[rule.proxy1.domain_keyword] +ads +[rule.proxy1.domain_regex] +^foo +[rule.proxy1.ip_cidr] +203.0.113.1,bad +[rule.proxy1.source_ip_cidr] +198.51.100.0/24 +[rule.proxy1.ports] +443,80,443-444 +[rule.proxy1.fully_routed_ips] +198.51.100.2 +[rule.proxy1.community_lists] +telegram +[rule.proxy1.rule_set] +https://example.com/remote.srs +[rule.proxy1.rule_set_with_subnets] +local.srs +[rule.proxy1.domain_ip_lists] +local.lst +[rule.out1.action] +outbound +[rule.out1.outbound_json] +{"type":"direct"} +[rule.out1.outbound_detour_enabled] +0 +[rule.out1.mixed_proxy_enabled] +0 +[rule.out1.resolve_real_ip_for_routing] +0 +[rule.out1.domain] + +[rule.out1.domain_suffix] + +[rule.out1.domain_keyword] + +[rule.out1.domain_regex] + +[rule.out1.ip_cidr] + +[rule.out1.source_ip_cidr] + +[rule.out1.ports] + +[rule.out1.fully_routed_ips] + +[rule.out1.community_lists] + +[rule.out1.rule_set] + +[rule.out1.rule_set_with_subnets] + +[rule.out1.domain_ip_lists] + +[rule.bye1.action] +byedpi +[rule.bye1.byedpi_index] +1 +[rule.bye1.mixed_proxy_enabled] +0 +[rule.bye1.resolve_real_ip_for_routing] +1 +[rule.bye1.domain] + +[rule.bye1.domain_suffix] + +[rule.bye1.domain_keyword] + +[rule.bye1.domain_regex] + +[rule.bye1.ip_cidr] + +[rule.bye1.source_ip_cidr] + +[rule.bye1.ports] + +[rule.bye1.fully_routed_ips] + +[rule.bye1.community_lists] + +[rule.bye1.rule_set] + +[rule.bye1.rule_set_with_subnets] + +[rule.bye1.domain_ip_lists] + +[rule.vpn1.action] +vpn +[rule.vpn1.interface] +wg0 +[rule.vpn1.domain_resolver_enabled] +1 +[rule.vpn1.domain_resolver_dns_type] +udp +[rule.vpn1.domain_resolver_dns_server] +8.8.8.8 +[rule.vpn1.mixed_proxy_enabled] +0 +[rule.vpn1.resolve_real_ip_for_routing] +1 +[rule.vpn1.domain] + +[rule.vpn1.domain_suffix] + +[rule.vpn1.domain_keyword] + +[rule.vpn1.domain_regex] + +[rule.vpn1.ip_cidr] + +[rule.vpn1.source_ip_cidr] + +[rule.vpn1.ports] + +[rule.vpn1.fully_routed_ips] + +[rule.vpn1.community_lists] + +[rule.vpn1.rule_set] + +[rule.vpn1.rule_set_with_subnets] + +[rule.vpn1.domain_ip_lists] + +[server.srv_disabled.enabled] +0 +[server.srv1.enabled] +1 +[server.srv1.label] +srv1 +[server.srv1.protocol] +vless +[server.srv1.listen] +0.0.0.0 +[server.srv1.listen_port] +8443 +[server.srv1.public_host] + +[server.srv1.inbound_json] + +[server.srv1.routing_mode] +rules +[server.srv1.routing_section] + +[server.srv1.security] +reality +[server.srv1.server_users] +alice bob +[server.srv1.tls_server_name] +example.com +[server.srv1.tls_alpn] + +[server.srv1.tls_certificate_path] + +[server.srv1.tls_key_path] + +[server.srv1.reality_handshake_server] + +[server.srv1.reality_handshake_server_port] + +[server.srv1.reality_private_key] + +[server.srv1.reality_public_key] + +[server.srv1.reality_short_id] + +[server.srv1.reality_max_time_difference] + +[server.srv1.transport] +ws +[server.srv1.transport_path] +/ws +[server.srv1.transport_host] + +[server.srv1.transport_service_name] + +[server.srv1.transport_hosts] + +[server.srv1.transport_xhttp_mode] + +[server.srv1.client_fingerprint] + +[server.srv1.server_uuid] + +[server.srv1.server_username] + +[server.srv1.server_password] + +[server.srv1.vless_flow] + +[server.srv1.vmess_alter_id] + +[server.srv1.shadowsocks_method] + +[server.srv1.hysteria2_up_mbps] + +[server.srv1.hysteria2_down_mbps] + +[server.srv1.hysteria2_obfs_type] + +[server.srv1.hysteria2_obfs_password] + +[server.srv1.mtproto_secret] + +[server.srv1.mtproto_faketls] + +[server.srv1.mtproto_padding] + +[server.srv1.mtproto_concurrency] + +[server.srv1.mtproto_domain_fronting_port] + +[server.srv1.mtproto_domain_fronting_ip] + +[server.srv1.mtproto_domain_fronting_proxy_protocol] + +[server.srv1.mtproto_prefer_ip] + +[server.srv1.mtproto_auto_update] + +[server.srv1.mtproto_allow_fallback_on_unknown_dc] + +[server.srv1.mtproto_tolerate_time_skewness] + +[server.srv1.mtproto_idle_timeout] + +[server.srv1.mtproto_handshake_timeout] + +[server.srv1.tailscale_auth_key] + +[server.srv1.tailscale_control_url] + +[server.srv1.tailscale_hostname] + +[server.srv1.tailscale_accept_routes] +1 +[server.srv1.tailscale_advertise_routes] + +[server.srv1.tailscale_advertise_exit_node] + +EOF_SING_BOX_SIG + +assert_eq "$(md5_file "$WORK_DIR/sing-box-signature.expected")" \ + "$(state_ucode sing-box-signature-fixture "$WORK_DIR/sing-box-signature.json")" \ + "sing-box signature hash" + +cat >"$WORK_DIR/dpi-signatures.json" <<'JSON' +{ + "section": [ + { + ".name": "disabled_zap", + ".type": "section", + "enabled": "0", + "action": "zapret", + "nfqws_opt": "--disabled" + }, + { + ".name": "zap1", + ".type": "section", + "enabled": "1", + "action": "zapret", + "domain": [ "legacy.example" ], + "domain_suffix": [ "full:full.example", "suffix.example", "keyword:ads" ], + "domain_suffix_text": "full:textfull.example text-suffix.example", + "community_lists": [ "telegram", "youtube" ], + "rule_set": [ "https://example.com/zap.srs" ], + "rule_set_with_subnets": [ "https://example.com/zap-sub.srs" ], + "domain_ip_lists": [ "local.lst", "https://example.com/ip.lst" ], + "user_domain_list_type": "dynamic", + "user_domains": [ "dyn.example" ], + "user_domains_text": "ignored.example", + "local_domain_lists": [ "local-a" ], + "remote_domain_lists": [ "remote-a" ] + }, + { + ".name": "zap2", + ".type": "section", + "enabled": "1", + "action": "zapret2", + "nfqws2_opt": "--z2\n opt", + "domain": [ "z2.example" ], + "domain_suffix": [ "z2-suffix.example" ], + "community_lists": [ "discord" ], + "rule_set": [ "https://example.com/zap2.srs" ], + "rule_set_with_subnets": [ "https://example.com/zap2-sub.srs" ], + "domain_ip_lists": [ "https://example.com/zap2-ip.lst" ], + "user_domain_list_type": "text", + "user_domains": [ "ignored-dyn.example" ], + "user_domains_text": "text.example text2.example", + "local_domain_lists": [ "local-b" ], + "remote_domain_lists": [ "remote-b" ] + }, + { + ".name": "bye_disabled", + ".type": "section", + "enabled": "0", + "action": "byedpi", + "byedpi_cmd_opts": "--disabled" + }, + { + ".name": "bye1", + ".type": "section", + "enabled": "1", + "action": "byedpi" + }, + { + ".name": "bye2", + ".type": "section", + "enabled": "1", + "action": "byedpi", + "byedpi_cmd_opts": "--disorder 3\n--fake-sni example.org" + } + ] +} +JSON + +cat >"$WORK_DIR/zapret-queue.expected" <<'EOF_ZAPRET_QUEUE' +[zapret_queue.section] +zap1 +EOF_ZAPRET_QUEUE + +cat >"$WORK_DIR/zapret2-queue.expected" <<'EOF_ZAPRET2_QUEUE' +[zapret2_queue.section] +zap2 +EOF_ZAPRET2_QUEUE + +cat >"$WORK_DIR/zapret-runtime.expected" <<'EOF_ZAPRET_RUNTIME' +[zapret.zap1.nfqws_opt] +--default-zapret +[zapret.zap1.domain] +textfull.example,full.example,legacy.example +[zapret.zap1.domain_suffix] +text-suffix.example,suffix.example +[zapret.zap1.community_lists] +telegram youtube +[zapret.zap1.rule_set] +https://example.com/zap.srs +[zapret.zap1.rule_set_with_subnets] +https://example.com/zap-sub.srs +[zapret.zap1.domain_ip_lists] +local.lst https://example.com/ip.lst +[zapret.zap1.user_domain_list_type] +dynamic +[zapret.zap1.local_domain_lists] +local-a +[zapret.zap1.remote_domain_lists] +remote-a +[zapret.zap1.user_domains] +dyn.example +EOF_ZAPRET_RUNTIME + +cat >"$WORK_DIR/zapret2-runtime.expected" <<'EOF_ZAPRET2_RUNTIME' +[zapret2.zap2.nfqws2_opt] +--z2 opt +[zapret2.zap2.domain] +z2.example +[zapret2.zap2.domain_suffix] +z2-suffix.example +[zapret2.zap2.community_lists] +discord +[zapret2.zap2.rule_set] +https://example.com/zap2.srs +[zapret2.zap2.rule_set_with_subnets] +https://example.com/zap2-sub.srs +[zapret2.zap2.domain_ip_lists] +https://example.com/zap2-ip.lst +[zapret2.zap2.user_domain_list_type] +text +[zapret2.zap2.local_domain_lists] +local-b +[zapret2.zap2.remote_domain_lists] +remote-b +[zapret2.zap2.user_domains] +text.example text2.example +EOF_ZAPRET2_RUNTIME + +cat >"$WORK_DIR/byedpi-runtime.expected" <<'EOF_BYEDPI_RUNTIME' +[byedpi.bye1.index] +1 +[byedpi.bye1.byedpi_cmd_opts] +--default-bye +[byedpi.bye2.index] +2 +[byedpi.bye2.byedpi_cmd_opts] +--disorder 3 --fake-sni example.org +EOF_BYEDPI_RUNTIME + +assert_eq "$(md5_file "$WORK_DIR/zapret-queue.expected")" \ + "$(state_ucode zapret-queue-signature-fixture "$WORK_DIR/dpi-signatures.json")" \ + "zapret queue signature hash" +assert_eq "$(md5_file "$WORK_DIR/zapret2-queue.expected")" \ + "$(state_ucode zapret2-queue-signature-fixture "$WORK_DIR/dpi-signatures.json")" \ + "zapret2 queue signature hash" +assert_eq "$(md5_file "$WORK_DIR/zapret-runtime.expected")" \ + "$(state_ucode zapret-runtime-signature-fixture "$WORK_DIR/dpi-signatures.json")" \ + "zapret runtime signature hash" +assert_eq "$(md5_file "$WORK_DIR/zapret2-runtime.expected")" \ + "$(state_ucode zapret2-runtime-signature-fixture "$WORK_DIR/dpi-signatures.json")" \ + "zapret2 runtime signature hash" +assert_eq "$(md5_file "$WORK_DIR/byedpi-runtime.expected")" \ + "$(state_ucode byedpi-runtime-signature-fixture "$WORK_DIR/dpi-signatures.json")" \ + "byedpi runtime signature hash" + +cat >"$WORK_DIR/reload-state-signatures.json" <<'JSON' +{ + "settings": { + "list_update_enabled": "1", + "update_interval": "6h" + }, + "section": [ + { + ".name": "disabled", + ".type": "section", + "enabled": "0", + "action": "proxy", + "ports": [ "1" ], + "community_lists": [ "telegram" ], + "remote_domain_lists": [ "https://example.com/disabled-domains.lst" ], + "remote_subnet_lists": [ "https://example.com/disabled-subnets.lst" ], + "rule_set_with_subnets": [ "https://example.com/disabled-subnets.srs" ], + "domain_ip_lists": [ "https://example.com/disabled-mixed.lst" ], + "subscription_urls": [ "https://example.com/disabled-sub.txt" ], + "urltest_enabled": "1" + }, + { + ".name": "list_proxy", + ".type": "section", + "enabled": "1", + "action": "proxy", + "ports": [ "443", "bad" ], + "ports_text": "80,443-444 # comment\nbad-port", + "community_lists": [ "geoblock", "meta", "telegram", "youtube", "discord" ], + "remote_domain_lists": [ "https://example.com/domains.lst", "local-domains" ], + "remote_subnet_lists": [ "https://example.com/subnets.lst" ], + "rule_set_with_subnets": [ "https://example.com/subnets.srs" ], + "domain_ip_lists": [ "local.lst", "https://example.com/mixed.lst" ], + "subscription_urls": [ "https://example.com/sub1.txt", "https://example.com/sub2.txt" ], + "urltest_enabled": "1" + }, + { + ".name": "sub_paused", + ".type": "section", + "enabled": "1", + "action": "proxy", + "subscription_urls": [ "https://example.com/off.txt" ], + "subscription_update_enabled": "0" + }, + { + ".name": "implicit_action", + ".type": "section", + "enabled": "1", + "subscription_urls": [ "https://example.com/implicit.txt" ], + "urltest_enabled": "1" + }, + { + ".name": "urltest_custom", + ".type": "section", + "enabled": "1", + "action": "proxy", + "urltest_enabled": "1", + "urltest_check_interval": "7m" + } + ] +} +JSON + +cat >"$WORK_DIR/list-signature.expected" <<'EOF_LIST_SIG' +[lists.list_proxy.ports] +443,80,443-444 +[lists.list_proxy.community_subnet_lists] +meta telegram discord +[lists.list_proxy.remote_domain_lists] +https://example.com/domains.lst local-domains +[lists.list_proxy.remote_subnet_lists] +https://example.com/subnets.lst +[lists.list_proxy.rule_set_with_subnets] +https://example.com/subnets.srs +[lists.list_proxy.domain_ip_lists] +local.lst https://example.com/mixed.lst +[lists.sub_paused.ports] + +[lists.sub_paused.community_subnet_lists] + +[lists.sub_paused.remote_domain_lists] + +[lists.sub_paused.remote_subnet_lists] + +[lists.sub_paused.rule_set_with_subnets] + +[lists.sub_paused.domain_ip_lists] + +[lists.implicit_action.ports] + +[lists.implicit_action.community_subnet_lists] + +[lists.implicit_action.remote_domain_lists] + +[lists.implicit_action.remote_subnet_lists] + +[lists.implicit_action.rule_set_with_subnets] + +[lists.implicit_action.domain_ip_lists] + +[lists.urltest_custom.ports] + +[lists.urltest_custom.community_subnet_lists] + +[lists.urltest_custom.remote_domain_lists] + +[lists.urltest_custom.remote_subnet_lists] + +[lists.urltest_custom.rule_set_with_subnets] + +[lists.urltest_custom.domain_ip_lists] + +EOF_LIST_SIG + +cat >"$WORK_DIR/cron-signature.expected" <<'EOF_CRON_SIG' +[settings.update_interval] +6h +[lists.list_proxy.ports] +443,80,443-444 +[lists.list_proxy.community_subnet_lists] +meta telegram discord +[lists.list_proxy.remote_domain_lists] +https://example.com/domains.lst local-domains +[lists.list_proxy.remote_subnet_lists] +https://example.com/subnets.lst +[lists.list_proxy.rule_set_with_subnets] +https://example.com/subnets.srs +[lists.list_proxy.domain_ip_lists] +local.lst https://example.com/mixed.lst +[lists.sub_paused.ports] + +[lists.sub_paused.community_subnet_lists] + +[lists.sub_paused.remote_domain_lists] + +[lists.sub_paused.remote_subnet_lists] + +[lists.sub_paused.rule_set_with_subnets] + +[lists.sub_paused.domain_ip_lists] + +[lists.implicit_action.ports] + +[lists.implicit_action.community_subnet_lists] + +[lists.implicit_action.remote_domain_lists] + +[lists.implicit_action.remote_subnet_lists] + +[lists.implicit_action.rule_set_with_subnets] + +[lists.implicit_action.domain_ip_lists] + +[lists.urltest_custom.ports] + +[lists.urltest_custom.community_subnet_lists] + +[lists.urltest_custom.remote_domain_lists] + +[lists.urltest_custom.remote_subnet_lists] + +[lists.urltest_custom.rule_set_with_subnets] + +[lists.urltest_custom.domain_ip_lists] + +[subscription.list_proxy.subscription_urls] +https://example.com/sub1.txt https://example.com/sub2.txt +[subscription.list_proxy.subscription_update_enabled] +1 +[subscription.list_proxy.subscription_update_interval] +1h +[subscription.sub_paused.subscription_urls] +https://example.com/off.txt +[subscription.sub_paused.subscription_update_enabled] +0 +[subscription.sub_paused.subscription_update_interval] + +EOF_CRON_SIG + +assert_eq "$(md5_file "$WORK_DIR/list-signature.expected")" \ + "$(state_ucode list-update-signature-fixture "$WORK_DIR/reload-state-signatures.json")" \ + "list update signature hash" +assert_eq "$(md5_file "$WORK_DIR/cron-signature.expected")" \ + "$(state_ucode cron-signature-fixture "$WORK_DIR/reload-state-signatures.json")" \ + "cron signature hash" +nft_signature="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/reload-state-signatures.json")" +assert_eq "$nft_signature" \ + "$(state_ucode nft-signature-fixture "$WORK_DIR/reload-state-signatures.json")" \ + "state reload nft signature parity" +assert_eq "list_proxy urltest_custom" \ + "$(state_ucode urltest-enabled-sections-fixture "$WORK_DIR/reload-state-signatures.json")" \ + "urltest enabled sections" +assert_eq "6h" \ + "$(state_ucode settings-update-interval-fixture "$WORK_DIR/reload-state-signatures.json")" \ + "settings update interval" +assert_eq "1h" \ + "$(state_ucode subscription-update-interval-fixture "$WORK_DIR/reload-state-signatures.json" list_proxy)" \ + "default subscription update interval" +assert_eq "" \ + "$(state_ucode subscription-update-interval-fixture "$WORK_DIR/reload-state-signatures.json" sub_paused)" \ + "disabled subscription update interval" +assert_eq "3m" \ + "$(state_ucode urltest-check-interval-fixture "$WORK_DIR/reload-state-signatures.json" list_proxy)" \ + "default urltest check interval" +assert_eq "7m" \ + "$(state_ucode urltest-check-interval-fixture "$WORK_DIR/reload-state-signatures.json" urltest_custom)" \ + "custom urltest check interval" +state_ucode has-subscription-update-sources-fixture "$WORK_DIR/reload-state-signatures.json" >/dev/null || + fail "fixture should have subscription update sources" + +cat >"$WORK_DIR/reload-state.expected" <"$WORK_DIR/disabled-update-interval.json" <<'JSON' +{ + "settings": { + "list_update_enabled": "0", + "update_interval": "6h" + } +} +JSON + +assert_eq "" \ + "$(state_ucode settings-update-interval-fixture "$WORK_DIR/disabled-update-interval.json")" \ + "disabled settings update interval" + +cat >"$WORK_DIR/empty-update-interval.json" <<'JSON' +{ + "settings": { + "list_update_enabled": "1", + "update_interval": "" + } +} +JSON + +assert_eq "1d" \ + "$(state_ucode settings-update-interval-fixture "$WORK_DIR/empty-update-interval.json")" \ + "empty settings update interval default" + +cat >"$WORK_DIR/no-subscription-source.json" <<'JSON' +{ + "section": [ + { + ".name": "implicit_action", + ".type": "section", + "enabled": "1", + "subscription_urls": [ "https://example.com/implicit.txt" ] + }, + { + ".name": "paused", + ".type": "section", + "enabled": "1", + "action": "proxy", + "subscription_urls": [ "https://example.com/off.txt" ], + "subscription_update_enabled": "0" + } + ] +} +JSON + +if state_ucode has-subscription-update-sources-fixture "$WORK_DIR/no-subscription-source.json" >/dev/null 2>&1; then + fail "implicit or disabled subscription updates should not require subscription update" +fi + +cat >"$WORK_DIR/source-detection.json" <<'JSON' +{ + "section": [ + { + ".name": "disabled", + ".type": "section", + "enabled": "0", + "community_lists": [ "telegram" ], + "remote_domain_lists": [ "https://example.com/disabled-domains.lst" ], + "remote_subnet_lists": [ "https://example.com/disabled-subnets.lst" ], + "rule_set": [ "https://example.com/disabled.srs" ], + "domain_ip_lists": [ "https://example.com/disabled-mixed.lst" ] + }, + { + ".name": "general", + ".type": "section", + "enabled": "1", + "remote_domain_lists": [ "https://example.com/domains.lst" ] + }, + { + ".name": "nft", + ".type": "section", + "remote_subnet_lists": [ "https://example.com/subnets.lst" ] + }, + { + ".name": "remote_ruleset", + ".type": "section", + "enabled": "1", + "rule_set": [ "/tmp/local.srs", "https://example.com/remote.srs" ] + } + ] +} +JSON + +state_ucode has-list-update-sources-fixture "$WORK_DIR/source-detection.json" >/dev/null || + fail "fixture should have general list update sources" +state_ucode has-nft-list-update-sources-fixture "$WORK_DIR/source-detection.json" >/dev/null || + fail "fixture should have nft list update sources" +state_ucode has-remote-sing-box-ruleset-sources-fixture "$WORK_DIR/source-detection.json" >/dev/null || + fail "fixture should have remote sing-box ruleset sources" + +cat >"$WORK_DIR/general-only-source.json" <<'JSON' +{ + "section": [ + { + ".name": "general", + ".type": "section", + "remote_domain_lists": [ "https://example.com/domains.lst" ] + } + ] +} +JSON + +state_ucode has-list-update-sources-fixture "$WORK_DIR/general-only-source.json" >/dev/null || + fail "remote domain list should require general list update" +if state_ucode has-nft-list-update-sources-fixture "$WORK_DIR/general-only-source.json" >/dev/null 2>&1; then + fail "remote domain list alone should not require nft list update" +fi + +cat >"$WORK_DIR/disabled-only-source.json" <<'JSON' +{ + "section": [ + { + ".name": "disabled", + ".type": "section", + "enabled": "0", + "community_lists": [ "telegram" ], + "remote_domain_lists": [ "https://example.com/domains.lst" ], + "remote_subnet_lists": [ "https://example.com/subnets.lst" ], + "rule_set": [ "https://example.com/remote.srs" ], + "rule_set_with_subnets": [ "https://example.com/subnets.srs" ], + "domain_ip_lists": [ "https://example.com/mixed.lst" ] + } + ] +} +JSON + +if state_ucode has-list-update-sources-fixture "$WORK_DIR/disabled-only-source.json" >/dev/null 2>&1; then + fail "disabled rule should not require general list update" +fi +if state_ucode has-nft-list-update-sources-fixture "$WORK_DIR/disabled-only-source.json" >/dev/null 2>&1; then + fail "disabled rule should not require nft list update" +fi +if state_ucode has-remote-sing-box-ruleset-sources-fixture "$WORK_DIR/disabled-only-source.json" >/dev/null 2>&1; then + fail "disabled rule should not require remote sing-box ruleset refresh" +fi + +printf 'runtime state predicate regression checks passed\n' diff --git a/tests/backend/semantic_json_compare_regression.sh b/tests/backend/semantic_json_compare_regression.sh new file mode 100644 index 00000000..07dcb4e0 --- /dev/null +++ b/tests/backend/semantic_json_compare_regression.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMPARE="$ROOT_DIR/tests/differential/semantic_json_compare.js" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +cat >"$WORK_DIR/expected.json" <<'JSON' +{ + "outbounds": [ + { "tag": "first", "type": "direct" }, + { "tag": "second", "type": "block" } + ], + "route": { + "rules": [ + { "domain_suffix": [ "example.org" ], "outbound": "first" } + ], + "auto_detect_interface": true + } +} +JSON + +cat >"$WORK_DIR/same.json" <<'JSON' +{ + "route": { + "auto_detect_interface": true, + "rules": [ + { "outbound": "first", "domain_suffix": [ "example.org" ] } + ] + }, + "outbounds": [ + { "type": "direct", "tag": "first" }, + { "type": "block", "tag": "second" } + ] +} +JSON + +node "$COMPARE" "$WORK_DIR/expected.json" "$WORK_DIR/same.json" + +cat >"$WORK_DIR/different-array-order.json" <<'JSON' +{ + "outbounds": [ + { "tag": "second", "type": "block" }, + { "tag": "first", "type": "direct" } + ], + "route": { + "rules": [ + { "domain_suffix": [ "example.org" ], "outbound": "first" } + ], + "auto_detect_interface": true + } +} +JSON + +if node "$COMPARE" "$WORK_DIR/expected.json" "$WORK_DIR/different-array-order.json" >/tmp/podkop-semantic-json.out 2>&1; then + fail "array order mismatch should be rejected" +fi + +grep -Fq '$.outbounds[0].tag: "first" != "second"' /tmp/podkop-semantic-json.out || + fail "array order mismatch reported unexpected diff" + +printf 'semantic JSON compare regression checks passed\n' diff --git a/tests/backend/server_runtime_regression.sh b/tests/backend/server_runtime_regression.sh new file mode 100644 index 00000000..b7d3568c --- /dev/null +++ b/tests/backend/server_runtime_regression.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SERVER_RUNTIME="$ROOT_DIR/podkop/files/usr/lib/server/service.uc" +UCODE_LIB="$ROOT_DIR/podkop/files/usr/lib" +WORK_DIR="$(mktemp -d)" +STATE="$WORK_DIR/uci.state" +LOG="$WORK_DIR/uci.log" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + printf 'UCI state:\n' >&2 + cat "$STATE" >&2 2>/dev/null || true + printf 'UCI log:\n' >&2 + cat "$LOG" >&2 2>/dev/null || true + exit 1 +} + +mkdir -p "$WORK_DIR/bin" +cat >"$WORK_DIR/bin/sing-box" <<'SINGBOX' +#!/usr/bin/env bash +set -eo pipefail + +case "$*" in + "generate uuid") + printf '%s\n' '33333333-3333-4333-8333-333333333333' + ;; + "generate rand --base64 18") + printf '%s\n' 'generated-password' + ;; + "generate rand --hex 4") + printf '%s\n' 'abcd1234' + ;; + "generate rand --hex 16") + printf '%s\n' '11111111111111111111111111111111' + ;; + "generate reality-keypair") + printf 'PrivateKey: private-key\nPublicKey: public-key\n' + ;; + *) + printf 'unsupported sing-box command: %s\n' "$*" >&2 + exit 2 + ;; +esac +SINGBOX +chmod 0755 "$WORK_DIR/bin/sing-box" + +cat >"$WORK_DIR/bin/logger" <<'LOGGER' +#!/usr/bin/env bash +set -eo pipefail +exit 0 +LOGGER +chmod 0755 "$WORK_DIR/bin/logger" + +export PATH="$WORK_DIR/bin:$PATH" +export PODKOP_UCI_STATE_FILE="$STATE" +export PODKOP_UCI_LOG_FILE="$LOG" +export PODKOP_CONFIG_NAME="podkop-plus" +export PODKOP_SERVER_RUNTIME_UC="$SERVER_RUNTIME" + +if grep -E 'uci -q|command -v uci' "$SERVER_RUNTIME" >/dev/null; then + fail "server/service.uc must use ucode UCI access instead of shelling out to uci" +fi + +cat >"$STATE" <<'EOF_STATE' +podkop-plus.vless=server +podkop-plus.vless.protocol=vless +podkop-plus.vless.server_users=client|22222222-2222-4222-8222-222222222222|xtls-rprx-vision +podkop-plus.socks=server +podkop-plus.socks.protocol=socks +podkop-plus.socks.label=desk +podkop-plus.tailscale=server +podkop-plus.tailscale.protocol=tailscale +podkop-plus.json=server +podkop-plus.json.protocol=json_inbound +EOF_STATE + +ucode -L "$UCODE_LIB" "$SERVER_RUNTIME" prepare-all-defaults + +uci_get() { + awk -F= -v key="$1" '$1 == key { print substr($0, length($1) + 2); found = 1 } END { exit found ? 0 : 1 }' "$STATE" +} + +assert_value() { + local path="$1" expected="$2" actual + actual="$(uci_get "$path" 2>/dev/null || true)" + [ "$actual" = "$expected" ] || fail "$path: expected '$expected', got '$actual'" +} + +assert_value podkop-plus.vless.security reality +assert_value podkop-plus.vless.listen 0.0.0.0 +assert_value podkop-plus.vless.listen_port 443 +assert_value podkop-plus.vless.server_uuid 22222222-2222-4222-8222-222222222222 +assert_value podkop-plus.vless.vless_flow xtls-rprx-vision +assert_value podkop-plus.vless.reality_short_id abcd1234 +assert_value podkop-plus.vless.reality_private_key private-key +assert_value podkop-plus.vless.reality_public_key public-key +assert_value podkop-plus.socks.security none +assert_value podkop-plus.socks.server_username desk +assert_value podkop-plus.socks.server_password generated-password +assert_value podkop-plus.tailscale.security none +assert_value podkop-plus.tailscale.tailscale_control_url https://controlplane.tailscale.com +assert_value podkop-plus.tailscale.tailscale_hostname podkop-tailscale +assert_value podkop-plus.tailscale.tailscale_advertise_exit_node 1 +assert_value podkop-plus.json.security none +grep -Fxq 'commit podkop-plus' "$LOG" || fail 'expected config commit' + +printf 'server runtime regression checks passed\n' diff --git a/tests/backend/service_reload_plan_regression.sh b/tests/backend/service_reload_plan_regression.sh new file mode 100644 index 00000000..6cf97aa1 --- /dev/null +++ b/tests/backend/service_reload_plan_regression.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RELOAD_UC="$ROOT_DIR/podkop/files/usr/lib/service/reload.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +base_args=( + svc dns sb nft zq zr z2q z2r br list cron 1 "alpha beta" + svc dns sb nft zq zr z2q z2r br list cron "alpha beta" 0 + 0 0 0 0 0 +) + +write_plan() { + local output="$1" + shift + ucode "$RELOAD_UC" plan "$@" >"$output" +} + +plan_value() { + local file="$1" + local key="$2" + + awk -F '\t' -v key="$key" ' + $1 == key { print $2; found = 1; exit } + END { if (!found) exit 1 } + ' "$file" +} + +assert_plan_value() { + local file="$1" + local key="$2" + local expected="$3" + local actual + + actual="$(plan_value "$file" "$key")" || + fail "missing plan key $key" + [ "$actual" = "$expected" ] || + fail "$key: expected '$expected', got '$actual'" +} + +run_case() { + local name="$1" + local output="$WORK_DIR/$name.plan" + shift + write_plan "$output" "$@" + printf '%s\n' "$output" +} + +write_state() { + local file="$1" + local service_key="$2" + local service_value="$3" + local dnsmasq="$4" + local sing_box="$5" + local nft="$6" + local zapret_queue="$7" + local zapret_runtime="$8" + local zapret2_queue="$9" + local zapret2_runtime="${10}" + local byedpi_runtime="${11}" + local list="${12}" + local cron="${13}" + local urltest_sections="${14}" + local dont_touch_dhcp="${15:-0}" + local format="${16:-1}" + + { + printf 'format=%s\n' "$format" + printf '%s=%s\n' "$service_key" "$service_value" + printf 'dnsmasq_signature=%s\n' "$dnsmasq" + printf 'sing_box_signature=%s\n' "$sing_box" + printf 'nft_signature=%s\n' "$nft" + printf 'zapret_queue_signature=%s\n' "$zapret_queue" + printf 'zapret_runtime_signature=%s\n' "$zapret_runtime" + printf 'zapret2_queue_signature=%s\n' "$zapret2_queue" + printf 'zapret2_runtime_signature=%s\n' "$zapret2_runtime" + printf 'byedpi_runtime_signature=%s\n' "$byedpi_runtime" + printf 'list_signature=%s\n' "$list" + printf 'cron_signature=%s\n' "$cron" + printf 'urltest_enabled_sections=%s\n' "$urltest_sections" + printf 'dont_touch_dhcp=%s\n' "$dont_touch_dhcp" + } >"$file" +} + +run_state_case() { + local name="$1" + local output="$WORK_DIR/$name.plan" + local previous="$2" + local current="$3" + shift 3 + + ucode "$RELOAD_UC" plan-state-files "$previous" "$current" "$@" >"$output" + printf '%s\n' "$output" +} + +assert_state_plan_exit() { + local expected="$1" + local previous="$2" + local current="$3" + local actual + shift 3 + + set +e + ucode "$RELOAD_UC" plan-state-files "$previous" "$current" "$@" >/dev/null 2>&1 + actual="$?" + set -e + [ "$actual" -eq "$expected" ] || + fail "plan-state-files: expected exit $expected, got $actual" +} + +args=("${base_args[@]}") +plan="$(run_case unchanged "${args[@]}")" +assert_plan_value "$plan" has_work 0 +assert_plan_value "$plan" changed_sing_box 0 +assert_plan_value "$plan" urltest_new_enabled_sections "" + +args=("${base_args[@]}") +args[24]="alpha beta gamma" +plan="$(run_case urltest_new_sections "${args[@]}")" +assert_plan_value "$plan" urltest_new_enabled_sections "gamma" + +args=("${base_args[@]}") +args[11]=0 +args[24]="alpha beta gamma" +plan="$(run_case urltest_unknown_previous "${args[@]}")" +assert_plan_value "$plan" urltest_new_enabled_sections "" + +args=("${base_args[@]}") +args[15]=sb2 +plan="$(run_case sing_box_changed "${args[@]}")" +assert_plan_value "$plan" changed_sing_box 1 +assert_plan_value "$plan" needs_sing_box_reload 1 +assert_plan_value "$plan" has_work 1 + +args=("${base_args[@]}") +args[17]=zq2 +plan="$(run_case zapret_queue_changed "${args[@]}")" +assert_plan_value "$plan" changed_zapret_queue 1 +assert_plan_value "$plan" needs_zapret_restart 1 +assert_plan_value "$plan" needs_nft_rebuild 1 +assert_plan_value "$plan" needs_sing_box_reload 1 + +args=("${base_args[@]}") +args[14]=dns2 +plan="$(run_case dnsmasq_configure "${args[@]}")" +assert_plan_value "$plan" changed_dnsmasq 1 +assert_plan_value "$plan" needs_dnsmasq_configure 1 +assert_plan_value "$plan" needs_dnsmasq_restore 0 + +args=("${base_args[@]}") +args[14]=dns2 +args[25]=1 +args[27]=1 +plan="$(run_case dnsmasq_restore "${args[@]}")" +assert_plan_value "$plan" needs_dnsmasq_configure 0 +assert_plan_value "$plan" needs_dnsmasq_restore 1 + +args=("${base_args[@]}") +args[22]=list2 +args[28]=1 +plan="$(run_case list_update_sources "${args[@]}")" +assert_plan_value "$plan" changed_list 1 +assert_plan_value "$plan" needs_list_update 1 + +args=("${base_args[@]}") +args[16]=nft2 +args[29]=1 +plan="$(run_case nft_list_sources "${args[@]}")" +assert_plan_value "$plan" needs_nft_rebuild 1 +assert_plan_value "$plan" needs_list_update 1 + +args=("${base_args[@]}") +args[30]=1 +plan="$(run_case cache_rebuild "${args[@]}")" +assert_plan_value "$plan" changed_sing_box 1 +assert_plan_value "$plan" needs_sing_box_reload 1 + +args=("${base_args[@]}") +args[26]=1 +plan="$(run_case forced_reload "${args[@]}")" +assert_plan_value "$plan" changed_sing_box 0 +assert_plan_value "$plan" needs_sing_box_reload 1 + +previous_state="$WORK_DIR/previous.state" +current_state="$WORK_DIR/current.state" +write_state "$previous_state" service_trigger_signature svc dns sb nft zq zr z2q z2r br list cron "alpha beta" 0 +write_state "$current_state" service_trigger_signature svc dns sb nft zq zr z2q z2r br list cron "alpha beta" 0 +plan="$(run_state_case state_unchanged "$previous_state" "$current_state" 0 0 0 0 0)" +assert_plan_value "$plan" has_work 0 +assert_plan_value "$plan" changed_sing_box 0 + +write_state "$current_state" service_trigger_signature svc dns sb nft zq zr z2q z2r br list cron "alpha beta gamma" 0 +plan="$(run_state_case state_urltest_new "$previous_state" "$current_state" 0 0 0 0 0)" +assert_plan_value "$plan" urltest_new_enabled_sections "gamma" + +write_state "$previous_state" restart_signature svc dns sb nft zq zr z2q z2r br list cron "alpha beta" 0 +write_state "$current_state" service_trigger_signature svc dns sb2 nft zq zr z2q z2r br list cron "alpha beta" 0 +plan="$(run_state_case state_legacy_restart_signature "$previous_state" "$current_state" 0 0 0 0 0)" +assert_plan_value "$plan" changed_service_triggers 0 +assert_plan_value "$plan" changed_sing_box 1 +assert_plan_value "$plan" needs_sing_box_reload 1 + +write_state "$previous_state" service_trigger_signature svc dns sb nft zq zr z2q z2r br list cron "alpha beta" 0 0 +write_state "$current_state" service_trigger_signature svc dns sb nft zq zr z2q z2r br list cron "alpha beta" 0 1 +assert_state_plan_exit 2 "$previous_state" "$current_state" 0 0 0 0 0 + +rm -f "$previous_state" +assert_state_plan_exit 2 "$previous_state" "$current_state" 0 0 0 0 0 + +printf 'service reload plan regression checks passed\n' diff --git a/tests/backend/service_start_trap_regression.sh b/tests/backend/service_start_trap_regression.sh new file mode 100644 index 00000000..af53b65d --- /dev/null +++ b/tests/backend/service_start_trap_regression.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +CLI_UC="$PODKOP_BIN" +LIFECYCLE_UC="$ROOT_DIR/podkop/files/usr/lib/service/lifecycle.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +require_pattern() { + local pattern="$1" + local label="$2" + + grep -Fq "$pattern" "$LIFECYCLE_UC" || fail "$label" +} + +reject_pattern() { + local pattern="$1" + local label="$2" + + ! grep -Fq "$pattern" "$PODKOP_BIN" || fail "$label" +} + +[ -r "$PODKOP_BIN" ] || fail "podkop binary source is missing" +[ -r "$PODKOP_BIN" ] || fail "podkop entrypoint is missing" +[ -r "$LIFECYCLE_UC" ] || fail "service/lifecycle.uc is missing" + +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +grep -Fq 'service/lifecycle.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch startup through service/lifecycle.uc" +reject_pattern "trap " \ + "podkop shell entrypoint must not own startup fail-safe traps" +reject_pattern "clear_startup_failsafe_trap" \ + "podkop shell entrypoint must not keep old trap helper" +require_pattern 'module_background(UPDATES_UC, [ "list-update" ])' \ + "startup list_update background job must be owned by service/lifecycle.uc" +require_pattern 'module_background(DIAGNOSTICS_UC, [ "get-system-info" ])' \ + "startup system-info background job must be owned by service/lifecycle.uc" +require_pattern "tproxy-marking-rule-present" \ + "service stop must use direct ucode tproxy marking rule check" +require_pattern "tproxy-route-present" \ + "service stop must use direct ucode tproxy route check" +grep -Fq 'require("core.uci")' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc must use core.uci for lifecycle UCI state" +if grep -n -E 'uci -q|require\("uci"\)\.cursor|function uci_|uci_set\(' "$LIFECYCLE_UC" >/dev/null 2>&1; then + fail "service/lifecycle.uc must not own direct UCI CLI/cursor helpers" +fi +reject_pattern "tproxy_marking_rule_present" \ + "service stop must not call the removed shell tproxy marking helper" +reject_pattern "tproxy_route_present" \ + "service stop must not call the removed shell tproxy route helper" +reject_pattern "has_tproxy_marking_rule" \ + "service stop must not call the removed shell tproxy marking helper" +reject_pattern "has_tproxy_route" \ + "service stop must not call the removed shell tproxy route helper" + +printf 'service start trap regression checks passed\n' diff --git a/tests/backend/shell_inventory_regression.sh b/tests/backend/shell_inventory_regression.sh new file mode 100644 index 00000000..a70ed949 --- /dev/null +++ b/tests/backend/shell_inventory_regression.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_FILES="$ROOT_DIR/podkop/files" +PODKOP_BIN="$PODKOP_FILES/usr/bin/podkop" +PODKOP_LIB="$PODKOP_FILES/usr/lib" +PODKOP_INIT="$PODKOP_FILES/etc/init.d/podkop" +LUCI_ROOT="$ROOT_DIR/luci-app-podkop-plus/root" +LUCI_UCI_DEFAULTS="$LUCI_ROOT/etc/uci-defaults/50_luci-podkop-plus" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ -d "$PODKOP_LIB" ] || fail "runtime library directory is missing" +[ -r "$PODKOP_BIN" ] || fail "podkop ucode entrypoint is missing" +[ -r "$PODKOP_INIT" ] || fail "podkop init.d entrypoint is missing" +[ -r "$LUCI_UCI_DEFAULTS" ] || fail "LuCI uci-defaults entrypoint is missing" + +runtime_shell_files="$(find "$PODKOP_LIB" -type f -name '*.sh' -print)" +[ -z "$runtime_shell_files" ] || + fail "runtime library must not contain shell owners: $runtime_shell_files" + +legacy_shell_owners='runtime_state\.sh|rules_nft_runtime\.sh|config_validation\.sh|sing_box_runtime\.sh|updates_runtime\.sh|updater\.sh|status_diagnostics\.sh|helpers\.sh|constants\.sh|subscription_runtime\.sh|byedpi\.sh|zapret\.sh|zapret2\.sh' +if find "$PODKOP_FILES" -type f -print | grep -E "$legacy_shell_owners" >/dev/null 2>&1; then + fail "legacy runtime shell owner file returned under podkop/files" +fi + +shell_scripts="$( + find "$PODKOP_FILES" "$LUCI_ROOT" -type f -print | + while IFS= read -r file; do + first_line="$(sed -n '1p' "$file")" + case "$first_line" in + '#!'*'/bin/sh'*|'#!'*'/bin/ash'*|'#!'*'rc.common'*|'#!'*' bash'*|'#!'*'/bash'*) + printf '%s\n' "${file#$ROOT_DIR/}" + ;; + esac + done | + sort +)" + +expected_shell_scripts="$( + printf '%s\n' \ + 'luci-app-podkop-plus/root/etc/uci-defaults/50_luci-podkop-plus' \ + 'podkop/files/etc/init.d/podkop' +)" + +[ "$shell_scripts" = "$expected_shell_scripts" ] || + fail "unexpected packaged shell inventory: +expected: +$expected_shell_scripts +actual: +$shell_scripts" + +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "/usr/bin/podkop must remain a direct ucode executable" +grep -Fq 'function command_spec(command)' "$PODKOP_BIN" || + fail "/usr/bin/podkop must own command routing in ucode" +if grep -n -E '#!/bin/(ba)?sh|exec[[:space:]]+ucode|run_module\(|PODKOP_COMMAND' "$PODKOP_BIN" >/dev/null 2>&1; then + fail "/usr/bin/podkop must not regress to a shell loader or shell router" +fi + +grep -Fq 'PODKOP_INITD_UC="$PODKOP_LIB/service/initd.uc"' "$PODKOP_INIT" || + fail "init.d must delegate service orchestration to service/initd.uc" +grep -Fq 'initd_ucode start-service' "$PODKOP_INIT" || + fail "init.d start path must delegate to ucode" +grep -Fq 'initd_ucode stop-service' "$PODKOP_INIT" || + fail "init.d stop path must delegate to ucode" +grep -Fq 'initd_ucode reload-service' "$PODKOP_INIT" || + fail "init.d reload path must delegate to ucode" +grep -Fq 'initd_ucode trigger-plan' "$PODKOP_INIT" || + fail "init.d trigger decisions must be produced by ucode" + +if grep -n -E '(^|[^[:alnum:]_])(uci|config_load|config_get|config_foreach|jsonfilter|nft|iptables|ip6?tables|sing-box|dnsmasq|curl|wget|opkg|apk)([[:space:]]|$)' "$PODKOP_INIT" >/dev/null 2>&1; then + fail "init.d must not own UCI, routing, download, package, dnsmasq, nft, or sing-box decisions" +fi +if grep -n -E 'PODKOP_RELOAD_LOCK|PODKOP_URLTEST_SELECTOR_SWITCHES|capture_reload_state|populate_nft_runtime_sets|rebuild_nft_runtime|apply_pending_urltest_selector_switches' "$PODKOP_INIT" >/dev/null 2>&1; then + fail "init.d must not own runtime state or reload decisions" +fi + +grep -Fq '/usr/bin/podkop-plus luci_postinst' "$LUCI_UCI_DEFAULTS" || + fail "LuCI uci-defaults must delegate postinstall work to ucode" +if grep -n -E '(^|[^[:alnum:]_])(uci|rm|logger|rpcd|killall|jsonfilter|config_load|config_get)([[:space:]]|$)' "$LUCI_UCI_DEFAULTS" >/dev/null 2>&1; then + fail "LuCI uci-defaults must not own cache, rpcd, logging, or UCI logic" +fi + +printf 'shell inventory regression checks passed\n' diff --git a/tests/backend/sing_box_runtime_regression.sh b/tests/backend/sing_box_runtime_regression.sh new file mode 100644 index 00000000..8b2413f2 --- /dev/null +++ b/tests/backend/sing_box_runtime_regression.sh @@ -0,0 +1,729 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +CLI_UC="$PODKOP_BIN" +SING_BOX_RUNTIME_SH="$PODKOP_LIB/sing_box_runtime.sh" +LIFECYCLE_UC="$PODKOP_LIB/service/lifecycle.uc" +SINGBOX_RUNTIME_UC="$PODKOP_LIB/singbox/runtime.uc" +SINGBOX_GENERATOR_UC="$PODKOP_LIB/singbox/generator.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ ! -e "$SING_BOX_RUNTIME_SH" ] || + fail "sing_box_runtime.sh shell owner must be removed" +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +grep -Fq 'service/lifecycle.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" +grep -Fq 'singbox/runtime.uc' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc must call singbox/runtime.uc for sing-box runtime operations" +if grep -R -n -E 'sing_box_runtime_ucode|rulesets_ucode|sing_box_configure_service|sing_box_init_config|get_service_listen_address|get_device_ipv4_address|get_download_detour_tag' "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "sing-box runtime shell symbols must not remain" +fi +grep -Fq 'mode == "configure-service"' "$SINGBOX_RUNTIME_UC" || + fail "singbox/runtime.uc must own sing-box service configuration" +grep -Fq 'mode == "init-config"' "$SINGBOX_RUNTIME_UC" || + fail "singbox/runtime.uc must own sing-box config initialization" +grep -Fq 'mode == "service-listen-address"' "$SINGBOX_RUNTIME_UC" || + fail "singbox/runtime.uc must own service listen address detection" +grep -Fq 'mode == "service-proxy-address"' "$SINGBOX_RUNTIME_UC" || + fail "singbox/runtime.uc must own service proxy address detection" +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$SINGBOX_RUNTIME_UC" >/dev/null 2>&1; then + fail "singbox/runtime.uc must use core.uci instead of owning direct UCI cursor or CLI calls" +fi +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$SINGBOX_GENERATOR_UC" >/dev/null 2>&1; then + fail "singbox/generator.uc must use core.uci instead of owning direct UCI cursor or CLI calls" +fi +grep -Fq 'require("core.uci")' "$SINGBOX_GENERATOR_UC" || + fail "singbox/generator.uc must import core.uci" +grep -Fq 'PODKOP_RULE_CONDITION_CACHE_DIR' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc must pass rule-condition cache dir through module environment" +if awk ' +/"write-current-reload-state-clean"|"write-captured-reload-state"/ { + in_block = 1 + bad = 0 +} +in_block && /SECTION_CACHE_DIR/ { + bad = 1 +} +in_block && /\]\);/ { + if (bad) + found_bad = 1 + in_block = 0 +} +END { + exit found_bad ? 0 : 1 +} +' "$LIFECYCLE_UC"; then + fail "reload-state cleanup must not delete subscription section-cache" +fi + +mkdir -p "$WORK_DIR/etc" "$WORK_DIR/tmp" +printf 'old config\n' >"$WORK_DIR/etc/config.json" +printf 'new config\n' >"$WORK_DIR/tmp/config.json" +ucode -L "$PODKOP_LIB" "$SINGBOX_RUNTIME_UC" save-config-file-fixture \ + "$WORK_DIR/tmp/config.json" "$WORK_DIR/etc/config.json" +[ "$(cat "$WORK_DIR/etc/config.json")" = "new config" ] || + fail "singbox/runtime.uc must replace an existing config from a temp file" +[ ! -e "$WORK_DIR/tmp/config.json" ] || + fail "singbox/runtime.uc must consume the temp config after save" + +generate_config() { + local fixture="$1" + local output="$2" + local mwan3_active="${3:-0}" + + mkdir -p "$output.section-cache" "$output.rulesets" + ucode -L "$PODKOP_LIB" "$PODKOP_LIB/singbox/generator.uc" generate-config-fixture \ + "$fixture" "$output" "127.0.0.1" "$mwan3_active" +} + +generate_config_with_subscription_cache() { + local fixture="$1" + local output="$2" + + mkdir -p "$output.section-cache" "$output.rulesets" + TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/subscriptions" \ + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/persistent-subscription-cache" \ + ucode -L "$PODKOP_LIB" "$PODKOP_LIB/singbox/generator.uc" generate-config-fixture \ + "$fixture" "$output" "127.0.0.1" "0" +} + +cat >"$WORK_DIR/generator-uci.state" <<'EOF_UCI' +podkop-plus.settings=settings +podkop-plus.settings.dns_server=1.1.1.1 +podkop-plus.settings.bootstrap_dns_server=1.1.1.1 +podkop-plus.settings.config_path=/tmp/sing-box/config.json +podkop-plus.settings.cache_path=/tmp/sing-box/cache.db +podkop-plus.settings.log_level=warn +podkop-plus.uci_proxy=section +podkop-plus.uci_proxy.enabled=1 +podkop-plus.uci_proxy.action=outbound +podkop-plus.uci_proxy.outbound_json={"type":"direct"} +podkop-plus.uci_proxy.domain_suffix=example.org +EOF_UCI +PODKOP_UCI_STATE_FILE="$WORK_DIR/generator-uci.state" \ + ucode -L "$PODKOP_LIB" "$SINGBOX_GENERATOR_UC" generate-config "$WORK_DIR/generated-from-uci.json" "127.0.0.1" "0" +grep -Fq '"example.org"' "$WORK_DIR/generated-from-uci.json" || + fail "singbox/generator.uc must read section matchers from core.uci" +grep -Fq '"uci_proxy-out"' "$WORK_DIR/generated-from-uci.json" || + fail "singbox/generator.uc must read section names from core.uci" + +cat >"$WORK_DIR/disabled-updates-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "list_update_enabled": "0", + "update_interval": "5m", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "domain_suffix": [ "example.org" ], + "rule_set": [ "https://example.com/rules.srs" ], + "resolve_real_ip_for_routing": "1" + } + ] +} +JSON + +cat >"$WORK_DIR/default-updates-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "rule_set": [ "https://example.com/rules.srs" ] + } + ] +} +JSON + +cat >"$WORK_DIR/server-inbound-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "domain_suffix": [ "example.org" ] + } + ], + "server": [ + { + ".name": "edge", + ".type": "server", + "enabled": "1", + "protocol": "socks", + "listen": "0.0.0.0", + "listen_port": "18080", + "server_username": "tester", + "server_password": "secret", + "routing_mode": "direct" + } + ] +} +JSON + +cat >"$WORK_DIR/runtime-matchers-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1", + "routing_excluded_ips": [ "192.168.1.5/32" ] + }, + "section": [ + { + ".name": "detour", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "domain_suffix": [ "detour.example" ] + }, + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"socks\",\"server\":\"127.0.0.1\",\"server_port\":1080}", + "domain_suffix": [ "example.org" ], + "source_ip_cidr": [ "10.0.0.2/32" ], + "outbound_detour_enabled": "1", + "outbound_detour_section": "detour", + "mixed_proxy_enabled": "1", + "mixed_proxy_port": "19090", + "mixed_proxy_auth_enabled": "1", + "mixed_proxy_username": "user", + "mixed_proxy_password": "pass" + } + ] +} +JSON + +cat >"$WORK_DIR/urltest-filter-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "proxy", + "selector_proxy_links": [ + "http://127.0.0.1:18081#Keep", + "http://127.0.0.1:18082#Drop" + ], + "urltest_enabled": "1", + "urltest_filter_mode": "include", + "urltest_include_outbounds": [ "Keep" ], + "domain_suffix": [ "example.org" ] + } + ] +} +JSON + +cat >"$WORK_DIR/provider-actions-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { ".name": "zap", ".type": "section", "enabled": "1", "action": "zapret", "domain_suffix": [ "zap.example" ] }, + { ".name": "zap2", ".type": "section", "enabled": "1", "action": "zapret2", "domain_suffix": [ "zap2.example" ] }, + { ".name": "bye", ".type": "section", "enabled": "1", "action": "byedpi", "domain_suffix": [ "bye.example" ] } + ] +} +JSON + +cat >"$WORK_DIR/manual-transport-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "proxy", + "selector_proxy_links": [ + "vless://11111111-1111-4111-8111-111111111111@127.0.0.1:443?security=tls&type=ws&path=%2Fws&host=example.com&sni=example.com&alpn=h2%2Chttp%2F1.1#WS", + "vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MgV1MiLCJhZGQiOiIxMjcuMC4wLjEiLCJwb3J0IjoiNDQzIiwiaWQiOiIyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJhaWQiOiI0Iiwic2N5IjoiYXV0byIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0Ijoidm1lc3MuZXhhbXBsZSIsInBhdGgiOiIvdm1lc3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJ2bWVzcy5leGFtcGxlIiwiYWxwbiI6ImgyLGh0dHAvMS4xIiwiZnAiOiJjaHJvbWUifQ==#VMess", + "ss://YWVzLTEyOC1nY206cGFzcw@127.0.0.1:8388#SS" + ], + "domain_suffix": [ "example.org" ] + } + ] +} +JSON + +cat >"$WORK_DIR/vpn-domain-resolver-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "vpn", + ".type": "section", + "enabled": "1", + "action": "vpn", + "interface": "tun0", + "domain_resolver_enabled": "1", + "domain_resolver_dns_type": "doh", + "domain_resolver_dns_server": "https://dns.example/dns-query", + "domain_suffix": [ "vpn.example" ] + } + ] +} +JSON + +cat >"$WORK_DIR/download-via-proxy-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1", + "download_lists_via_proxy": "1", + "download_subscriptions_via_proxy": "1", + "download_components_via_proxy": "1", + "download_lists_via_proxy_section": "proxy" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"socks\",\"server\":\"127.0.0.1\",\"server_port\":1080}", + "domain_suffix": [ "example.org" ], + "rule_set": [ "https://example.com/rules.srs" ] + } + ] +} +JSON + +cat >"$WORK_DIR/fully-routed-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "fully_routed_ips": [ "192.168.1.20/32", "192.168.1.30/32" ], + "domain_suffix": [ "example.org" ] + } + ] +} +JSON + +cat >"$WORK_DIR/mwan3-auto-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "domain_suffix": [ "example.org" ] + } + ] +} +JSON + +cat >"$WORK_DIR/mwan3-pinned-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1", + "output_network_interface": "wan2" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "outbound", + "outbound_json": "{\"type\":\"direct\"}", + "domain_suffix": [ "example.org" ] + } + ] +} +JSON + +cat >"$WORK_DIR/domain-ip.lst" <<'EOF_LIST' +example.net +203.0.113.0/24 +EOF_LIST + +cat >"$WORK_DIR/with-subnets.json" <<'JSON' +{ + "version": 3, + "rules": [ + { "domain_suffix": [ "ruleset.example" ], "ip_cidr": [ "198.51.100.0/24" ] } + ] +} +JSON + +cat >"$WORK_DIR/domain-ip-rulesets-fixture.json" <"$WORK_DIR/subscription-metadata-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_path": "/tmp/sing-box/config.json", + "dns_server": "1.1.1.1", + "service_listen_address": "127.0.0.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "proxy", + "subscription_urls": [ + "https://example.com/sub.txt | v2rayN", + "https://example.com/sub.txt" + ], + "domain_suffix": [ "proxy.example" ] + }, + { + ".name": "test", + ".type": "section", + "enabled": "1", + "action": "proxy", + "subscription_urls": [ + "https://example.com/sub.txt | v2rayN" + ], + "domain_suffix": [ "test.example" ] + } + ] +} +JSON + +mkdir -p "$WORK_DIR/subscriptions" "$WORK_DIR/persistent-subscription-cache" +for source in proxy-subscription-1 proxy-subscription-2 test-subscription-1; do + cat >"$WORK_DIR/subscriptions/$source.json" <<'JSON' +{"outbounds":[{"type":"socks","tag":"subscription-proxy","server":"127.0.0.1","server_port":1080}]} +JSON + printf '%s' 'https://example.com/sub.txt' >"$WORK_DIR/subscriptions/$source.url" + cat >"$WORK_DIR/persistent-subscription-cache/$source.metadata.json" <<'JSON' +{"version":1,"title":"WolfPN"} +JSON + printf '%s' 'https://example.com/sub.txt' >"$WORK_DIR/persistent-subscription-cache/$source.url" +done +printf '%s' 'v2rayN' >"$WORK_DIR/subscriptions/proxy-subscription-1.user_agent" +printf '%s' 'sing-box/default' >"$WORK_DIR/subscriptions/proxy-subscription-2.user_agent" +printf '%s' 'v2rayN' >"$WORK_DIR/subscriptions/test-subscription-1.user_agent" +printf '%s' 'v2rayN' >"$WORK_DIR/persistent-subscription-cache/proxy-subscription-1.user_agent" +printf '%s' 'sing-box/default' >"$WORK_DIR/persistent-subscription-cache/proxy-subscription-2.user_agent" +printf '%s' 'v2rayN' >"$WORK_DIR/persistent-subscription-cache/test-subscription-1.user_agent" + +generate_config "$WORK_DIR/disabled-updates-fixture.json" "$WORK_DIR/disabled.json" +generate_config "$WORK_DIR/default-updates-fixture.json" "$WORK_DIR/default.json" +generate_config "$WORK_DIR/server-inbound-fixture.json" "$WORK_DIR/server.json" +generate_config "$WORK_DIR/runtime-matchers-fixture.json" "$WORK_DIR/matchers.json" +generate_config "$WORK_DIR/urltest-filter-fixture.json" "$WORK_DIR/urltest.json" +generate_config "$WORK_DIR/provider-actions-fixture.json" "$WORK_DIR/providers.json" +generate_config "$WORK_DIR/manual-transport-fixture.json" "$WORK_DIR/manual.json" +generate_config "$WORK_DIR/vpn-domain-resolver-fixture.json" "$WORK_DIR/vpn.json" +generate_config "$WORK_DIR/download-via-proxy-fixture.json" "$WORK_DIR/download.json" +generate_config "$WORK_DIR/fully-routed-fixture.json" "$WORK_DIR/fully-routed.json" +generate_config "$WORK_DIR/mwan3-auto-fixture.json" "$WORK_DIR/mwan3-auto.json" 1 +generate_config "$WORK_DIR/mwan3-pinned-fixture.json" "$WORK_DIR/mwan3-pinned.json" 1 +generate_config "$WORK_DIR/domain-ip-rulesets-fixture.json" "$WORK_DIR/domain-ip-rulesets.json" +generate_config_with_subscription_cache "$WORK_DIR/subscription-metadata-fixture.json" "$WORK_DIR/subscription-metadata.json" + +ucode -e ' +let fs = require("fs"); +let dir = ARGV[0]; + +function cfg(name) { + return json(fs.readfile(dir + "/" + name + ".json")); +} + +function as_array(value) { + if (value == null) + return []; + return type(value) == "array" ? value : [ value ]; +} + +function contains(values, needle) { + for (let value in as_array(values)) + if (value == needle) + return true; + return false; +} + +function assert(condition, message) { + if (!condition) { + warn("FAIL: ", message, "\n"); + exit(1); + } +} + +function no_internal_fields(value) { + if (type(value) == "array") { + for (let item in value) + if (!no_internal_fields(item)) + return false; + } + else if (type(value) == "object") { + for (let key, item in value) { + if (substr(key, 0, 2) == "__") + return false; + if (!no_internal_fields(item)) + return false; + } + } + return true; +} + +function first_remote_ruleset(config) { + for (let rule_set in config.route.rule_set || []) + if (rule_set && rule_set.type == "remote") + return rule_set; + return null; +} + +function outbound(config, tag) { + for (let item in config.outbounds || []) + if (item && item.tag == tag) + return item; + return null; +} + +function inbound(config, tag) { + for (let item in config.inbounds || []) + if (item && item.tag == tag) + return item; + return null; +} + +function route_rule(config, predicate) { + for (let rule in config.route.rules || []) + if (rule && predicate(rule)) + return rule; + return null; +} + +function dns_rule(config, predicate) { + for (let rule in config.dns.rules || []) + if (rule && predicate(rule)) + return rule; + return null; +} + +function dns_server(config, predicate) { + for (let server in config.dns.servers || []) + if (server && predicate(server)) + return server; + return null; +} + +let disabled = cfg("disabled"); +assert(first_remote_ruleset(disabled).update_interval == "876000h", "disabled list update interval"); +assert(route_rule(disabled, r => r.action == "resolve" && r.server == "dns-server") != null, "resolve rule generated"); + +let defaults = cfg("default"); +assert(first_remote_ruleset(defaults).update_interval == "1d", "default list update interval"); + +let server = cfg("server"); +let socks = inbound(server, "server-edge-in"); +assert(socks && socks.type == "socks" && socks.listen_port == 18080, "server socks inbound"); +assert(socks.users && socks.users[0].username == "tester", "server socks auth"); +assert(route_rule(server, r => r.inbound == "server-edge-in" && r.outbound == "direct-out") != null, "server direct route"); + +let matchers = cfg("matchers"); +assert(outbound(matchers, "proxy-out").detour == "detour-out", "outbound detour"); +let mixed = inbound(matchers, "proxy-mixed-in"); +assert(mixed && mixed.listen_port == 19090 && mixed.users[0].username == "user", "mixed inbound auth"); +assert(route_rule(matchers, r => r.inbound == "proxy-mixed-in" && r.outbound == "proxy-out") != null, "mixed inbound route"); +assert(route_rule(matchers, r => r.outbound == "direct-out" && contains(r.source_ip_cidr, "192.168.1.5/32")) != null, "routing excluded source"); +assert(route_rule(matchers, r => r.outbound == "proxy-out" && contains(r.source_ip_cidr, "10.0.0.2/32")) != null, "source_ip_cidr matcher"); + +let urltest = cfg("urltest"); +let urltest_out = outbound(urltest, "proxy-urltest-out"); +assert(urltest_out && length(urltest_out.outbounds) == 1 && urltest_out.outbounds[0] == "proxy-1-out", "URLTest include filter"); +assert(outbound(urltest, "proxy-out").default == "proxy-urltest-out", "selector defaults to URLTest"); + +let providers = cfg("providers"); +assert(outbound(providers, "zap-out").routing_mark == 0x01000001, "Zapret mark"); +assert(outbound(providers, "zap2-out").routing_mark == 0x01010001, "Zapret2 mark"); +assert(outbound(providers, "bye-out").type == "socks" && outbound(providers, "bye-out").server_port == 1080, "ByeDPI outbound"); + +let manual = cfg("manual"); +let vless = outbound(manual, "proxy-1-out"); +assert(vless.transport.type == "ws" && vless.transport.path == "/ws", "manual VLESS WS transport"); +assert(vless.tls.server_name == "example.com", "manual VLESS TLS"); +assert(length(vless.tls.alpn) == 1 && vless.tls.alpn[0] == "http/1.1", "manual VLESS WS ALPN normalized"); +let vmess = outbound(manual, "proxy-2-out"); +assert(vmess.type == "vmess" && vmess.uuid == "22222222-2222-4222-8222-222222222222", "manual VMess link"); +assert(vmess.alter_id == 4 && vmess.security == "auto", "manual VMess alter_id and security"); +assert(vmess.transport.type == "ws" && vmess.transport.path == "/vmess", "manual VMess WS transport"); +assert(vmess.transport.headers.Host == "vmess.example", "manual VMess WS Host header"); +assert(vmess.tls.server_name == "vmess.example", "manual VMess TLS SNI"); +assert(length(vmess.tls.alpn) == 1 && vmess.tls.alpn[0] == "http/1.1", "manual VMess WS ALPN normalized"); +assert(vmess.tls.utls.fingerprint == "chrome", "manual VMess fingerprint"); +assert(outbound(manual, "proxy-3-out").type == "shadowsocks", "manual Shadowsocks link"); + +let vpn = cfg("vpn"); +assert(outbound(vpn, "vpn-out").bind_interface == "tun0", "VPN interface outbound"); +assert(outbound(vpn, "vpn-out").domain_resolver == "vpn-domain-resolver", "VPN domain resolver tag"); +assert(dns_server(vpn, r => r.tag == "vpn-domain-resolver") != null, "VPN domain resolver DNS server"); + +let download = cfg("download"); +assert(inbound(download, "service-mixed-in") != null, "service mixed inbound"); +assert(route_rule(download, r => r.inbound == "service-mixed-in" && r.outbound == "proxy-out") != null, "service mixed route"); +assert(first_remote_ruleset(download).download_detour == "proxy-out", "download_detour on remote ruleset"); + +let fully = cfg("fully-routed"); +assert(route_rule(fully, r => contains(r.source_ip_cidr, "192.168.1.20/32") && contains(r.source_ip_cidr, "192.168.1.30/32")) != null, "fully routed IP route"); + +let mwan3_auto = cfg("mwan3-auto"); +assert(mwan3_auto.route.auto_detect_interface === false, "mwan3 disables auto_detect_interface"); +assert(mwan3_auto.route.default_interface == null, "mwan3 without pinned interface does not set default_interface"); + +let mwan3_pinned = cfg("mwan3-pinned"); +assert(mwan3_pinned.route.auto_detect_interface === false, "mwan3 pinned interface disables auto_detect_interface"); +assert(mwan3_pinned.route.default_interface == "wan2", "mwan3 pinned interface is preserved"); + +let lists = cfg("domain-ip-rulesets"); +assert(no_internal_fields(lists), "internal runtime fields stripped from generated config"); +let local_ruleset = null; +for (let rule_set in lists.route.rule_set || []) + if (rule_set.tag == "proxy-lists-ruleset") + local_ruleset = rule_set; +assert(local_ruleset && local_ruleset.type == "local" && local_ruleset.format == "source", "domain_ip_lists local ruleset"); +assert(route_rule(lists, r => contains(r.rule_set, "proxy-lists-ruleset") && length(as_array(r.rule_set)) >= 2) != null, "domain_ip_lists and rule_set_with_subnets route"); +assert(dns_rule(lists, r => contains(r.rule_set, "proxy-lists-ruleset")) != null, "domain_ip_lists fakeip DNS rule"); + +let generated_list = json(fs.readfile(dir + "/domain-ip-rulesets.json.rulesets/proxy-lists-ruleset.json")); +let has_domain = false; +let has_ip = false; +for (let rule in generated_list.rules || []) { + if (contains(rule.domain_suffix, "example.net")) + has_domain = true; + if (contains(rule.ip_cidr, "203.0.113.0/24")) + has_ip = true; +} +assert(has_domain && has_ip, "generated domain/IP source ruleset contents"); + +let proxy_cache = json(fs.readfile(dir + "/subscription-metadata.json.section-cache/proxy.json")); +let test_cache = json(fs.readfile(dir + "/subscription-metadata.json.section-cache/test.json")); +let proxy_metadata = as_array(proxy_cache.subscriptionMetadata); +let test_metadata = as_array(test_cache.subscriptionMetadata); +assert(length(proxy_metadata) == 2, "duplicate subscription URL metadata kept for both proxy sources"); +assert(proxy_metadata[0].sourceSection == "proxy-subscription-1" && proxy_metadata[0].title == "WolfPN", "proxy source 1 metadata marker"); +assert(proxy_metadata[1].sourceSection == "proxy-subscription-2" && proxy_metadata[1].title == "WolfPN", "proxy source 2 metadata marker"); +assert(length(test_metadata) == 1, "same subscription URL metadata kept for second section"); +assert(test_metadata[0].sourceSection == "test-subscription-1" && test_metadata[0].title == "WolfPN", "test source metadata marker"); +' "$WORK_DIR" + +printf 'sing-box runtime regression checks passed\n' diff --git a/tests/backend/singbox_rulesets_regression.sh b/tests/backend/singbox_rulesets_regression.sh new file mode 100644 index 00000000..79117df4 --- /dev/null +++ b/tests/backend/singbox_rulesets_regression.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RULESETS_UC="$ROOT_DIR/podkop/files/usr/lib/singbox/rulesets.uc" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +assert_eq srs \ + "$(ucode -L "$PODKOP_LIB" "$RULESETS_UC" file-extension 'https://example.com/path/rule.srs?token=1#x')" \ + "ruleset file extension" +ucode -L "$PODKOP_LIB" "$RULESETS_UC" is-community youtube >/dev/null || + fail "youtube should be a community ruleset" +if ucode -L "$PODKOP_LIB" "$RULESETS_UC" is-community unknown_service >/dev/null 2>&1; then + fail "unknown service should not be a community ruleset" +fi +assert_eq domains \ + "$(ucode -L "$PODKOP_LIB" "$RULESETS_UC" kind-from-reference-hint 'https://example.com/geosite-custom.srs')" \ + "domain ruleset hint" +assert_eq subnets \ + "$(ucode -L "$PODKOP_LIB" "$RULESETS_UC" kind-from-reference-hint '/tmp/geoip-cidr.json')" \ + "subnet ruleset hint" +assert_eq unknown \ + "$(ucode -L "$PODKOP_LIB" "$RULESETS_UC" kind-from-reference-hint '/tmp/custom.srs')" \ + "unknown ruleset hint" +assert_eq source \ + "$(ucode -L "$PODKOP_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.json')" \ + "json remote ruleset format" +assert_eq binary \ + "$(ucode -L "$PODKOP_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.srs')" \ + "srs remote ruleset format" +assert_eq binary \ + "$(ucode -L "$PODKOP_LIB" "$RULESETS_UC" remote-format 'https://example.com/rules.unknown')" \ + "unknown remote ruleset format" + +ucode -L "$PODKOP_LIB" -e 'let rulesets = require("singbox.rulesets"); if (rulesets.kind_from_reference_hint("geoip") != "subnets") exit(1);' + +printf 'singbox rulesets regression checks passed\n' diff --git a/tests/backend/subscription_alpn_regression.sh b/tests/backend/subscription_alpn_regression.sh index c30e0ed1..bb21273c 100644 --- a/tests/backend/subscription_alpn_regression.sh +++ b/tests/backend/subscription_alpn_regression.sh @@ -2,8 +2,7 @@ set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -PARSER="$ROOT_DIR/podkop/files/usr/lib/subscription_parser.uc" -FACADE="$ROOT_DIR/podkop/files/usr/lib/sing_box_config_facade.uc" +PARSER="$ROOT_DIR/podkop/files/usr/lib/subscription/parser.uc" WORK_DIR="$(mktemp -d)" cleanup() { @@ -159,48 +158,17 @@ ucode "$PARSER" normalize-content "$xray_input" "$xray_output" assert_contains "$xray_output" '"alpn": [ "http/1.1" ]' "xray-vless-ws" assert_not_contains "$xray_output" '"alpn": [ "h2", "http/1.1" ]' "xray-vless-ws" -facade_ws="$(ucode "$FACADE" tls-alpn-json-array "h2,http/1.1" ws)" -[ "$facade_ws" = '["http/1.1"]' ] || fail "facade ws: got $facade_ws" - -facade_httpupgrade="$(ucode "$FACADE" tls-alpn-json-array "h2,http/1.1" httpupgrade)" -[ "$facade_httpupgrade" = '["http/1.1"]' ] || fail "facade httpupgrade: got $facade_httpupgrade" - -facade_xhttp="$(ucode "$FACADE" tls-alpn-json-array "" xhttp)" -[ "$facade_xhttp" = '["h2","http/1.1"]' ] || fail "facade xhttp: got $facade_xhttp" - -facade_grpc="$(ucode "$FACADE" tls-alpn-json-array "h2,http/1.1" grpc)" -[ "$facade_grpc" = '["h2","http/1.1"]' ] || fail "facade grpc: got $facade_grpc" - -export PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" -# shellcheck source=/dev/null -. "$PODKOP_LIB/helpers.sh" -# shellcheck source=/dev/null -. "$PODKOP_LIB/sing_box_config_manager.sh" -# shellcheck source=/dev/null -. "$PODKOP_LIB/sing_box_config_facade.sh" - -manual_config='{"outbounds":[]}' -manual_ws="$( - sing_box_cf_add_proxy_outbound \ - "$manual_config" \ - "manual" \ - "vless://$UUID@example.com:443?type=ws&$BASE_QUERY&path=%2Fws#manual-ws" \ - 0 -)" -manual_ws_output="$WORK_DIR/manual-ws.json" -printf '%s\n' "$manual_ws" > "$manual_ws_output" -assert_contains "$manual_ws_output" '"alpn": [ "http/1.1" ]' "manual-vless-ws" -assert_not_contains "$manual_ws_output" '"alpn": [ "h2", "http/1.1" ]' "manual-vless-ws" - -manual_httpupgrade="$( - sing_box_cf_add_proxy_outbound \ - "$manual_config" \ - "manual" \ - "vless://$UUID@example.com:443?type=httpupgrade&$BASE_QUERY&path=%2Fupgrade#manual-httpupgrade" \ - 0 -)" -manual_httpupgrade_output="$WORK_DIR/manual-httpupgrade.json" -printf '%s\n' "$manual_httpupgrade" > "$manual_httpupgrade_output" -assert_contains "$manual_httpupgrade_output" '"alpn": [ "http/1.1" ]' "manual-vless-httpupgrade" +gzip_plain="$WORK_DIR/gzip-subscription.txt" +gzip_input="$WORK_DIR/gzip-subscription.txt.gz" +gzip_decoded="$WORK_DIR/gzip-decoded.txt.gz" +gzip_output="$WORK_DIR/gzip-normalized.json" +printf 'vless://%s@example.com:443?type=ws&%s&path=%%2Fws#gzip-vless\n' "$UUID" "$BASE_QUERY" > "$gzip_plain" +gzip -c "$gzip_plain" > "$gzip_input" +cp "$gzip_input" "$gzip_decoded" +ucode "$PARSER" try-decode-gzip-content "$gzip_decoded" +assert_contains "$gzip_decoded" 'gzip-vless' "gzip decode in place" +ucode "$PARSER" normalize-content-validated "$gzip_input" "$gzip_output" +assert_contains "$gzip_output" '"tag": "gzip-vless"' "gzip normalize validated" +assert_contains "$gzip_output" '"alpn": [ "http/1.1" ]' "gzip normalized ALPN" printf 'subscription ALPN regression checks passed\n' diff --git a/tests/backend/subscription_cache_state_regression.sh b/tests/backend/subscription_cache_state_regression.sh new file mode 100644 index 00000000..21a4cd08 --- /dev/null +++ b/tests/backend/subscription_cache_state_regression.sh @@ -0,0 +1,360 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CACHE_UC="$ROOT_DIR/podkop/files/usr/lib/subscription/cache.uc" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +cache_ucode() { + ucode -L "$PODKOP_LIB" "$CACHE_UC" "$@" +} + +assert_eq "first second" \ + "$(cache_ucode append-state-list-once "first" second)" \ + "append new state item" +assert_eq "first second" \ + "$(cache_ucode append-state-list-once "first second" second)" \ + "do not append duplicate state item" +assert_eq "first second" \ + "$(cache_ucode append-state-list-once " first second " "")" \ + "empty state item keeps normalized list" + +cache_ucode state-list-contains "first second" second >/dev/null || + fail "state list should contain second" +if cache_ucode state-list-contains "first second" third >/dev/null 2>&1; then + fail "state list should not contain third" +fi + +SUBSCRIPTION_RUNTIME_SH="$ROOT_DIR/podkop/files/usr/lib/subscription_runtime.sh" +UPDATES_RUNTIME_SH="$ROOT_DIR/podkop/files/usr/lib/updates_runtime.sh" + +[ ! -e "$SUBSCRIPTION_RUNTIME_SH" ] || + fail "subscription_runtime.sh shell owner must be removed" +[ ! -e "$UPDATES_RUNTIME_SH" ] || + fail "updates_runtime.sh shell owner must be removed" + +if grep -R -n "subscription_runtime.sh" "$ROOT_DIR/podkop/files" >/dev/null 2>&1; then + fail "runtime files must not reference subscription_runtime.sh" +fi + +subscription_runtime_shell_symbols='subscription_runtime_|prepare_subscription_caches_for_startup|prepare_subscription_caches_for_runtime_generation|run_deferred_subscription_bootstrap|stop_deferred_subscription_bootstrap_retry_worker' +if grep -R -n -E "$subscription_runtime_shell_symbols" "$ROOT_DIR/podkop/files/usr/bin/podkop" "$ROOT_DIR/podkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then + fail "subscription_runtime shell symbols must not remain in runtime shell" +fi + +if grep -R -n -E 'get_subscription_metadata_path|get_outbound_metadata_path' "$ROOT_DIR/podkop/files/usr/bin/podkop" "$ROOT_DIR/podkop/files/usr/lib" --include='*.sh' >/dev/null 2>&1; then + fail "subscription metadata path helpers must be owned by subscription/cache.uc" +fi + +if grep -n -E 'require\("uci"\)\.cursor|uci -q' "$CACHE_UC" >/dev/null 2>&1; then + fail "subscription/cache.uc must use core.uci instead of owning direct UCI cursor or CLI calls" +fi + +grep -q 'mode == "ensure-runtime-dirs"' "$CACHE_UC" || + fail "subscription/cache.uc must expose ensure-runtime-dirs owner mode" +grep -q 'mode == "ensure-runtime-cache-format"' "$CACHE_UC" || + fail "subscription/cache.uc must expose ensure-runtime-cache-format owner mode" +grep -q 'mode == "prepare-caches"' "$CACHE_UC" || + fail "subscription/cache.uc must expose prepare-caches owner mode" +grep -q 'mode == "update-source"' "$CACHE_UC" || + fail "subscription/cache.uc must expose update-source owner mode" +grep -q 'mode == "update-request"' "$CACHE_UC" || + fail "subscription/cache.uc must expose update-request owner mode" +grep -q 'mode == "update-section"' "$CACHE_UC" || + fail "subscription/cache.uc must expose update-section owner mode" +grep -q 'mode == "section-is-subscription-proxy"' "$CACHE_UC" || + fail "subscription/cache.uc must expose subscription proxy predicate mode" +grep -q 'mode == "subscription-metadata-path"' "$CACHE_UC" || + fail "subscription/cache.uc must expose subscription metadata path mode" +grep -q 'mode == "outbound-metadata-path"' "$CACHE_UC" || + fail "subscription/cache.uc must expose outbound metadata path mode" +grep -q 'mode == "run-deferred-bootstrap"' "$CACHE_UC" || + fail "subscription/cache.uc must expose deferred bootstrap owner mode" +grep -q 'mode == "stop-deferred-bootstrap-worker"' "$CACHE_UC" || + fail "subscription/cache.uc must expose deferred worker stop owner mode" +assert_eq "proxy-subscription-2" \ + "$(cache_ucode source-id proxy 2)" \ + "source id mode" +assert_eq "/var/run/podkop-plus/subscription-metadata/proxy.json" \ + "$(cache_ucode subscription-metadata-path proxy)" \ + "subscription metadata path mode" +assert_eq "/var/run/podkop-plus/outbound-metadata/proxy.json" \ + "$(cache_ucode outbound-metadata-path proxy)" \ + "outbound metadata path mode" +if cache_ucode subscription-metadata-path '../bad' >/dev/null 2>&1; then + fail "subscription metadata path mode should reject unsafe section names" +fi + +runtime_env() { + TMP_SING_BOX_FOLDER="$WORK_DIR/runtime/tmp-sing-box" \ + TMP_RULESET_FOLDER="$WORK_DIR/runtime/tmp-sing-box/rulesets" \ + TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/runtime/tmp-sing-box/subscriptions" \ + PODKOP_RUNTIME_STATE_DIR="$WORK_DIR/runtime/run" \ + PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR="$WORK_DIR/runtime/run/subscription-update" \ + PODKOP_SUBSCRIPTION_LINKS_DIR="$WORK_DIR/runtime/run/subscription-links" \ + PODKOP_SUBSCRIPTION_METADATA_DIR="$WORK_DIR/runtime/run/subscription-metadata" \ + PODKOP_OUTBOUND_METADATA_DIR="$WORK_DIR/runtime/run/outbound-metadata" \ + PODKOP_SECTION_CACHE_DIR="$WORK_DIR/runtime/run/section-cache" \ + PODKOP_RUNTIME_CACHE_FORMAT_FILE="$WORK_DIR/runtime/run/cache-format" \ + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR="$WORK_DIR/runtime/persistent" \ + PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE="$WORK_DIR/runtime/persistent/cache-format" \ + PODKOP_SUBSCRIPTION_BOOTSTRAP_RETRY_PID_FILE="$WORK_DIR/runtime/run/bootstrap.pid" \ + "$@" +} + +runtime_env cache_ucode ensure-runtime-dirs +for dir in \ + "$WORK_DIR/runtime/tmp-sing-box" \ + "$WORK_DIR/runtime/tmp-sing-box/rulesets" \ + "$WORK_DIR/runtime/tmp-sing-box/subscriptions" \ + "$WORK_DIR/runtime/run" \ + "$WORK_DIR/runtime/run/subscription-update" \ + "$WORK_DIR/runtime/run/subscription-links" \ + "$WORK_DIR/runtime/run/subscription-metadata" \ + "$WORK_DIR/runtime/run/outbound-metadata" \ + "$WORK_DIR/runtime/run/section-cache"; do + [ -d "$dir" ] || fail "ensure-runtime-dirs should create $dir" +done + +printf 'old\n' >"$WORK_DIR/runtime/run/cache-format" +printf 'stale\n' >"$WORK_DIR/runtime/run/section-cache/stale.json" +runtime_env cache_ucode ensure-runtime-cache-format +assert_eq "7" \ + "$(sed -n '1p' "$WORK_DIR/runtime/run/cache-format")" \ + "runtime cache format" +[ ! -e "$WORK_DIR/runtime/run/section-cache/stale.json" ] || + fail "ensure-runtime-cache-format should clear old runtime section cache" +assert_eq "7" \ + "$(sed -n '1p' "$WORK_DIR/runtime/persistent/cache-format")" \ + "persistent cache format" +runtime_env cache_ucode run-deferred-bootstrap "" +runtime_env cache_ucode stop-deferred-bootstrap-worker + +mkdir -p \ + "$WORK_DIR/tmp" \ + "$WORK_DIR/persistent" \ + "$WORK_DIR/sections" \ + "$WORK_DIR/links" \ + "$WORK_DIR/metadata" \ + "$WORK_DIR/outbound" + +cat >"$WORK_DIR/cache-maintenance.json" <<'JSON' +{ + "section": [ + { + ".name": "proxy", + "enabled": "1", + "action": "proxy", + "subscription_urls": [ + "https://example.com/one.txt", + "https://example.com/two.txt" + ] + }, + { + ".name": "direct", + "enabled": "1", + "action": "direct" + }, + { + ".name": "disabled", + "enabled": "0", + "action": "proxy", + "subscription_urls": [ + "https://example.com/disabled.txt" + ] + }, + { + ".name": "bad/name", + "enabled": "1", + "action": "proxy", + "subscription_urls": [ + "https://example.com/bad.txt" + ] + } + ] +} +JSON + +assert_eq $'sections\tproxy direct disabled\nsources\tproxy-subscription-1 proxy-subscription-2 disabled-subscription-1\nall\tproxy proxy-subscription-1 proxy-subscription-2 direct disabled disabled-subscription-1\nmissing\t1' \ + "$(cache_ucode maintenance-plan-fixture "$WORK_DIR/cache-maintenance.json" "$WORK_DIR/sections")" \ + "cache maintenance plan with missing proxy section cache" + +touch "$WORK_DIR/sections/proxy.json" +assert_eq $'sections\tproxy direct disabled\nsources\tproxy-subscription-1 proxy-subscription-2 disabled-subscription-1\nall\tproxy proxy-subscription-1 proxy-subscription-2 direct disabled disabled-subscription-1\nmissing\t0' \ + "$(cache_ucode maintenance-plan-fixture "$WORK_DIR/cache-maintenance.json" "$WORK_DIR/sections")" \ + "cache maintenance plan with available proxy section cache" + +cache_ucode runtime-cache-needs-rebuild-fixture "$WORK_DIR/cache-maintenance.json" "$WORK_DIR/sections" >/dev/null && + fail "runtime cache should not need rebuild after proxy cache exists" +cache_ucode prepared-runtime-cache-should-skip-fixture "$WORK_DIR/cache-maintenance.json" "$WORK_DIR/sections" runtime 1 >/dev/null || + fail "prepared runtime cache should skip only when section cache exists" +rm -f "$WORK_DIR/sections/proxy.json" +cache_ucode runtime-cache-needs-rebuild-fixture "$WORK_DIR/cache-maintenance.json" "$WORK_DIR/sections" >/dev/null || + fail "runtime cache should need rebuild when proxy cache is missing" +if cache_ucode prepared-runtime-cache-should-skip-fixture "$WORK_DIR/cache-maintenance.json" "$WORK_DIR/sections" runtime 1 >/dev/null 2>&1; then + fail "prepared runtime cache must not skip when section cache is missing" +fi + +touch \ + "$WORK_DIR/tmp/proxy-subscription-1.json" \ + "$WORK_DIR/tmp/stale-subscription-1.json" \ + "$WORK_DIR/persistent/proxy-subscription-2.url" \ + "$WORK_DIR/persistent/stale-subscription-2.user_agent" \ + "$WORK_DIR/sections/proxy.json" \ + "$WORK_DIR/sections/proxy-subscription-1.json" \ + "$WORK_DIR/sections/stale.json" \ + "$WORK_DIR/links/proxy.json" \ + "$WORK_DIR/links/proxy-subscription-1.json" \ + "$WORK_DIR/metadata/direct.metadata.json" \ + "$WORK_DIR/outbound/stale.json" \ + "$WORK_DIR/outbound/cache-format" + +candidate_paths="$( + printf '%s\n' \ + "$WORK_DIR/tmp/proxy-subscription-1.json" \ + "$WORK_DIR/tmp/stale-subscription-1.json" \ + "$WORK_DIR/persistent/proxy-subscription-2.url" \ + "$WORK_DIR/persistent/stale-subscription-2.user_agent" \ + "$WORK_DIR/sections/proxy.json" \ + "$WORK_DIR/sections/proxy-subscription-1.json" \ + "$WORK_DIR/sections/stale.json" \ + "$WORK_DIR/links/proxy.json" \ + "$WORK_DIR/links/proxy-subscription-1.json" \ + "$WORK_DIR/metadata/direct.metadata.json" \ + "$WORK_DIR/outbound/stale.json" \ + "$WORK_DIR/outbound/cache-format" +)" + +assert_eq "$( + printf '%s\n' \ + "$WORK_DIR/tmp/stale-subscription-1.json" \ + "$WORK_DIR/persistent/stale-subscription-2.user_agent" \ + "$WORK_DIR/sections/stale.json" \ + "$WORK_DIR/links/proxy-subscription-1.json" \ + "$WORK_DIR/outbound/stale.json" +)" \ + "$(printf '%s\n' "$candidate_paths" | cache_ucode stale-cache-delete-paths-fixture \ + "$WORK_DIR/cache-maintenance.json" \ + "$WORK_DIR/tmp" \ + "$WORK_DIR/persistent" \ + "$WORK_DIR/sections" \ + "$WORK_DIR/links" \ + "$WORK_DIR/metadata" \ + "$WORK_DIR/outbound")" \ + "stale cache delete path plan" + +mkdir -p "$WORK_DIR/runtime-cache" "$WORK_DIR/persistent-cache" +cat >"$WORK_DIR/cache-current.json" <<'JSON' +{ + "section": [ + { + ".name": "proxy", + "subscription_urls": [ + "https://example.com/one.txt | v2rayN", + "https://example.com/two.txt" + ] + }, + { + ".name": "manual", + "selector_proxy_links": "vless://manual" + } + ] +} +JSON + +cat >"$WORK_DIR/runtime-cache/proxy-subscription-1.json" <<'JSON' +{"outbounds":[{"type":"direct","tag":"one"}]} +JSON +printf '%s' 'https://example.com/one.txt' >"$WORK_DIR/runtime-cache/proxy-subscription-1.url" +printf '%s' 'v2rayN' >"$WORK_DIR/runtime-cache/proxy-subscription-1.user_agent" + +cache_ucode section-current-usable-cache-fixture \ + "$WORK_DIR/cache-current.json" proxy "$WORK_DIR/runtime-cache" "$WORK_DIR/persistent-cache" "sing-box/default" >/dev/null || + fail "runtime cache should be current and usable" + +printf '%s' 'https://example.com/stale.txt' >"$WORK_DIR/runtime-cache/proxy-subscription-1.url" +if cache_ucode section-current-usable-cache-fixture \ + "$WORK_DIR/cache-current.json" proxy "$WORK_DIR/runtime-cache" "$WORK_DIR/persistent-cache" "sing-box/default" >/dev/null 2>&1; then + fail "stale runtime cache URL should not be current" +fi + +rm -f "$WORK_DIR/runtime-cache/proxy-subscription-1.json" "$WORK_DIR/runtime-cache/proxy-subscription-1.url" "$WORK_DIR/runtime-cache/proxy-subscription-1.user_agent" +cat >"$WORK_DIR/persistent-cache/proxy-subscription-2.json" <<'JSON' +{"outbounds":[{"type":"direct","tag":"two"}]} +JSON +printf '%s' 'https://example.com/two.txt' >"$WORK_DIR/persistent-cache/proxy-subscription-2.url" +printf '%s' 'sing-box/default' >"$WORK_DIR/persistent-cache/proxy-subscription-2.user_agent" + +cache_ucode section-current-usable-cache-fixture \ + "$WORK_DIR/cache-current.json" proxy "$WORK_DIR/runtime-cache" "$WORK_DIR/persistent-cache" "sing-box/default" >/dev/null || + fail "persistent cache should restore and satisfy current cache" +test -s "$WORK_DIR/runtime-cache/proxy-subscription-2.json" || + fail "persistent cache restore should write runtime json" +assert_eq 'https://example.com/two.txt' \ + "$(cat "$WORK_DIR/runtime-cache/proxy-subscription-2.url")" \ + "restored subscription URL" + +if cache_ucode section-current-usable-cache-fixture \ + "$WORK_DIR/cache-current.json" manual "$WORK_DIR/runtime-cache" "$WORK_DIR/persistent-cache" "sing-box/default" >/dev/null 2>&1; then + fail "section without subscription URLs should not have usable subscription cache" +fi + +mkdir -p "$WORK_DIR/persist-runtime" "$WORK_DIR/persist-empty" "$WORK_DIR/persist-fresh" +cat >"$WORK_DIR/persist-runtime/proxy-subscription-1.json" <<'JSON' +{"outbounds":[{"type":"direct","tag":"one"}]} +JSON +cat >"$WORK_DIR/runtime/persistent/proxy-subscription-1.metadata.json" <<'JSON' +{"version":1,"title":"WolfPN"} +JSON +printf '%s' 'https://example.com/one.txt' >"$WORK_DIR/runtime/persistent/proxy-subscription-1.url" +printf '%s' 'v2rayN' >"$WORK_DIR/runtime/persistent/proxy-subscription-1.user_agent" +cat >"$WORK_DIR/persist-empty/empty.metadata.json" <<'JSON' +{} +JSON + +runtime_env cache_ucode persist-source-cache \ + proxy-subscription-1 \ + "$WORK_DIR/persist-runtime/proxy-subscription-1.json" \ + 'https://example.com/one.txt' \ + 'v2rayN' \ + "$WORK_DIR/persist-empty/empty.metadata.json" +test -s "$WORK_DIR/runtime/persistent/proxy-subscription-1.metadata.json" || + fail "same subscription source with empty fresh metadata must keep previous metadata" +node - "$WORK_DIR/runtime/persistent/proxy-subscription-1.metadata.json" <<'JS' +const fs = require("fs"); +const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +if (value.title !== "WolfPN") { + console.error("previous metadata was not preserved"); + process.exit(1); +} +JS + +runtime_env cache_ucode persist-source-cache \ + proxy-subscription-1 \ + "$WORK_DIR/persist-runtime/proxy-subscription-1.json" \ + 'https://example.com/changed.txt' \ + 'v2rayN' \ + "$WORK_DIR/persist-empty/empty.metadata.json" +[ ! -e "$WORK_DIR/runtime/persistent/proxy-subscription-1.metadata.json" ] || + fail "changed subscription URL with empty metadata must drop stale metadata" + +printf 'subscription cache state regression checks passed\n' diff --git a/tests/backend/subscription_source_entry_regression.sh b/tests/backend/subscription_source_entry_regression.sh new file mode 100644 index 00000000..0604150f --- /dev/null +++ b/tests/backend/subscription_source_entry_regression.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +PARSER="$ROOT_DIR/podkop/files/usr/lib/subscription/parser.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_tsv() { + local entry="$1" + local expected_url="$2" + local expected_user_agent="$3" + local parsed tab url user_agent + + parsed="$(ucode "$PARSER" parse-source-entry-tsv "$entry")" + tab="$(printf '\t')" + url="${parsed%%"$tab"*}" + if [ "$url" = "$parsed" ]; then + user_agent="" + else + user_agent="${parsed#*"$tab"}" + fi + + [ "$url" = "$expected_url" ] || fail "expected url $expected_url, got $url" + [ "$user_agent" = "$expected_user_agent" ] || fail "expected user-agent $expected_user_agent, got $user_agent" +} + +assert_rejects() { + local entry="$1" + local expected="$2" + local output + + if output="$(ucode "$PARSER" parse-source-entry-tsv "$entry" 2>/dev/null)"; then + fail "entry should be rejected: $entry" + fi + printf '%s\n' "$output" | grep -q "$expected" || fail "expected reject message containing $expected" +} + +assert_tsv ' https://example.com/sub.txt ' 'https://example.com/sub.txt' '' +assert_tsv 'https://example.com/a | Custom Agent/1.0' 'https://example.com/a' 'Custom Agent/1.0' +assert_tsv 'https://example.com/a | Agent One | Agent Two' 'https://example.com/a | Agent One' 'Agent Two' + +assert_rejects 'https://example.com/a| Agent' "Use 'URL | User-Agent'" +assert_rejects 'file:///tmp/sub.txt' 'Subscription URL must start with http:// or https://' + +cat >"$WORK_DIR/require-subscription-parser.uc" <<'UCODE' +let parser = require("subscription.parser"); + +let parsed = parser.parse_subscription_source_entry("https://example.com/a | Agent"); +if (!parsed.valid || parsed.url != "https://example.com/a" || parsed.user_agent != "Agent") + exit(1); + +let invalid = parser.parse_subscription_source_entry("file:///tmp/sub.txt"); +if (invalid.valid || invalid.error != "Subscription URL must start with http:// or https://") + exit(1); +UCODE + +ucode -L "$PODKOP_LIB" "$WORK_DIR/require-subscription-parser.uc" + +printf 'subscription source entry regression checks passed\n' diff --git a/tests/backend/subscription_update_job_regression.sh b/tests/backend/subscription_update_job_regression.sh new file mode 100644 index 00000000..c7a8ea29 --- /dev/null +++ b/tests/backend/subscription_update_job_regression.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +UPDATES_UC="$ROOT_DIR/podkop/files/usr/lib/components/updates.uc" +UPDATES_RUNTIME="$ROOT_DIR/podkop/files/usr/lib/updates_runtime.sh" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +write_state() { + local name="$1" + local content="$2" + + printf '%s\n' "$content" >"$WORK_DIR/$name.json" + printf '%s\n' "$WORK_DIR/$name.json" +} + +updates_ucode() { + ucode -L "$PODKOP_LIB" "$UPDATES_UC" "$@" +} + +[ ! -e "$UPDATES_RUNTIME" ] || + fail "updates_runtime.sh shell owner must be removed" + +assert_eq "/tmp/sub-jobs/job_1.json" \ + "$(updates_ucode subscription-job-state-path /tmp/sub-jobs job_1)" \ + "valid subscription job path" + +if updates_ucode subscription-job-state-path /tmp/sub-jobs '../bad' >/dev/null 2>&1; then + fail "invalid subscription job id should be rejected" +fi + +running="$(write_state running '{"running":true,"pid":"321","started_at":100}')" +assert_eq "$(printf 'pid\t321\t0')" \ + "$(updates_ucode subscription-job-refresh-plan "$running" 105 15)" \ + "running job within grace" +assert_eq "$(printf 'pid\t321\t1')" \ + "$(updates_ucode subscription-job-refresh-plan "$running" 120 15)" \ + "running job after grace" + +invalid_pid="$(write_state invalid-pid '{"running":true,"pid":"","started_at":100}')" +assert_eq "skip" \ + "$(updates_ucode subscription-job-refresh-plan "$invalid_pid" 105 15)" \ + "invalid pid within grace" +assert_eq "stale" \ + "$(updates_ucode subscription-job-refresh-plan "$invalid_pid" 120 15)" \ + "invalid pid after grace" + +finished="$(write_state finished '{"running":false,"pid":"321","started_at":100}')" +assert_eq "skip" \ + "$(updates_ucode subscription-job-refresh-plan "$finished" 120 15)" \ + "finished job refresh" + +stale_json="$(updates_ucode subscription-stale-job-state 200 proxy 2 100)" +JSON_VALUE="$stale_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.running !== false || value.success !== false || value.section !== "proxy" || value.source_index !== "2") { + console.error("subscription stale state shape mismatch"); + process.exit(1); +} +NODE + +mkdir -p "$WORK_DIR/bin" +cat >"$WORK_DIR/bin/podkop-plus" <<'SH' +#!/usr/bin/env sh +if [ "$1" != "subscription_update" ]; then + exit 64 +fi +printf 'fake update for %s/%s\n' "$2" "$3" +printf 'Subscription update completed by fake worker\n' +SH +chmod +x "$WORK_DIR/bin/podkop-plus" + +ASYNC_ENV=( + "PODKOP_LIB=$ROOT_DIR/podkop/files/usr/lib" + "PODKOP_BIN=$WORK_DIR/bin/podkop-plus" + "TMP_SING_BOX_FOLDER=$WORK_DIR/tmp/sing-box" + "TMP_RULESET_FOLDER=$WORK_DIR/tmp/sing-box/rulesets" + "TMP_SUBSCRIPTION_FOLDER=$WORK_DIR/tmp/sing-box/subscriptions" + "PODKOP_RUNTIME_STATE_DIR=$WORK_DIR/run" + "PODKOP_SUBSCRIPTION_UPDATE_STATE_DIR=$WORK_DIR/run/subscription-update" + "PODKOP_SUBSCRIPTION_UPDATE_JOB_DIR=$WORK_DIR/run/subscription-update-jobs" + "PODKOP_SUBSCRIPTION_LINKS_DIR=$WORK_DIR/run/subscription-links" + "PODKOP_SUBSCRIPTION_METADATA_DIR=$WORK_DIR/run/subscription-metadata" + "PODKOP_OUTBOUND_METADATA_DIR=$WORK_DIR/run/outbound-metadata" + "PODKOP_SECTION_CACHE_DIR=$WORK_DIR/run/section-cache" + "PODKOP_RUNTIME_CACHE_FORMAT_FILE=$WORK_DIR/run/cache-format" + "PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_DIR=$WORK_DIR/persistent/subscription-cache" + "PODKOP_PERSISTENT_SUBSCRIPTION_CACHE_FORMAT_FILE=$WORK_DIR/persistent/subscription-cache/cache-format" +) + +async_response="$(env "${ASYNC_ENV[@]}" ucode -L "$ROOT_DIR/podkop/files/usr/lib" "$UPDATES_UC" subscription-update-async proxy 1)" +job_id="$(JSON_VALUE="$async_response" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.success !== true || !value.job_id) { + console.error("subscription async response shape mismatch"); + process.exit(1); +} +process.stdout.write(value.job_id); +NODE +)" + +final_status="" +for _ in $(seq 1 30); do + final_status="$(env "${ASYNC_ENV[@]}" ucode -L "$ROOT_DIR/podkop/files/usr/lib" "$UPDATES_UC" subscription-update-status "$job_id")" + if JSON_VALUE="$final_status" node -e 'process.exit(JSON.parse(process.env.JSON_VALUE).running ? 1 : 0)' >/dev/null 2>&1; then + break + fi + sleep 0.1 +done + +JSON_VALUE="$final_status" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.success !== true || value.running !== false || value.section !== "proxy" || value.source_index !== "1" || value.exit_code !== 0) { + console.error("subscription async final state mismatch"); + process.exit(1); +} +if (value.message !== "Subscription update completed by fake worker") { + console.error(`unexpected final message: ${value.message}`); + process.exit(1); +} +NODE + +printf 'subscription update job regression checks passed\n' diff --git a/tests/backend/subscription_user_agent_regression.sh b/tests/backend/subscription_user_agent_regression.sh new file mode 100644 index 00000000..6eda95d7 --- /dev/null +++ b/tests/backend/subscription_user_agent_regression.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CACHE_UC="$ROOT_DIR/podkop/files/usr/lib/subscription/cache.uc" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_file() { + local path="$1" + local expected="$2" + local actual + + actual="$(cat "$path")" + [ "$actual" = "$expected" ] || fail "expected candidates '$expected', got '$actual'" +} + +default_ua="sing-box/1.12.0" + +cache_ucode() { + ucode -L "$PODKOP_LIB" "$CACHE_UC" "$@" +} + +cache_ucode write-user-agent-candidates "$WORK_DIR/default.txt" "" "" "$default_ua" +assert_file "$WORK_DIR/default.txt" "$(printf '%s\n%s\n%s\n%s\n%s\n%s' "$default_ua" v2rayN Happ Hiddify Clash.Meta ClashMetaForAndroid)" + +cache_ucode write-user-agent-candidates "$WORK_DIR/preferred.txt" "" Hiddify "$default_ua" +assert_file "$WORK_DIR/preferred.txt" "$(printf '%s\n%s\n%s\n%s\n%s\n%s' "$default_ua" v2rayN Hiddify Happ Clash.Meta ClashMetaForAndroid)" + +cache_ucode write-user-agent-candidates "$WORK_DIR/custom.txt" "Custom/1.0" Hiddify "$default_ua" +assert_file "$WORK_DIR/custom.txt" "Custom/1.0" + +cache_ucode user-agent-matches-config "" "$default_ua" "$default_ua" >/dev/null || + fail "default UA should match automatic config" +cache_ucode user-agent-matches-config "" Hiddify "$default_ua" >/dev/null || + fail "known fallback UA should match automatic config" +if cache_ucode user-agent-matches-config "" "Unknown" "$default_ua" >/dev/null 2>&1; then + fail "unknown cached UA should not match automatic config" +fi +cache_ucode user-agent-matches-config "Custom/1.0" "Custom/1.0" "$default_ua" >/dev/null || + fail "configured UA should match exact cached UA" +if cache_ucode user-agent-matches-config "Custom/1.0" Hiddify "$default_ua" >/dev/null 2>&1; then + fail "configured UA should reject fallback cached UA" +fi + +printf 'subscription user-agent regression checks passed\n' diff --git a/tests/backend/ui_runtime_job_regression.sh b/tests/backend/ui_runtime_job_regression.sh new file mode 100644 index 00000000..cdd1d006 --- /dev/null +++ b/tests/backend/ui_runtime_job_regression.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +UI_UC="$ROOT_DIR/podkop/files/usr/lib/service/ui.uc" +PODKOP_FILES="$ROOT_DIR/podkop/files" +PODKOP_BIN="$PODKOP_FILES/usr/bin/podkop" +PODKOP_INIT="$PODKOP_FILES/etc/init.d/podkop" +UI_RUNTIME_SH="$PODKOP_FILES/usr/lib/ui_runtime.sh" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +ui_ucode() { + ucode -L "$PODKOP_FILES/usr/lib" "$UI_UC" "$@" +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +write_state() { + local name="$1" + local content="$2" + + printf '%s\n' "$content" >"$WORK_DIR/$name.json" + printf '%s\n' "$WORK_DIR/$name.json" +} + +[ ! -e "$UI_RUNTIME_SH" ] || + fail "ui_runtime.sh shell owner must be removed" + +if grep -R -n "ui_runtime.sh" "$PODKOP_FILES" >/dev/null 2>&1; then + fail "runtime files must not reference ui_runtime.sh" +fi + +ui_runtime_shell_symbols='ui_runtime_|podkop_fast_get_ui_state|PODKOP_UI_RUNTIME|load_ui_runtime' +if grep -R -n -E "$ui_runtime_shell_symbols" "$PODKOP_BIN" "$PODKOP_INIT" "$PODKOP_FILES/usr/lib" --include='*.sh' >/dev/null 2>&1; then + fail "ui_runtime shell symbols must not remain in runtime shell" +fi + +if grep -R -n '^get_ui_capabilities()' "$PODKOP_FILES/usr/lib" --include='*.sh' >/dev/null 2>&1; then + fail "UI capabilities must be owned by service/ui.uc, not shell" +fi +grep -Fq 'require("core.uci")' "$UI_UC" || + fail "service/ui.uc must use core.uci for UI UCI-derived state" +if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$UI_UC" >/dev/null 2>&1; then + fail "service/ui.uc must not own direct UCI cursor or CLI reads" +fi + +for mode in \ + get-ui-capabilities \ + get-ui-state \ + component-action-running-for \ + service-action-begin-if-idle \ + service-action-update-pid \ + service-action-finish-after-command \ + service-action-async \ + service-action-status \ + latency-test-async \ + latency-test-status \ + action-ack; do + grep -Fq "\"$mode\"" "$UI_UC" || + fail "service/ui.uc must expose $mode" +done + +assert_eq "/tmp/ui/job-1.json" \ + "$(ui_ucode job-state-path /tmp/ui job-1)" \ + "valid UI job path" + +if ui_ucode job-state-path /tmp/ui '../bad' >/dev/null 2>&1; then + fail "invalid UI job id should be rejected" +fi + +ui_ucode service-action-valid restart >/dev/null || + fail "restart service action should be valid" +if ui_ucode service-action-valid invalid >/dev/null 2>&1; then + fail "invalid service action should be rejected" +fi +assert_eq "1" \ + "$(ui_ucode service-action-expected-running start)" \ + "start expects running service" +assert_eq "1" \ + "$(ui_ucode service-action-expected-running reload)" \ + "reload expects running service" +assert_eq "0" \ + "$(ui_ucode service-action-expected-running stop)" \ + "stop expects stopped service" +if ui_ucode service-action-expected-running invalid >/dev/null 2>&1; then + fail "invalid service action expected state should be rejected" +fi +ui_ucode latency-type-valid proxy_list >/dev/null || + fail "proxy_list latency type should be valid" +if ui_ucode latency-type-valid direct >/dev/null 2>&1; then + fail "invalid latency type should be rejected" +fi + +assert_eq "running & enabled" \ + "$(ui_ucode service-status-text 1 1)" \ + "running enabled status" +assert_eq "running but disabled" \ + "$(ui_ucode service-status-text 1 0)" \ + "running disabled status" +assert_eq "stopped but enabled" \ + "$(ui_ucode service-status-text 0 1)" \ + "stopped enabled status" +assert_eq "stopped & disabled" \ + "$(ui_ucode service-status-text 0 0)" \ + "stopped disabled status" + +running="$(write_state running '{"running":true,"pid":"456","started_at":100}')" +assert_eq "$(printf 'pid\t456\t0')" \ + "$(ui_ucode job-refresh-plan "$running" 105 15)" \ + "running UI job within grace" +assert_eq "$(printf 'pid\t456\t1')" \ + "$(ui_ucode job-refresh-plan "$running" 120 15)" \ + "running UI job after grace" + +invalid_pid="$(write_state invalid-pid '{"running":true,"pid":"","started_at":100}')" +assert_eq "skip" \ + "$(ui_ucode job-refresh-plan "$invalid_pid" 105 15)" \ + "invalid UI pid within grace" +assert_eq "stale" \ + "$(ui_ucode job-refresh-plan "$invalid_pid" 120 15)" \ + "invalid UI pid after grace" + +finished="$(write_state finished '{"running":false,"pid":"456","started_at":100}')" +assert_eq "skip" \ + "$(ui_ucode job-refresh-plan "$finished" 120 15)" \ + "finished UI job refresh" + +stale_json="$(ui_ucode stale-action-state "$invalid_pid" "worker exited" 200)" +JSON_VALUE="$stale_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.running !== false || value.success !== false || value.message !== "worker exited") { + console.error("UI stale state shape mismatch"); + process.exit(1); +} +NODE + +acked="$(write_state acked '{"running":false,"acked_at":100}')" +ui_ucode action-ack-expired "$acked" 200 90 >/dev/null || + fail "acked state should be expired" +if ui_ucode action-ack-expired "$acked" 150 90 >/dev/null 2>&1; then + fail "acked state should not expire before TTL" +fi + +bad_ack="$(write_state bad-ack '{"running":false,"acked_at":"invalid"}')" +if ui_ucode action-ack-expired "$bad_ack" 200 90 >/dev/null 2>&1; then + fail "invalid ack timestamp should not expire" +fi + +cleanup_dir="$WORK_DIR/cleanup-actions" +mkdir -p "$cleanup_dir" +printf '%s\n' '{"running":false,"acked_at":1}' >"$cleanup_dir/done.json" +printf 'stdout\n' >"$cleanup_dir/done.out" +printf '{}\n' >"$cleanup_dir/done.out.json" +ui_ucode cleanup-action-dir-fixture "$cleanup_dir" +[ ! -e "$cleanup_dir/done.json" ] || + fail "expired acknowledged action state should be cleaned" +[ ! -e "$cleanup_dir/done.out" ] || + fail "expired acknowledged action stdout sidecar should be cleaned" +[ ! -e "$cleanup_dir/done.out.json" ] || + fail "expired acknowledged action JSON sidecar should be cleaned" + +export PODKOP_UI_STATE_DIR="$WORK_DIR/ui-state" +export PODKOP_UI_SERVICE_ACTION_DIR="$PODKOP_UI_STATE_DIR/service-actions" +export PODKOP_UI_SERVICE_ACTION_LOCK_DIR="$PODKOP_UI_STATE_DIR/service-actions.lock" +export PODKOP_UI_LATENCY_ACTION_DIR="$PODKOP_UI_STATE_DIR/latency-actions" +export PODKOP_UI_COMPONENT_ACTION_DIR="$PODKOP_UI_STATE_DIR/component-actions" +export PODKOP_UI_SUBSCRIPTION_ACTION_DIR="$PODKOP_UI_STATE_DIR/subscription-actions" + +job_id="$(ui_ucode service-action-begin-if-idle reload test)" +[ -n "$job_id" ] || fail "service-action-begin-if-idle should create a job" +service_state="$PODKOP_UI_SERVICE_ACTION_DIR/$job_id.json" +[ -f "$service_state" ] || fail "service action state file should be created" + +if ui_ucode service-action-begin-if-idle restart test >/dev/null 2>&1; then + fail "second service action should be rejected while first is running" +fi + +ui_ucode service-action-update-pid "$job_id" "$$" >/dev/null || + fail "service-action-update-pid should update running job" + +PODKOP_UI_SERVICE_ACTION_TIMEOUT_SECONDS=1 \ +PODKOP_UI_SERVICE_ACTION_SETTLE_SECONDS=0 \ +PODKOP_SERVICE_INIT=/bin/true \ +PODKOP_BIN=/bin/true \ +PODKOP_LIB="$PODKOP_FILES/usr/lib" \ + ui_ucode service-action-finish-after-command reload "$job_id" 0 >/dev/null || + fail "service-action-finish-after-command should spawn waiter without ucode declaration-order failure" + +sleep 2 +if grep -q '"running"[[:space:]]*:[[:space:]]*true' "$service_state"; then + fail "service-action-finish-after-command waiter should finish the running service action" +fi + +ui_ucode service-action-finish "$job_id" true done 0 >/dev/null || + fail "service-action-finish should finish running job" + +JOB_STATE="$service_state" node - <<'NODE' +const fs = require("fs"); +const value = JSON.parse(fs.readFileSync(process.env.JOB_STATE, "utf8")); +if (value.running !== false || value.success !== true || value.message !== "done" || value.exit_code !== 0) { + console.error("finished service action state mismatch"); + process.exit(1); +} +NODE + +component_job="$PODKOP_UI_COMPONENT_ACTION_DIR/component-1.json" +mkdir -p "$PODKOP_UI_COMPONENT_ACTION_DIR" +printf '%s\n' '{"running":true,"component":"sing_box","started_at":100}' >"$component_job" +ui_ucode component-action-running-for sing_box >/dev/null || + fail "component-action-running-for should detect running sing-box component action" +if ui_ucode component-action-running-for zapret >/dev/null 2>&1; then + fail "component-action-running-for should ignore other components" +fi + +ui_ucode action-ack service "$job_id" >/dev/null || + fail "action-ack should acknowledge finished service action" + +printf 'UI runtime job regression checks passed\n' diff --git a/tests/backend/updates_due_regression.sh b/tests/backend/updates_due_regression.sh new file mode 100644 index 00000000..c82a2d01 --- /dev/null +++ b/tests/backend/updates_due_regression.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +UPDATES_UC="$ROOT_DIR/podkop/files/usr/lib/components/updates.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_status() { + local expected="$1" + local label="$2" + shift 2 + local status=0 + + updates_ucode update-is-due "$@" >/dev/null 2>&1 || status="$?" + [ "$status" = "$expected" ] || + fail "$label: expected exit $expected, got $status" +} + +updates_ucode() { + ucode -L "$PODKOP_LIB" "$UPDATES_UC" "$@" +} + +if grep -n -E 'require\("uci"\)\.cursor|uci -q' "$UPDATES_UC" >/dev/null 2>&1; then + fail "components/updates.uc must use core.uci instead of owning direct UCI cursor or CLI calls" +fi + +assert_updates_status() { + local expected="$1" + local label="$2" + shift 2 + local status=0 + + updates_ucode "$@" >/dev/null 2>&1 || status="$?" + [ "$status" = "$expected" ] || + fail "$label: expected exit $expected, got $status" +} + +assert_status 0 "never-run update should be due" 100 0 60 +assert_status 0 "expired update should be due" 100 30 60 +assert_status 1 "fresh update should not be due" 100 50 60 +assert_status 1 "future timestamp should not be due" 100 130 60 +assert_status 0 "invalid last-run timestamp should be treated as never-run" 100 invalid 60 +assert_status 2 "invalid current timestamp should fail" invalid 0 60 +assert_status 2 "invalid interval should fail" 100 0 invalid +assert_status 2 "zero interval should fail" 100 0 0 + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" +} + +assert_eq 180 \ + "$(updates_ucode duration-to-seconds 3m)" \ + "minutes duration" +assert_eq 5400 \ + "$(updates_ucode duration-to-seconds 1.5h)" \ + "decimal duration" +if updates_ucode duration-to-seconds invalid >/dev/null 2>&1; then + fail "invalid duration should fail" +fi + +assert_eq "* * * * *" \ + "$(updates_ucode due-check-cron-schedule 60)" \ + "minute cron schedule" +assert_eq "0 * * * *" \ + "$(updates_ucode due-check-cron-schedule 3600)" \ + "hour cron schedule" +assert_eq "0 */2 * * *" \ + "$(updates_ucode due-check-cron-schedule 7200)" \ + "two-hour cron schedule" +assert_eq "*/30 * * * *" \ + "$(updates_ucode due-check-cron-schedule 1800)" \ + "half-hour cron schedule" +assert_eq "0 0 * * *" \ + "$(updates_ucode due-check-cron-schedule 86400)" \ + "daily cron schedule" + +assert_eq "*/30 * * * * /usr/bin/podkop list_update_if_due # list" \ + "$(updates_ucode list-update-cron-job 30m /usr/bin/podkop '# list')" \ + "list update cron job" +assert_eq "0 */2 * * * /usr/bin/podkop subscription_update_if_due # subscription" \ + "$(updates_ucode subscription-update-cron-job 7200 /usr/bin/podkop '# subscription')" \ + "subscription update cron job" +assert_eq $'error\trule3\tinvalid\nmin\t1800' \ + "$(printf 'rule1\t2h\nrule2\t30m\nrule3\tinvalid\nrule4\t\n' | updates_ucode subscription-update-interval-plan)" \ + "subscription interval plan" +assert_eq $'min\t0' \ + "$(printf 'rule1\t\n' | updates_ucode subscription-update-interval-plan)" \ + "empty subscription interval plan" +if updates_ucode list-update-cron-job invalid /usr/bin/podkop '# list' >/dev/null 2>&1; then + fail "invalid list update interval should fail" +fi +if updates_ucode subscription-update-cron-job 0 /usr/bin/podkop '# subscription' >/dev/null 2>&1; then + fail "invalid subscription update interval should fail" +fi + +WORK_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +cat >"$WORK_DIR/cron-plan.json" <<'JSON' +{ + "settings": { + "list_update_enabled": "1", + "update_interval": "30m" + }, + "section": [ + { + ".name": "list_rule", + "enabled": "1", + "action": "proxy", + "community_lists": [ "telegram" ], + "subscription_urls": [ "https://example.com/sub.txt" ], + "subscription_update_interval": "2h" + }, + { + ".name": "fast_sub", + "enabled": "1", + "action": "proxy", + "subscription_urls": [ "https://example.com/fast.txt" ], + "subscription_update_interval": "45m" + }, + { + ".name": "bad_sub", + "enabled": "1", + "action": "proxy", + "subscription_urls": [ "https://example.com/bad.txt" ], + "subscription_update_interval": "bad" + }, + { + ".name": "disabled", + "enabled": "0", + "action": "proxy", + "remote_domain_lists": [ "https://example.com/disabled.lst" ], + "subscription_urls": [ "https://example.com/disabled.txt" ] + } + ] +} +JSON + +assert_eq $'list\t*/30 * * * * /usr/bin/podkop list_update_if_due # list\nsubscription-error\tbad_sub\tbad\nsubscription\t*/45 * * * * /usr/bin/podkop subscription_update_if_due # subscription' \ + "$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan.json" /usr/bin/podkop '# list' '# subscription')" \ + "cron refresh plan" + +cat >"$WORK_DIR/cron-plan-list-disabled.json" <<'JSON' +{ + "settings": { + "list_update_enabled": "0", + "update_interval": "30m" + }, + "section": [ + { + ".name": "list_rule", + "enabled": "1", + "action": "proxy", + "remote_domain_lists": [ "https://example.com/list.txt" ] + } + ] +} +JSON + +assert_eq "list-disabled" \ + "$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan-list-disabled.json" /usr/bin/podkop '# list' '# subscription')" \ + "disabled list cron plan" + +cat >"$WORK_DIR/cron-plan-invalid-list.json" <<'JSON' +{ + "settings": { + "list_update_enabled": "1", + "update_interval": "bad" + }, + "section": [ + { + ".name": "list_rule", + "enabled": "1", + "action": "proxy", + "remote_domain_lists": [ "https://example.com/list.txt" ] + } + ] +} +JSON + +status=0 +invalid_plan="$(updates_ucode cron-refresh-plan-fixture "$WORK_DIR/cron-plan-invalid-list.json" /usr/bin/podkop '# list' '# subscription')" || status="$?" +assert_eq 1 "$status" "invalid list cron plan status" +assert_eq $'list-error\tbad' "$invalid_plan" "invalid list cron plan output" + +cat >"$WORK_DIR/existing.cron" <<'CRON' +0 1 * * * /bin/true # keep +* * * * * /usr/bin/podkop list_update_if_due # list +* * * * * /usr/bin/podkop subscription_update_if_due # subscription +CRON + +updates_ucode refresh-cron-fixture "$WORK_DIR/cron-plan.json" "$WORK_DIR/existing.cron" /usr/bin/podkop '# list' '# subscription' >"$WORK_DIR/cron-apply.json" +node - "$WORK_DIR/cron-apply.json" <<'JS' +const fs = require("fs"); +const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const expected = [ + "0 1 * * * /bin/true # keep", + "*/30 * * * * /usr/bin/podkop list_update_if_due # list", + "*/45 * * * * /usr/bin/podkop subscription_update_if_due # subscription", + "" +].join("\n"); +if (value.crontab !== expected) { + console.error("unexpected applied crontab", JSON.stringify(value.crontab)); + process.exit(1); +} +const messages = value.logs.map(item => item.message); +if (!messages.includes("The cron job removed") || + !messages.includes("The cron job has been created: */30 * * * * /usr/bin/podkop list_update_if_due # list") || + !messages.includes("The subscription cron job has been created: */45 * * * * /usr/bin/podkop subscription_update_if_due # subscription")) { + console.error("unexpected cron apply logs", JSON.stringify(value.logs)); + process.exit(1); +} +JS + +status=0 +updates_ucode refresh-cron-fixture "$WORK_DIR/cron-plan-invalid-list.json" "$WORK_DIR/existing.cron" /usr/bin/podkop '# list' '# subscription' >"$WORK_DIR/cron-apply-invalid.json" || status="$?" +assert_eq 1 "$status" "invalid cron apply status" +node - "$WORK_DIR/cron-apply-invalid.json" <<'JS' +const fs = require("fs"); +const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +if (value.crontab !== "0 1 * * * /bin/true # keep\n") { + console.error("invalid plan should remove old podkop cron jobs only", JSON.stringify(value.crontab)); + process.exit(1); +} +if (!value.logs.some(item => item.level === "error" && item.message === "Invalid update_interval value: bad")) { + console.error("invalid plan error log missing", JSON.stringify(value.logs)); + process.exit(1); +} +JS + +LIST_TIMESTAMP="$WORK_DIR/list.timestamp" +SUB_TIMESTAMP="$WORK_DIR/sub.timestamp" +rm -f "$LIST_TIMESTAMP" "$SUB_TIMESTAMP" + +assert_updates_status 0 "list update should be due without timestamp" \ + list-update-due-status-fixture "$WORK_DIR/cron-plan.json" "$LIST_TIMESTAMP" 1000 +printf '900\n' >"$LIST_TIMESTAMP" +assert_updates_status 1 "list update should not be due before interval" \ + list-update-due-status-fixture "$WORK_DIR/cron-plan.json" "$LIST_TIMESTAMP" 1000 +assert_updates_status 1 "disabled list update should not be due" \ + list-update-due-status-fixture "$WORK_DIR/cron-plan-list-disabled.json" "$LIST_TIMESTAMP" 1000 +status=0 +invalid_due="$(updates_ucode list-update-due-status-fixture "$WORK_DIR/cron-plan-invalid-list.json" "$LIST_TIMESTAMP" 1000)" || status="$?" +assert_eq 2 "$status" "invalid list due status" +assert_eq $'error\tbad' "$invalid_due" "invalid list due output" + +rm -f "$SUB_TIMESTAMP" +assert_updates_status 0 "subscription update should be due without timestamp" \ + subscription-update-section-due-status-fixture "$WORK_DIR/cron-plan.json" fast_sub "$SUB_TIMESTAMP" 1000 +printf '900\n' >"$SUB_TIMESTAMP" +assert_updates_status 1 "subscription update should not be due before interval" \ + subscription-update-section-due-status-fixture "$WORK_DIR/cron-plan.json" fast_sub "$SUB_TIMESTAMP" 1000 +assert_updates_status 1 "missing subscription section should not be due" \ + subscription-update-section-due-status-fixture "$WORK_DIR/cron-plan.json" missing "$SUB_TIMESTAMP" 1000 +status=0 +invalid_due="$(updates_ucode subscription-update-section-due-status-fixture "$WORK_DIR/cron-plan.json" bad_sub "$SUB_TIMESTAMP" 1000)" || status="$?" +assert_eq 2 "$status" "invalid subscription due status" +assert_eq $'error\tbad' "$invalid_due" "invalid subscription due output" + +printf 'updates due regression checks passed\n' diff --git a/tests/backend/urltest_interrupt_regression.sh b/tests/backend/urltest_interrupt_regression.sh index e71ad07f..e59e5712 100644 --- a/tests/backend/urltest_interrupt_regression.sh +++ b/tests/backend/urltest_interrupt_regression.sh @@ -39,204 +39,38 @@ assert_not_contains() { fi } -config_get() { - local __var="$1" - local section="$2" - local option="$3" - local default="${4:-}" - local value_var="CONFIG_${section}_${option}" - local config_value="${!value_var-}" - - if [ -n "$config_value" ]; then - printf -v "$__var" '%s' "$config_value" - else - printf -v "$__var" '%s' "$default" - fi +cat >"$WORK_DIR/fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "log_level": "warn" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "proxy", + "urltest_enabled": "1", + "urltest_check_interval": "3m", + "urltest_tolerance": "50", + "urltest_testing_url": "https://www.gstatic.com/generate_204", + "urltest_filter_mode": "disabled", + "detect_server_country": "flag_emoji", + "selector_proxy_links": [ + "vless://00000000-0000-4000-8000-000000000001@example.com:443?encryption=none&security=tls&sni=example.com#first", + "vless://00000000-0000-4000-8000-000000000002@example.org:443?encryption=none&security=tls&sni=example.org#second" + ] + } + ] } - -config_get_bool() { - local __var="$1" - local section="$2" - local option="$3" - local default="${4:-0}" - local bool_value - - config_get bool_value "$section" "$option" "$default" - case "$bool_value" in - 1 | true | yes | on) - printf -v "$__var" '%s' 1 - ;; - *) - printf -v "$__var" '%s' 0 - ;; - esac -} - -config_list_foreach() { - local section="$1" - local option="$2" - local callback="$3" - shift 3 - local list_var="CONFIG_LIST_${section}_${option}" - local values="${!list_var-}" - local value - - [ -n "$values" ] || return 0 - while IFS= read -r value || [ -n "$value" ]; do - "$callback" "$value" "$@" - done <<< "$values" -} - -config_foreach() { - local callback="$1" - local type="$2" - shift 2 - - [ "$type" = "section" ] || return 0 - "$callback" "proxy" -} - -log() { - : -} - -rule_is_enabled() { - [ "$1" = "proxy" ] -} - -get_rule_action() { - [ "$1" = "proxy" ] && printf 'proxy\n' -} - -rule_has_subscription_urls() { - return 1 -} - -urltest_filter_mode_filters_enabled() { - case "$1" in - exclude | include | mixed) - return 0 - ;; - esac - - return 1 -} - -get_urltest_check_interval_for_rule() { - local section="$1" - local enabled interval - - config_get_bool enabled "$section" "urltest_enabled" 0 - if [ "$enabled" -eq 0 ]; then - printf '\n' - return 0 - fi - - config_get interval "$section" "urltest_check_interval" - if [ -n "$interval" ]; then - printf '%s\n' "$interval" - else - printf '3m\n' - fi -} - -write_subscription_metadata_json() { - : -} - -write_subscription_outbound_link_cache() { - : -} - -write_outbound_metadata() { - : -} - -subscription_section_is_deferred() { - return 1 -} - -duration_to_seconds() { - ucode "$PODKOP_LIB/updates.uc" duration-to-seconds "$1" -} - -normalize_detect_server_country_method() { - case "$1" in - country_is) - printf 'country_is\n' - ;; - *) - printf 'flag_emoji\n' - ;; - esac -} - -is_zapret_installed() { - return 1 -} - -is_zapret2_installed() { - return 1 -} - -is_byedpi_installed() { - return 1 -} - -get_outbound_detour_tag_for_rule() { - return 0 -} - -export PODKOP_LIB -TMP_SING_BOX_FOLDER="$WORK_DIR/sing-box" -TMP_SUBSCRIPTION_FOLDER="$TMP_SING_BOX_FOLDER/subscriptions" -PODKOP_SECTION_CACHE_DIR="$WORK_DIR/section-cache" -PODKOP_RUNTIME_CACHE_FORMAT=6 -SING_BOX_DISABLED_UPDATE_INTERVAL="876000h" -SING_BOX_URLTEST_DEFAULT_IDLE_TIMEOUT="30m" -SERVER_COUNTRY_METHOD_FLAG_EMOJI="flag_emoji" -SERVER_COUNTRY_METHOD_COUNTRY_IS="country_is" -PODKOP_URLTEST_NEW_ENABLED_SECTIONS="" -PODKOP_URLTEST_SELECTOR_SWITCHES="" -SB_DIRECT_OUTBOUND_TAG="direct-out" -SB_DNS_SERVER_TAG="dns-server" -SB_FAKEIP_DNS_SERVER_TAG="fakeip-server" -SB_BOOTSTRAP_SERVER_TAG="bootstrap-dns-server" -SB_FAKEIP_DNS_RULE_TAG="fakeip-dns-rule-tag" -SB_FAKEIP_RULESET_DNS_RULE_TAG="fakeip-ruleset-dns-rule-tag" -SB_SERVICE_FAKEIP_DNS_RULE_TAG="service-fakeip-dns-rule-tag" -SB_TPROXY_INBOUND_TAG="tproxy-in" -SB_DNS_INBOUND_TAG="dns-in" -SB_SERVICE_MIXED_INBOUND_TAG="service-mixed-in" - -mkdir -p "$TMP_SUBSCRIPTION_FOLDER" "$PODKOP_SECTION_CACHE_DIR" - -# shellcheck source=/dev/null -. "$PODKOP_LIB/helpers.sh" -# shellcheck source=/dev/null -. "$PODKOP_LIB/subscription_parser.sh" -# shellcheck source=/dev/null -. "$PODKOP_LIB/sing_box_config_manager.sh" -# shellcheck source=/dev/null -. "$PODKOP_LIB/sing_box_config_facade.sh" -# shellcheck source=/dev/null -. "$PODKOP_LIB/sing_box_runtime.sh" - -CONFIG_proxy_action="proxy" -CONFIG_proxy_urltest_enabled="1" -CONFIG_proxy_urltest_check_interval="3m" -CONFIG_proxy_urltest_tolerance="50" -CONFIG_proxy_urltest_testing_url="https://www.gstatic.com/generate_204" -CONFIG_proxy_urltest_filter_mode="disabled" -CONFIG_proxy_detect_server_country="flag_emoji" -CONFIG_LIST_proxy_selector_proxy_links=$'vless://00000000-0000-4000-8000-000000000001@example.com:443?encryption=none&security=tls&sni=example.com#first\nvless://00000000-0000-4000-8000-000000000002@example.org:443?encryption=none&security=tls&sni=example.org#second' - -config='{"outbounds":[]}' -config="$(sing_box_cm_add_direct_outbound "$config" "$SB_DIRECT_OUTBOUND_TAG")" -configure_outbound_handler "proxy" +JSON output="$WORK_DIR/config.json" -printf '%s\n' "$config" > "$output" +mkdir -p "$output.section-cache" +ucode -L "$PODKOP_LIB" "$PODKOP_LIB/singbox/generator.uc" generate-config-fixture \ + "$WORK_DIR/fixture.json" "$output" "127.0.0.1" urltest_count="$(ucode -e ' let fs = require("fs"); @@ -247,10 +81,8 @@ for (let outbound in cfg.outbounds || []) count++; print(count, "\n"); ' "$output")" -if [ "$urltest_count" != "1" ]; then - cat "$output" >&2 +[ "$urltest_count" = "1" ] || fail "expected exactly one URLTest outbound with interrupt_exist_connections=true, got $urltest_count" -fi selector_count="$(ucode -e ' let fs = require("fs"); @@ -261,7 +93,8 @@ for (let outbound in cfg.outbounds || []) count++; print(count, "\n"); ' "$output")" -[ "$selector_count" = "1" ] || fail "expected exactly one selector outbound with interrupt_exist_connections=true, got $selector_count" +[ "$selector_count" = "1" ] || + fail "expected exactly one selector outbound with interrupt_exist_connections=true, got $selector_count" assert_contains "$output" '"tag": "proxy-urltest-out"' "generated config" assert_contains "$output" '"url": "https://www.gstatic.com/generate_204"' "generated config" diff --git a/tests/backend/zapret_runtime_owner_regression.sh b/tests/backend/zapret_runtime_owner_regression.sh new file mode 100644 index 00000000..507ec793 --- /dev/null +++ b/tests/backend/zapret_runtime_owner_regression.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_BIN="$ROOT_DIR/podkop/files/usr/bin/podkop" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +CLI_UC="$PODKOP_BIN" +LIFECYCLE_UC="$PODKOP_LIB/service/lifecycle.uc" +ZAPRET_RUNTIME="$PODKOP_LIB/providers/zapret/runtime.uc" +ZAPRET2_RUNTIME="$PODKOP_LIB/providers/zapret2/runtime.uc" +ZAPRET_COMMON="$PODKOP_LIB/providers/zapret/common.uc" +ZAPRET2_COMMON="$PODKOP_LIB/providers/zapret2/common.uc" +NFQUEUE_RUNTIME="$PODKOP_LIB/providers/nfqueue/runtime.uc" +NFQUEUE_CHECK="$PODKOP_LIB/providers/nfqueue/check.uc" +NFQUEUE_VALIDATOR="$PODKOP_LIB/providers/nfqueue/validator.uc" +ZAPRET2_CHECK="$PODKOP_LIB/providers/zapret2/check.uc" +ZAPRET2_VALIDATOR="$PODKOP_LIB/providers/zapret2/validator.uc" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ ! -e "$PODKOP_LIB/zapret.sh" ] || + fail "zapret.sh shell owner must be removed" +[ ! -e "$PODKOP_LIB/zapret2.sh" ] || + fail "zapret2.sh shell owner must be removed" + +grep -Fq '#!/usr/bin/ucode' "$PODKOP_BIN" || + fail "podkop entrypoint must be a direct ucode executable" +grep -Fq 'service/lifecycle.uc' "$CLI_UC" || + fail "service/cli.uc must dispatch lifecycle orchestration through service/lifecycle.uc" +grep -Fq 'providers/zapret/runtime.uc' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc must call providers/zapret/runtime.uc" +grep -Fq 'providers/zapret2/runtime.uc' "$LIFECYCLE_UC" || + fail "service/lifecycle.uc must call providers/zapret2/runtime.uc" +grep -Fq 'require("providers.nfqueue.runtime")' "$ZAPRET_RUNTIME" || + fail "providers/zapret/runtime.uc must use the shared NFQUEUE runtime engine" +grep -Fq 'require("providers.nfqueue.runtime")' "$ZAPRET2_RUNTIME" || + fail "providers/zapret2/runtime.uc must use the shared NFQUEUE runtime engine" +grep -Fq 'require("providers.zapret.common")' "$ZAPRET_RUNTIME" || + fail "providers/zapret/runtime.uc must load zapret config" +grep -Fq 'require("providers.zapret2.common")' "$ZAPRET2_RUNTIME" || + fail "providers/zapret2/runtime.uc must load zapret2 config" +grep -Fq 'providers/zapret/check.uc' "$ZAPRET_COMMON" || + fail "providers/zapret/common.uc must point at its provider check wrapper" +grep -Fq 'providers/zapret2/check.uc' "$ZAPRET2_COMMON" || + fail "providers/zapret2/common.uc must point at its provider check wrapper" +grep -Fq 'providers.zapret2.validator' "$ZAPRET2_COMMON" || + fail "providers/zapret2/common.uc must load its provider validator" +grep -Fq 'function start_runtime' "$NFQUEUE_RUNTIME" || + fail "providers/nfqueue/runtime.uc must own NFQUEUE start" +grep -Fq 'function stop_runtime' "$NFQUEUE_RUNTIME" || + fail "providers/nfqueue/runtime.uc must own NFQUEUE stop" +grep -Fq 'function status_json' "$NFQUEUE_RUNTIME" || + fail "providers/nfqueue/runtime.uc must own NFQUEUE status" +grep -Fq 'function create_nft_rules' "$NFQUEUE_RUNTIME" || + fail "providers/nfqueue/runtime.uc must own NFQUEUE nft rules" +grep -Fq 'function validate_strategy' "$NFQUEUE_VALIDATOR" || + fail "providers/nfqueue/validator.uc must own shared NFQUEUE strategy validation" +grep -Fq 'function nft_queue_overlap' "$NFQUEUE_CHECK" || + fail "providers/nfqueue/check.uc must own shared NFQUEUE overlap checks" +if grep -n -E 'function (start_runtime|stop_runtime|status_json|create_nft_rules|supervisor)\b' "$ZAPRET_COMMON" "$ZAPRET2_COMMON" >/dev/null 2>&1; then + fail "provider common modules must stay thin config wrappers, not runtime engines" +fi +if grep -R -n -E 'providers/zapret/runtime\.uc" (start-runtime|stop-runtime|status|check|installed|package-installed|package-version|enabled-rule-count) zapret2' \ + "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "zapret2 must not be multiplexed through providers/zapret/runtime.uc" +fi +if grep -R -n -E 'providers\.zapret\.|providers/zapret/' "$PODKOP_LIB/providers/zapret2" >/dev/null 2>&1; then + fail "zapret2 provider modules must not import or execute providers/zapret modules" +fi +if grep -n 'zapret2' "$ZAPRET_COMMON" >/dev/null 2>&1; then + fail "providers/zapret/common.uc must not contain zapret2 ownership" +fi +if grep -R -n -E 'start_zapret2?_runtime|stop_zapret2?_runtime|get_zapret2?_status_json|check_zapret2?_runtime_json|create_zapret2?_nft_rules|validate_rule_nfqws2?_opt|has_enabled_zapret2?_rules|get_zapret2?_rule_' \ + "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "zapret runtime shell symbols must not remain" +fi +if grep -R -n -E 'is_zapret2?_installed|get_zapret2?_package_version' \ + "$PODKOP_BIN" "$PODKOP_LIB" --include='*.sh' >/dev/null 2>&1; then + fail "zapret installed/version shell predicates must not remain" +fi +if grep -n -E 'require\("uci"\)\.cursor|uci -q|"uci", "-q"|command_output\(command_from_args\(\[ "uci"' "$NFQUEUE_RUNTIME" "$ZAPRET_COMMON" "$ZAPRET2_COMMON" >/dev/null 2>&1; then + fail "zapret provider runtimes must use core.uci instead of direct UCI cursor or CLI access" +fi +grep -Fq 'require("core.uci")' "$NFQUEUE_RUNTIME" || + fail "providers/nfqueue/runtime.uc must use core.uci for runtime UCI access" + +for mode in \ + 'mode == "start-runtime"' \ + 'mode == "stop-runtime"' \ + 'mode == "create-nft-rules"' \ + 'mode == "status"' \ + 'mode == "check"' \ + 'mode == "supervisor"' +do + grep -Fq "$mode" "$NFQUEUE_RUNTIME" || + fail "providers/nfqueue/runtime.uc missing $mode" +done +if grep -Fq 'runtime.run("zapret", ARGV)' "$ZAPRET_RUNTIME"; then + fail "providers/zapret/runtime.uc must not carry the old hard-coded engine call" +fi +grep -Fq 'runtime.run(provider, ARGV)' "$ZAPRET_RUNTIME" || + fail "providers/zapret/runtime.uc must be the zapret entrypoint" +grep -Fq 'require("providers.zapret2.common")' "$ZAPRET2_RUNTIME" || + fail "providers/zapret2/runtime.uc must import providers/zapret2/common.uc" +if grep -Fq 'runtime.run("zapret2", ARGV)' "$ZAPRET2_RUNTIME"; then + fail "providers/zapret2/runtime.uc must not carry the old hard-coded engine call" +fi +grep -Fq 'runtime.run(provider, ARGV)' "$ZAPRET2_RUNTIME" || + fail "providers/zapret2/runtime.uc must be the zapret2 entrypoint" +grep -Fq 'providers/zapret2/check.uc' "$ZAPRET2_COMMON" || + fail "providers/zapret2/common.uc must use its own check module" +grep -Fq 'providers.zapret2.validator' "$ZAPRET2_COMMON" || + fail "providers/zapret2/common.uc must use its own validator module" + +status_json="$(PODKOP_CONFIG_NAME=podkop-plus-definitely-missing ucode -L "$PODKOP_LIB" "$ZAPRET_RUNTIME" status)" +JSON_VALUE="$status_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (value.configured !== false || value.enabled_rule_count !== 0 || typeof value.provider_path !== 'string') { + console.error('zapret status shape mismatch'); + process.exit(1); +} +NODE + +check_json="$(ucode -L "$PODKOP_LIB" "$ZAPRET2_RUNTIME" check)" +JSON_VALUE="$check_json" node - <<'NODE' +const value = JSON.parse(process.env.JSON_VALUE); +if (!Object.prototype.hasOwnProperty.call(value, 'zapret2_installed') || + !Object.prototype.hasOwnProperty.call(value, 'zapret2_package_installed') || + !Object.prototype.hasOwnProperty.call(value, 'zapret2_provider_path')) { + console.error('zapret2 check shape mismatch'); + process.exit(1); +} +NODE + +ucode -L "$PODKOP_LIB" "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--name podkop --intercept=1' >/dev/null || + fail "providers/zapret2/validator.uc must validate nfqws2 strategies" +if ucode -L "$PODKOP_LIB" "$ZAPRET2_VALIDATOR" validate-json nfqws '--dpi-desync=fake' >/dev/null 2>&1; then + fail "providers/zapret2/validator.uc must not validate nfqws strategies" +fi + +printf 'table inet x { chain y { queue num 4301 bypass } }\n' | + ucode -L "$PODKOP_LIB" "$ZAPRET2_CHECK" nft-queue-overlap PodkopPlusTable 4300 4555 >/dev/null || + fail "providers/zapret2/check.uc must own zapret2 queue overlap checks" + +printf 'zapret runtime ownership regression checks passed\n' diff --git a/tests/backend/zapret_validator_regression.sh b/tests/backend/zapret_validator_regression.sh new file mode 100644 index 00000000..d062c832 --- /dev/null +++ b/tests/backend/zapret_validator_regression.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -eo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib" +VALIDATOR="$ROOT_DIR/podkop/files/usr/lib/providers/zapret/validator.uc" +ZAPRET2_VALIDATOR="$ROOT_DIR/podkop/files/usr/lib/providers/zapret2/validator.uc" +WORK_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +json_field() { + local json="$1" + local field="$2" + + JSON_VALUE="$json" node - "$field" <<'NODE' +const field = process.argv[2]; +const value = JSON.parse(process.env.JSON_VALUE); +const actual = value[field]; +if (Array.isArray(actual)) { + console.log(actual.join("\n")); +} else { + console.log(String(actual)); +} +NODE +} + +assert_json_field() { + local json="$1" + local field="$2" + local expected="$3" + local actual + + actual="$(json_field "$json" "$field")" + [ "$actual" = "$expected" ] || fail "expected $field=$expected, got $actual" +} + +valid_nfqws="$(ucode -L "$PODKOP_LIB" "$VALIDATOR" validate-json nfqws '--dpi-desync=fake --dpi-desync-repeats 2')" +assert_json_field "$valid_nfqws" valid true + +configured="$(ucode -L "$PODKOP_LIB" "$VALIDATOR" strategy-or-default "$(printf -- '--dpi-desync=fake\t--dpi-desync-repeats 2')" '--default')" +[ "$configured" = "--dpi-desync=fake --dpi-desync-repeats 2" ] || + fail "configured zapret strategy should be normalized, got '$configured'" + +defaulted="$(ucode -L "$PODKOP_LIB" "$VALIDATOR" strategy-or-default "" "$(printf -- '--default\t1')")" +[ "$defaulted" = "--default 1" ] || + fail "empty zapret strategy should use normalized default, got '$defaulted'" + +if invalid_nfqws="$(ucode -L "$PODKOP_LIB" "$VALIDATOR" validate-json nfqws '--hostlist domains.txt' 2>/dev/null)"; then + fail "hostlist selection should be rejected for nfqws" +fi +assert_json_field "$invalid_nfqws" valid false +assert_json_field "$invalid_nfqws" needle --hostlist + +if ucode -L "$PODKOP_LIB" "$VALIDATOR" validate nfqws '--qnum=200' >/tmp/zapret-validator.out 2>/dev/null; then + fail "qnum override should be rejected for nfqws" +fi +grep -q 'NFQUEUE number is assigned by Podkop Plus' /tmp/zapret-validator.out || + fail "qnum rejection should explain ownership" + +valid_nfqws2="$(ucode -L "$PODKOP_LIB" "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--name podkop --intercept=1')" +assert_json_field "$valid_nfqws2" valid true + +if invalid_nfqws2="$(ucode -L "$PODKOP_LIB" "$ZAPRET2_VALIDATOR" validate-json nfqws2 '--intercept=0' 2>/dev/null)"; then + fail "disabled nfqws2 intercept should be rejected" +fi +assert_json_field "$invalid_nfqws2" valid false +assert_json_field "$invalid_nfqws2" needle --intercept + +if ucode -L "$PODKOP_LIB" "$VALIDATOR" validate-json nfqws2 '--name podkop --intercept=1' >/dev/null 2>&1; then + fail "zapret validator must not own nfqws2 validation" +fi +if ucode -L "$PODKOP_LIB" "$ZAPRET2_VALIDATOR" validate-json nfqws '--dpi-desync=fake' >/dev/null 2>&1; then + fail "zapret2 validator must not own nfqws validation" +fi + +cat >"$WORK_DIR/require-zapret-validator.uc" <<'UCODE' +let validator = require("providers.zapret.validator"); +let zapret2_validator = require("providers.zapret2.validator"); + +let valid_nfqws = validator.validate_strategy("nfqws", "--dpi-desync=fake --dpi-desync-repeats 2", ""); +if (!valid_nfqws.valid) + exit(1); + +let invalid_nfqws = validator.validate_strategy("nfqws", "--hostlist domains.txt", ""); +if (invalid_nfqws.valid || invalid_nfqws.needle != "--hostlist") + exit(1); + +let valid_nfqws2 = zapret2_validator.validate_strategy("nfqws2", "--name podkop --intercept=1", ""); +if (!valid_nfqws2.valid) + exit(1); + +if (validator.validate_strategy("nfqws2", "--name podkop --intercept=1", "").valid) + exit(1); +if (zapret2_validator.validate_strategy("nfqws", "--dpi-desync=fake", "").valid) + exit(1); + +if (validator.strategy_or_default("", "--default\t1") != "--default 1") + exit(1); +UCODE + +ucode -L "$PODKOP_LIB" "$WORK_DIR/require-zapret-validator.uc" + +printf 'Zapret validator regression checks passed\n' diff --git a/tests/differential/config_contract_matrix.js b/tests/differential/config_contract_matrix.js new file mode 100644 index 00000000..40d65cc8 --- /dev/null +++ b/tests/differential/config_contract_matrix.js @@ -0,0 +1,308 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +function usage() { + console.error("Usage: config_contract_matrix.js --current --stable [--check]"); + process.exit(2); +} + +function parseArgs(argv) { + const args = { check: false }; + for (let i = 2; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--check") { + args.check = true; + } else if (arg === "--current" || arg === "--stable") { + if (!argv[i + 1]) usage(); + args[arg.slice(2)] = path.resolve(argv[++i]); + } else { + usage(); + } + } + if (!args.current || !args.stable) usage(); + return args; +} + +function gitDescribe(repo) { + const result = spawnSync("git", ["describe", "--tags", "--always", "HEAD"], { + cwd: repo, + encoding: "utf8", + }); + if (result.status === 0 && result.stdout.trim()) return result.stdout.trim(); + + const fallback = path.basename(repo).match(/[0-9]+\.[0-9]+\.[0-9]+(?:[.-][0-9]+)?/); + return fallback ? fallback[0] : ""; +} + +function readFileIfExists(file) { + try { + return fs.readFileSync(file, "utf8"); + } catch { + return ""; + } +} + +function walkFiles(dir, result = []) { + if (!fs.existsSync(dir)) return result; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === ".git" || entry.name === "sandbox") continue; + walkFiles(full, result); + } else { + result.push(full); + } + } + return result; +} + +function ensure(map, key) { + if (!map.has(key)) { + map.set(key, { + name: key, + ui: [], + backend: [], + defaults: [], + examples: [], + migrations: [], + }); + } + return map.get(key); +} + +function addUnique(array, value) { + if (value && !array.includes(value)) array.push(value); +} + +function rel(repo, file) { + return path.relative(repo, file).replace(/\\/g, "/"); +} + +function extractBraceBody(data, marker) { + const markerIndex = data.indexOf(marker); + if (markerIndex < 0) return ""; + + const start = data.indexOf("{", markerIndex); + if (start < 0) return ""; + + let depth = 0; + for (let i = start; i < data.length; i++) { + const char = data[i]; + if (char === "{") { + depth++; + } else if (char === "}") { + depth--; + if (depth === 0) return data.slice(start + 1, i); + } + } + + return ""; +} + +function extractObjectKeys(data, constName) { + const body = extractBraceBody(data, `const ${constName}`); + return [...body.matchAll(/^\s*([A-Za-z0-9_]+)\s*:/gm)].map((match) => match[1]); +} + +function extractFunctionFirstStringArgs(data, functionName, callName) { + const body = extractBraceBody(data, `function ${functionName}`); + const pattern = new RegExp(`${callName}\\(\\s*["']([^"']*)["']`, "g"); + return [...body.matchAll(pattern)].map((match) => match[1]); +} + +function extractFunctionSecondStringArgs(data, functionName, callName) { + const body = extractBraceBody(data, `function ${functionName}`); + const pattern = new RegExp(`${callName}\\(\\s*[^,]+,\\s*["']([^"']*)["']`, "g"); + return [...body.matchAll(pattern)].map((match) => match[1]); +} + +function enrichUiValues(fileData, option, block, values) { + if (option === "action" && block.includes("populateActionOptionValues(")) { + for (const value of extractFunctionFirstStringArgs(fileData, "populateActionOptionValues", "option.value")) + addUnique(values, value); + } + + if (option === "protocol" && block.includes("populateProtocolValues(")) { + for (const constName of ["TAILSCALE_PROTOCOL_LABELS", "BASE_PROTOCOL_LABELS", "EXTENDED_PROTOCOL_LABELS", "CUSTOM_PROTOCOL_LABELS"]) { + for (const value of extractObjectKeys(fileData, constName)) + addUnique(values, value); + } + } + + if (option === "transport" && block.includes("populateTransportValues(")) { + for (const value of extractFunctionSecondStringArgs(fileData, "populateTransportValues", "addOptionValue")) + addUnique(values, value); + } +} + +function extractUi(repo, fields) { + const viewDir = path.join( + repo, + "luci-app-podkop-plus", + "htdocs", + "luci-static", + "resources", + "view", + "podkop", + ); + const files = walkFiles(viewDir).filter((file) => file.endsWith(".js")); + + for (const file of files) { + const data = readFileIfExists(file); + const optionRe = /\.(option|taboption)\(\s*(?:(["'])[^"']+\2\s*,\s*)?([^,\n]+)\s*,\s*["']([^"']+)["']/g; + const matches = [...data.matchAll(optionRe)]; + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const option = match[4]; + const start = match.index; + const end = i + 1 < matches.length ? matches[i + 1].index : data.length; + const block = data.slice(start, end); + const field = ensure(fields, option); + const type = match[3].trim().replace(/\s+/g, " "); + const defaults = [...block.matchAll(/\bo\.default\s*=\s*["']([^"']*)["']/g)].map((m) => m[1]); + const values = [...block.matchAll(/\bo\.value\(\s*["']([^"']*)["']/g)].map((m) => m[1]); + enrichUiValues(data, option, block, values); + addUnique( + field.ui, + JSON.stringify({ + file: rel(repo, file), + type, + default: defaults[0] || "", + values: values.sort(), + }), + ); + } + } +} + +function extractConfigDefaults(repo, fields) { + const file = path.join(repo, "podkop", "files", "etc", "config", "podkop"); + const data = readFileIfExists(file); + for (const line of data.split(/\n/)) { + const match = line.match(/^\s*(#\s*)?(option|list)\s+([A-Za-z0-9_]+)\s+['"]?([^'"]*)/); + if (!match) continue; + const field = ensure(fields, match[3]); + const item = `${rel(repo, file)}:${match[2]}:${match[4]}`; + addUnique(match[1] ? field.examples : field.defaults, item); + } +} + +function extractBackend(repo, fields) { + const roots = [ + path.join(repo, "podkop", "files", "usr", "lib"), + path.join(repo, "podkop", "files", "usr", "bin"), + path.join(repo, "podkop", "files", "etc", "init.d"), + ]; + const files = roots.flatMap((root) => walkFiles(root)).filter((file) => /\.(sh|uc)$/.test(file) || /[\\/]podkop$/.test(file)); + + const shellOptionRe = /\bconfig_(?:get|get_bool|list_foreach)\s+\S+\s+(?:"[^"]+"|'[^']+'|\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]+\})\s+["']([A-Za-z0-9_]+)["']/g; + const ucodeOptionRe = /\b(?:option|list_option|bool_option|int_option)\(\s*[^,\n]+,\s*["']([A-Za-z0-9_]+)["']/g; + const ucodeStaticOptionKeyArrayRe = /^\s*\[\s*["']([A-Za-z0-9_]+)["']\s*,/gm; + const migrationRe = /\bpodkop_uci_(?:set_option|set_option_if_missing|delete_option|add_list_unique)\s+["'$A-Za-z0-9_{}.-]+\s+["']([A-Za-z0-9_]+)["']/g; + const ucodeMigrationRe = /\b(?:set_option|set_option_if_missing|delete_option|add_list_unique)\(\s*[^,\n]+,\s*[^,\n]+,\s*["']([A-Za-z0-9_]+)["']/g; + + for (const file of files) { + const data = readFileIfExists(file); + const backendScanData = data.replace(/\\"/g, '"'); + for (const match of backendScanData.matchAll(shellOptionRe)) { + addUnique(ensure(fields, match[1]).backend, rel(repo, file)); + } + for (const match of backendScanData.matchAll(ucodeOptionRe)) { + addUnique(ensure(fields, match[1]).backend, rel(repo, file)); + } + if (backendScanData.includes("option(server, field[0]")) { + for (const match of backendScanData.matchAll(ucodeStaticOptionKeyArrayRe)) { + addUnique(ensure(fields, match[1]).backend, rel(repo, file)); + } + } + for (const match of backendScanData.matchAll(migrationRe)) { + addUnique(ensure(fields, match[1]).migrations, rel(repo, file)); + } + for (const match of backendScanData.matchAll(ucodeMigrationRe)) { + addUnique(ensure(fields, match[1]).migrations, rel(repo, file)); + } + } +} + +function extractRepo(repo) { + const fields = new Map(); + extractUi(repo, fields); + extractConfigDefaults(repo, fields); + extractBackend(repo, fields); + return fields; +} + +function publicPresence(field) { + return field && (field.ui.length || field.backend.length || field.defaults.length || field.examples.length || field.migrations.length); +} + +function compactField(field) { + if (!field) return null; + return { + ui: field.ui.map((item) => JSON.parse(item)).sort((a, b) => a.file.localeCompare(b.file) || a.type.localeCompare(b.type)), + backend: field.backend.slice().sort(), + defaults: field.defaults.slice().sort(), + examples: field.examples.slice().sort(), + migrations: field.migrations.slice().sort(), + }; +} + +function classify(stable, current) { + if (publicPresence(stable) && publicPresence(current)) return "supported"; + if (publicPresence(stable) && current && current.migrations.length) return "migrated"; + if (publicPresence(stable)) return "missing_current"; + return "added_current"; +} + +function buildMatrix(currentRepo, stableRepo) { + const stable = extractRepo(stableRepo); + const current = extractRepo(currentRepo); + const names = Array.from(new Set([...stable.keys(), ...current.keys()])).sort(); + const fields = names.map((name) => { + const stableField = stable.get(name); + const currentField = current.get(name); + return { + name, + status: classify(stableField, currentField), + stable: compactField(stableField), + current: compactField(currentField), + }; + }); + + const summary = {}; + for (const field of fields) summary[field.status] = (summary[field.status] || 0) + 1; + + return { + stable: { + path: stableRepo, + version: gitDescribe(stableRepo), + }, + current: { + path: currentRepo, + version: gitDescribe(currentRepo), + }, + summary, + fields, + }; +} + +function main() { + const args = parseArgs(process.argv); + const matrix = buildMatrix(args.current, args.stable); + process.stdout.write(JSON.stringify(matrix, null, 2) + "\n"); + + if (args.check) { + const missing = matrix.fields.filter((field) => field.status === "missing_current"); + if (missing.length) { + console.error(`Missing current config fields from stable contract: ${missing.map((f) => f.name).join(", ")}`); + process.exit(1); + } + } +} + +main(); diff --git a/tests/differential/semantic_json_compare.js b/tests/differential/semantic_json_compare.js new file mode 100644 index 00000000..df02d248 --- /dev/null +++ b/tests/differential/semantic_json_compare.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); + +function usage() { + console.error("Usage: semantic_json_compare.js "); + process.exit(2); +} + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + console.error(`Failed to read JSON ${file}: ${error.message}`); + process.exit(2); + } +} + +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === "object") { + const result = {}; + for (const key of Object.keys(value).sort()) result[key] = normalize(value[key]); + return result; + } + return value; +} + +function diff(expected, actual, path) { + if (Object.is(expected, actual)) return null; + + const expectedArray = Array.isArray(expected); + const actualArray = Array.isArray(actual); + if (expectedArray || actualArray) { + if (!expectedArray || !actualArray) return `${path}: type mismatch`; + if (expected.length !== actual.length) return `${path}: array length ${expected.length} != ${actual.length}`; + for (let i = 0; i < expected.length; i++) { + const nested = diff(expected[i], actual[i], `${path}[${i}]`); + if (nested) return nested; + } + return null; + } + + const expectedObject = expected && typeof expected === "object"; + const actualObject = actual && typeof actual === "object"; + if (expectedObject || actualObject) { + if (!expectedObject || !actualObject) return `${path}: type mismatch`; + const expectedKeys = Object.keys(expected).sort(); + const actualKeys = Object.keys(actual).sort(); + if (expectedKeys.join("\n") !== actualKeys.join("\n")) { + const missing = expectedKeys.filter((key) => !actualKeys.includes(key)); + const extra = actualKeys.filter((key) => !expectedKeys.includes(key)); + return `${path}: object keys differ missing=[${missing.join(",")}] extra=[${extra.join(",")}]`; + } + for (const key of expectedKeys) { + const nested = diff(expected[key], actual[key], `${path}.${key}`); + if (nested) return nested; + } + return null; + } + + return `${path}: ${JSON.stringify(expected)} != ${JSON.stringify(actual)}`; +} + +function main() { + if (process.argv.length !== 4) usage(); + const expected = normalize(readJson(process.argv[2])); + const actual = normalize(readJson(process.argv[3])); + const mismatch = diff(expected, actual, "$"); + if (mismatch) { + console.error(mismatch); + process.exit(1); + } +} + +main();