Compare commits

..

285 Commits

Author SHA1 Message Date
Kirill Sobakin
2ba1c2f740 Merge pull request #188 from itdoginfo/feat/fe-app-podkop
feat: Introduce new fe modular format with minimal scope of refactoring
2025-10-07 17:38:19 +03:00
divocat
5d0f8ce5bf fix: resolve copilot suggestions 2025-10-07 17:23:26 +03:00
divocat
ddad137fc1 Merge branch 'feat/yacd-exp' into feat/fe-app-podkop 2025-10-07 17:16:36 +03:00
divocat
7b2e5d2838 feat: add missing locales 2025-10-07 17:14:28 +03:00
divocat
9a72785fa7 feat: migrate to _ locales handler 2025-10-07 16:55:50 +03:00
divocat
e0874c3775 refactor: make dashboard widgets reactive 2025-10-07 16:26:06 +03:00
divocat
1e6c827f2b fix: cleanup global styles 2025-10-07 01:07:48 +03:00
divocat
c8c0025470 feat: set clash delay timeout to 5s 2025-10-07 01:07:48 +03:00
divocat
c78f97d64f fix: run prettier & remove unused fragments 2025-10-07 01:07:48 +03:00
divocat
7cb43ffb65 feat: implement dashboard tab 2025-10-07 01:07:48 +03:00
divocat
1e4cda9400 feat: add loaders to test latency buttons 2025-10-07 01:07:48 +03:00
divocat
caf82b096f feat: add test latency & select tag functionality 2025-10-07 01:07:48 +03:00
divocat
6117b0ef9b feat: colorize status ans latency 2025-10-07 01:07:48 +03:00
Andrey Petelin
5418187dd3 feat: enable Clash API with YACD or online mode in podkop configuration 2025-10-07 01:07:48 +03:00
Andrey Petelin
31b09cc3d2 feat: conditionally include external_ui in clash_api config if external_ui path is provided 2025-10-07 01:07:48 +03:00
divocat
b2a473573b feat: add vpn section outbound displaying 2025-10-06 15:13:55 +03:00
divocat
aad6d8c002 feat: implement dashboard prototype 2025-10-06 03:43:55 +03:00
divocat
c75dd3e78b feat: add base clash api methods 2025-10-05 18:36:39 +03:00
divocat
341f260fcf refactor: change vless validation logic 2025-10-05 18:13:19 +03:00
divocat
c5e19a0f2d fix: remove unused params for url test string 2025-10-05 16:59:02 +03:00
divocat
d50b6dbab6 fix: correct output format for test 2025-10-05 16:37:56 +03:00
divocat
99c8ead148 fix: correct output format for test
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-05 16:17:35 +03:00
divocat
d605094a9d Merge remote-tracking branch 'origin/feat/fe-app-podkop' into feat/fe-app-podkop 2025-10-05 16:13:08 +03:00
divocat
eb60e6edec fix: run prettier for luci app js assets 2025-10-05 16:12:56 +03:00
itdoginfo
08f5b31d58 CI: Add great succces to summary 2025-10-04 13:19:03 +03:00
itdoginfo
f69e3478c8 CI: Add frontend workflow 2025-10-04 12:47:39 +03:00
divocat
d9a4f50f62 feat: finalize first modular pack 2025-10-04 01:15:12 +03:00
divocat
eb52d52eb4 feat: implement validateProxyUrl validation 2025-10-03 21:44:42 +03:00
divocat
3f4a0cf094 feat: make URLTest Proxy Links options textarea 2025-10-03 21:16:38 +03:00
Kirill Sobakin
b0a8526c90 Merge pull request #187 from itdoginfo/hotfix
Fix DNS server address validation
2025-10-03 16:17:29 +03:00
Andrey Petelin
e9d5b18816 fix: resolve domain resolver DNS server address before IPv4 validation in VPN and DNS configuration sections 2025-10-03 18:07:24 +05:00
divocat
7b06f422af feat: add trojan link support to Proxy Configuration URL validation 2025-10-03 14:32:17 +03:00
divocat
96bcc36cf1 refactor: remove unused variables 2025-10-03 14:12:08 +03:00
divocat
db8e8e8298 refactor: migrate global styles to injectGlobalStyles 2025-10-03 14:12:08 +03:00
divocat
eb0617eef1 fix: corrent naming for User Domains List validation 2025-10-03 14:12:08 +03:00
divocat
8f9bff9a64 refactor: migrate Outbound Configuration validation to modular 2025-10-03 14:12:08 +03:00
divocat
65d3a9253f refactor: migrate Proxy Configuration URL validation to modular 2025-10-03 14:12:08 +03:00
divocat
b99116fbf3 feat: implement ss/vless validations 2025-10-03 14:12:08 +03:00
divocat
8f19f31e7a refactor: migrate User Domains List validation to modular 2025-10-03 14:12:08 +03:00
divocat
327c3d2b68 feat: implement parseValueList helper 2025-10-03 14:12:08 +03:00
divocat
260b7b9558 refactor: migrate User Subnets List validation to modular 2025-10-03 14:12:08 +03:00
divocat
df9dba9742 feat: implement bulk validate 2025-10-03 14:12:08 +03:00
divocat
547feb0e06 feat: implement validateSubnet 2025-10-03 14:12:08 +03:00
divocat
77e141b305 feat: add soft wrap to Proxy Configuration URL textarea 2025-10-03 14:12:08 +03:00
divocat
cfc5d995a8 refactor: change Network Interface filter logic 2025-10-03 14:12:08 +03:00
divocat
e84233a10c refactor: change Interface for monitoring filter logic 2025-10-03 14:12:08 +03:00
divocat
b71c7b379d refactor: change Source Network Interface filter logic 2025-10-03 14:12:08 +03:00
divocat
3988588c9f feat: migrate yacd url to dynamic 2025-10-03 14:12:08 +03:00
divocat
cd133838cb feat: add BOOTSTRAP_DNS_SERVER_OPTIONS to constants 2025-10-03 14:12:08 +03:00
divocat
f58472a53d feat: migrate validatePath to modular 2025-10-03 14:12:08 +03:00
divocat
5e95148492 feat: migrate constants to modular 2025-10-03 14:12:08 +03:00
divocat
df9400514b feat: migrate some validation places of additional tab to modular 2025-10-03 14:12:08 +03:00
divocat
14eec8e600 feat: migrate some validation places of config sections to modular 2025-10-03 14:12:08 +03:00
divocat
294cb21e91 feat: Introduce fe modular build system 2025-10-03 14:12:08 +03:00
Kirill Sobakin
4ef15f7340 Merge pull request #186 from itdoginfo/trojan
Trojan
2025-10-03 14:10:33 +03:00
Andrey Petelin
41563a5828 fix: correct Russian translation for "works on router" in podkop.po file 2025-10-03 16:08:25 +05:00
Andrey Petelin
2e99ee3a17 fix: pass outbound tag to security and transport functions for accurate config updates 2025-10-03 16:03:36 +05:00
Andrey Petelin
a8db33dd28 feat: add Trojan proxy support (#172) 2025-10-03 15:40:16 +05:00
Andrey Petelin
1295e0dcb2 feat: add function to append Trojan outbound to sing-box JSON configuration 2025-10-03 14:19:45 +05:00
Andrey Petelin
b6bec0fc51 refactor: rename VLESS-specific functions to generic outbound transport and TLS setters 2025-10-03 13:45:00 +05:00
Andrey Petelin
769d263be2 chore: update String-example.md with detailed Shadowsocks, VLESS, and Trojan protocol examples and configurations 2025-10-03 13:35:18 +05:00
Kirill Sobakin
470f11699c Merge pull request #184 from itdoginfo/split_dns
Replacing Split DNS with Domain Resolver and Bootstrap DNS
2025-10-02 18:18:28 +03:00
Andrey Petelin
852b6c043a i18n: update Russian translations and template with new DNS and domain resolver entries 2025-10-02 19:52:21 +05:00
Andrey Petelin
f5cafd5573 chore: add --no-location option to msgmerge and msginit to omit source code references in PO files 2025-10-02 19:44:39 +05:00
Andrey Petelin
3562b913a2 chore: update DNS protocol and server field labels 2025-10-02 19:35:40 +05:00
Andrey Petelin
f4ac9dcc77 feat: add domain resolver support to VPN mode 2025-10-02 17:49:23 +05:00
Andrey Petelin
f5a629afcf feat: add optional domain_resolver parameter to interface outbound config function 2025-10-02 17:48:08 +05:00
Andrey Petelin
aea201bf24 fix: replace non-working split DNS with bootstrap DNS for upstream DNS resolution 2025-10-02 15:58:26 +05:00
Kirill Sobakin
1313c3b26f Merge pull request #182 from itdoginfo/translation
Translation
2025-10-02 10:54:26 +03:00
Andrey Petelin
a3f4e942c3 chore: update Russian translation file encoding to UTF-8 and reformat multiline strings for better readability 2025-10-02 11:17:33 +05:00
Andrey Petelin
4d8e4c1c13 chore: set width variable to 120 for consistent msgmerge and xgettext formatting in localization scripts 2025-10-02 11:16:50 +05:00
itdoginfo
0cb5c2daae Stop podkop before update sing-box 2025-10-01 14:38:32 +03:00
Kirill Sobakin
19fbfff555 Merge pull request #180 from itdoginfo/translation
Translation
2025-10-01 10:33:57 +03:00
Kirill Sobakin
75a2ed1e29 Merge pull request #179 from itdoginfo/fix
refactor: Add version checks and service existence validation
2025-09-30 19:26:45 +03:00
Andrey Petelin
759b6748c6 refactor: Replace opkg version checks with direct command execution 2025-09-30 19:55:25 +05:00
Andrey Petelin
0a27784f85 chore: Update translation template and Russian translations 2025-09-30 19:30:23 +05:00
Andrey Petelin
3b95ac2bc3 chore: Add width option and package name to xgettext and msgmerge scripts 2025-09-30 19:29:46 +05:00
Andrey Petelin
5c51d99d73 chore: Improve NTP exclusion option description for clarity 2025-09-30 13:25:12 +05:00
Andrey Petelin
904b90e012 fix: Remove empty string translations from UI labels 2025-09-30 13:06:52 +05:00
Andrey Petelin
5fb8343cf8 fix: Remove translation function from Yacd link in additional settings tab 2025-09-30 13:05:56 +05:00
Andrey Petelin
014f0f4bdf feat: Add scripts for generating and updating translation templates 2025-09-30 13:04:44 +05:00
Andrey Petelin
dd44e0156e fix: restore default cachesize and noresolv values in dnsmasq configuration if unset 2025-09-27 12:22:50 +05:00
Andrey Petelin
927b8a53b0 fix: restore default resolvfile in DNS settings if backup servers are missing to prevent resolution issues 2025-09-27 11:47:01 +05:00
itdoginfo
7ba20905d5 Fix sing-box remove 2025-09-26 13:21:53 +03:00
Andrey Petelin
5b15a56502 fix: Add local declaration for lowest variable and improve opkg status error redirection spacing 2025-09-25 11:43:03 +05:00
Andrey Petelin
c31df68bec refactor: Add version checks and service existence validation for required packages before starting podkop 2025-09-25 11:11:41 +05:00
Kirill Sobakin
0a5229f4f6 Merge pull request #173 from itdoginfo/fix
fix: Remove URL fragment before parsing VLESS links
2025-09-18 11:13:50 +03:00
Andrey Petelin
5ecb6ef997 fix: Remove URL fragment before parsing VLESS links 2025-09-18 12:59:17 +05:00
Kirill Sobakin
340c2b3505 Merge pull request #171 from itdoginfo/fix
Fix
2025-09-17 19:17:58 +03:00
Andrey Petelin
515c0be38b fix: revert changes from issue #148 2025-09-17 21:14:57 +05:00
Andrey Petelin
59c59bcb17 fix: Improve shadowsocks userinfo decoding with format validation and error handling` 2025-09-17 21:09:03 +05:00
Kirill Sobakin
e5eff41a0f Merge pull request #170 from itdoginfo/fix
Fix: Mask urltest_proxy_links and move sing-box config check to init config function
2025-09-17 13:04:32 +03:00
Andrey Petelin
bb1c06951c fix: Exclusion of ruleset subnets from dns rules (#148) 2025-09-17 13:31:00 +05:00
Andrey Petelin
4999840340 fix: Support comments in user domain/subnet parsing 2025-09-17 11:58:55 +05:00
Andrey Petelin
6c5a271105 fix: Move sing-box config check to after temp file creation 2025-09-16 20:11:13 +05:00
Andrey Petelin
e336bb831c fix: Mask urltest_proxy_links in config output 2025-09-16 19:45:32 +05:00
Kirill Sobakin
00db99723c Merge pull request #169 from itdoginfo/urltest
fix: Correct boolean value for interrupt_exist_connections in JSON
2025-09-16 15:14:43 +03:00
Andrey Petelin
5439504de7 fix: Correct boolean value for interrupt_exist_connections in JSON generation 2025-09-16 17:12:19 +05:00
Kirill Sobakin
c3072162de Merge pull request #168 from itdoginfo/urltest
feat: Add URLTest proxy configuration type with dynamic list support
2025-09-16 15:10:37 +03:00
Andrey Petelin
d021636f85 chore: Fix placeholder text typo in proxy links field 2025-09-16 17:09:37 +05:00
Andrey Petelin
a06aac0613 feat: Add URLTest proxy configuration type with dynamic list support 2025-09-16 16:58:39 +05:00
Kirill Sobakin
29159243ea Merge pull request #167 from itdoginfo/fix
Fix
2025-09-16 11:44:27 +03:00
Andrey Petelin
269123600a fix: Correct variable usage in domain/subnet parsing function (#165) 2025-09-16 13:39:40 +05:00
Andrey Petelin
49add27f81 fix: Improve domain validation to support suffix matching (#166) 2025-09-16 13:19:02 +05:00
itdoginfo
c929c74da5 DeepWiki 2025-09-15 23:29:04 +03:00
itdoginfo
bb91144a91 Update 2025-09-15 22:22:10 +03:00
itdoginfo
2291d9fb9d Check 23.05 2025-09-15 22:15:10 +03:00
Kirill Sobakin
f722a513d0 Merge pull request #163 from itdoginfo/fix
fix: Use correct variable for detour service address
2025-09-15 17:27:15 +03:00
Andrey Petelin
a71707f174 fix: Use correct variable for detour service address 2025-09-15 19:22:52 +05:00
Kirill Sobakin
983f05345b Merge pull request #161 from itdoginfo/refactoring
Refactoring
2025-09-15 15:52:22 +03:00
Andrey Petelin
ee246895de fix: Redirect base64 decode errors to /dev/null 2025-09-15 17:41:17 +05:00
Andrey Petelin
27719f90ee feat: Add support for DoH URLs with paths and UDP port specification 2025-09-14 17:51:20 +05:00
Andrey Petelin
4a17cf66a3 refactor: Add file existence checks and improve startup reliability 2025-09-14 09:26:26 +05:00
Andrey Petelin
db956452d1 refactor: Move logging functions to library file 2025-09-14 09:10:42 +05:00
Andrey Petelin
4897d3d292 refactor: Split nftables rules for TCP and UDP protocols separately 2025-09-14 08:55:34 +05:00
itdoginfo
0aa0a4a9c8 Add /usr/lib/podkop path 2025-09-13 19:23:09 +03:00
Andrey Petelin
7d082c5def chore: Update README task status from done to pending 2025-09-12 14:05:11 +05:00
Andrey Petelin
8845749517 chore: Update README.md to mark completed tasks 2025-09-12 14:03:32 +05:00
Andrey Petelin
054ed355cf fix: Add packet_encoding support for VLESS outbound configuration 2025-09-11 17:52:47 +05:00
Andrey Petelin
304c57edfa fix: Fix translation for "Local Subnet List Paths" 2025-09-11 17:04:22 +05:00
Andrey Petelin
8dd33cdde2 fix: Remove non-existent filename variable 2025-09-11 17:01:39 +05:00
Andrey Petelin
3d3fbe3bfb fix: Fix variable name in HTTP proxy check for wget command 2025-09-11 16:58:40 +05:00
Andrey Petelin
427ea3bc9a fix: Remove unused server_address variable from DNS configuration 2025-09-11 16:56:30 +05:00
Andrey Petelin
a7f6a993ac chore: shfmt formatting 2025-09-11 16:40:06 +05:00
Andrey Petelin
074c1a9349 chore: Remove TODO comments from user domains and subnets text input fields 2025-09-11 15:32:39 +05:00
Andrey Petelin
b6a6db71a8 refactor: Implement user domain and subnet list handling 2025-09-11 15:31:21 +05:00
Andrey Petelin
38fcb59ed7 fix: Reload config after commit to ensure runtime consistency 2025-09-11 13:37:49 +05:00
Andrey Petelin
5a2ffcfd38 refactor: Add graceful shutdown handling for dnsmasq reconfiguration 2025-09-11 11:43:58 +05:00
Andrey Petelin
49f12b212d chore: Update required sing-box version to 1.12.0 2025-09-10 19:44:31 +05:00
Andrey Petelin
489c61baa2 refactor: Move constants to constants.sh 2025-09-10 19:40:12 +05:00
Andrey Petelin
d4b5431db4 refactor: Refactor nft rules to use named sets for interfaces and localv4 2025-09-10 17:52:14 +05:00
Andrey Petelin
d0ea39abd0 refactor: Rename TEST_DOMAIN variable to FAKEIP_TEST_DOMAIN 2025-09-10 15:39:23 +05:00
Andrey Petelin
d4e754d2eb refactor: Rename FAKEIP constant to SB_FAKEIP_INET4_RANG 2025-09-10 14:14:15 +05:00
Andrey Petelin
82f9ae4c6a refactor: Remove redundant FakeIP config verification 2025-09-10 14:10:17 +05:00
Andrey Petelin
775b0073d3 refactor: Fixed nft rule for routing tagged traffic to localhost tproxy 2025-09-10 13:33:07 +05:00
Andrey Petelin
b477a8abc0 fix: Assign domain resolver tag correctly to DNS servers 2025-09-09 16:30:11 +05:00
Andrey Petelin
81e0c86060 refactor: Add block section support 2025-09-09 15:51:09 +05:00
Andrey Petelin
191522f396 chore: Rename download_to_tempfile to download_to_file for clarity 2025-09-09 11:48:03 +05:00
Andrey Petelin
79cea7a31a chore: remove .shellcheckrc file 2025-09-09 11:20:19 +05:00
Andrey Petelin
6c094aceae fix: Ensure unique values when patching local source ruleset 2025-09-09 11:15:29 +05:00
Andrey Petelin
1e8c2b50f7 chore: Rename NFT_GENERAL_SET_NAME to NFT_COMMON_SET_NAME 2025-09-08 23:10:53 +05:00
Andrey Petelin
27d2366208 chore: Remove list_update refactor TODO 2025-09-08 23:03:28 +05:00
Andrey Petelin
c1133827a2 fix: Pass HTTP proxy address to download functions for remote subnet imports 2025-09-08 23:00:26 +05:00
Andrey Petelin
a187192a88 chore: Move and rename _get_download_detour_tag to get_download_detour_tag 2025-09-08 22:55:42 +05:00
Andrey Petelin
fe30cf9e55 refactor: Refactoring list_update function 2025-09-08 22:43:27 +05:00
Andrey Petelin
9496a88774 refactor: Add ip addresses to nft set for local ruleset handling 2025-09-08 10:46:29 +05:00
Andrey Petelin
f54e92cd7a fix: Update config key from cache_file to cache_path in cache file configuration 2025-09-08 10:38:49 +05:00
Andrey Petelin
d70a04b144 refactor: Improve dnsmasq configuration logic for DNS handling 2025-09-07 18:11:46 +05:00
Andrey Petelin
e5be9c3fd1 refactor: Avoid unnecessary sing-box config writes by comparing hashes before saving (#128) 2025-09-07 12:45:27 +05:00
Andrey Petelin
9762b9cca4 refactor: Remove unused functions 2025-09-07 12:14:02 +05:00
Andrey Petelin
9d861cf3e0 feat: Add sing-box config path option (#128) 2025-09-07 12:12:08 +05:00
Andrey Petelin
49836e4adc feat: Add local subnet lists support with UI and backend integration (#156) 2025-09-05 21:23:55 +05:00
Andrey Petelin
5273935d25 chore: fix my perfect English 2025-09-05 17:01:36 +05:00
Andrey Petelin
d03167f49d chore: fix my perfect English 2025-09-05 16:49:05 +05:00
Andrey Petelin
da89c5c7df refactor: rename parameters for migration 2025-09-05 15:02:24 +05:00
Andrey Petelin
acfc95e86d refactor: Refactoring configuration of local domain lists 2025-09-05 14:50:43 +05:00
Andrey Petelin
17c1d09aa8 refactor: Enable cron job scheduling 2025-09-04 20:11:16 +05:00
Andrey Petelin
c7e21010bd refactor: Add download helper functions to helpers.sh and remove from main script 2025-09-04 20:09:44 +05:00
Andrey Petelin
f70e2ac557 refactor: Simplify cron job logic and renaming variable 2025-09-04 20:02:43 +05:00
Andrey Petelin
cb4e3036be chore: Remove module logging from log function 2025-09-04 18:00:28 +05:00
Andrey Petelin
12fc6bd9ac chore: undo renaming of taboption classarg 2025-09-04 16:43:39 +05:00
Andrey Petelin
2794cad533 fix: Remove direct outbound from DNS server configuration to prevent invalid detour 2025-09-04 12:35:53 +05:00
Andrey Petelin
9b182a3045 fix: fix detour parameter for remote ruleset 2025-09-04 12:28:38 +05:00
Andrey Petelin
f07d90a524 refactor: intermediate refactoring commit 2025-09-04 12:10:05 +05:00
Andrey Petelin
75fc377c22 Merge branch 'main' into refactoring 2025-09-04 11:52:52 +05:00
itdoginfo
33ecb771f9 Fix UDP over TCP for extra section 2025-09-02 14:44:14 +03:00
itdoginfo
86038e2756 Delay setting option 2025-09-02 14:09:35 +03:00
Andrey Petelin
db91c628c8 Merge remote-tracking branch 'origin/refactoring' into refactoring
# Conflicts:
#	podkop/files/usr/lib/sing_box_config_manager.sh
2025-08-31 20:25:16 +05:00
Andrey Petelin
41ce41945c feat: Add domain_resolver and detour parameters to DNS server configurations in sing-box manager script 2025-08-31 20:22:57 +05:00
Andrey Petelin
2753a44440 feat: Add domain_resolver parameter to DNS server configurations in sing-box manager script 2025-08-31 19:43:21 +05:00
Andrey Petelin
cd1a4e2a8e chore: update example links with valid values 2025-08-31 13:19:04 +05:00
Andrey Petelin
7e041da8c6 feat: add sing-box configuration manager script and jq helpers 2025-08-31 13:09:55 +05:00
itdoginfo
f3f5bca555 Add HODCA, CloudFront, DO 2025-08-29 15:02:43 +03:00
itdoginfo
174f16bc76 Update 2025-08-28 19:11:37 +03:00
itdoginfo
7c63a35faa Old value #131 2025-08-28 18:40:53 +03:00
itdoginfo
86a86df982 Fix template 2025-08-28 18:12:35 +03:00
itdoginfo
ac445bc227 Fix template config 2025-08-28 18:08:10 +03:00
itdoginfo
4398e6885b Add template for issues and PR 2025-08-28 18:06:08 +03:00
itdoginfo
9974b42cc2 NFT: output chain for traffic from the router 2025-08-27 00:31:56 +03:00
itdoginfo
8cd990f8a3 Fix extension for list 2025-08-26 14:25:28 +03:00
itdoginfo
c509fd38c7 Fix version for docker 2025-08-26 14:16:08 +03:00
itdoginfo
38991a803a Fix 2025-08-25 19:44:16 +03:00
itdoginfo
29c34e31db Fix 2025-08-25 19:41:04 +03:00
itdoginfo
a77e8fae7d Disable tag check 2025-08-25 19:13:01 +03:00
itdoginfo
6d83737336 Auto version for make 2025-08-25 19:11:12 +03:00
itdoginfo
84115e2f3b v0.4.7 2025-08-25 17:02:46 +03:00
itdoginfo
2dbdb9d2c1 Global check: WG Route 2025-08-24 16:13:41 +03:00
itdoginfo
88c6717152 Disable delay 2025-08-24 13:44:49 +03:00
itdoginfo
b3986308ce Cut WRP prefix 2025-08-24 13:44:39 +03:00
itdoginfo
a15c3cf171 Update #147 2025-08-24 11:59:40 +03:00
itdoginfo
4c91223f85 Merge pull request #136 from SaltyMonkey:main
User Subnet validation for glob ip, init.d/zapret proper check, passwall in conflicts
2025-08-24 11:05:55 +03:00
itdoginfo
7cf7b1f626 Merge branch 'main' into main 2025-08-24 11:04:39 +03:00
SaltyMonkey
a2536534f8 Remove opkg list-installed checks for packages from conflicts 2025-08-24 10:00:13 +03:00
itdoginfo
c49354fe38 Merge pull request #149 from ampetelin:json_srs_lists
Added support for JSON and SRS
2025-08-23 18:53:37 +03:00
Andrey Petelin
6e01e036eb handle missing ip_cidr in rulesets 2025-08-23 20:42:43 +05:00
Andrey Petelin
7484d0c203 Fixed logging of custom ruleset preparation 2025-08-23 19:36:34 +05:00
Andrey Petelin
0eb4ca4ea9 Removed filepath from wget when downloading via proxy 2025-08-23 19:33:47 +05:00
Andrey Petelin
c2d95162b7 Added support for JSON and SRS rulesets 2025-08-20 21:42:46 +05:00
SaltyMonkey
1fc2947fbc Log messages for nextdns 2025-07-25 09:41:58 +03:00
SaltyMonkey
ea931d8463 added nextdns package in conflicts 2025-07-25 09:38:48 +03:00
SaltyMonkey
e2f36c35d4 Log messages for luci-app-passwall and luci-app-passwall in binary 2025-07-12 01:08:39 +03:00
SaltyMonkey
e8f8dcc5e7 added passwall and passwall2(paid version?) in conflicts 2025-07-12 01:00:00 +03:00
SaltyMonkey
1e2174bb80 User Subnets validation: 0.0.0.0 is not allowed 2025-07-12 00:40:51 +03:00
SaltyMonkey
85e515ef15 Fix /etc/init.d/zapret check in global_check, cleanup useless whitespaces 2025-07-12 00:29:33 +03:00
itdoginfo
418cdc4366 rm iptables-mod-extra check 2025-06-30 16:56:39 +03:00
itdoginfo
25b0dcaad5 v0.4.6 2025-06-30 16:27:44 +03:00
itdoginfo
cc59e756dd br_netfilter. Cache size unset. Mixed & source_ip_cidr 2025-06-30 16:26:31 +03:00
itdoginfo
210714c499 unnecessary check 2025-06-30 16:24:33 +03:00
itdoginfo
8b6c336584 Change to 1.1.1.1 2025-06-27 23:54:52 +03:00
itdoginfo
5c543c1608 Change to procd_add_interface_trigger. Added PROCD_RELOAD_DELAY 2025-06-27 23:54:31 +03:00
itdoginfo
ac274d8796 v0.4.5 2025-06-25 23:38:55 +03:00
itdoginfo
ce1f86ceb7 Added split dns. Func for build sing-box config 2025-06-25 23:34:39 +03:00
itdoginfo
1fd67eefb3 Fix eng phrase 2025-06-25 23:32:33 +03:00
itdoginfo
e7b726d27c Merge pull request #127 from procudin/fix/extra-configs-visibility
fix: hide extra configs for non-basic tabs
2025-06-23 13:58:25 +03:00
Artem Prokudin
adb16e7f74 fix: hide extra configs for non-basic tabs 2025-06-15 10:57:16 +03:00
itdoginfo
51da8c22fd Update 2025-06-03 15:47:13 +03:00
itdoginfo
41351dafd2 Removed the installation awg/wg/ovpn/oc. Refactoring 2025-06-03 15:45:27 +03:00
itdoginfo
2aee77b9a2 v0.4.4 Added independent_cache 2025-06-02 15:40:14 +03:00
itdoginfo
2a1a220dc8 v0.4.3 2025-05-22 12:07:08 +03:00
itdoginfo
608caba090 Merge pull request #115 from itdoginfo/fix/comments
♻️ refactor(podkop): update command scheduling and priority handling
2025-05-22 12:01:53 +03:00
Ivan K
04af8c9649 ♻️ refactor(podkop): update command scheduling and priority handling 2025-05-22 11:09:02 +03:00
itdoginfo
88d108e5ab Fix i18n version 2025-05-21 19:43:27 +03:00
itdoginfo
8ce6790355 v0.4.2 2025-05-21 19:18:07 +03:00
itdoginfo
8e7b40cf56 Ready image 2025-05-21 19:17:55 +03:00
itdoginfo
21fa017443 Merge pull request #114 from itdoginfo/fix/comments
Fix/comments
2025-05-21 15:43:58 +03:00
Ivan K
f1954df83b ♻️ refactor(diagnosticTab): move command execution helpers to utils.js 2025-05-21 15:09:42 +03:00
Ivan K
8573bd99b5 ♻️ refactor(diagnosticTab): remove unused debug 2025-05-21 14:21:37 +03:00
Ivan K
c3f44bd124 fix(diagnosticTab): add error polling and notification system 2025-05-21 14:18:23 +03:00
itdoginfo
59e394c4f2 v0.4.1 JS refactoring 2025-05-21 14:16:16 +03:00
itdoginfo
c897c90371 Merge pull request #110 from itdoginfo/fix/comments
♻️ refactor(podkop): modularize configuration and diagnostics sections
2025-05-21 13:48:30 +03:00
Ivan K
bcab66f88c ♻️ refactor(podkop): enhance check_nft function for domain-specific set statistics 2025-05-21 09:48:53 +03:00
Ivan K
05a551e5e3 💄 style(podkop): remove extra newline in NFT check completed message 2025-05-20 19:28:40 +03:00
Ivan K
1f81ec8403 🔧 chore(podkop): use check_nft in global_check 2025-05-20 17:32:04 +03:00
Ivan K
9748178562 ♻️ refactor(podkop): enhance nft set statistics and chain configurations 2025-05-20 17:28:00 +03:00
Ivan K
1411e7d403 ♻️ refactor(diagnosticTab): improve command execution and UI updates 2025-05-19 20:32:08 +03:00
Ivan K
d81a90bd28 ♻️ refactor(diagnosticTab): improve status updates and caching 2025-05-19 19:59:29 +03:00
Ivan K
82f4720326 ♻️ refactor(podkop): rename sections into correct files 2025-05-18 15:58:59 +03:00
Ivan K
10f246ea61 ♻️ refactor(podkop): move URL validation to config.js 2025-05-16 23:30:23 +03:00
Ivan K
c0571320f1 ♻️ refactor(networkUtils): remove custom network functions 2025-05-16 22:22:07 +03:00
Ivan K
a658ca5518 💄 style(podkop): remove unused networkUtils import 2025-05-16 18:40:29 +03:00
Ivan K
08709c93c7 ♻️ refactor(podkop): rename section variables for clarity 2025-05-16 18:28:06 +03:00
Ivan K
cf5b2216be ♻️ refactor(podkop): reorganize sections into subdirectory 2025-05-16 18:23:16 +03:00
Ivan K
682913ade0 ♻️ refactor(podkop): remove unused parameter from createAdditionalSection 2025-05-16 18:08:29 +03:00
Ivan K
3b2cbd0332 ♻️ refactor(podkop): modularize configuration and diagnostics sections 2025-05-16 18:04:33 +03:00
itdoginfo
8f9dcf2c55 Merge pull request #109 from itdoginfo/fix/comments
♻️ refactor(podkop): refactor domain list and API endpoints
2025-05-16 14:50:59 +03:00
Ivan K
91d027b5fe ♻️ refactor(podkop): refactor domain list and API endpoints 2025-05-16 14:46:06 +03:00
itdoginfo
f90ab7f468 v0.4.0 beta 2025-05-15 13:26:57 +03:00
itdoginfo
e4bfd447ce Merge pull request #108 from itdoginfo/fix/comments
fix(podkop): add dont touch my dhcp logic to fake IP check functions
2025-05-15 12:07:08 +03:00
Ivan K
fbdd759b83 ♻️ refactor(podkop): remove redundant DNS/sing-box checks in fakeip status 2025-05-15 11:53:30 +03:00
Ivan K
2488bc30b1 fix(podkop): add dont touch my dhcp logic to fake IP check functions 2025-05-15 11:31:33 +03:00
itdoginfo
dcc12cf920 Merge pull request #107 from itdoginfo/fix/comments
💄 style(podkop): update modal button titles and clipboard content
2025-05-14 14:33:53 +03:00
Ivan K
c99cef9f27 💄 style(podkop): update modal button titles and clipboard content 2025-05-14 14:30:52 +03:00
itdoginfo
8a68f3fcc2 Update 2025-05-13 14:26:50 +03:00
itdoginfo
ed2994be3a v0.3.50 Adde Google AI, Play, HTZ, OVH of list 2025-05-12 23:31:06 +03:00
itdoginfo
77ff5ab781 Merge pull request #105 from itdoginfo/fix/comments
️ perf(dns): improve DNS query performance and error handling
2025-05-12 18:17:50 +03:00
Ivan K
1c80bc5a5e ️ perf(dns): improve DNS query performance and error handling 2025-05-12 17:50:50 +03:00
itdoginfo
f688d74c32 v0.3.49 Improved diagnostics 2025-05-12 17:32:51 +03:00
itdoginfo
7bc50d58d3 Merge pull request #104 from itdoginfo/fix/comments
Fix/comments
2025-05-12 17:25:59 +03:00
Ivan K
77ce0c380b 🐛 fix(dns): improve DNS availability check logic 2025-05-12 17:21:38 +03:00
Ivan K
47d1b349c7 ♻️ refactor(podkop): add local var 2025-05-12 16:51:59 +03:00
Ivan K
e9face1f4a ♻️ refactor(podkop): simplify logging function 2025-05-12 16:48:59 +03:00
itdoginfo
e5bf7d9bed Merge pull request #103 from itdoginfo/fix/comments
♻️ refactor(podkop): improve diagnostics and error handling
2025-05-12 16:48:15 +03:00
Ivan K
dd4722f3e1 ♻️ refactor(podkop): simplify logging functions 2025-05-12 16:40:44 +03:00
Ivan K
1e945dafe7 feat(logging): add colored logging to stdout and syslog 2025-05-12 15:54:12 +03:00
itdoginfo
b080521a58 Update 2025-05-12 00:57:52 +03:00
itdoginfo
6a96a85773 Update 2025-05-12 00:49:19 +03:00
itdoginfo
6fb3a36974 Update 2025-05-12 00:46:30 +03:00
Ivan K
b3dbee1dbe 💄 style(podkop): adjust margin styles in status panel 2025-05-11 20:30:16 +03:00
Ivan K
916321578d ️ feat(dns): add random DNS query ID generation 2025-05-11 19:33:08 +03:00
Ivan K
c74d733717 ♻️ refactor(podkop): improve diagnostics and error handling 2025-05-11 19:26:29 +03:00
itdoginfo
433724f762 v0.3.48 Custom URL CRLF 2025-05-10 18:55:35 +03:00
itdoginfo
6378aa9910 Update 2025-05-10 16:15:53 +03:00
itdoginfo
68f5f123ca v0.3.47 Fix noresolv 1 2025-05-10 12:50:01 +03:00
itdoginfo
fae43d0471 v0.3.46 2025-05-08 19:24:08 +03:00
itdoginfo
9d6dc45fdb #99 Block mode 2025-05-08 19:23:45 +03:00
itdoginfo
9aa5a2d242 Fix site. #100 added ntpd 2025-05-08 10:14:58 +03:00
itdoginfo
63dc86fca4 v0.3.45 Update checker domain 2025-05-07 22:39:23 +03:00
itdoginfo
4d9cedaf4c Return upgrade command 2025-05-07 17:58:19 +03:00
itdoginfo
14e7cbae01 v0.3.44 2025-05-07 17:26:57 +03:00
itdoginfo
c9f610bb1e Change to ip.podkop.net. Fix log. Added restart 2025-05-07 17:24:32 +03:00
itdoginfo
19671c7f67 Stop btn. Change to ip.podkop.net 2025-05-07 17:23:20 +03:00
itdoginfo
6d1e4091e5 Detour 2025-05-07 00:22:04 +03:00
itdoginfo
96d661c49f Fixed default values ttl in comment 2025-05-03 18:52:57 +03:00
itdoginfo
da8dd06b34 Move doc to wiki 2025-05-03 18:12:00 +03:00
itdoginfo
2c1bcffb6d fix iptables 2025-05-03 18:11:49 +03:00
109 changed files with 15062 additions and 5626 deletions

74
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
---
name: 🐛 Сообщение об ошибке
description: Создавайте только, если проблема точно не на вашей стороне.
title: "[BUG] "
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: |
Спасибо за создание отчета об ошибке!
Перед отправкой, пожалуйста:
- Проверьте [существующие issues](https://github.com/itdoginfo/podkop/issues)
- Просмотрите [документацию](https://podkop.net)
- type: textarea
id: description
attributes:
label: 📝 Описание проблемы
description: Четкое и краткое описание того, что не работает
placeholder: Опишите проблему
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Шаги для воспроизведения
description: Шаги для воспроизведения проблемы. Если вы настраваете что-то по мануалу, приложите ссылку на него.
placeholder: |
1.
2.
3.
4.
validations:
required: true
- type: textarea
id: expected
attributes:
label: ✅ Ожидаемое поведение
description: Четкое и краткое описание того, что должно было произойти
placeholder: Опишите ожидаемое поведение
validations:
required: true
- type: textarea
id: environment
attributes:
label: 🖥️ Информация о системе
description: |
Информация о вашей системе (заполните всё применимое)
value: |
- **OpenWrt версия**:
- **Podkop версия**:
- **Роутер модель**:
- **Sing-box версия**:
render: markdown
validations:
required: true
- type: textarea
id: config
attributes:
label: ⚙️ Конфигурация
description: |
Релевантные части конфигурации (удалите чувствительную информацию!)
placeholder: |
Например:
- Содержимое /etc/config/podkop
- Конфигурация sing-box (если релевантно)
- Дополнительные конфиги, которые потребуются wireless/network/dhcp и т.д.
render: shell

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Если у вас что-то не работает, прежде всего прочитайте README проекта
url: https://github.com/itdoginfo/podkop
about: README проекта
- name: 📚 Если вы не нашли в README документацию, то вот ссылка на неё
url: https://podkop.net
about: Официальная документация PodKop

View File

@@ -0,0 +1,68 @@
---
name: ✨ Запрос новой функции
description: Предложите новую функцию или улучшение для Podkop
title: "[FEATURE] "
labels: ["enhancement", "needs-discussion"]
assignees: []
body:
- type: markdown
attributes:
value: |
Спасибо за предложение новой функции!
Перед отправкой, пожалуйста:
- Проверьте [существующие запросы](https://github.com/itdoginfo/podkop/issues?q=is%3Aissue+label%3Aenhancement)
- Убедитесь, что функции не существует в [документации](https://podkop.net)
- type: textarea
id: summary
attributes:
label: Краткое описание
description: Краткое описание предлагаемой функции
placeholder: В одном предложении опишите, что вы хотите добавить...
validations:
required: true
- type: textarea
id: problem
attributes:
label: Проблема, которую решает
description: |
Описание проблемы или неудобства, которое решит эта функция
placeholder: |
Сейчас нет возможности [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: 💡 Предлагаемое решение
description: Четкое и краткое описание того, что вы хотите реализовать
placeholder: |
Я хочу, чтобы Podkop мог [...]
Предлагаю добавить функцию, которая [...]
Можно было бы улучшить [...] путем [...]
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Workaround
description: |
Опишите альтернативные решения или функции, которые вы рассматривали
Есть ли обходные пути, которые вы используете сейчас?
placeholder: |
Сейчас я решаю это проблему путем [...]
Альтернативой могло бы быть [...]
Пробовал использовать [...], но это не подходит потому что [...]
- type: textarea
id: implementation
attributes:
label: Идеи реализации (опционально)
description: |
Если у вас есть идеи о том, как это можно реализовать, поделитесь ими. Помните про ограничения LuCI.
placeholder: |
Это можно реализовать с помощью [...]

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
# Описание изменений
Краткое описание ваших изменений и их цель.
## Что изменено
Детальное описание изменений:
-
-
-
(Этим вы экономите время ревьювера)

View File

@@ -10,28 +10,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.1
with:
fetch-depth: 0
- name: Check version match
- name: Extract version
id: version
run: |
PODKOP_VERSION=$(grep '^PKG_VERSION:=' podkop/Makefile | cut -d '=' -f 2)
LUCI_APP_PODKOP_VERSION=$(grep '^PKG_VERSION:=' luci-app-podkop/Makefile | cut -d '=' -f 2)
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "Podkop version: $PODKOP_VERSION"
echo "Luci-app-podkop version: $LUCI_APP_PODKOP_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$PODKOP_VERSION" != "$TAG_VERSION" ] || [ "$LUCI_APP_PODKOP_VERSION" != "$TAG_VERSION" ]; then
echo "Error: Version mismatch"
exit 1
fi
VERSION=$(git describe --tags --exact-match 2>/dev/null || echo "dev_$(date +%d%m%Y)")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6.9.0
with:
context: .
tags: podkop:ci
build-args: |
PKG_VERSION=${{ steps.version.outputs.version }}
- name: Create Docker container
run: docker create --name podkop podkop:ci

78
.github/workflows/frontend-ci.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Frontend CI
on:
pull_request:
paths:
- 'fe-app-podkop/**'
- '.github/workflows/frontend-ci.yml'
jobs:
frontend-checks:
name: Frontend Quality Checks
runs-on: ubuntu-24.04
defaults:
run:
working-directory: fe-app-podkop
steps:
- name: Checkout code
uses: actions/checkout@v5.0.0
- name: Setup Node.js
uses: actions/setup-node@v5.0.0
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Get yarn cache directory path
id: yarn-cache-dir-path
working-directory: fe-app-podkop
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v4.3.0
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('fe-app-podkop/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Check formatting
id: format
run: |
yarn format
if ! git diff --exit-code; then
echo "::error::Code is not formatted. Run 'yarn format' locally."
exit 1
fi
- name: Run linter
run: yarn lint --max-warnings=0
- name: Run tests
run: yarn test --run
- name: Build project
id: build
run: |
yarn build
if ! git diff --exit-code; then
echo "::error::Build generated changes. Check build output."
exit 1
fi
- name: Summary
if: always()
run: |
echo "## Frontend CI Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Format check: ${{ steps.format.outcome }}" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Lint check: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Tests: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Build: ${{ steps.build.outcome }}" >> $GITHUB_STEP_SUMMARY
echo "![Success](https://cdn2.combot.org/boratbrat/webp/6xf09f988f.webp)" >> $GITHUB_STEP_SUMMARY

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.idea
fe-app-podkop/node_modules
fe-app-podkop/.env

View File

@@ -1,6 +1,7 @@
FROM openwrt/sdk:x86_64-v23.05.5
FROM itdoginfo/openwrt-sdk:24.10.1
RUN ./scripts/feeds update -a && ./scripts/feeds install luci-base && mkdir -p /builder/package/feeds/utilites/ && mkdir -p /builder/package/feeds/luci/
ARG PKG_VERSION
ENV PKG_VERSION=${PKG_VERSION}
COPY ./podkop /builder/package/feeds/utilites/podkop
COPY ./luci-app-podkop /builder/package/feeds/luci/luci-app-podkop

3
Dockerfile-SDK Normal file
View File

@@ -0,0 +1,3 @@
FROM openwrt/sdk:x86_64-v24.10.1
RUN ./scripts/feeds update -a && ./scripts/feeds install luci-base && mkdir -p /builder/package/feeds/utilites/ && mkdir -p /builder/package/feeds/luci/

202
README.md
View File

@@ -1,191 +1,55 @@
# Вещи, которые вам нужно знать перед установкой
- Это альфа версия, которая находится в активной разработке. Из версии в версию что-то может меняться.
- Основной функционал работает, но побочные штуки сейчас могут сбоить.
- При обновлении **обязательно** сбрасывайте кэш LuCI.
- Это бета-версия, которая находится в активной разработке. Из версии в версию что-то может меняться.
- При возникновении проблем, нужен технически грамотный фидбэк в чат.
- При обновлении **обязательно** [сбрасывайте кэш LuCI](https://podkop.net/docs/clear-browser-cache/).
- Также при обновлении всегда заходите в конфигурацию и проверяйте свои настройки. Конфигурация может измениться.
- Необходимо минимум 15МБ свободного места на роутере. Роутерами с флешками на 16МБ сразу мимо.
- Необходимо минимум 25МБ свободного места на роутере. Роутеры с флешками на 16МБ сразу мимо.
- При старте программы редактируется конфиг Dnsmasq.
- Podkop редактирует конфиг sing-box. Обязательно сохраните ваш конфиг sing-box перед установкой, если он вам нужен.
- Информация здесь может быть устаревшей. Все изменения фиксируются в телеграм-чате https://t.me/itdogchat - топик **Podkop**.
- Если у вас не что-то не работает, то следуюет сходить в телеграм чат, прочитать закрепы и выполнить что там написано..
- Если у вас установлен Getdomains, его следует удалить.
- Информация здесь может быть устаревшей. Все изменения фиксируются в [телеграм-чате](https://t.me/itdogchat/81758/420321).
- [Если у вас не что-то не работает.](https://podkop.net/docs/diagnostics/)
- Если у вас установлен Getdomains, [его следует удалить](https://github.com/itdoginfo/domain-routing-openwrt?tab=readme-ov-file#%D1%81%D0%BA%D1%80%D0%B8%D0%BF%D1%82-%D0%B4%D0%BB%D1%8F-%D1%83%D0%B4%D0%B0%D0%BB%D0%B5%D0%BD%D0%B8%D1%8F).
- Требуется версия OpenWrt 24.10.
# Удаление GetDomains скриптом
```
sh <(wget -O - https://raw.githubusercontent.com/itdoginfo/domain-routing-openwrt/refs/heads/master/getdomains-uninstall.sh)
```
Оставляет туннели, зоны, forwarding. А также stubby и dnscrypt. Они не помешают. Конфиг sing-box будет перезаписан в podkop.
# Документация
https://podkop.net/
# Установка Podkop
Пакет работает на всех архитектурах.
Тестировался на **ванильной** OpenWrt 23.05 и OpenWrt 24.10.
На FriendlyWrt 23.05 присуствуют зависимости от iptables, которые ломают tproxy. Если у вас появляется warning про это в логах, следуйте инструкции по приведённой там ссылке.
Полная информация в [документации](https://podkop.net/docs/install/)
Поддержки APK на данный момент нет. APK будет сделан после того как разгребу основное.
## Автоматическая
Вкратце, достаточно одного скрипта для установки и обновления:
```
sh <(wget -O - https://raw.githubusercontent.com/itdoginfo/podkop/refs/heads/main/install.sh)
```
Скрипт также предложит выбрать, какой туннель будет использоваться. Для выбранного туннеля будут установлены нужные пакеты, а для Wireguard и AmneziaWG также будет предложена автоматическая настройка - прямо в консоли скрипт запросит данные конфига. Для AmneziaWG можно также выбрать вариант с использованием конфига обычного Wireguard и автоматической обфускацией до AmneziaWG.
Для AmneziaWG скрипт проверяет наличие пакетов под вашу платформу в [стороннем репозитории](https://github.com/Slava-Shchipunov/awg-openwrt/releases), так как в официальном репозитории OpenWRT они отсутствуют, и автоматически их устанавливает.
## Вручную
Сделать `opkg update`, чтоб установились зависимости.
Скачать пакеты `podkop_*.ipk` и `luci-app-podkop_*.ipk` из релиза. `opkg install` сначала первый, потом второй.
# Обновление
Та же самая команда, что для установки. Но с флагом **upgrade** сразу передёт к обновлению.
```
sh <(wget -qO- https://raw.githubusercontent.com/itdoginfo/podkop/refs/heads/main/install.sh) --upgrade
```
# Удаление
```
opkg remove luci-i18n-podkop-ru luci-app-podkop podkop
```
# Использование
Конфиг: /etc/config/podkop
Luci: Services/podkop
## Режимы
### Proxy
Для VLESS и Shadowsocks. Другие протоколы тоже будут, кидайте в чат примеры строк без чувствительных данных.
В этом режиме просто копируйте строку в **Proxy String** и из неё автоматически настроится sing-box.
### VPN
Здесь у вас должен быть уже настроен WG/OpenVPN/OpenConnect etc, зона Zone и Forwarding не обязательны.
Просто выбрать интерфейс из списка.
## Настройка доменов и подсетей
**Community Lists** - Включить списки комьюнити
**Custom domains enable** - Добавить свои домены
**Custom subnets enable** - Добавить подсети или IP-адреса. Для подсетей задать маску.
# ToDo
Этот раздел не означает задачи, которые нужно брать и делать. Это общий список хотелок. Если вы хотите помочь, пожалуйста, спросите сначала в телеграмме.
Основные задачи в issues.
Низкий приоритет
- [ ] Галочка, которая режет доступ к doh серверам
- [ ] IPv6. Только после наполнения Wiki
## Рефактор
- [x] Очевидные повторения в `/usr/bin/podkop` загнать в переменые
- [x] Возможно поменять структуру
Рефактор
- [ ] Handle для sing-box
- [ ] Handle для dnsmasq
## Списки
- [x] CloudFront
- [x] DO
- [x] HODCA
## Будущее
- [ ] [Подписка](https://github.com/itdoginfo/podkop/issues/118). Здесь нужна реализация, чтоб для каждой секции помимо ручного выбора, был выбор фильтрации по тегу. Например, для main выбираем ключевые слова NL, DE, FI. А для extra секции фильтруем по RU. И создаётся outbound c urltest в которых перечислены outbound из фильтров.
- [x] Опция, когда все запросы (с роутера в первую очередь), а не только br-lan идут в прокси. С этим связана #95. Требуется много переделать для nftables.
- [ ] Весь трафик в Proxy\VPN. Вопрос, что делать с экстрасекциями в этом случае. FakeIP здесь скорее не нужен, а значит только main секция остаётся. Всё что касается fakeip проверок, придётся выключать в этом режиме.
- [x] Поддержка Source format. Нужна расшифровка в json и если присуствуют подсети, заносить их в custom subnet nftset.
- [x] Переделывание функции формирования кастомных списков в JSON. Обрабатывать сразу скопом, а не по одному.
- [ ] При успешном запуске переходит в фоновый режим и следит за состоянием sing-box. Если вдруг идёт exit 1, выполняется dnsmasq restore и снова следит за состоянием. Вопрос в том, как это искусcтвенно провернуть. Попробовать положить прокси и посмотреть, останется ли работать DNS в этом случае. И здесь, вероятно, можно обойтись триггером в init.d. [Issue](https://github.com/itdoginfo/podkop/issues/111)
- [x] Формирование конфига sing-box в /tmp
- [ ] Галочка, которая режет доступ к doh серверам.
- [ ] IPv6. Только после наполнения Wiki.
## Тесты
- [ ] Unit тесты (BATS)
- [ ] Интеграционые тесты бекенда (OpenWrt rootfs + BATS)
# Don't touch my dhcp
Нужно в первую очередь, чтоб использовать опцию `server`.
В случае если опция активна, podkop не трогает /etc/config/dhcp. И вам требуется самостоятельно указать следующие значения:
```
option noresolv '1'
option cachesize '0'
list server '127.0.0.42'
```
Без этого podkop работать не будет.
Если нужно до определённых доменов ходить через определённый DNS-сервер, то конфиг выглядит так
```
option noresolv '1'
option cachesize '0'
list server '/itdog.info/1.1.1.1'
list server '127.0.0.42'
```
В этом случае домен и все субдомены ресурса itdog.info будут резолвится через DNS-сервер 1.1.1.1
# Bad WAN
При использовании опции **Interface monitoring** необходимо рестартовать podkop, чтоб init.d подхватил это
```
service podkop restart
```
# Разработка
Есть два варианта:
- Просто поставить пакет на роутер или виртуалку и прям редактировать через SFTP (opkg install openssh-sftp-server)
- SDK, чтоб собирать пакеты
Для сборки пакетов нужен SDK, один из вариантов скачать прям файл и разархивировать
https://downloads.openwrt.org/releases/23.05.5/targets/x86/64/
Нужен файл с SDK в имени
```
wget https://downloads.openwrt.org/releases/23.05.5/targets/x86/64/openwrt-sdk-23.05.5-x86-64_gcc-12.3.0_musl.Linux-x86_64.tar.xz
tar xf openwrt-sdk-23.05.5-x86-64_gcc-12.3.0_musl.Linux-x86_64.tar.xz
mv openwrt-sdk-23.05.5-x86-64_gcc-12.3.0_musl.Linux-x86_64 SDK
```
Последнее для удобства.
Создаём директорию для пакета
```
mkdir package/utilites
```
Симлинк из репозитория
```
ln -s ~/podkop/podkop package/utilites/podkop
ln -s ~/podkop/luci-app-podkop package/luci-app-podkop
```
В первый раз для сборки luci-app необходимо обновить пакеты
```
./scripts/feeds update -a
```
Для make можно добавить флаг -j N, где N - количество ядер для сборки. Первый раз пройдёт быстрее.
При первом make выводится менюшка, можно просто save, exit и всё. Первый раз долго грузит зависимости.
Сборка пакета. Сами пакеты собираются быстро.
```
make package/podkop/{clean,compile} V=s
```
Также для luci
```
make package/luci-app-podkop/{clean,compile} V=s
```
.ipk лежат в `bin/packages/x86_64/base/`
## Примеры строк
https://github.com/itdoginfo/podkop/blob/main/String-example.md
## Ошибки
```
Makefile:17: /SDK/feeds/luci/luci.mk: No such file or directory
make[2]: *** No rule to make target '/SDK/feeds/luci/luci.mk'. Stop.
time: package/luci/luci-app-podkop/clean#0.00#0.00#0.00
ERROR: package/luci/luci-app-podkop failed to build.
make[1]: *** [package/Makefile:129: package/luci/luci-app-podkop/clean] Error 1
make[1]: Leaving directory '/SDK'
make: *** [/SDK/include/toplevel.mk:226: package/luci-app-podkop/clean] Error 2
```
Не загружены пакеты для luci
## make зависимости
https://openwrt.org/docs/guide-developer/toolchain/install-buildsystem
Ubuntu
```
sudo apt update
sudo apt install build-essential clang flex bison g++ gawk \
gcc-multilib g++-multilib gettext git libncurses-dev libssl-dev \
python3-distutils rsync unzip zlib1g-dev file wget
```
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/itdoginfo/podkop)

View File

@@ -1,63 +1,76 @@
# Shadowsocks
Тут всё просто
## Shadowsocks-old
## Shadowsocks
```
ss://YWVzLTI1Ni1nY206RmJwUDJnSStPczJKK1kzdkVhTnVuOUZ2ZjJZYUhNUlN1L1BBdEVqMks1VT0@example.com:80?type=tcp#example-ss-old
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
```
## Shadowsocks-2022
## VLESS
```
ss://2022-blake3-aes-128-gcm:5NgF%2B9eM8h4OnrTbHp%2B8UA%3D%3D%3Am8tbs5aKLYG7dN9f3xsiKA%3D%3D@example.com:80#example-ss2022
# 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://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
```
ss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Y21lZklCdDhwMTJaZm1QWUplMnNCNThRd3R3NXNKeVpUV0Z6ZENKV2taOD06eEJHZUxiMWNPTjFIeE9CenF6UlN0VFdhUUh6YWM2cFhRVFNZd2dVV2R1RT0@example.com:81?type=tcp#example-ss2022
```
Может быть без `?type=tcp`
# 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
# VLESS
# mKCP
trojan://N5v7iIOe9G@127.0.0.1:36319?type=kcp&headerType=none&seed=P91wFIfjzZ&security=none#trojan-mKCP
## Reality
```
vless://eb445f4b-ddb4-4c79-86d5-0833fc674379@example.com:443?type=tcp&security=reality&pbk=ARQzddtXPJZHinwkPbgVpah9uwPTuzdjU9GpbUkQJkc&fp=chrome&sni=yahoo.com&sid=6cabf01472a3&spx=%2F&flow=xtls-rprx-vision#vless-reality
```
# 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
```
vless://UUID@IP:2082?security=reality&sni=dash.cloudflare.com&alpn=h2,http/1.1&allowInsecure=1&fp=chrome&pbk=pukkey&sid=id&type=grpc&encryption=none#vless-reality-strange
```
# 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
## TLS
1.
```
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443?type=tcp&security=tls&fp=&alpn=h3%2Ch2%2Chttp%2F1.1#vless-tls
```
# 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
2.
```
vless://8b60389a-7a01-4365-9244-c87f12bb98cf@example.com:443?security=tls&sni=SITE&fp=chrome&type=tcp&flow=xtls-rprx-vision&encryption=none#vless-tls-withot-alpn
```
3.
```
vless://8b60389a-7a01-4365-9244-c87f12bb98cf@example.com:443/?type=ws&encryption=none&path=%2Fwebsocket&security=tls&sni=sni.server.com&fp=chrome#vless-tls-ws
```
4.
```
vless://[someid]@[someserver]?security=tls&sni=[somesni]&type=ws&path=/?ed%3D2560&host=[somesni]&encryption=none#vless-tls-ws-2
```
5.
```
vless://uuid@server:443?security=tls&sni=server&fp=chrome&type=ws&path=/websocket&encryption=none#vless-tls-ws-3
```
6.
```
vless://33333@example.com:443/?type=ws&encryption=none&path=%2Fwebsocket&security=tls&sni=example.com&fp=chrome#vless-tls-ws-4
```
## No security
```
vless://8b60389a-7a01-4365-9244-c87f12bb98cf@example.com:443?type=tcp&security=none#vless-tls-no-encrypt
# XHTTP
trojan://VEetltxLtw@127.0.0.1:59072?type=xhttp&path=%2Fxhttppath&host=google.com&mode=auto&security=none#trojan-xhttp
```

View File

@@ -0,0 +1,8 @@
{
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true
}

View File

@@ -0,0 +1,27 @@
// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['node_modules', 'watch-upload.js'],
},
{
rules: {
'no-console': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
prettier,
];

View File

@@ -0,0 +1,31 @@
{
"name": "fe-app-podkop",
"version": "1.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"format": "prettier --write src",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"build": "tsup src/main.ts",
"dev": "tsup src/main.ts --watch",
"test": "vitest",
"ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build",
"watch:sftp": "node watch-upload.js"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "8.45.0",
"@typescript-eslint/parser": "8.45.0",
"chokidar": "4.0.3",
"dotenv": "17.2.3",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"glob": "11.0.3",
"prettier": "3.6.2",
"ssh2-sftp-client": "12.0.1",
"tsup": "8.5.0",
"typescript": "5.9.3",
"typescript-eslint": "8.45.0",
"vitest": "3.2.4"
}
}

View File

@@ -0,0 +1,2 @@
export * from './types';
export * from './methods';

View File

@@ -0,0 +1,28 @@
import { IBaseApiResponse } from '../types';
export async function createBaseApiRequest<T>(
fetchFn: () => Promise<Response>,
): Promise<IBaseApiResponse<T>> {
try {
const response = await fetchFn();
if (!response.ok) {
return {
success: false as const,
message: `${_('HTTP error')} ${response.status}: ${response.statusText}`,
};
}
const data: T = await response.json();
return {
success: true as const,
data,
};
} catch (e) {
return {
success: false as const,
message: e instanceof Error ? e.message : _('Unknown error'),
};
}
}

View File

@@ -0,0 +1,14 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashConfig(): Promise<
IBaseApiResponse<ClashAPI.Config>
> {
return createBaseApiRequest<ClashAPI.Config>(() =>
fetch(`${getClashApiUrl()}/configs`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -0,0 +1,20 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashGroupDelay(
group: string,
url = 'https://www.gstatic.com/generate_204',
timeout = 2000,
): Promise<IBaseApiResponse<ClashAPI.Delays>> {
const endpoint = `${getClashApiUrl()}/group/${group}/delay?url=${encodeURIComponent(
url,
)}&timeout=${timeout}`;
return createBaseApiRequest<ClashAPI.Delays>(() =>
fetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -0,0 +1,14 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashProxies(): Promise<
IBaseApiResponse<ClashAPI.Proxies>
> {
return createBaseApiRequest<ClashAPI.Proxies>(() =>
fetch(`${getClashApiUrl()}/proxies`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -0,0 +1,14 @@
import { ClashAPI, IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function getClashVersion(): Promise<
IBaseApiResponse<ClashAPI.Version>
> {
return createBaseApiRequest<ClashAPI.Version>(() =>
fetch(`${getClashApiUrl()}/version`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
}

View File

@@ -0,0 +1,7 @@
export * from './createBaseApiRequest';
export * from './getConfig';
export * from './getGroupDelay';
export * from './getProxies';
export * from './getVersion';
export * from './triggerProxySelector';
export * from './triggerLatencyTest';

View File

@@ -0,0 +1,35 @@
import { IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function triggerLatencyGroupTest(
tag: string,
timeout: number = 5000,
url: string = 'https://www.gstatic.com/generate_204',
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(
`${getClashApiUrl()}/group/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
),
);
}
export async function triggerLatencyProxyTest(
tag: string,
timeout: number = 2000,
url: string = 'https://www.gstatic.com/generate_204',
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(
`${getClashApiUrl()}/proxies/${tag}/delay?url=${encodeURIComponent(url)}&timeout=${timeout}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
),
);
}

View File

@@ -0,0 +1,16 @@
import { IBaseApiResponse } from '../types';
import { createBaseApiRequest } from './createBaseApiRequest';
import { getClashApiUrl } from '../../helpers';
export async function triggerProxySelector(
selector: string,
outbound: string,
): Promise<IBaseApiResponse<void>> {
return createBaseApiRequest<void>(() =>
fetch(`${getClashApiUrl()}/proxies/${selector}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: outbound }),
}),
);
}

View File

@@ -0,0 +1,53 @@
export type IBaseApiResponse<T> =
| {
success: true;
data: T;
}
| {
success: false;
message: string;
};
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ClashAPI {
export interface Version {
meta: boolean;
premium: boolean;
version: string;
}
export interface Config {
port: number;
'socks-port': number;
'redir-port': number;
'tproxy-port': number;
'mixed-port': number;
'allow-lan': boolean;
'bind-address': string;
mode: 'Rule' | 'Global' | 'Direct';
'mode-list': string[];
'log-level': 'debug' | 'info' | 'warn' | 'error';
ipv6: boolean;
tun: null | Record<string, unknown>;
}
export interface ProxyHistoryEntry {
time: string;
delay: number;
}
export interface ProxyBase {
type: string;
name: string;
udp: boolean;
history: ProxyHistoryEntry[];
now?: string;
all?: string[];
}
export interface Proxies {
proxies: Record<string, ProxyBase>;
}
export type Delays = Record<string, number>;
}

View File

@@ -0,0 +1,107 @@
export const STATUS_COLORS = {
SUCCESS: '#4caf50',
ERROR: '#f44336',
WARNING: '#ff9800',
};
export const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi';
export const IP_CHECK_DOMAIN = 'ip.podkop.fyi';
export const REGIONAL_OPTIONS = [
'russia_inside',
'russia_outside',
'ukraine_inside',
];
export const ALLOWED_WITH_RUSSIA_INSIDE = [
'russia_inside',
'meta',
'twitter',
'discord',
'telegram',
'cloudflare',
'google_ai',
'google_play',
'hetzner',
'ovh',
'hodca',
'digitalocean',
'cloudfront',
];
export const DOMAIN_LIST_OPTIONS = {
russia_inside: 'Russia inside',
russia_outside: 'Russia outside',
ukraine_inside: 'Ukraine',
geoblock: 'Geo Block',
block: 'Block',
porn: 'Porn',
news: 'News',
anime: 'Anime',
youtube: 'Youtube',
discord: 'Discord',
meta: 'Meta',
twitter: 'Twitter (X)',
hdrezka: 'HDRezka',
tiktok: 'Tik-Tok',
telegram: 'Telegram',
cloudflare: 'Cloudflare',
google_ai: 'Google AI',
google_play: 'Google Play',
hodca: 'H.O.D.C.A',
hetzner: 'Hetzner ASN',
ovh: 'OVH ASN',
digitalocean: 'Digital Ocean ASN',
cloudfront: 'CloudFront ASN',
};
export const UPDATE_INTERVAL_OPTIONS = {
'1h': 'Every hour',
'3h': 'Every 3 hours',
'12h': 'Every 12 hours',
'1d': 'Every day',
'3d': 'Every 3 days',
};
export const DNS_SERVER_OPTIONS = {
'1.1.1.1': '1.1.1.1 (Cloudflare)',
'8.8.8.8': '8.8.8.8 (Google)',
'9.9.9.9': '9.9.9.9 (Quad9)',
'dns.adguard-dns.com': 'dns.adguard-dns.com (AdGuard Default)',
'unfiltered.adguard-dns.com':
'unfiltered.adguard-dns.com (AdGuard Unfiltered)',
'family.adguard-dns.com': 'family.adguard-dns.com (AdGuard Family)',
};
export const BOOTSTRAP_DNS_SERVER_OPTIONS = {
'77.88.8.8': '77.88.8.8 (Yandex DNS)',
'77.88.8.1': '77.88.8.1 (Yandex DNS)',
'1.1.1.1': '1.1.1.1 (Cloudflare DNS)',
'1.0.0.1': '1.0.0.1 (Cloudflare DNS)',
'8.8.8.8': '8.8.8.8 (Google DNS)',
'8.8.4.4': '8.8.4.4 (Google DNS)',
'9.9.9.9': '9.9.9.9 (Quad9 DNS)',
'9.9.9.11': '9.9.9.11 (Quad9 DNS)',
};
export const DIAGNOSTICS_UPDATE_INTERVAL = 10000; // 10 seconds
export const CACHE_TIMEOUT = DIAGNOSTICS_UPDATE_INTERVAL - 1000; // 9 seconds
export const ERROR_POLL_INTERVAL = 10000; // 10 seconds
export const COMMAND_TIMEOUT = 10000; // 10 seconds
export const FETCH_TIMEOUT = 10000; // 10 seconds
export const BUTTON_FEEDBACK_TIMEOUT = 1000; // 1 second
export const DIAGNOSTICS_INITIAL_DELAY = 100; // 100 milliseconds
// Command scheduling intervals in diagnostics (in milliseconds)
export const COMMAND_SCHEDULING = {
P0_PRIORITY: 0, // Highest priority (no delay)
P1_PRIORITY: 100, // Very high priority
P2_PRIORITY: 300, // High priority
P3_PRIORITY: 500, // Above average
P4_PRIORITY: 700, // Standard priority
P5_PRIORITY: 900, // Below average
P6_PRIORITY: 1100, // Low priority
P7_PRIORITY: 1300, // Very low priority
P8_PRIORITY: 1500, // Background execution
P9_PRIORITY: 1700, // Idle mode execution
P10_PRIORITY: 1900, // Lowest priority
} as const;

View File

@@ -0,0 +1,32 @@
import { COMMAND_TIMEOUT } from '../constants';
import { withTimeout } from './withTimeout';
interface ExecuteShellCommandParams {
command: string;
args: string[];
timeout?: number;
}
interface ExecuteShellCommandResponse {
stdout: string;
stderr: string;
code?: number;
}
export async function executeShellCommand({
command,
args,
timeout = COMMAND_TIMEOUT,
}: ExecuteShellCommandParams): Promise<ExecuteShellCommandResponse> {
try {
return withTimeout(
fs.exec(command, args),
timeout,
[command, ...args].join(' '),
);
} catch (err) {
const error = err as Error;
return { stdout: '', stderr: error?.message, code: 0 };
}
}

View File

@@ -0,0 +1,4 @@
export function getBaseUrl(): string {
const { protocol, hostname } = window.location;
return `${protocol}//${hostname}`;
}

View File

@@ -0,0 +1,11 @@
export function getClashApiUrl(): string {
const { protocol, hostname } = window.location;
return `${protocol}//${hostname}:9090`;
}
export function getClashWsUrl(): string {
const { hostname } = window.location;
return `ws://${hostname}:9090`;
}

View File

@@ -0,0 +1,13 @@
export function getProxyUrlName(url: string) {
try {
const [_link, hash] = url.split('#');
if (!hash) {
return '';
}
return decodeURIComponent(hash);
} catch {
return '';
}
}

View File

@@ -0,0 +1,9 @@
export * from './getBaseUrl';
export * from './parseValueList';
export * from './injectGlobalStyles';
export * from './withTimeout';
export * from './executeShellCommand';
export * from './maskIP';
export * from './getProxyUrlName';
export * from './onMount';
export * from './getClashApiUrl';

View File

@@ -0,0 +1,12 @@
import { GlobalStyles } from '../styles';
export function injectGlobalStyles() {
document.head.insertAdjacentHTML(
'beforeend',
`
<style>
${GlobalStyles}
</style>
`,
);
}

View File

@@ -0,0 +1,5 @@
export function maskIP(ip: string = ''): string {
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
return ip.replace(ipv4Regex, (_match, _p1, _p2, _p3, p4) => `XX.XX.XX.${p4}`);
}

View File

@@ -0,0 +1,30 @@
export async function onMount(id: string): Promise<HTMLElement> {
return new Promise((resolve) => {
const el = document.getElementById(id);
if (el && el.offsetParent !== null) {
return resolve(el);
}
const observer = new MutationObserver(() => {
const target = document.getElementById(id);
if (target) {
const io = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (visible) {
observer.disconnect();
io.disconnect();
resolve(target);
}
});
io.observe(target);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
}

View File

@@ -0,0 +1,9 @@
export function parseValueList(value: string): string[] {
return value
.split(/\n/) // Split to array by newline separator
.map((line) => line.split('//')[0]) // Remove comments
.join(' ') // Build clean string
.split(/[,\s]+/) // Split to array by comma and space
.map((s) => s.trim()) // Remove extra spaces
.filter(Boolean); // Leave nonempty items
}

View File

@@ -0,0 +1,12 @@
// steal from https://github.com/sindresorhus/pretty-bytes/blob/master/index.js
export function prettyBytes(n: number) {
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
if (n < 1000) {
return n + ' B';
}
const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1);
n = Number((n / Math.pow(1000, exponent)).toPrecision(3));
const unit = UNITS[exponent];
return n + ' ' + unit;
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { maskIP } from '../maskIP';
export const validIPs = [
['Standard private IP', '192.168.0.1', 'XX.XX.XX.1'],
['Public IP', '8.8.8.8', 'XX.XX.XX.8'],
['Mixed digits', '10.0.255.99', 'XX.XX.XX.99'],
['Edge values', '255.255.255.255', 'XX.XX.XX.255'],
['Zeros', '0.0.0.0', 'XX.XX.XX.0'],
];
export const invalidIPs = [
['Empty string', '', ''],
['Missing octets', '192.168.1', '192.168.1'],
['Extra octets', '1.2.3.4.5', '1.2.3.4.5'],
['Letters inside', 'abc.def.ghi.jkl', 'abc.def.ghi.jkl'],
['Spaces inside', '1. 2.3.4', '1. 2.3.4'],
['Just dots', '...', '...'],
['IP with port', '127.0.0.1:8080', '127.0.0.1:8080'],
['IP with text', 'ip=192.168.0.1', 'ip=192.168.0.1'],
];
describe('maskIP', () => {
describe.each(validIPs)('Valid IPv4: %s', (_desc, ip, expected) => {
it(`masks "${ip}" → "${expected}"`, () => {
expect(maskIP(ip)).toBe(expected);
});
});
describe.each(invalidIPs)(
'Invalid or malformed IP: %s',
(_desc, ip, expected) => {
it(`returns original string for "${ip}"`, () => {
expect(maskIP(ip)).toBe(expected);
});
},
);
it('defaults to empty string if no param passed', () => {
expect(maskIP()).toBe('');
});
});

View File

@@ -0,0 +1,21 @@
export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operationName: string,
timeoutMessage = _('Operation timed out'),
): Promise<T> {
let timeoutId;
const start = performance.now();
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timeoutId);
const elapsed = performance.now() - start;
console.log(`[${operationName}] Execution time: ${elapsed.toFixed(2)} ms`);
}
}

40
fe-app-podkop/src/luci.d.ts vendored Normal file
View File

@@ -0,0 +1,40 @@
type HtmlTag = keyof HTMLElementTagNameMap;
type HtmlElement<T extends HtmlTag> = HTMLElementTagNameMap[T];
type HtmlAttributes<T extends HtmlTag = 'div'> = Partial<
Omit<HtmlElement<T>, 'style' | 'children'> & {
style?: string | Partial<CSSStyleDeclaration>;
class?: string;
onclick?: (event: MouseEvent) => void;
}
>;
declare global {
const fs: {
exec(
command: string,
args?: string[],
env?: Record<string, string>,
): Promise<{
stdout: string;
stderr: string;
code?: number;
}>;
};
const E: <T extends HtmlTag>(
type: T,
attr?: HtmlAttributes<T> | null,
children?: (Node | string)[] | Node | string,
) => HTMLElementTagNameMap[T];
const uci: {
load: (packages: string | string[]) => Promise<string>;
sections: (conf: string, type?: string, cb?: () => void) => Promise<T>;
};
const _ = (_key: string) => string;
}
export {};

10
fe-app-podkop/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
'use strict';
'require baseclass';
'require fs';
'require uci';
export * from './validators';
export * from './helpers';
export * from './clash';
export * from './podkop';
export * from './constants';

View File

@@ -0,0 +1,3 @@
export * from './methods';
export * from './services';
export * from './tabs';

View File

@@ -0,0 +1,5 @@
import { Podkop } from '../types';
export async function getConfigSections(): Promise<Podkop.ConfigSection[]> {
return uci.load('podkop').then(() => uci.sections('podkop'));
}

View File

@@ -0,0 +1,153 @@
import { Podkop } from '../types';
import { getConfigSections } from './getConfigSections';
import { getClashProxies } from '../../clash';
import { getProxyUrlName } from '../../helpers';
interface IGetDashboardSectionsResponse {
success: boolean;
data: Podkop.OutboundGroup[];
}
export async function getDashboardSections(): Promise<IGetDashboardSectionsResponse> {
const configSections = await getConfigSections();
const clashProxies = await getClashProxies();
if (!clashProxies.success) {
return {
success: false,
data: [],
};
}
const proxies = Object.entries(clashProxies.data.proxies).map(
([key, value]) => ({
code: key,
value,
}),
);
const data = configSections
.filter((section) => section.mode !== 'block')
.map((section) => {
if (section.mode === 'proxy') {
if (section.proxy_config_type === 'url') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName:
getProxyUrlName(section.proxy_string) ||
outbound?.value?.name ||
'',
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
if (section.proxy_config_type === 'outbound') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName:
decodeURIComponent(JSON.parse(section.outbound_json)?.tag) ||
outbound?.value?.name ||
'',
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
if (section.proxy_config_type === 'urltest') {
const selector = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-urltest-out`,
);
const outbounds = (outbound?.value?.all ?? [])
.map((code) => proxies.find((item) => item.code === code))
.map((item, index) => ({
code: item?.code || '',
displayName:
getProxyUrlName(section.urltest_proxy_links?.[index]) ||
item?.value?.name ||
'',
latency: item?.value?.history?.[0]?.delay || 0,
type: item?.value?.type || '',
selected: selector?.value?.now === item?.code,
}));
return {
withTagSelect: true,
code: selector?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || '',
displayName: _('Fastest'),
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: selector?.value?.now === outbound?.code,
},
...outbounds,
],
};
}
}
if (section.mode === 'vpn') {
const outbound = proxies.find(
(proxy) => proxy.code === `${section['.name']}-out`,
);
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName: section.interface || outbound?.value?.name || '',
latency: outbound?.value?.history?.[0]?.delay || 0,
type: outbound?.value?.type || '',
selected: true,
},
],
};
}
return {
withTagSelect: false,
code: section['.name'],
displayName: section['.name'],
outbounds: [],
};
});
return {
success: true,
data,
};
}

View File

@@ -0,0 +1,21 @@
import { executeShellCommand } from '../../helpers';
export async function getPodkopStatus(): Promise<{
enabled: number;
status: string;
}> {
const response = await executeShellCommand({
command: '/usr/bin/podkop',
args: ['get_status'],
timeout: 1000,
});
if (response.stdout) {
return JSON.parse(response.stdout.replace(/\n/g, '')) as {
enabled: number;
status: string;
};
}
return { enabled: 0, status: 'unknown' };
}

View File

@@ -0,0 +1,23 @@
import { executeShellCommand } from '../../helpers';
export async function getSingboxStatus(): Promise<{
running: number;
enabled: number;
status: string;
}> {
const response = await executeShellCommand({
command: '/usr/bin/podkop',
args: ['get_sing_box_status'],
timeout: 1000,
});
if (response.stdout) {
return JSON.parse(response.stdout.replace(/\n/g, '')) as {
running: number;
enabled: number;
status: string;
};
}
return { running: 0, enabled: 0, status: 'unknown' };
}

View File

@@ -0,0 +1,4 @@
export * from './getConfigSections';
export * from './getDashboardSections';
export * from './getPodkopStatus';
export * from './getSingboxStatus';

View File

@@ -0,0 +1,13 @@
import { TabServiceInstance } from './tab.service';
import { store } from '../../store';
export function coreService() {
TabServiceInstance.onChange((activeId, tabs) => {
store.set({
tabService: {
current: activeId || '',
all: tabs.map((tab) => tab.id),
},
});
});
}

View File

@@ -0,0 +1,2 @@
export * from './tab.service';
export * from './core.service';

View File

@@ -0,0 +1,92 @@
type TabInfo = {
el: HTMLElement;
id: string;
active: boolean;
};
type TabChangeCallback = (activeId: string | null, allTabs: TabInfo[]) => void;
export class TabService {
private static instance: TabService;
private observer: MutationObserver | null = null;
private callback?: TabChangeCallback;
private lastActiveId: string | null = null;
private constructor() {
this.init();
}
public static getInstance(): TabService {
if (!TabService.instance) {
TabService.instance = new TabService();
}
return TabService.instance;
}
private init() {
this.observer = new MutationObserver(() => this.handleMutations());
this.observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class'],
});
// initial check
this.notify();
}
private handleMutations() {
this.notify();
}
private getTabsInfo(): TabInfo[] {
const tabs = Array.from(
document.querySelectorAll<HTMLElement>('.cbi-tab, .cbi-tab-disabled'),
);
return tabs.map((el) => ({
el,
id: el.dataset.tab || '',
active:
el.classList.contains('cbi-tab') &&
!el.classList.contains('cbi-tab-disabled'),
}));
}
private getActiveTabId(): string | null {
const active = document.querySelector<HTMLElement>(
'.cbi-tab:not(.cbi-tab-disabled)',
);
return active?.dataset.tab || null;
}
private notify() {
const tabs = this.getTabsInfo();
const activeId = this.getActiveTabId();
if (activeId !== this.lastActiveId) {
this.lastActiveId = activeId;
this.callback?.(activeId, tabs);
}
}
public onChange(callback: TabChangeCallback) {
this.callback = callback;
this.notify();
}
public getAllTabs(): TabInfo[] {
return this.getTabsInfo();
}
public getActiveTab(): string | null {
return this.getActiveTabId();
}
public disconnect() {
this.observer?.disconnect();
this.observer = null;
}
}
export const TabServiceInstance = TabService.getInstance();

View File

@@ -0,0 +1,2 @@
export * from './renderDashboard';
export * from './initDashboardController';

View File

@@ -0,0 +1,393 @@
import {
getDashboardSections,
getPodkopStatus,
getSingboxStatus,
} from '../../methods';
import { getClashWsUrl, onMount } from '../../../helpers';
import {
triggerLatencyGroupTest,
triggerLatencyProxyTest,
triggerProxySelector,
} from '../../../clash';
import { store, StoreType } from '../../../store';
import { socket } from '../../../socket';
import { prettyBytes } from '../../../helpers/prettyBytes';
import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
// Fetchers
async function fetchDashboardSections() {
const prev = store.get().sectionsWidget;
store.set({
sectionsWidget: {
...prev,
failed: false,
},
});
const { data, success } = await getDashboardSections();
store.set({
sectionsWidget: {
loading: false,
failed: !success,
data,
},
});
}
async function fetchServicesInfo() {
const [podkop, singbox] = await Promise.all([
getPodkopStatus(),
getSingboxStatus(),
]);
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled },
},
});
}
async function connectToClashSockets() {
socket.subscribe(
`${getClashWsUrl()}/traffic?token=`,
(msg) => {
const parsedMsg = JSON.parse(msg);
store.set({
bandwidthWidget: {
loading: false,
failed: false,
data: { up: parsedMsg.up, down: parsedMsg.down },
},
});
},
(_err) => {
store.set({
bandwidthWidget: {
loading: false,
failed: true,
data: { up: 0, down: 0 },
},
});
},
);
socket.subscribe(
`${getClashWsUrl()}/connections?token=`,
(msg) => {
const parsedMsg = JSON.parse(msg);
store.set({
trafficTotalWidget: {
loading: false,
failed: false,
data: {
downloadTotal: parsedMsg.downloadTotal,
uploadTotal: parsedMsg.uploadTotal,
},
},
systemInfoWidget: {
loading: false,
failed: false,
data: {
connections: parsedMsg.connections?.length,
memory: parsedMsg.memory,
},
},
});
},
(_err) => {
store.set({
trafficTotalWidget: {
loading: false,
failed: true,
data: { downloadTotal: 0, uploadTotal: 0 },
},
systemInfoWidget: {
loading: false,
failed: true,
data: {
connections: 0,
memory: 0,
},
},
});
},
);
}
// Handlers
async function handleChooseOutbound(selector: string, tag: string) {
await triggerProxySelector(selector, tag);
await fetchDashboardSections();
}
async function handleTestGroupLatency(tag: string) {
await triggerLatencyGroupTest(tag);
await fetchDashboardSections();
}
async function handleTestProxyLatency(tag: string) {
await triggerLatencyProxyTest(tag);
await fetchDashboardSections();
}
function replaceTestLatencyButtonsWithSkeleton() {
document
.querySelectorAll('.dashboard-sections-grid-item-test-latency')
.forEach((el) => {
const newDiv = document.createElement('div');
newDiv.className = 'skeleton';
newDiv.style.width = '99px';
newDiv.style.height = '28px';
el.replaceWith(newDiv);
});
}
// Renderer
async function renderSectionsWidget() {
console.log('renderSectionsWidget');
const sectionsWidget = store.get().sectionsWidget;
const container = document.getElementById('dashboard-sections-grid');
if (sectionsWidget.loading || sectionsWidget.failed) {
const renderedWidget = renderSections({
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section: {
code: '',
displayName: '',
outbounds: [],
withTagSelect: false,
},
onTestLatency: () => {},
onChooseOutbound: () => {},
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidgets = sectionsWidget.data.map((section) =>
renderSections({
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section,
onTestLatency: (tag) => {
replaceTestLatencyButtonsWithSkeleton();
if (section.withTagSelect) {
return handleTestGroupLatency(tag);
}
return handleTestProxyLatency(tag);
},
onChooseOutbound: (selector, tag) => {
handleChooseOutbound(selector, tag);
},
}),
);
return container!.replaceChildren(...renderedWidgets);
}
async function renderBandwidthWidget() {
console.log('renderBandwidthWidget');
const traffic = store.get().bandwidthWidget;
const container = document.getElementById('dashboard-widget-traffic');
if (traffic.loading || traffic.failed) {
const renderedWidget = renderWidget({
loading: traffic.loading,
failed: traffic.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: traffic.loading,
failed: traffic.failed,
title: _('Traffic'),
items: [
{ key: _('Uplink'), value: `${prettyBytes(traffic.data.up)}/s` },
{ key: _('Downlink'), value: `${prettyBytes(traffic.data.down)}/s` },
],
});
container!.replaceChildren(renderedWidget);
}
async function renderTrafficTotalWidget() {
console.log('renderTrafficTotalWidget');
const trafficTotalWidget = store.get().trafficTotalWidget;
const container = document.getElementById('dashboard-widget-traffic-total');
if (trafficTotalWidget.loading || trafficTotalWidget.failed) {
const renderedWidget = renderWidget({
loading: trafficTotalWidget.loading,
failed: trafficTotalWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: trafficTotalWidget.loading,
failed: trafficTotalWidget.failed,
title: _('Traffic Total'),
items: [
{
key: _('Uplink'),
value: String(prettyBytes(trafficTotalWidget.data.uploadTotal)),
},
{
key: _('Downlink'),
value: String(prettyBytes(trafficTotalWidget.data.downloadTotal)),
},
],
});
container!.replaceChildren(renderedWidget);
}
async function renderSystemInfoWidget() {
console.log('renderSystemInfoWidget');
const systemInfoWidget = store.get().systemInfoWidget;
const container = document.getElementById('dashboard-widget-system-info');
if (systemInfoWidget.loading || systemInfoWidget.failed) {
const renderedWidget = renderWidget({
loading: systemInfoWidget.loading,
failed: systemInfoWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: systemInfoWidget.loading,
failed: systemInfoWidget.failed,
title: _('System info'),
items: [
{
key: _('Active Connections'),
value: String(systemInfoWidget.data.connections),
},
{
key: _('Memory Usage'),
value: String(prettyBytes(systemInfoWidget.data.memory)),
},
],
});
container!.replaceChildren(renderedWidget);
}
async function renderServicesInfoWidget() {
console.log('renderServicesInfoWidget');
const servicesInfoWidget = store.get().servicesInfoWidget;
const container = document.getElementById('dashboard-widget-service-info');
if (servicesInfoWidget.loading || servicesInfoWidget.failed) {
const renderedWidget = renderWidget({
loading: servicesInfoWidget.loading,
failed: servicesInfoWidget.failed,
title: '',
items: [],
});
return container!.replaceChildren(renderedWidget);
}
const renderedWidget = renderWidget({
loading: servicesInfoWidget.loading,
failed: servicesInfoWidget.failed,
title: _('Services info'),
items: [
{
key: _('Podkop'),
value: servicesInfoWidget.data.podkop
? _('✔ Enabled')
: _('✘ Disabled'),
attributes: {
class: servicesInfoWidget.data.podkop
? 'pdk_dashboard-page__widgets-section__item__row--success'
: 'pdk_dashboard-page__widgets-section__item__row--error',
},
},
{
key: _('Sing-box'),
value: servicesInfoWidget.data.singbox
? _('✔ Running')
: _('✘ Stopped'),
attributes: {
class: servicesInfoWidget.data.singbox
? 'pdk_dashboard-page__widgets-section__item__row--success'
: 'pdk_dashboard-page__widgets-section__item__row--error',
},
},
],
});
container!.replaceChildren(renderedWidget);
}
async function onStoreUpdate(
next: StoreType,
prev: StoreType,
diff: Partial<StoreType>,
) {
if (diff.sectionsWidget) {
renderSectionsWidget();
}
if (diff.bandwidthWidget) {
renderBandwidthWidget();
}
if (diff.trafficTotalWidget) {
renderTrafficTotalWidget();
}
if (diff.systemInfoWidget) {
renderSystemInfoWidget();
}
if (diff.servicesInfoWidget) {
renderServicesInfoWidget();
}
}
export async function initDashboardController(): Promise<void> {
onMount('dashboard-status').then(() => {
// Remove old listener
store.unsubscribe(onStoreUpdate);
// Clear store
store.reset();
// Add new listener
store.subscribe(onStoreUpdate);
// Initial sections fetch
fetchDashboardSections();
fetchServicesInfo();
connectToClashSockets();
});
}

View File

@@ -0,0 +1,54 @@
import { renderSections } from './renderSections';
import { renderWidget } from './renderWidget';
export function renderDashboard() {
return E(
'div',
{
id: 'dashboard-status',
class: 'pdk_dashboard-page',
},
[
// Widgets section
E('div', { class: 'pdk_dashboard-page__widgets-section' }, [
E(
'div',
{ id: 'dashboard-widget-traffic' },
renderWidget({ loading: true, failed: false, title: '', items: [] }),
),
E(
'div',
{ id: 'dashboard-widget-traffic-total' },
renderWidget({ loading: true, failed: false, title: '', items: [] }),
),
E(
'div',
{ id: 'dashboard-widget-system-info' },
renderWidget({ loading: true, failed: false, title: '', items: [] }),
),
E(
'div',
{ id: 'dashboard-widget-service-info' },
renderWidget({ loading: true, failed: false, title: '', items: [] }),
),
]),
// All outbounds
E(
'div',
{ id: 'dashboard-sections-grid' },
renderSections({
loading: true,
failed: false,
section: {
code: '',
displayName: '',
outbounds: [],
withTagSelect: false,
},
onTestLatency: () => {},
onChooseOutbound: () => {},
}),
),
],
);
}

View File

@@ -0,0 +1,125 @@
import { Podkop } from '../../types';
interface IRenderSectionsProps {
loading: boolean;
failed: boolean;
section: Podkop.OutboundGroup;
onTestLatency: (tag: string) => void;
onChooseOutbound: (selector: string, tag: string) => void;
}
function renderFailedState() {
return E(
'div',
{
class: 'pdk_dashboard-page__outbound-section centered',
style: 'height: 127px',
},
E('span', {}, _('Dashboard currently unavailable')),
);
}
function renderLoadingState() {
return E('div', {
id: 'dashboard-sections-grid-skeleton',
class: 'pdk_dashboard-page__outbound-section skeleton',
style: 'height: 127px',
});
}
export function renderDefaultState({
section,
onChooseOutbound,
onTestLatency,
}: IRenderSectionsProps) {
function testLatency() {
if (section.withTagSelect) {
return onTestLatency(section.code);
}
if (section.outbounds.length) {
return onTestLatency(section.outbounds[0].code);
}
}
function renderOutbound(outbound: Podkop.Outbound) {
function getLatencyClass() {
if (!outbound.latency) {
return 'pdk_dashboard-page__outbound-grid__item__latency--empty';
}
if (outbound.latency < 200) {
return 'pdk_dashboard-page__outbound-grid__item__latency--green';
}
if (outbound.latency < 400) {
return 'pdk_dashboard-page__outbound-grid__item__latency--yellow';
}
return 'pdk_dashboard-page__outbound-grid__item__latency--red';
}
return E(
'div',
{
class: `pdk_dashboard-page__outbound-grid__item ${outbound.selected ? 'pdk_dashboard-page__outbound-grid__item--active' : ''} ${section.withTagSelect ? 'pdk_dashboard-page__outbound-grid__item--selectable' : ''}`,
click: () =>
section.withTagSelect &&
onChooseOutbound(section.code, outbound.code),
},
[
E('b', {}, outbound.displayName),
E('div', { class: 'pdk_dashboard-page__outbound-grid__item__footer' }, [
E(
'div',
{ class: 'pdk_dashboard-page__outbound-grid__item__type' },
outbound.type,
),
E(
'div',
{ class: getLatencyClass() },
outbound.latency ? `${outbound.latency}ms` : 'N/A',
),
]),
],
);
}
return E('div', { class: 'pdk_dashboard-page__outbound-section' }, [
// Title with test latency
E('div', { class: 'pdk_dashboard-page__outbound-section__title-section' }, [
E(
'div',
{
class: 'pdk_dashboard-page__outbound-section__title-section__title',
},
section.displayName,
),
E(
'button',
{
class: 'btn dashboard-sections-grid-item-test-latency',
click: () => testLatency(),
},
'Test latency',
),
]),
E(
'div',
{ class: 'pdk_dashboard-page__outbound-grid' },
section.outbounds.map((outbound) => renderOutbound(outbound)),
),
]);
}
export function renderSections(props: IRenderSectionsProps) {
if (props.failed) {
return renderFailedState();
}
if (props.loading) {
return renderLoadingState();
}
return renderDefaultState(props);
}

View File

@@ -0,0 +1,78 @@
interface IRenderWidgetProps {
loading: boolean;
failed: boolean;
title: string;
items: Array<{
key: string;
value: string;
attributes?: {
class?: string;
};
}>;
}
function renderFailedState() {
return E(
'div',
{
id: '',
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item centered',
},
_('Currently unavailable'),
);
}
function renderLoadingState() {
return E(
'div',
{
id: '',
style: 'height: 78px',
class: 'pdk_dashboard-page__widgets-section__item skeleton',
},
'',
);
}
function renderDefaultState({ title, items }: IRenderWidgetProps) {
return E('div', { class: 'pdk_dashboard-page__widgets-section__item' }, [
E(
'b',
{ class: 'pdk_dashboard-page__widgets-section__item__title' },
title,
),
...items.map((item) =>
E(
'div',
{
class: `pdk_dashboard-page__widgets-section__item__row ${item?.attributes?.class || ''}`,
},
[
E(
'span',
{ class: 'pdk_dashboard-page__widgets-section__item__row__key' },
`${item.key}: `,
),
E(
'span',
{ class: 'pdk_dashboard-page__widgets-section__item__row__value' },
item.value,
),
],
),
),
]);
}
export function renderWidget(props: IRenderWidgetProps) {
if (props.loading) {
return renderLoadingState();
}
if (props.failed) {
return renderFailedState();
}
return renderDefaultState(props);
}

View File

@@ -0,0 +1 @@
export * from './dashboard';

View File

@@ -0,0 +1,56 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Podkop {
export interface Outbound {
code: string;
displayName: string;
latency: number;
type: string;
selected: boolean;
}
export interface OutboundGroup {
withTagSelect: boolean;
code: string;
displayName: string;
outbounds: Outbound[];
}
export interface ConfigProxyUrlTestSection {
mode: 'proxy';
proxy_config_type: 'urltest';
urltest_proxy_links: string[];
}
export interface ConfigProxyUrlSection {
mode: 'proxy';
proxy_config_type: 'url';
proxy_string: string;
}
export interface ConfigProxyOutboundSection {
mode: 'proxy';
proxy_config_type: 'outbound';
outbound_json: string;
}
export interface ConfigVpnSection {
mode: 'vpn';
interface: string;
}
export interface ConfigBlockSection {
mode: 'block';
}
export type ConfigBaseSection =
| ConfigProxyUrlTestSection
| ConfigProxyUrlSection
| ConfigProxyOutboundSection
| ConfigVpnSection
| ConfigBlockSection;
export type ConfigSection = ConfigBaseSection & {
'.name': string;
'.type': 'main' | 'extra';
};
}

121
fe-app-podkop/src/socket.ts Normal file
View File

@@ -0,0 +1,121 @@
// eslint-disable-next-line
type Listener = (data: any) => void;
type ErrorListener = (error: Event | string) => void;
class SocketManager {
private static instance: SocketManager;
private sockets = new Map<string, WebSocket>();
private listeners = new Map<string, Set<Listener>>();
private connected = new Map<string, boolean>();
private errorListeners = new Map<string, Set<ErrorListener>>();
private constructor() {}
static getInstance(): SocketManager {
if (!SocketManager.instance) {
SocketManager.instance = new SocketManager();
}
return SocketManager.instance;
}
connect(url: string): void {
if (this.sockets.has(url)) return;
const ws = new WebSocket(url);
this.sockets.set(url, ws);
this.connected.set(url, false);
this.listeners.set(url, new Set());
this.errorListeners.set(url, new Set());
ws.addEventListener('open', () => {
this.connected.set(url, true);
console.info(`Connected: ${url}`);
});
ws.addEventListener('message', (event) => {
const handlers = this.listeners.get(url);
if (handlers) {
for (const handler of handlers) {
try {
handler(event.data);
} catch (err) {
console.error(`Handler error for ${url}:`, err);
}
}
}
});
ws.addEventListener('close', () => {
this.connected.set(url, false);
console.warn(`Disconnected: ${url}`);
this.triggerError(url, 'Connection closed');
});
ws.addEventListener('error', (err) => {
console.error(`Socket error for ${url}:`, err);
this.triggerError(url, err);
});
}
subscribe(url: string, listener: Listener, onError?: ErrorListener): void {
if (!this.sockets.has(url)) {
this.connect(url);
}
this.listeners.get(url)?.add(listener);
if (onError) {
this.errorListeners.get(url)?.add(onError);
}
}
unsubscribe(url: string, listener: Listener, onError?: ErrorListener): void {
this.listeners.get(url)?.delete(listener);
if (onError) {
this.errorListeners.get(url)?.delete(onError);
}
}
// eslint-disable-next-line
send(url: string, data: any): void {
const ws = this.sockets.get(url);
if (ws && this.connected.get(url)) {
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
} else {
console.warn(`Cannot send: not connected to ${url}`);
this.triggerError(url, 'Not connected');
}
}
disconnect(url: string): void {
const ws = this.sockets.get(url);
if (ws) {
ws.close();
this.sockets.delete(url);
this.listeners.delete(url);
this.errorListeners.delete(url);
this.connected.delete(url);
}
}
disconnectAll(): void {
for (const url of this.sockets.keys()) {
this.disconnect(url);
}
}
private triggerError(url: string, err: Event | string): void {
const handlers = this.errorListeners.get(url);
if (handlers) {
for (const cb of handlers) {
try {
cb(err);
} catch (e) {
console.error(`Error handler threw for ${url}:`, e);
}
}
}
}
}
export const socket = SocketManager.getInstance();

179
fe-app-podkop/src/store.ts Normal file
View File

@@ -0,0 +1,179 @@
import { Podkop } from './podkop/types';
function jsonStableStringify<T, V>(obj: T): string {
return JSON.stringify(obj, (_, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value)
.sort()
.reduce(
(acc, key) => {
acc[key] = value[key];
return acc;
},
{} as Record<string, V>,
);
}
return value;
});
}
function jsonEqual<A, B>(a: A, b: B): boolean {
try {
return jsonStableStringify(a) === jsonStableStringify(b);
} catch {
return false;
}
}
type Listener<T> = (next: T, prev: T, diff: Partial<T>) => void;
// eslint-disable-next-line
class Store<T extends Record<string, any>> {
private value: T;
private readonly initial: T;
private listeners = new Set<Listener<T>>();
private lastHash = '';
constructor(initial: T) {
this.value = initial;
this.initial = structuredClone(initial);
this.lastHash = jsonStableStringify(initial);
}
get(): T {
return this.value;
}
set(next: Partial<T>): void {
const prev = this.value;
const merged = { ...prev, ...next };
if (jsonEqual(prev, merged)) return;
this.value = merged;
this.lastHash = jsonStableStringify(merged);
const diff: Partial<T> = {};
for (const key in merged) {
if (!jsonEqual(merged[key], prev[key])) diff[key] = merged[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
reset(): void {
const prev = this.value;
const next = structuredClone(this.initial);
if (jsonEqual(prev, next)) return;
this.value = next;
this.lastHash = jsonStableStringify(next);
const diff: Partial<T> = {};
for (const key in next) {
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
subscribe(cb: Listener<T>): () => void {
this.listeners.add(cb);
cb(this.value, this.value, {});
return () => this.listeners.delete(cb);
}
unsubscribe(cb: Listener<T>): void {
this.listeners.delete(cb);
}
patch<K extends keyof T>(key: K, value: T[K]): void {
this.set({ [key]: value } as unknown as Partial<T>);
}
getKey<K extends keyof T>(key: K): T[K] {
return this.value[key];
}
subscribeKey<K extends keyof T>(
key: K,
cb: (value: T[K]) => void,
): () => void {
let prev = this.value[key];
const wrapper: Listener<T> = (val) => {
if (!jsonEqual(val[key], prev)) {
prev = val[key];
cb(val[key]);
}
};
this.listeners.add(wrapper);
return () => this.listeners.delete(wrapper);
}
}
export interface StoreType {
tabService: {
current: string;
all: string[];
};
bandwidthWidget: {
loading: boolean;
failed: boolean;
data: { up: number; down: number };
};
trafficTotalWidget: {
loading: boolean;
failed: boolean;
data: { downloadTotal: number; uploadTotal: number };
};
systemInfoWidget: {
loading: boolean;
failed: boolean;
data: { connections: number; memory: number };
};
servicesInfoWidget: {
loading: boolean;
failed: boolean;
data: { singbox: number; podkop: number };
};
sectionsWidget: {
loading: boolean;
failed: boolean;
data: Podkop.OutboundGroup[];
};
}
const initialStore: StoreType = {
tabService: {
current: '',
all: [],
},
bandwidthWidget: {
loading: true,
failed: false,
data: { up: 0, down: 0 },
},
trafficTotalWidget: {
loading: true,
failed: false,
data: { downloadTotal: 0, uploadTotal: 0 },
},
systemInfoWidget: {
loading: true,
failed: false,
data: { connections: 0, memory: 0 },
},
servicesInfoWidget: {
loading: true,
failed: false,
data: { singbox: 0, podkop: 0 },
},
sectionsWidget: {
loading: true,
failed: false,
data: [],
},
};
export const store = new Store<StoreType>(initialStore);

177
fe-app-podkop/src/styles.ts Normal file
View File

@@ -0,0 +1,177 @@
// language=CSS
export const GlobalStyles = `
.cbi-value {
margin-bottom: 10px !important;
}
#diagnostics-status .table > div {
background: var(--background-color-primary);
border: 1px solid var(--border-color-medium);
border-radius: var(--border-radius);
}
#diagnostics-status .table > div pre,
#diagnostics-status .table > div div[style*="monospace"] {
color: var(--color-text-primary);
}
#diagnostics-status .alert-message {
background: var(--background-color-primary);
border-color: var(--border-color-medium);
}
#cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra {
display: none;
}
#cbi-podkop-main-_status > div {
width: 100%;
}
/* Dashboard styles */
.pdk_dashboard-page {
width: 100%;
--dashboard-grid-columns: 4;
}
@media (max-width: 900px) {
.pdk_dashboard-page {
--dashboard-grid-columns: 2;
}
}
.pdk_dashboard-page__widgets-section {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr);
grid-gap: 10px;
}
.pdk_dashboard-page__widgets-section__item {
border: 2px var(--background-color-low, lightgray) solid;
border-radius: 4px;
padding: 10px;
}
.pdk_dashboard-page__widgets-section__item__title {}
.pdk_dashboard-page__widgets-section__item__row {}
.pdk_dashboard-page__widgets-section__item__row--success .pdk_dashboard-page__widgets-section__item__row__value {
color: var(--success-color-medium, green);
}
.pdk_dashboard-page__widgets-section__item__row--error .pdk_dashboard-page__widgets-section__item__row__value {
color: var(--error-color-medium, red);
}
.pdk_dashboard-page__widgets-section__item__row__key {}
.pdk_dashboard-page__widgets-section__item__row__value {}
.pdk_dashboard-page__outbound-section {
margin-top: 10px;
border: 2px var(--background-color-low, lightgray) solid;
border-radius: 4px;
padding: 10px;
}
.pdk_dashboard-page__outbound-section__title-section {
display: flex;
align-items: center;
justify-content: space-between;
}
.pdk_dashboard-page__outbound-section__title-section__title {
color: var(--text-color-high);
font-weight: 700;
}
.pdk_dashboard-page__outbound-grid {
margin-top: 5px;
display: grid;
grid-template-columns: repeat(var(--dashboard-grid-columns), 1fr);
grid-gap: 10px;
}
.pdk_dashboard-page__outbound-grid__item {
border: 2px var(--background-color-low, lightgray) solid;
border-radius: 4px;
padding: 10px;
transition: border 0.2s ease;
}
.pdk_dashboard-page__outbound-grid__item--selectable {
cursor: pointer;
}
.pdk_dashboard-page__outbound-grid__item--selectable:hover {
border-color: var(--primary-color-high, dodgerblue);
}
.pdk_dashboard-page__outbound-grid__item--active {
border-color: var(--success-color-medium, green);
}
.pdk_dashboard-page__outbound-grid__item__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
.pdk_dashboard-page__outbound-grid__item__type {}
.pdk_dashboard-page__outbound-grid__item__latency--empty {
color: var(--primary-color-low, lightgray);
}
.pdk_dashboard-page__outbound-grid__item__latency--green {
color: var(--success-color-medium, green);
}
.pdk_dashboard-page__outbound-grid__item__latency--yellow {
color: var(--warn-color-medium, orange);
}
.pdk_dashboard-page__outbound-grid__item__latency--red {
color: var(--error-color-medium, red);
}
.centered {
display: flex;
align-items: center;
justify-content: center;
}
/* Skeleton styles*/
.skeleton {
background-color: var(--background-color-low, #e0e0e0);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton::after {
content: '';
position: absolute;
top: 0;
left: -150%;
width: 150%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: skeleton-shimmer 1.6s infinite;
}
@keyframes skeleton-shimmer {
100% {
left: 150%;
}
}
`;

View File

@@ -0,0 +1,13 @@
import { BulkValidationResult, ValidationResult } from './types';
export function bulkValidate<T>(
values: T[],
validate: (value: T) => ValidationResult,
): BulkValidationResult<T> {
const results = values.map((value) => ({ ...validate(value), value }));
return {
valid: results.every((r) => r.valid),
results,
};
}

View File

@@ -0,0 +1,12 @@
export * from './validateIp';
export * from './validateDomain';
export * from './validateDns';
export * from './validateUrl';
export * from './validatePath';
export * from './validateSubnet';
export * from './bulkValidate';
export * from './validateShadowsocksUrl';
export * from './validateVlessUrl';
export * from './validateOutboundJson';
export * from './validateTrojanUrl';
export * from './validateProxyUrl';

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { validateDNS } from '../validateDns.js';
import { invalidIPs, validIPs } from './validateIp.test';
import { invalidDomains, validDomains } from './validateDomain.test';
const validDns = [...validIPs, ...validDomains];
const invalidDns = [...invalidIPs, ...invalidDomains];
describe('validateDns', () => {
describe.each(validDns)('Valid dns: %s', (_desc, domain) => {
it(`returns valid=true for "${domain}"`, () => {
const res = validateDNS(domain);
expect(res.valid).toBe(true);
});
});
describe.each(invalidDns)('Invalid dns: %s', (_desc, domain) => {
it(`returns valid=false for "${domain}"`, () => {
const res = validateDNS(domain);
expect(res.valid).toBe(false);
});
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { validateDomain } from '../validateDomain';
export const validDomains = [
['Simple domain', 'example.com'],
['Subdomain', 'sub.example.com'],
['With dash', 'my-site.org'],
['With numbers', 'site123.net'],
['Deep subdomain', 'a.b.c.example.co.uk'],
['With path', 'example.com/path/to/resource'],
['Punycode RU', 'xn--d1acufc.xn--p1ai'],
['Adguard dns', 'dns.adguard-dns.com'],
['Nextdns dns', 'dns.nextdns.io/xxxxxxx'],
['Long domain (63 chars in label)', 'a'.repeat(63) + '.com'],
];
export const invalidDomains = [
['No TLD', 'localhost'],
['Only TLD', '.com'],
['Double dot', 'example..com'],
['Illegal chars', 'exa!mple.com'],
['Space inside', 'exa mple.com'],
['Ending with dash', 'example-.com'],
['Starting with dash', '-example.com'],
['Trailing dot', 'example.com.'],
['Too short TLD', 'example.c'],
['With protocol (not allowed)', 'http://example.com'],
['Too long label (>63 chars)', 'a'.repeat(64) + '.com'],
['Too long domain (>253 chars)', Array(40).fill('abcdef').join('.') + '.com'],
];
describe('validateDomain', () => {
describe.each(validDomains)('Valid domain: %s', (_desc, domain) => {
it(`returns valid=true for "${domain}"`, () => {
const res = validateDomain(domain);
expect(res.valid).toBe(true);
});
});
describe.each(invalidDomains)('Invalid domain: %s', (_desc, domain) => {
it(`returns valid=false for "${domain}"`, () => {
const res = validateDomain(domain);
expect(res.valid).toBe(false);
});
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { validateIPV4 } from '../validateIp';
export const validIPs = [
['Private LAN', '192.168.1.1'],
['All zeros', '0.0.0.0'],
['Broadcast', '255.255.255.255'],
['Simple', '1.2.3.4'],
['Loopback', '127.0.0.1'],
];
export const invalidIPs = [
['Octet too large', '256.0.0.1'],
['Too few octets', '192.168.1'],
['Too many octets', '1.2.3.4.5'],
['Leading zero (1st octet)', '01.2.3.4'],
['Leading zero (2nd octet)', '1.02.3.4'],
['Leading zero (3rd octet)', '1.2.003.4'],
['Leading zero (4th octet)', '1.2.3.004'],
['Four digits in octet', '1.2.3.0004'],
['Trailing dot', '1.2.3.'],
];
describe('validateIPV4', () => {
describe.each(validIPs)('Valid IP: %s', (_desc, ip) => {
it(`returns {valid:true} for "${ip}"`, () => {
const res = validateIPV4(ip);
expect(res.valid).toBe(true);
});
});
describe.each(invalidIPs)('Invalid IP: %s', (_desc, ip) => {
it(`returns {valid:false} for "${ip}"`, () => {
const res = validateIPV4(ip);
expect(res.valid).toBe(false);
});
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { validatePath } from '../validatePath';
export const validPaths = [
['Single level', '/etc'],
['Nested path', '/usr/local/bin'],
['With dash', '/var/log/nginx-access'],
['With underscore', '/opt/my_app/config'],
['With numbers', '/data123/files'],
['With dots', '/home/user/.config'],
['Deep nested', '/a/b/c/d/e/f/g'],
];
export const invalidPaths = [
['Empty string', ''],
['Missing starting slash', 'usr/local'],
['Only dot', '.'],
['Space inside', '/path with space'],
['Illegal char', '/path$'],
['Backslash not allowed', '\\windows\\path'],
['Relative path ./', './relative'],
['Relative path ../', '../parent'],
];
describe('validatePath', () => {
describe.each(validPaths)('Valid path: %s', (_desc, path) => {
it(`returns valid=true for "${path}"`, () => {
const res = validatePath(path);
expect(res.valid).toBe(true);
});
});
describe.each(invalidPaths)('Invalid path: %s', (_desc, path) => {
it(`returns valid=false for "${path}"`, () => {
const res = validatePath(path);
expect(res.valid).toBe(false);
});
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { validateSubnet } from '../validateSubnet';
export const validSubnets = [
['Simple IP', '192.168.1.1'],
['With CIDR /24', '192.168.1.1/24'],
['CIDR /0', '10.0.0.1/0'],
['CIDR /32', '172.16.0.1/32'],
['Loopback', '127.0.0.1'],
['Broadcast with mask', '255.255.255.255/32'],
];
export const invalidSubnets = [
['Empty string', ''],
['Bad format letters', 'abc.def.ghi.jkl'],
['Octet too large', '300.1.1.1'],
['Negative octet', '-1.2.3.4'],
['Too many octets', '1.2.3.4.5'],
['Not enough octets', '192.168.1'],
['Leading zero octet', '01.2.3.4'],
['Invalid CIDR (too high)', '192.168.1.1/33'],
['Invalid CIDR (negative)', '192.168.1.1/-1'],
['CIDR not number', '192.168.1.1/abc'],
['Forbidden 0.0.0.0', '0.0.0.0'],
];
describe('validateSubnet', () => {
describe.each(validSubnets)('Valid subnet: %s', (_desc, subnet) => {
it(`returns {valid:true} for "${subnet}"`, () => {
const res = validateSubnet(subnet);
expect(res.valid).toBe(true);
});
});
describe.each(invalidSubnets)('Invalid subnet: %s', (_desc, subnet) => {
it(`returns {valid:false} for "${subnet}"`, () => {
const res = validateSubnet(subnet);
expect(res.valid).toBe(false);
});
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { validateUrl } from '../validateUrl';
const validUrls = [
['Simple HTTP', 'http://example.com'],
['Simple HTTPS', 'https://example.com'],
['With path', 'https://example.com/path/to/page'],
['With query', 'https://example.com/?q=test'],
['With port', 'http://example.com:8080'],
['With subdomain', 'https://sub.example.com'],
];
const invalidUrls = [
['Invalid format', 'not a url'],
['Missing protocol', 'example.com'],
['Unsupported protocol (ftp)', 'ftp://example.com'],
['Unsupported protocol (ws)', 'ws://example.com'],
['Empty string', ''],
];
describe('validateUrl', () => {
describe.each(validUrls)('Valid URL: %s', (_desc, url) => {
it(`returns valid=true for "${url}"`, () => {
const res = validateUrl(url);
expect(res.valid).toBe(true);
});
});
describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => {
it(`returns valid=false for "${url}"`, () => {
const res = validateUrl(url);
expect(res.valid).toBe(false);
});
});
it('allows custom protocol list (ftp)', () => {
const res = validateUrl('ftp://example.com', ['ftp:']);
expect(res.valid).toBe(true);
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { validateVlessUrl } from '../validateVlessUrl';
const validUrls = [
// TCP
[
'tcp + none',
'vless://94792286-7bbe-4f33-8b36-18d1bbf70723@127.0.0.1:34520?type=tcp&encryption=none&security=none#vless-tcp-none',
],
[
'tcp + reality',
'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',
],
[
'tcp + tls',
'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',
],
// mKCP
[
'mKCP + none',
'vless://72e201d7-7841-4a32-b266-4aa3eb776d51@127.0.0.1:17270?type=kcp&encryption=none&headerType=none&seed=AirziWi4ng&security=none#vless-mKCP',
],
// WebSocket
[
'ws + none',
'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',
],
[
'ws + tls',
'vless://fe0f0941-09a9-4e46-bc69-e00190d7bb9c@127.0.0.1:10156?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&sni=google.com#vless-websocket-tls',
],
// gRPC
[
'grpc + none',
'vless://974b39e3-f7bf-42b9-933c-16699c635e77@127.0.0.1:15633?type=grpc&encryption=none&serviceName=TunService&security=none#vless-gRPC-none',
],
[
'grpc + reality',
'vless://651e7eca-5152-46f1-baf2-d502e0af7b27@127.0.0.1:28535?type=grpc&encryption=none&serviceName=TunService&security=reality&pbk=nhZ7NiKfcqESa5ZeBFfsq9o18W-OWOAHLln9UmuVXSk&fp=chrome&sni=google.com&sid=11cbaeaa&spx=%2F#vless-gRPC-reality',
],
// HTTPUpgrade
[
'httpupgrade + none',
'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',
],
[
'httpupgrade + tls',
'vless://76dbd0ff-1a35-4f0c-a9ba-3c5890b7dea6@127.0.0.1:50639?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&sni=google.com#vless-httpupgrade-tls',
],
// XHTTP
[
'xhttp + none',
'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',
],
];
const invalidUrls = [
['No prefix', 'uuid@host:443?type=tcp&security=tls'],
['No uuid', 'vless://@127.0.0.1:443?type=tcp&security=tls'],
['No host', 'vless://uuid@:443?type=tcp&security=tls'],
['No port', 'vless://uuid@127.0.0.1?type=tcp&security=tls'],
['Invalid port', 'vless://uuid@127.0.0.1:abc?type=tcp&security=tls'],
['Missing type', 'vless://uuid@127.0.0.1:443?security=tls'],
['Missing security', 'vless://uuid@127.0.0.1:443?type=tcp'],
[
'reality without pbk',
'vless://uuid@127.0.0.1:443?type=tcp&security=reality&fp=chrome',
],
[
'reality without fp',
'vless://uuid@127.0.0.1:443?type=tcp&security=reality&pbk=abc',
],
[
'tcp + reality + unexpected spaces',
'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',
],
];
describe('validateVlessUrl', () => {
describe.each(validUrls)('Valid URL: %s', (_desc, url) => {
it(`returns valid=true for "${url}"`, () => {
const res = validateVlessUrl(url);
expect(res.valid).toBe(true);
});
});
describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => {
it(`returns valid=false for "${url}"`, () => {
const res = validateVlessUrl(url);
expect(res.valid).toBe(false);
});
});
it('detects invalid port range', () => {
const res = validateVlessUrl(
'vless://uuid@127.0.0.1:99999?type=tcp&security=tls',
);
expect(res.valid).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
export interface ValidationResult {
valid: boolean;
message: string;
}
export interface BulkValidationResultItem<T> extends ValidationResult {
value: T;
}
export interface BulkValidationResult<T> {
valid: boolean;
results: BulkValidationResultItem<T>[];
}

View File

@@ -0,0 +1,24 @@
import { validateDomain } from './validateDomain';
import { validateIPV4 } from './validateIp';
import { ValidationResult } from './types';
export function validateDNS(value: string): ValidationResult {
if (!value) {
return { valid: false, message: _('DNS server address cannot be empty') };
}
if (validateIPV4(value).valid) {
return { valid: true, message: _('Valid') };
}
if (validateDomain(value).valid) {
return { valid: true, message: _('Valid') };
}
return {
valid: false,
message: _(
'Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH',
),
};
}

View File

@@ -0,0 +1,21 @@
import { ValidationResult } from './types';
export function validateDomain(domain: string): ValidationResult {
const domainRegex =
/^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/;
if (!domainRegex.test(domain)) {
return { valid: false, message: _('Invalid domain address') };
}
const hostname = domain.split('/')[0];
const parts = hostname.split('.');
const atLeastOneInvalidPart = parts.some((part) => part.length > 63);
if (atLeastOneInvalidPart) {
return { valid: false, message: _('Invalid domain address') };
}
return { valid: true, message: _('Valid') };
}

View File

@@ -0,0 +1,12 @@
import { ValidationResult } from './types';
export function validateIPV4(ip: string): ValidationResult {
const ipRegex =
/^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/;
if (ipRegex.test(ip)) {
return { valid: true, message: _('Valid') };
}
return { valid: false, message: _('Invalid IP address') };
}

View File

@@ -0,0 +1,21 @@
import { ValidationResult } from './types';
// TODO refactor current validation and add tests
export function validateOutboundJson(value: string): ValidationResult {
try {
const parsed = JSON.parse(value);
if (!parsed.type || !parsed.server || !parsed.server_port) {
return {
valid: false,
message: _(
'Outbound JSON must contain at least "type", "server" and "server_port" fields',
),
};
}
return { valid: true, message: _('Valid') };
} catch {
return { valid: false, message: _('Invalid JSON format') };
}
}

View File

@@ -0,0 +1,26 @@
import { ValidationResult } from './types';
export function validatePath(value: string): ValidationResult {
if (!value) {
return {
valid: false,
message: _('Path cannot be empty'),
};
}
const pathRegex = /^\/[a-zA-Z0-9_\-/.]+$/;
if (pathRegex.test(value)) {
return {
valid: true,
message: _('Valid'),
};
}
return {
valid: false,
message: _(
'Invalid path format. Path must start with "/" and contain valid characters',
),
};
}

View File

@@ -0,0 +1,24 @@
import { ValidationResult } from './types';
import { validateShadowsocksUrl } from './validateShadowsocksUrl';
import { validateVlessUrl } from './validateVlessUrl';
import { validateTrojanUrl } from './validateTrojanUrl';
// TODO refactor current validation and add tests
export function validateProxyUrl(url: string): ValidationResult {
if (url.startsWith('ss://')) {
return validateShadowsocksUrl(url);
}
if (url.startsWith('vless://')) {
return validateVlessUrl(url);
}
if (url.startsWith('trojan://')) {
return validateTrojanUrl(url);
}
return {
valid: false,
message: _('URL must start with vless:// or ss:// or trojan://'),
};
}

View File

@@ -0,0 +1,96 @@
import { ValidationResult } from './types';
// TODO refactor current validation and add tests
export function validateShadowsocksUrl(url: string): ValidationResult {
if (!url.startsWith('ss://')) {
return {
valid: false,
message: _('Invalid Shadowsocks URL: must start with ss://'),
};
}
try {
if (!url || /\s/.test(url)) {
return {
valid: false,
message: _('Invalid Shadowsocks URL: must not contain spaces'),
};
}
const mainPart = url.includes('?') ? url.split('?')[0] : url.split('#')[0];
const encryptedPart = mainPart.split('/')[2]?.split('@')[0];
if (!encryptedPart) {
return {
valid: false,
message: _('Invalid Shadowsocks URL: missing credentials'),
};
}
try {
const decoded = atob(encryptedPart);
if (!decoded.includes(':')) {
return {
valid: false,
message: _(
'Invalid Shadowsocks URL: decoded credentials must contain method:password',
),
};
}
} catch (_e) {
if (!encryptedPart.includes(':') && !encryptedPart.includes('-')) {
return {
valid: false,
message: _(
'Invalid Shadowsocks URL: missing method and password separator ":"',
),
};
}
}
const serverPart = url.split('@')[1];
if (!serverPart) {
return {
valid: false,
message: _('Invalid Shadowsocks URL: missing server address'),
};
}
const [server, portAndRest] = serverPart.split(':');
if (!server) {
return {
valid: false,
message: _('Invalid Shadowsocks URL: missing server'),
};
}
const port = portAndRest ? portAndRest.split(/[?#]/)[0] : null;
if (!port) {
return {
valid: false,
message: _('Invalid Shadowsocks URL: missing port'),
};
}
const portNum = parseInt(port, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
return {
valid: false,
message: _('Invalid port number. Must be between 1 and 65535'),
};
}
} catch (_e) {
return {
valid: false,
message: _('Invalid Shadowsocks URL: parsing failed'),
};
}
return { valid: true, message: _('Valid') };
}

View File

@@ -0,0 +1,39 @@
import { ValidationResult } from './types';
import { validateIPV4 } from './validateIp';
export function validateSubnet(value: string): ValidationResult {
// Must be in form X.X.X.X or X.X.X.X/Y
const subnetRegex = /^(\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?$/;
if (!subnetRegex.test(value)) {
return {
valid: false,
message: _('Invalid format. Use X.X.X.X or X.X.X.X/Y'),
};
}
const [ip, cidr] = value.split('/');
if (ip === '0.0.0.0') {
return { valid: false, message: _('IP address 0.0.0.0 is not allowed') };
}
const ipCheck = validateIPV4(ip);
if (!ipCheck.valid) {
return ipCheck;
}
// Validate CIDR if present
if (cidr) {
const cidrNum = parseInt(cidr, 10);
if (cidrNum < 0 || cidrNum > 32) {
return {
valid: false,
message: _('CIDR must be between 0 and 32'),
};
}
}
return { valid: true, message: _('Valid') };
}

View File

@@ -0,0 +1,35 @@
import { ValidationResult } from './types';
// TODO refactor current validation and add tests
export function validateTrojanUrl(url: string): ValidationResult {
if (!url.startsWith('trojan://')) {
return {
valid: false,
message: _('Invalid Trojan URL: must start with trojan://'),
};
}
if (!url || /\s/.test(url)) {
return {
valid: false,
message: _('Invalid Trojan URL: must not contain spaces'),
};
}
try {
const parsedUrl = new URL(url);
if (!parsedUrl.username || !parsedUrl.hostname || !parsedUrl.port) {
return {
valid: false,
message: _(
'Invalid Trojan URL: must contain username, hostname and port',
),
};
}
} catch (_e) {
return { valid: false, message: _('Invalid Trojan URL: parsing failed') };
}
return { valid: true, message: _('Valid') };
}

View File

@@ -0,0 +1,20 @@
import { ValidationResult } from './types';
export function validateUrl(
url: string,
protocols: string[] = ['http:', 'https:'],
): ValidationResult {
try {
const parsedUrl = new URL(url);
if (!protocols.includes(parsedUrl.protocol)) {
return {
valid: false,
message: `${_('URL must use one of the following protocols:')} ${protocols.join(', ')}`,
};
}
return { valid: true, message: _('Valid') };
} catch (_e) {
return { valid: false, message: _('Invalid URL format') };
}
}

View File

@@ -0,0 +1,112 @@
import { ValidationResult } from './types';
export function validateVlessUrl(url: string): ValidationResult {
try {
const parsedUrl = new URL(url);
if (!url || /\s/.test(url)) {
return {
valid: false,
message: _('Invalid VLESS URL: must not contain spaces'),
};
}
if (parsedUrl.protocol !== 'vless:') {
return {
valid: false,
message: _('Invalid VLESS URL: must start with vless://'),
};
}
if (!parsedUrl.username) {
return { valid: false, message: _('Invalid VLESS URL: missing UUID') };
}
if (!parsedUrl.hostname) {
return { valid: false, message: _('Invalid VLESS URL: missing server') };
}
if (!parsedUrl.port) {
return { valid: false, message: _('Invalid VLESS URL: missing port') };
}
if (
isNaN(+parsedUrl.port) ||
+parsedUrl.port < 1 ||
+parsedUrl.port > 65535
) {
return {
valid: false,
message: _(
'Invalid VLESS URL: invalid port number. Must be between 1 and 65535',
),
};
}
if (!parsedUrl.search) {
return {
valid: false,
message: _('Invalid VLESS URL: missing query parameters'),
};
}
const params = new URLSearchParams(parsedUrl.search);
const type = params.get('type');
const validTypes = [
'tcp',
'raw',
'udp',
'grpc',
'http',
'httpupgrade',
'xhttp',
'ws',
'kcp',
];
if (!type || !validTypes.includes(type)) {
return {
valid: false,
message: _(
'Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws',
),
};
}
const security = params.get('security');
const validSecurities = ['tls', 'reality', 'none'];
if (!security || !validSecurities.includes(security)) {
return {
valid: false,
message: _(
'Invalid VLESS URL: security must be one of tls, reality, none',
),
};
}
if (security === 'reality') {
if (!params.get('pbk')) {
return {
valid: false,
message: _(
'Invalid VLESS URL: missing pbk parameter for reality security',
),
};
}
if (!params.get('fp')) {
return {
valid: false,
message: _(
'Invalid VLESS URL: missing fp parameter for reality security',
),
};
}
}
return { valid: true, message: _('Valid') };
} catch (_e) {
return { valid: false, message: _('Invalid VLESS URL: parsing failed') };
}
}

View File

@@ -0,0 +1,2 @@
// tests/setup/global-mocks.ts
globalThis._ = (key: string) => key;

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist"
},
"include": ["src"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from 'tsup';
import fs from 'fs';
import path from 'path';
export default defineConfig({
entry: ['src/main.ts'],
format: ['esm'], // пусть tsup генерит export {...}
outDir: '../luci-app-podkop/htdocs/luci-static/resources/view/podkop',
outExtension: () => ({ js: '.js' }),
dts: false,
clean: false,
sourcemap: false,
banner: {
js: `// This file is autogenerated, please don't change manually \n"use strict";`,
},
esbuildOptions(options) {
options.legalComments = 'none';
},
onSuccess: () => {
const outDir =
'../luci-app-podkop/htdocs/luci-static/resources/view/podkop';
const file = path.join(outDir, 'main.js');
let code = fs.readFileSync(file, 'utf8');
code = code.replace(
/export\s*{([\s\S]*?)}/,
(match, group) => {
return `return baseclass.extend({${group}})`;
}
);
fs.writeFileSync(file, code, 'utf8');
console.log(`✅ Patched LuCI build: ${file}`);
},
});

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./tests/setup/global-mocks.ts'],
},
});

View File

@@ -0,0 +1,82 @@
import 'dotenv/config';
import chokidar from 'chokidar';
import SFTPClient from 'ssh2-sftp-client';
import path from 'path';
import fs from 'fs';
import { glob } from 'glob';
const sftp = new SFTPClient();
const config = {
host: process.env.SFTP_HOST,
port: Number(process.env.SFTP_PORT || 22),
username: process.env.SFTP_USER,
...(process.env.SFTP_PRIVATE_KEY
? { privateKey: fs.readFileSync(process.env.SFTP_PRIVATE_KEY) }
: { password: process.env.SFTP_PASS }),
};
const localDir = path.resolve(process.env.LOCAL_DIR || './dist');
const remoteDir = process.env.REMOTE_DIR || '/www/luci-static/mypkg';
async function uploadFile(filePath) {
const relativePath = path.relative(localDir, filePath);
const remotePath = path.posix.join(remoteDir, relativePath);
console.log(`Uploading: ${relativePath} -> ${remotePath}`);
try {
await sftp.fastPut(filePath, remotePath);
console.log(`Uploaded: ${relativePath}`);
} catch (err) {
console.error(`Failed: ${relativePath}: ${err.message}`);
}
}
async function deleteFile(filePath) {
const relativePath = path.relative(localDir, filePath);
const remotePath = path.posix.join(remoteDir, relativePath);
console.log(`Removing: ${relativePath}`);
try {
await sftp.delete(remotePath);
console.log(`Removed: ${relativePath}`);
} catch (err) {
console.warn(`Could not delete ${relativePath}: ${err.message}`);
}
}
async function uploadAllFiles() {
console.log('Uploading all files from', localDir);
const files = await glob(`${localDir}/**/*`, { nodir: true });
for (const file of files) {
await uploadFile(file);
}
console.log('Initial upload complete!');
}
async function main() {
await sftp.connect(config);
console.log(`Connected to ${config.host}`);
await uploadAllFiles();
chokidar
.watch(localDir, { ignoreInitial: true })
.on('all', async (event, filePath) => {
if (event === 'add' || event === 'change') {
await uploadFile(filePath);
} else if (event === 'unlink') {
await deleteFile(filePath);
}
});
process.on('SIGINT', async () => {
console.log('Disconnecting...');
await sftp.end();
process.exit();
});
}
main().catch(console.error);

2025
fe-app-podkop/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,66 +1,35 @@
#!/bin/sh
REPO="https://api.github.com/repos/itdoginfo/podkop/releases/latest"
IS_SHOULD_RESTART_NETWORK=
DOWNLOAD_DIR="/tmp/podkop"
COUNT=3
UPGRADE=0
rm -rf "$DOWNLOAD_DIR"
mkdir -p "$DOWNLOAD_DIR"
for arg in "$@"; do
if [ "$arg" = "--upgrade" ]; then
UPGRADE=1
fi
done
msg() {
printf "\033[32;1m%s\033[0m\n" "$1"
}
main() {
check_system
sing_box
opkg update
/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
opkg update || { echo "opkg update failed"; exit 1; }
if [ -f "/etc/init.d/podkop" ]; then
if [ "$UPGRADE" -eq 1 ]; then
echo "Upgraded podkop with flag..."
break
else
printf "\033[32;1mPodkop is already installed. Just upgrade it?\033[0m\n"
printf "\033[32;1my - Only upgrade podkop\033[0m\n"
printf "\033[32;1mn - Upgrade and install tunnels (WG, AWG, OpenVPN, OC)\033[0m\n"
while true; do
printf "\033[32;1mEnter (y/n): \033[0m"
read -r -p '' UPDATE
case $UPDATE in
y)
echo "Upgraded podkop..."
break
;;
n)
add_tunnel
break
;;
*)
echo "Please enter y or n"
;;
esac
done
fi
msg "Podkop is already installed. Upgraded..."
else
echo "Installed podkop..."
add_tunnel
msg "Installed podkop..."
fi
if command -v curl &> /dev/null; then
check_response=$(curl -s "https://api.github.com/repos/itdoginfo/podkop/releases/latest")
if echo "$check_response" | grep -q 'API rate limit '; then
echo "You've reached rate limit from GitHub. Repeat in five minutes."
msg "You've reached rate limit from GitHub. Repeat in five minutes."
exit 1
fi
fi
@@ -72,33 +41,33 @@ main() {
attempt=0
while [ $attempt -lt $COUNT ]; do
echo "Download $filename (count $((attempt+1)))..."
msg "Download $filename (count $((attempt+1)))..."
if wget -q -O "$filepath" "$url"; then
if [ -s "$filepath" ]; then
echo "$filename successfully downloaded"
msg "$filename successfully downloaded"
download_success=1
break
fi
fi
echo "Download error $filename. Retry..."
msg "Download error $filename. Retry..."
rm -f "$filepath"
attempt=$((attempt+1))
done
if [ $attempt -eq $COUNT ]; then
echo "Failed to download $filename after $COUNT attempts"
msg "Failed to download $filename after $COUNT attempts"
fi
done < <(wget -qO- "$REPO" | grep -o 'https://[^"[:space:]]*\.ipk')
if [ $download_success -eq 0 ]; then
echo "No packages were downloaded successfully"
msg "No packages were downloaded successfully"
exit 1
fi
for pkg in podkop luci-app-podkop; do
file=$(ls "$DOWNLOAD_DIR" | grep "^$pkg" | head -n 1)
if [ -n "$file" ]; then
echo "Installing $file"
msg "Installing $file"
opkg install "$DOWNLOAD_DIR/$file"
sleep 3
fi
@@ -106,340 +75,66 @@ main() {
ru=$(ls "$DOWNLOAD_DIR" | grep "luci-i18n-podkop-ru" | head -n 1)
if [ -n "$ru" ]; then
printf "\033[32;1mРусский язык интерфейса ставим? y/n (Need a Russian translation?)\033[0m "
while true; do
read -r -p '' RUS
case $RUS in
y)
if opkg list-installed | grep -q luci-i18n-podkop-ru; then
msg "Upgraded ru translation..."
opkg remove luci-i18n-podkop*
opkg install "$DOWNLOAD_DIR/$ru"
break
;;
n)
break
;;
*)
echo "Введите y или n"
;;
esac
done
else
msg "Русский язык интерфейса ставим? y/n (Need a Russian translation?)"
while true; do
read -r -p '' RUS
case $RUS in
y)
opkg remove luci-i18n-podkop*
opkg install "$DOWNLOAD_DIR/$ru"
break
;;
n)
break
;;
*)
echo "Введите y или n"
;;
esac
done
fi
fi
find "$DOWNLOAD_DIR" -type f -name '*podkop*' -exec rm {} \;
if [ "$IS_SHOULD_RESTART_NETWORK" ]; then
printf "\033[32;1mRestart network\033[0m\n"
/etc/init.d/network restart
fi
}
add_tunnel() {
printf "\033[32;1mWill you be using Wireguard, AmneziaWG, OpenVPN, OpenConnect? If yes, select a number and they will be automatically installed\033[0m\n"
echo "1) Wireguard"
echo "2) AmneziaWG"
echo "3) OpenVPN"
echo "4) OpenConnect"
echo "5) I use VLESS/SS. Skip this step"
while true; do
read -r -p '' TUNNEL
case $TUNNEL in
1)
opkg install wireguard-tools luci-proto-wireguard luci-app-wireguard
printf "\033[32;1mDo you want to configure the wireguard interface? (y/n): \033[0m\n"
read IS_SHOULD_CONFIGURE_WG_INTERFACE
if [ "$IS_SHOULD_CONFIGURE_WG_INTERFACE" = "y" ] || [ "$IS_SHOULD_CONFIGURE_WG_INTERFACE" = "Y" ]; then
wg_awg_setup Wireguard
else
printf "\e[1;32mUse these instructions to manual configure https://itdog.info/nastrojka-klienta-wireguard-na-openwrt/\e[0m\n"
fi
break
;;
2)
install_awg_packages
printf "\033[32;1mThere are no instructions for manual configure yet. Do you want to configure the amneziawg interface? (y/n): \033[0m\n"
read IS_SHOULD_CONFIGURE_WG_INTERFACE
if [ "$IS_SHOULD_CONFIGURE_WG_INTERFACE" = "y" ] || [ "$IS_SHOULD_CONFIGURE_WG_INTERFACE" = "Y" ]; then
wg_awg_setup AmneziaWG
fi
break
;;
3)
opkg install openvpn-openssl luci-app-openvpn
printf "\e[1;32mUse these instructions to configure https://itdog.info/nastrojka-klienta-openvpn-na-openwrt/\e[0m\n"
break
;;
4)
opkg install openconnect luci-proto-openconnect
printf "\e[1;32mUse these instructions to configure https://itdog.info/nastrojka-klienta-openconnect-na-openwrt/\e[0m\n"
break
;;
5)
echo "Installation without additional dependencies."
break
;;
*)
echo "Choose from the following options"
;;
esac
done
}
handler_network_restart() {
IS_SHOULD_RESTART_NETWORK=true
}
install_awg_packages() {
# Получение pkgarch с наибольшим приоритетом
PKGARCH=$(opkg print-architecture | awk 'BEGIN {max=0} {if ($3 > max) {max = $3; arch = $2}} END {print arch}')
TARGET=$(ubus call system board | jsonfilter -e '@.release.target' | cut -d '/' -f 1)
SUBTARGET=$(ubus call system board | jsonfilter -e '@.release.target' | cut -d '/' -f 2)
VERSION=$(ubus call system board | jsonfilter -e '@.release.version')
PKGPOSTFIX="_v${VERSION}_${PKGARCH}_${TARGET}_${SUBTARGET}.ipk"
BASE_URL="https://github.com/Slava-Shchipunov/awg-openwrt/releases/download/"
AWG_DIR="/tmp/amneziawg"
mkdir -p "$AWG_DIR"
if opkg list-installed | grep -q kmod-amneziawg; then
echo "kmod-amneziawg already installed"
else
KMOD_AMNEZIAWG_FILENAME="kmod-amneziawg${PKGPOSTFIX}"
DOWNLOAD_URL="${BASE_URL}v${VERSION}/${KMOD_AMNEZIAWG_FILENAME}"
wget -O "$AWG_DIR/$KMOD_AMNEZIAWG_FILENAME" "$DOWNLOAD_URL"
if [ $? -eq 0 ]; then
echo "kmod-amneziawg file downloaded successfully"
else
echo "Error downloading kmod-amneziawg. Please, install kmod-amneziawg manually and run the script again"
exit 1
fi
opkg install "$AWG_DIR/$KMOD_AMNEZIAWG_FILENAME"
if [ $? -eq 0 ]; then
echo "kmod-amneziawg file downloaded successfully"
else
echo "Error installing kmod-amneziawg. Please, install kmod-amneziawg manually and run the script again"
exit 1
fi
fi
if opkg list-installed | grep -q amneziawg-tools; then
echo "amneziawg-tools already installed"
else
AMNEZIAWG_TOOLS_FILENAME="amneziawg-tools${PKGPOSTFIX}"
DOWNLOAD_URL="${BASE_URL}v${VERSION}/${AMNEZIAWG_TOOLS_FILENAME}"
wget -O "$AWG_DIR/$AMNEZIAWG_TOOLS_FILENAME" "$DOWNLOAD_URL"
if [ $? -eq 0 ]; then
echo "amneziawg-tools file downloaded successfully"
else
echo "Error downloading amneziawg-tools. Please, install amneziawg-tools manually and run the script again"
exit 1
fi
opkg install "$AWG_DIR/$AMNEZIAWG_TOOLS_FILENAME"
if [ $? -eq 0 ]; then
echo "amneziawg-tools file downloaded successfully"
else
echo "Error installing amneziawg-tools. Please, install amneziawg-tools manually and run the script again"
exit 1
fi
fi
if opkg list-installed | grep -qE 'luci-app-amneziawg|luci-proto-amneziawg'; then
echo "luci-app-amneziawg or luci-proto-amneziawg already installed"
else
LUCI_APP_AMNEZIAWG_FILENAME="luci-app-amneziawg${PKGPOSTFIX}"
DOWNLOAD_URL="${BASE_URL}v${VERSION}/${LUCI_APP_AMNEZIAWG_FILENAME}"
wget -O "$AWG_DIR/$LUCI_APP_AMNEZIAWG_FILENAME" "$DOWNLOAD_URL"
if [ $? -eq 0 ]; then
echo "luci-app-amneziawg file downloaded successfully"
else
echo "Error downloading luci-app-amneziawg. Please, install luci-app-amneziawg manually and run the script again"
exit 1
fi
opkg install "$AWG_DIR/$LUCI_APP_AMNEZIAWG_FILENAME"
if [ $? -eq 0 ]; then
echo "luci-app-amneziawg file downloaded successfully"
else
echo "Error installing luci-app-amneziawg. Please, install luci-app-amneziawg manually and run the script again"
exit 1
fi
fi
rm -rf "$AWG_DIR"
}
wg_awg_setup() {
PROTOCOL_NAME=$1
printf "\033[32;1mConfigure ${PROTOCOL_NAME}\033[0m\n"
if [ "$PROTOCOL_NAME" = 'Wireguard' ]; then
INTERFACE_NAME="wg0"
CONFIG_NAME="wireguard_wg0"
PROTO="wireguard"
ZONE_NAME="wg"
fi
if [ "$PROTOCOL_NAME" = 'AmneziaWG' ]; then
INTERFACE_NAME="awg0"
CONFIG_NAME="amneziawg_awg0"
PROTO="amneziawg"
ZONE_NAME="awg"
echo "Do you want to use AmneziaWG config or basic Wireguard config + automatic obfuscation?"
echo "1) AmneziaWG"
echo "2) Wireguard + automatic obfuscation"
read CONFIG_TYPE
fi
read -r -p "Enter the private key (from [Interface]):"$'\n' WG_PRIVATE_KEY_INT
while true; do
read -r -p "Enter internal IP address with subnet, example 192.168.100.5/24 (from [Interface]):"$'\n' WG_IP
if echo "$WG_IP" | egrep -oq '^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]+$'; then
break
else
echo "This IP is not valid. Please repeat"
fi
done
read -r -p "Enter the public key (from [Peer]):"$'\n' WG_PUBLIC_KEY_INT
read -r -p "If use PresharedKey, Enter this (from [Peer]). If your don't use leave blank:"$'\n' WG_PRESHARED_KEY_INT
read -r -p "Enter Endpoint host without port (Domain or IP) (from [Peer]):"$'\n' WG_ENDPOINT_INT
read -r -p "Enter Endpoint host port (from [Peer]) [51820]:"$'\n' WG_ENDPOINT_PORT_INT
WG_ENDPOINT_PORT_INT=${WG_ENDPOINT_PORT_INT:-51820}
if [ "$WG_ENDPOINT_PORT_INT" = '51820' ]; then
echo $WG_ENDPOINT_PORT_INT
fi
if [ "$PROTOCOL_NAME" = 'AmneziaWG' ]; then
if [ "$CONFIG_TYPE" = '1' ]; then
read -r -p "Enter Jc value (from [Interface]):"$'\n' AWG_JC
read -r -p "Enter Jmin value (from [Interface]):"$'\n' AWG_JMIN
read -r -p "Enter Jmax value (from [Interface]):"$'\n' AWG_JMAX
read -r -p "Enter S1 value (from [Interface]):"$'\n' AWG_S1
read -r -p "Enter S2 value (from [Interface]):"$'\n' AWG_S2
read -r -p "Enter H1 value (from [Interface]):"$'\n' AWG_H1
read -r -p "Enter H2 value (from [Interface]):"$'\n' AWG_H2
read -r -p "Enter H3 value (from [Interface]):"$'\n' AWG_H3
read -r -p "Enter H4 value (from [Interface]):"$'\n' AWG_H4
elif [ "$CONFIG_TYPE" = '2' ]; then
#Default values to wg automatic obfuscation
AWG_JC=4
AWG_JMIN=40
AWG_JMAX=70
AWG_S1=0
AWG_S2=0
AWG_H1=1
AWG_H2=2
AWG_H3=3
AWG_H4=4
fi
fi
uci set network.${INTERFACE_NAME}=interface
uci set network.${INTERFACE_NAME}.proto=$PROTO
uci set network.${INTERFACE_NAME}.private_key=$WG_PRIVATE_KEY_INT
uci set network.${INTERFACE_NAME}.listen_port='51821'
uci set network.${INTERFACE_NAME}.addresses=$WG_IP
if [ "$PROTOCOL_NAME" = 'AmneziaWG' ]; then
uci set network.${INTERFACE_NAME}.awg_jc=$AWG_JC
uci set network.${INTERFACE_NAME}.awg_jmin=$AWG_JMIN
uci set network.${INTERFACE_NAME}.awg_jmax=$AWG_JMAX
uci set network.${INTERFACE_NAME}.awg_s1=$AWG_S1
uci set network.${INTERFACE_NAME}.awg_s2=$AWG_S2
uci set network.${INTERFACE_NAME}.awg_h1=$AWG_H1
uci set network.${INTERFACE_NAME}.awg_h2=$AWG_H2
uci set network.${INTERFACE_NAME}.awg_h3=$AWG_H3
uci set network.${INTERFACE_NAME}.awg_h4=$AWG_H4
fi
if ! uci show network | grep -q ${CONFIG_NAME}; then
uci add network ${CONFIG_NAME}
fi
uci set network.@${CONFIG_NAME}[0]=$CONFIG_NAME
uci set network.@${CONFIG_NAME}[0].name="${INTERFACE_NAME}_client"
uci set network.@${CONFIG_NAME}[0].public_key=$WG_PUBLIC_KEY_INT
uci set network.@${CONFIG_NAME}[0].preshared_key=$WG_PRESHARED_KEY_INT
uci set network.@${CONFIG_NAME}[0].route_allowed_ips='0'
uci set network.@${CONFIG_NAME}[0].persistent_keepalive='25'
uci set network.@${CONFIG_NAME}[0].endpoint_host=$WG_ENDPOINT_INT
uci set network.@${CONFIG_NAME}[0].allowed_ips='0.0.0.0/0'
uci set network.@${CONFIG_NAME}[0].endpoint_port=$WG_ENDPOINT_PORT_INT
uci commit network
if ! uci show firewall | grep -q "@zone.*name='${ZONE_NAME}'"; then
printf "\033[32;1mZone Create\033[0m\n"
uci add firewall zone
uci set firewall.@zone[-1].name=$ZONE_NAME
uci set firewall.@zone[-1].network=$INTERFACE_NAME
uci set firewall.@zone[-1].forward='REJECT'
uci set firewall.@zone[-1].output='ACCEPT'
uci set firewall.@zone[-1].input='REJECT'
uci set firewall.@zone[-1].masq='1'
uci set firewall.@zone[-1].mtu_fix='1'
uci set firewall.@zone[-1].family='ipv4'
uci commit firewall
fi
if ! uci show firewall | grep -q "@forwarding.*name='${ZONE_NAME}'"; then
printf "\033[32;1mConfigured forwarding\033[0m\n"
uci add firewall forwarding
uci set firewall.@forwarding[-1]=forwarding
uci set firewall.@forwarding[-1].name="${ZONE_NAME}-lan"
uci set firewall.@forwarding[-1].dest=${ZONE_NAME}
uci set firewall.@forwarding[-1].src='lan'
uci set firewall.@forwarding[-1].family='ipv4'
uci commit firewall
fi
handler_network_restart
}
check_system() {
# Get router model
MODEL=$(cat /tmp/sysinfo/model)
echo "Router model: $MODEL"
msg "Router model: $MODEL"
# Check OpenWrt version
openwrt_version=$(cat /etc/openwrt_release | grep DISTRIB_RELEASE | cut -d"'" -f2 | cut -d'.' -f1)
if [ "$openwrt_version" = "23" ]; then
msg "OpenWrt 23.05 не поддерживается начиная с podkop 0.5.0"
msg "Для OpenWrt 23.05 используйте podkop версии 0.4.11 или устанавливайте зависимости и podkop вручную"
msg "Подробности: https://podkop.net/docs/install/#%d1%83%d1%81%d1%82%d0%b0%d0%bd%d0%be%d0%b2%d0%ba%d0%b0-%d0%bd%d0%b0-2305"
exit 1
fi
# Check available space
AVAILABLE_SPACE=$(df /overlay | awk 'NR==2 {print $4}')
REQUIRED_SPACE=15360 # 15MB in KB
if [ "$AVAILABLE_SPACE" -lt "$REQUIRED_SPACE" ]; then
printf "\033[31;1mError: Insufficient space in flash\033[0m\n"
echo "Available: $((AVAILABLE_SPACE/1024))MB"
echo "Required: $((REQUIRED_SPACE/1024))MB"
msg "Error: Insufficient space in flash"
msg "Available: $((AVAILABLE_SPACE/1024))MB"
msg "Required: $((REQUIRED_SPACE/1024))MB"
exit 1
fi
if ! nslookup google.com >/dev/null 2>&1; then
printf "\033[31;1mDNS not working\033[0m\n"
msg "DNS not working"
exit 1
fi
if opkg list-installed | grep -q https-dns-proxy; then
printf "\033[31;1mСonflicting package detected: https-dns-proxy. Remove? yes/no\033[0m\n"
msg "Сonflicting package detected: https-dns-proxy. Remove?"
while true; do
read -r -p '' DNSPROXY
@@ -450,16 +145,12 @@ check_system() {
break
;;
*)
echo "Exit"
msg "Exit"
exit 1
;;
esac
done
fi
if opkg list-installed | grep -qE "iptables|kmod-iptab"; then
printf "\033[31;1mFound incompatible iptables packages. If you're using FriendlyWrt: https://t.me/itdogchat/44512/181082\033[0m\n"
fi
}
sing_box() {
@@ -468,10 +159,13 @@ sing_box() {
fi
sing_box_version=$(sing-box version | head -n 1 | awk '{print $3}')
required_version="1.11.1"
required_version="1.12.4"
if [ "$(echo -e "$sing_box_version\n$required_version" | sort -V | head -n 1)" != "$required_version" ]; then
opkg remove sing-box
msg "sing-box version $sing_box_version is older than required $required_version"
msg "Removing old version..."
service podkop stop
opkg remove sing-box --force-depends
fi
}

View File

@@ -1,7 +1,9 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-podkop
PKG_VERSION:=0.3.43
PKG_VERSION := $(if $(PKG_VERSION),$(PKG_VERSION),dev_$(shell date +%d%m%Y))
PKG_RELEASE:=1
LUCI_TITLE:=LuCI podkop app

View File

@@ -0,0 +1,362 @@
'use strict';
'require form';
'require baseclass';
'require tools.widgets as widgets';
'require view.podkop.main as main';
function createAdditionalSection(mainSection) {
let o = mainSection.tab('additional', _('Additional Settings'));
o = mainSection.taboption(
'additional',
form.Flag,
'yacd',
_('Yacd enable'),
`<a href="${main.getBaseUrl()}:9090/ui" target="_blank">${main.getBaseUrl()}:9090/ui</a>`,
);
o.default = '0';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'additional',
form.Flag,
'exclude_ntp',
_('Exclude NTP'),
_('Allows you to exclude NTP protocol traffic from the tunnel'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'additional',
form.Flag,
'quic_disable',
_('QUIC disable'),
_('For issues with the video stream'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'additional',
form.ListValue,
'update_interval',
_('List Update Frequency'),
_('Select how often the lists will be updated'),
);
Object.entries(main.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.default = '1d';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'additional',
form.ListValue,
'dns_type',
_('DNS Protocol Type'),
_('Select DNS protocol to use'),
);
o.value('doh', _('DNS over HTTPS (DoH)'));
o.value('dot', _('DNS over TLS (DoT)'));
o.value('udp', _('UDP (Unprotected DNS)'));
o.default = 'udp';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'additional',
form.Value,
'dns_server',
_('DNS Server'),
_('Select or enter DNS server address'),
);
Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.default = '8.8.8.8';
o.rmempty = false;
o.ucisection = 'main';
o.validate = function (section_id, value) {
const validation = main.validateDNS(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = mainSection.taboption(
'additional',
form.Value,
'bootstrap_dns_server',
_('Bootstrap DNS server'),
_(
'The DNS server used to look up the IP address of an upstream DNS server',
),
);
Object.entries(main.BOOTSTRAP_DNS_SERVER_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.default = '77.88.8.8';
o.rmempty = false;
o.ucisection = 'main';
o.validate = function (section_id, value) {
const validation = main.validateDNS(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = mainSection.taboption(
'additional',
form.Value,
'dns_rewrite_ttl',
_('DNS Rewrite TTL'),
_('Time in seconds for DNS record caching (default: 60)'),
);
o.default = '60';
o.rmempty = false;
o.ucisection = 'main';
o.validate = function (section_id, value) {
if (!value) {
return _('TTL value cannot be empty');
}
const ttl = parseInt(value);
if (isNaN(ttl) || ttl < 0) {
return _('TTL must be a positive number');
}
return true;
};
o = mainSection.taboption(
'additional',
form.ListValue,
'config_path',
_('Config File Path'),
_(
'Select path for sing-box config file. Change this ONLY if you know what you are doing',
),
);
o.value('/etc/sing-box/config.json', 'Flash (/etc/sing-box/config.json)');
o.value('/tmp/sing-box/config.json', 'RAM (/tmp/sing-box/config.json)');
o.default = '/etc/sing-box/config.json';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'additional',
form.Value,
'cache_path',
_('Cache File Path'),
_(
'Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing',
),
);
o.value('/tmp/sing-box/cache.db', 'RAM (/tmp/sing-box/cache.db)');
o.value(
'/usr/share/sing-box/cache.db',
'Flash (/usr/share/sing-box/cache.db)',
);
o.default = '/tmp/sing-box/cache.db';
o.rmempty = false;
o.ucisection = 'main';
o.validate = function (section_id, value) {
if (!value) {
return _('Cache file path cannot be empty');
}
if (!value.startsWith('/')) {
return _('Path must be absolute (start with /)');
}
if (!value.endsWith('cache.db')) {
return _('Path must end with cache.db');
}
const parts = value.split('/').filter(Boolean);
if (parts.length < 2) {
return _('Path must contain at least one directory (like /tmp/cache.db)');
}
return true;
};
o = mainSection.taboption(
'additional',
widgets.DeviceSelect,
'iface',
_('Source Network Interface'),
_('Select the network interface from which the traffic will originate'),
);
o.ucisection = 'main';
o.default = 'br-lan';
o.noaliases = true;
o.nobridges = false;
o.noinactive = false;
o.multiple = true;
o.filter = function (section_id, value) {
// Block specific interface names from being selectable
const blocked = ['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan'];
if (blocked.includes(value)) {
return false;
}
// Try to find the device object by its name
const device = this.devices.find((dev) => dev.getName() === value);
// If no device is found, allow the value
if (!device) {
return true;
}
// Check the type of the device
const type = device.getType();
// Consider any Wi-Fi / wireless / wlan device as invalid
const isWireless =
type === 'wifi' || type === 'wireless' || type.includes('wlan');
// Allow only non-wireless devices
return !isWireless;
};
o = mainSection.taboption(
'additional',
form.Flag,
'mon_restart_ifaces',
_('Interface monitoring'),
_('Interface monitoring for bad WAN'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'additional',
widgets.NetworkSelect,
'restart_ifaces',
_('Interface for monitoring'),
_('Select the WAN interfaces to be monitored'),
);
o.ucisection = 'main';
o.depends('mon_restart_ifaces', '1');
o.multiple = true;
o.filter = function (section_id, value) {
// Reject if the value is in the blocked list ['lan', 'loopback']
if (['lan', 'loopback'].includes(value)) {
return false;
}
// Reject if the value starts with '@' (means it's an alias/reference)
if (value.startsWith('@')) {
return false;
}
// Otherwise allow it
return true;
};
o = mainSection.taboption(
'additional',
form.Value,
'procd_reload_delay',
_('Interface Monitoring Delay'),
_('Delay in milliseconds before reloading podkop after interface UP'),
);
o.ucisection = 'main';
o.depends('mon_restart_ifaces', '1');
o.default = '2000';
o.rmempty = false;
o.validate = function (section_id, value) {
if (!value) {
return _('Delay value cannot be empty');
}
return true;
};
o = mainSection.taboption(
'additional',
form.Flag,
'dont_touch_dhcp',
_('Dont touch my DHCP!'),
_('Podkop will not change the DHCP config'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'additional',
form.Flag,
'detour',
_('Proxy download of lists'),
_('Downloading all lists via main Proxy/VPN'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = 'main';
// Extra IPs and exclusions (main section)
o = mainSection.taboption(
'basic',
form.Flag,
'exclude_from_ip_enabled',
_('IP for exclusion'),
_('Specify local IP addresses that will never use the configured route'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = 'main';
o = mainSection.taboption(
'basic',
form.DynamicList,
'exclude_traffic_ip',
_('Local IPs'),
_('Enter valid IPv4 addresses'),
);
o.placeholder = 'IP';
o.depends('exclude_from_ip_enabled', '1');
o.rmempty = false;
o.ucisection = 'main';
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateIPV4(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = mainSection.taboption(
'basic',
form.Flag,
'socks5',
_('Mixed enable'),
_('Browser port: 2080'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = 'main';
}
return baseclass.extend({
createAdditionalSection,
});

View File

@@ -0,0 +1,783 @@
'use strict';
'require baseclass';
'require form';
'require ui';
'require network';
'require view.podkop.main as main';
'require tools.widgets as widgets';
function createConfigSection(section) {
const s = section;
let o = s.tab('basic', _('Basic Settings'));
o = s.taboption(
'basic',
form.ListValue,
'mode',
_('Connection Type'),
_('Select between VPN and Proxy connection methods for traffic routing'),
);
o.value('proxy', 'Proxy');
o.value('vpn', 'VPN');
o.value('block', 'Block');
o.ucisection = s.section;
o = s.taboption(
'basic',
form.ListValue,
'proxy_config_type',
_('Configuration Type'),
_('Select how to configure the proxy'),
);
o.value('url', _('Connection URL'));
o.value('outbound', _('Outbound Config'));
o.value('urltest', _('URLTest'));
o.default = 'url';
o.depends('mode', 'proxy');
o.ucisection = s.section;
o = s.taboption(
'basic',
form.TextValue,
'proxy_string',
_('Proxy Configuration URL'),
'',
);
o.depends('proxy_config_type', 'url');
o.rows = 5;
// Enable soft wrapping for multi-line proxy URLs (e.g., for URLTest proxy links)
o.wrap = 'soft';
// Render as a textarea to allow multiple proxy URLs/configs
o.textarea = true;
o.rmempty = false;
o.ucisection = s.section;
o.sectionDescriptions = new Map();
o.placeholder =
'vless://uuid@server:port?type=tcp&security=tls#main\n// backup ss://method:pass@server:port\n// backup2 vless://uuid@server:port?type=grpc&security=reality#alt\n// backup3 trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none';
o.renderWidget = function (section_id, option_index, cfgvalue) {
const original = form.TextValue.prototype.renderWidget.apply(this, [
section_id,
option_index,
cfgvalue,
]);
const container = E('div', {});
container.appendChild(original);
if (cfgvalue) {
try {
const activeConfig = cfgvalue
.split('\n')
.map((line) => line.trim())
.find((line) => line && !line.startsWith('//'));
if (activeConfig) {
if (activeConfig.includes('#')) {
const label = activeConfig.split('#').pop();
if (label && label.trim()) {
const decodedLabel = decodeURIComponent(label);
const descDiv = E(
'div',
{ class: 'cbi-value-description' },
_('Current config: ') + decodedLabel,
);
container.appendChild(descDiv);
} else {
const descDiv = E(
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
);
container.appendChild(descDiv);
}
} else {
const descDiv = E(
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
);
container.appendChild(descDiv);
}
}
} catch (e) {
console.error('Error parsing config label:', e);
const descDiv = E(
'div',
{ class: 'cbi-value-description' },
_('Config without description'),
);
container.appendChild(descDiv);
}
} else {
const defaultDesc = E(
'div',
{ class: 'cbi-value-description' },
_(
'Enter connection string starting with vless:// or ss:// for proxy configuration. Add comments with // for backup configs',
),
);
container.appendChild(defaultDesc);
}
return container;
};
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
try {
const activeConfigs = value
.split('\n')
.map((line) => line.trim())
.filter((line) => !line.startsWith('//'))
.filter(Boolean);
if (!activeConfigs.length) {
return _(
'No active configuration found. One configuration is required.',
);
}
if (activeConfigs.length > 1) {
return _(
'Multiply active configurations found. Please leave one configuration.',
);
}
const validation = main.validateProxyUrl(activeConfigs[0]);
if (validation.valid) {
return true;
}
return validation.message;
} catch (e) {
return `${_('Invalid URL format:')} ${e?.message}`;
}
};
o = s.taboption(
'basic',
form.TextValue,
'outbound_json',
_('Outbound Configuration'),
_('Enter complete outbound configuration in JSON format'),
);
o.depends('proxy_config_type', 'outbound');
o.rows = 10;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateOutboundJson(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.DynamicList,
'urltest_proxy_links',
_('URLTest Proxy Links'),
);
o.depends('proxy_config_type', 'urltest');
o.placeholder = 'vless://, ss://, trojan:// links';
o.rmempty = false;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateProxyUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'ss_uot',
_('Shadowsocks UDP over TCP'),
_('Apply for SS2022'),
);
o.default = '0';
o.depends('mode', 'proxy');
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
widgets.DeviceSelect,
'interface',
_('Network Interface'),
_('Select network interface for VPN connection'),
);
o.depends('mode', 'vpn');
o.ucisection = s.section;
o.noaliases = true;
o.nobridges = false;
o.noinactive = false;
o.filter = function (section_id, value) {
// Blocked interface names that should never be selectable
const blockedInterfaces = [
'br-lan',
'eth0',
'eth1',
'wan',
'phy0-ap0',
'phy1-ap0',
'pppoe-wan',
'lan',
];
// Reject immediately if the value matches any blocked interface
if (blockedInterfaces.includes(value)) {
return false;
}
// Try to find the device object with the given name
const device = this.devices.find((dev) => dev.getName() === value);
// If no device is found, allow the value
if (!device) {
return true;
}
// Get the device type (e.g., "wifi", "ethernet", etc.)
const type = device.getType();
// Reject wireless-related devices
const isWireless =
type === 'wifi' || type === 'wireless' || type.includes('wlan');
return !isWireless;
};
o = s.taboption(
'basic',
form.Flag,
'domain_resolver_enabled',
_('Domain Resolver'),
_('Enable built-in DNS resolver for domains handled by this section'),
);
o.default = '0';
o.rmempty = false;
o.depends('mode', 'vpn');
o.ucisection = s.section;
o = s.taboption(
'basic',
form.ListValue,
'domain_resolver_dns_type',
_('DNS Protocol Type'),
_('Select the DNS protocol type for the domain resolver'),
);
o.value('doh', _('DNS over HTTPS (DoH)'));
o.value('dot', _('DNS over TLS (DoT)'));
o.value('udp', _('UDP (Unprotected DNS)'));
o.default = 'udp';
o.rmempty = false;
o.depends('domain_resolver_enabled', '1');
o.ucisection = s.section;
o = s.taboption(
'basic',
form.Value,
'domain_resolver_dns_server',
_('DNS Server'),
_('Select or enter DNS server address'),
);
Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.default = '8.8.8.8';
o.rmempty = false;
o.depends('domain_resolver_enabled', '1');
o.ucisection = s.section;
o.validate = function (section_id, value) {
const validation = main.validateDNS(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'community_lists_enabled',
_('Community Lists'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'community_lists',
_('Service List'),
_('Select predefined service for routing') +
' <a href="https://github.com/itdoginfo/allow-domains" target="_blank">github.com/itdoginfo/allow-domains</a>',
);
o.placeholder = 'Service list';
Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.depends('community_lists_enabled', '1');
o.rmempty = false;
o.ucisection = s.section;
let lastValues = [];
let isProcessing = false;
o.onchange = function (ev, section_id, value) {
if (isProcessing) return;
isProcessing = true;
try {
const values = Array.isArray(value) ? value : [value];
let newValues = [...values];
let notifications = [];
const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) =>
newValues.includes(opt),
);
if (selectedRegionalOptions.length > 1) {
const lastSelected =
selectedRegionalOptions[selectedRegionalOptions.length - 1];
const removedRegions = selectedRegionalOptions.slice(0, -1);
newValues = newValues.filter(
(v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v),
);
notifications.push(
E('p', { class: 'alert-message warning' }, [
E('strong', {}, _('Regional options cannot be used together')),
E('br'),
_(
'Warning: %s cannot be used together with %s. Previous selections have been removed.',
).format(removedRegions.join(', '), lastSelected),
]),
);
}
if (newValues.includes('russia_inside')) {
const removedServices = newValues.filter(
(v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
);
if (removedServices.length > 0) {
newValues = newValues.filter((v) =>
main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
);
notifications.push(
E('p', { class: 'alert-message warning' }, [
E('strong', {}, _('Russia inside restrictions')),
E('br'),
_(
'Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.',
).format(
main.ALLOWED_WITH_RUSSIA_INSIDE.map(
(key) => main.DOMAIN_LIST_OPTIONS[key],
)
.filter((label) => label !== 'Russia inside')
.join(', '),
removedServices.join(', '),
),
]),
);
}
}
if (JSON.stringify(newValues.sort()) !== JSON.stringify(values.sort())) {
this.getUIElement(section_id).setValue(newValues);
}
notifications.forEach((notification) =>
ui.addNotification(null, notification),
);
lastValues = newValues;
} catch (e) {
console.error('Error in onchange handler:', e);
} finally {
isProcessing = false;
}
};
o = s.taboption(
'basic',
form.ListValue,
'user_domain_list_type',
_('User Domain List Type'),
_('Select how to add your custom domains'),
);
o.value('disabled', _('Disabled'));
o.value('dynamic', _('Dynamic List'));
o.value('text', _('Text List'));
o.default = 'disabled';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'user_domains',
_('User Domains'),
_(
'Enter domain names without protocols (example: sub.example.com or example.com)',
),
);
o.placeholder = 'Domains list';
o.depends('user_domain_list_type', 'dynamic');
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateDomain(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.TextValue,
'user_domains_text',
_('User Domains List'),
_(
'Enter domain names separated by comma, space or newline. You can add comments after //',
),
);
o.placeholder =
'example.com, sub.example.com\n// Social networks\ndomain.com test.com // personal domains';
o.depends('user_domain_list_type', 'text');
o.rows = 8;
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const domains = main.parseValueList(value);
if (!domains.length) {
return _(
'At least one valid domain must be specified. Comments-only content is not allowed.',
);
}
const { valid, results } = main.bulkValidate(domains, main.validateDomain);
if (!valid) {
const errors = results
.filter((validation) => !validation.valid) // Leave only failed validations
.map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors
return [_('Validation errors:'), ...errors].join('\n');
}
return true;
};
o = s.taboption(
'basic',
form.Flag,
'local_domain_lists_enabled',
_('Local Domain Lists'),
_('Use the list from the router filesystem'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'local_domain_lists',
_('Local Domain List Paths'),
_('Enter the list file path'),
);
o.placeholder = '/path/file.lst';
o.depends('local_domain_lists_enabled', '1');
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validatePath(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'remote_domain_lists_enabled',
_('Remote Domain Lists'),
_('Download and use domain lists from remote URLs'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'remote_domain_lists',
_('Remote Domain URLs'),
_('Enter full URLs starting with http:// or https://'),
);
o.placeholder = 'URL';
o.depends('remote_domain_lists_enabled', '1');
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'local_subnet_lists_enabled',
_('Local Subnet Lists'),
_('Use the list from the router filesystem'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'local_subnet_lists',
_('Local Subnet List Paths'),
_('Enter the list file path'),
);
o.placeholder = '/path/file.lst';
o.depends('local_subnet_lists_enabled', '1');
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validatePath(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.ListValue,
'user_subnet_list_type',
_('User Subnet List Type'),
_('Select how to add your custom subnets'),
);
o.value('disabled', _('Disabled'));
o.value('dynamic', _('Dynamic List'));
o.value('text', _('Text List (comma/space/newline separated)'));
o.default = 'disabled';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'user_subnets',
_('User Subnets'),
_(
'Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses',
),
);
o.placeholder = 'IP or subnet';
o.depends('user_subnet_list_type', 'dynamic');
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateSubnet(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.TextValue,
'user_subnets_text',
_('User Subnets List'),
_(
'Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments after //',
),
);
o.placeholder =
'103.21.244.0/22\n// Google DNS\n8.8.8.8\n1.1.1.1/32, 9.9.9.9 // Cloudflare and Quad9';
o.depends('user_subnet_list_type', 'text');
o.rows = 10;
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const subnets = main.parseValueList(value);
if (!subnets.length) {
return _(
'At least one valid subnet or IP must be specified. Comments-only content is not allowed.',
);
}
const { valid, results } = main.bulkValidate(subnets, main.validateSubnet);
if (!valid) {
const errors = results
.filter((validation) => !validation.valid) // Leave only failed validations
.map((validation) => _(`${validation.value}: ${validation.message}`)); // Collect validation errors
return [_('Validation errors:'), ...errors].join('\n');
}
return true;
};
o = s.taboption(
'basic',
form.Flag,
'remote_subnet_lists_enabled',
_('Remote Subnet Lists'),
_('Download and use subnet lists from remote URLs'),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'remote_subnet_lists',
_('Remote Subnet URLs'),
_('Enter full URLs starting with http:// or https://'),
);
o.placeholder = 'URL';
o.depends('remote_subnet_lists_enabled', '1');
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateUrl(value);
if (validation.valid) {
return true;
}
return validation.message;
};
o = s.taboption(
'basic',
form.Flag,
'all_traffic_from_ip_enabled',
_('IP for full redirection'),
_(
'Specify local IP addresses whose traffic will always use the configured route',
),
);
o.default = '0';
o.rmempty = false;
o.ucisection = s.section;
o = s.taboption(
'basic',
form.DynamicList,
'all_traffic_ip',
_('Local IPs'),
_('Enter valid IPv4 addresses'),
);
o.placeholder = 'IP';
o.depends('all_traffic_from_ip_enabled', '1');
o.rmempty = false;
o.ucisection = s.section;
o.validate = function (section_id, value) {
// Optional
if (!value || value.length === 0) {
return true;
}
const validation = main.validateIPV4(value);
if (validation.valid) {
return true;
}
return validation.message;
};
}
return baseclass.extend({
createConfigSection,
});

View File

@@ -0,0 +1,26 @@
'use strict';
'require baseclass';
'require form';
'require ui';
'require uci';
'require fs';
'require view.podkop.utils as utils';
'require view.podkop.main as main';
function createDashboardSection(mainSection) {
let o = mainSection.tab('dashboard', _('Dashboard'));
o = mainSection.taboption('dashboard', form.DummyValue, '_status');
o.rawhtml = true;
o.cfgvalue = () => {
main.initDashboardController();
return main.renderDashboard();
};
}
const EntryPoint = {
createDashboardSection,
};
return baseclass.extend(EntryPoint);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
'use strict';
'require baseclass';
'require ui';
'require fs';
'require view.podkop.main as main';
// Flag to track if this is the first error check
let isInitialCheck = true;
// Set to track which errors we've already seen
const lastErrorsSet = new Set();
// Timer for periodic error polling
let errorPollTimer = null;
// Helper function to fetch errors from the podkop command
async function getPodkopErrors() {
return new Promise((resolve) => {
safeExec('/usr/bin/podkop', ['check_logs'], 'P0_PRIORITY', (result) => {
if (!result || !result.stdout) return resolve([]);
const logs = result.stdout.split('\n');
const errors = logs.filter((log) => log.includes('[critical]'));
resolve(errors);
});
});
}
// Show error notification to the user
function showErrorNotification(error, isMultiple = false) {
const notificationContent = E('div', { class: 'alert-message error' }, [
E('pre', { class: 'error-log' }, error),
]);
ui.addNotification(null, notificationContent);
}
// Helper function for command execution with prioritization
function safeExec(
command,
args,
priority,
callback,
timeout = main.COMMAND_TIMEOUT,
) {
// Default to highest priority execution if priority is not provided or invalid
let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY;
// If priority is a string, try to get the corresponding delay value
if (
typeof priority === 'string' &&
main.COMMAND_SCHEDULING[priority] !== undefined
) {
schedulingDelay = main.COMMAND_SCHEDULING[priority];
}
const executeCommand = async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const result = await Promise.race([
fs.exec(command, args),
new Promise((_, reject) => {
controller.signal.addEventListener('abort', () => {
reject(new Error('Command execution timed out'));
});
}),
]);
clearTimeout(timeoutId);
if (callback && typeof callback === 'function') {
callback(result);
}
return result;
} catch (error) {
console.warn(
`Command execution failed or timed out: ${command} ${args.join(' ')}`,
);
const errorResult = { stdout: '', stderr: error.message, error: error };
if (callback && typeof callback === 'function') {
callback(errorResult);
}
return errorResult;
}
};
if (callback && typeof callback === 'function') {
setTimeout(executeCommand, schedulingDelay);
return;
} else {
return executeCommand();
}
}
// Check for critical errors and show notifications
async function checkForCriticalErrors() {
try {
const errors = await getPodkopErrors();
if (errors && errors.length > 0) {
// Filter out errors we've already seen
const newErrors = errors.filter((error) => !lastErrorsSet.has(error));
if (newErrors.length > 0) {
// On initial check, just store errors without showing notifications
if (!isInitialCheck) {
// Show each new error as a notification
newErrors.forEach((error) => {
showErrorNotification(error, newErrors.length > 1);
});
}
// Add new errors to our set of seen errors
newErrors.forEach((error) => lastErrorsSet.add(error));
}
}
// After first check, mark as no longer initial
isInitialCheck = false;
} catch (error) {
console.error('Error checking for critical messages:', error);
}
}
// Start polling for errors at regular intervals
function startErrorPolling() {
if (errorPollTimer) {
clearInterval(errorPollTimer);
}
// Reset initial check flag to make sure we show errors
isInitialCheck = false;
// Immediately check for errors on start
checkForCriticalErrors();
// Then set up periodic checks
errorPollTimer = setInterval(
checkForCriticalErrors,
main.ERROR_POLL_INTERVAL,
);
}
// Stop polling for errors
function stopErrorPolling() {
if (errorPollTimer) {
clearInterval(errorPollTimer);
errorPollTimer = null;
}
}
return baseclass.extend({
startErrorPolling,
stopErrorPolling,
checkForCriticalErrors,
safeExec,
});

View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
PODIR="po"
POTFILE="$PODIR/templates/podkop.pot"
WIDTH=120
if [ $# -ne 1 ]; then
echo "Usage: $0 <language_code> (e.g., ru, de, fr)"
exit 1
fi
LANG="$1"
POFILE="$PODIR/$LANG/podkop.po"
if [ ! -f "$POTFILE" ]; then
echo "Template $POTFILE not found. Run xgettext first."
exit 1
fi
if [ -f "$POFILE" ]; then
echo "Updating $POFILE"
msgmerge --update --width="$WIDTH" --no-location "$POFILE" "$POTFILE"
else
echo "Creating new $POFILE using msginit"
mkdir -p "$PODIR/$LANG"
msginit --no-translator --no-location --locale="$LANG" --width="$WIDTH" --input="$POTFILE" --output-file="$POFILE"
fi
echo "Translation file for $LANG updated."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
#!/bin/bash
SRC_DIR="htdocs/luci-static/resources/view/podkop"
OUT_POT="po/templates/podkop.pot"
ENCODING="UTF-8"
WIDTH=120
mapfile -t FILES < <(find "$SRC_DIR" -type f -name "*.js")
if [ ${#FILES[@]} -eq 0 ]; then
echo "No JS files found in $SRC_DIR"
exit 1
fi
mkdir -p "$(dirname "$OUT_POT")"
echo "Generating POT template from JS files in $SRC_DIR"
xgettext --language=JavaScript \
--keyword=_ \
--from-code="$ENCODING" \
--output="$OUT_POT" \
--width="$WIDTH" \
--package-name="PODKOP" \
"${FILES[@]}"
echo "POT template generated: $OUT_POT"

View File

@@ -1,7 +1,9 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=podkop
PKG_VERSION:=0.3.43
PKG_VERSION := $(if $(PKG_VERSION),$(PKG_VERSION),dev_$(shell date +%d%m%Y))
PKG_RELEASE:=1
PKG_MAINTAINER:=ITDog <podkop@itdog.info>
@@ -13,9 +15,9 @@ define Package/podkop
SECTION:=net
CATEGORY:=Network
DEPENDS:=+sing-box +curl +jq +kmod-nft-tproxy +coreutils-base64
CONFLICTS:=https-dns-proxy
CONFLICTS:=https-dns-proxy nextdns luci-app-passwall luci-app-passwall2
TITLE:=Domain routing app
URL:=https://itdog.info
URL:=https://podkop.net
PKGARCH:=all
endef
@@ -53,6 +55,9 @@ define Package/podkop/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ./files/usr/bin/podkop $(1)/usr/bin/podkop
$(INSTALL_DIR) $(1)/usr/lib/podkop
$(CP) ./files/usr/lib/* $(1)/usr/lib/podkop/
endef
$(eval $(call BuildPackage,podkop))

View File

@@ -4,25 +4,24 @@ config main 'main'
option proxy_config_type 'url'
#option outbound_json ''
option proxy_string ''
option domain_list_enabled '1'
list domain_list 'russia_inside'
option subnets_list_enabled '0'
option custom_domains_list_type 'disabled'
#list custom_domains ''
#option custom_domains_text ''
option custom_local_domains_list_enabled '0'
#list custom_local_domains ''
option custom_download_domains_list_enabled '0'
#list custom_download_domains ''
option custom_domains_list_type 'disable'
#list custom_subnets ''
#custom_subnets_text ''
option custom_download_subnets_list_enabled '0'
#list custom_download_subnets ''
option community_lists_enabled '1'
list community_lists 'russia_inside'
option user_domain_list_type 'disabled'
#list user_domains ''
#option user_domains_text ''
option local_domain_lists_enabled '0'
#list local_domain_lists ''
option remote_domain_lists_enabled '0'
#list remote_domain_lists ''
option user_subnet_list_type 'disable'
#list user_subnets ''
#option user_subnets_text ''
option local_subnet_lists_enabled '0'
#list local_subnet_lists ''
option remote_subnet_lists_enabled '0'
#list remote_subnet_lists ''
option all_traffic_from_ip_enabled '0'
#list all_traffic_ip ''
option delist_domains_enabled '0'
#list delist_domains ''
option exclude_from_ip_enabled '0'
#list exclude_traffic_ip ''
option yacd '0'
@@ -31,11 +30,18 @@ config main 'main'
option quic_disable '0'
option dont_touch_dhcp '0'
option update_interval '1d'
option dns_type 'doh'
option dns_type 'udp'
option dns_server '8.8.8.8'
option split_dns_enabled '1'
option split_dns_type 'udp'
option split_dns_server '1.1.1.1'
option dns_rewrite_ttl '60'
option cache_file '/tmp/cache.db'
option config_path '/etc/sing-box/config.json'
option cache_path '/tmp/sing-box/cache.db'
list iface 'br-lan'
option mon_restart_ifaces '0'
#list restart_ifaces 'wan'
option ss_uot '0'
option procd_reload_delay '2000'
option ss_uot '0'
option detour '0'
option shutdown_correctly '1'

Some files were not shown because too many files have changed in this diff Show More