Compare commits

...

104 Commits

Author SHA1 Message Date
Kirill Sobakin
984ae5f2a9 Merge pull request #213 from itdoginfo/fix/diagnostic
fix: correct luci-version displaying
2025-10-10 14:46:59 +03:00
divocat
7a62898541 fix: correct luci-version displaying 2025-10-10 14:41:35 +03:00
itdoginfo
7911d1d29f Draft false 2025-10-10 14:23:37 +03:00
Kirill Sobakin
bc673b7881 Merge pull request #212 from itdoginfo/fix/dashboard
fix: correct vless/trojan validation on some browsers
2025-10-10 14:21:25 +03:00
divocat
0493565c5f fix: implement query params parsing func 2025-10-10 14:06:19 +03:00
itdoginfo
4cd1094395 Check ipk without v 2025-10-09 19:53:37 +03:00
itdoginfo
e87b431d86 Check v* 2025-10-09 19:33:27 +03:00
itdoginfo
b9ee917abf Fix PODKOP_VERSION for ipk 2025-10-09 19:20:35 +03:00
divocat
715a278af8 fix: force http for yacd enable link 2025-10-09 18:23:35 +03:00
divocat
9bc2b5ffef fix: correct link validation & some points on dash 2025-10-09 18:23:35 +03:00
divocat
9d89258c0c fix: correct vless/trojan validation on some browsers 2025-10-09 18:23:35 +03:00
itdoginfo
52d1c5d95f Fix PKG_VERSION -> PODKOP_VERSION 2025-10-09 18:15:54 +03:00
itdoginfo
587e5245d3 Test without v* 2025-10-09 16:01:31 +03:00
itdoginfo
e7578d61bc Rm v* 2025-10-09 15:34:23 +03:00
itdoginfo
9918b71a82 Fix version tag 3 2025-10-09 15:32:01 +03:00
itdoginfo
f48c4ff2bb Fix version tag 2 2025-10-09 15:08:37 +03:00
itdoginfo
e77bcc386a Fix version tag 2025-10-09 14:59:22 +03:00
itdoginfo
455c19ab2e Fix #211 2025-10-09 14:40:45 +03:00
Kirill Sobakin
914e1792f3 Merge pull request #211 from SaltyMonkey/build-process-improvements
chore: Build process improvements
2025-10-09 11:13:59 +03:00
SaltyMonkey
826245a89a fix: Minor changes and bugfixes, ci fix 2025-10-09 00:56:15 +03:00
SaltyMonkey
b5cfc017fe chore: Automatic build process rewrite
* Added apk packages support
* Move to matrix builds
* Minor versions update for some actions just in case
* Automatic release with ipk/apk packages
2025-10-08 23:15:52 +03:00
SaltyMonkey
267fd2b793 refactor: Added .gitattributes for better dev life at win and linux 2025-10-08 22:26:16 +03:00
SaltyMonkey
c0b400dfb0 refactor: New docker files for build process 2025-10-08 22:25:06 +03:00
SaltyMonkey
752636347e refactor: Remove old docker files 2025-10-08 22:20:33 +03:00
SaltyMonkey
28aeb29c51 refactor: Update luci-app-podkop package
* Removed direct package manager calls
* Removed commands related to optional luci package
* Update external global_check call with version pass
* Removed useless external calls in version check cases
* Improved build process support: version will be automatically set at installation time from package metadata and will be readable from JS as constant
2025-10-08 22:09:48 +03:00
SaltyMonkey
6ff543d7fb refactor: Update podkop package
* Removed direct package manager calls
* Removed commands related to optional luci package
* Added optional parameter for global_check for cases when function called by LuCI package
* Removed useless external calls in version check cases
* Improved build process support: version will be automatically set at installation time from package metadata and will be readable from script itself
2025-10-08 21:57:46 +03:00
SaltyMonkey
b89fe33296 chore: Added apk package manager support for install script 2025-10-08 21:42:19 +03:00
Kirill Sobakin
3d63a82815 Merge pull request #209 from itdoginfo/fix/dashboard
fix: force http for clash api
2025-10-08 00:05:04 +03:00
divocat
934f802879 fix: force http for clash api 2025-10-08 00:02:41 +03:00
Kirill Sobakin
4d0755e4c0 Merge pull request #208 from itdoginfo/fix/dashboard
feat: change get latency class coloring
2025-10-07 23:48:29 +03:00
divocat
88ee7b4a54 feat: change get latency class coloring 2025-10-07 23:45:07 +03:00
Kirill Sobakin
0eb575d171 Merge pull request #207 from itdoginfo/fix/dashboard
fix: dashboard behavior on corner cases
2025-10-07 23:41:06 +03:00
divocat
9a46d731c9 fix: correct dashboard displaying 2025-10-07 23:33:57 +03:00
divocat
a45ab62885 fix: correct section display name for json outbound 2025-10-07 22:56:40 +03:00
Kirill Sobakin
b7bad57299 Merge pull request #206 from itdoginfo/feat/all_traffic_ip_cidr
feat: add support IP/CIDR format in LuCI for all_traffic_ip
2025-10-07 22:33:43 +03:00
divocat
4ac755bd36 feat: add support IP/CIDR format in LuCI for all_traffic_ip 2025-10-07 22:26:29 +03:00
Kirill Sobakin
e9a0c96882 Merge pull request #205 from itdoginfo/hotfix
Hotfix
2025-10-07 21:34:26 +03:00
divocat
48c8f01d2f fix: correct proxy string label displaying on dashboard 2025-10-07 20:34:38 +03:00
divocat
72b2a34af9 fix: allow .tld for user_domains_text & user_domains 2025-10-07 19:19:10 +03:00
Andrey Petelin
ae4a3781e6 i18n: update Russian translations for additional settings and related messages 2025-10-07 20:42:34 +05:00
divocat
1bce7c0c98 fix: migrate test latency to locales 2025-10-07 18:26:59 +03:00
Andrey Petelin
a8b2001cc1 fix: sort input files before processing in xgettext.sh to ensure consistent POT generation 2025-10-07 20:12:46 +05:00
Andrey Petelin
d6481675e0 fix: update shebang to env bash and add strict mode for safer script execution in xgettext.sh 2025-10-07 20:12:14 +05:00
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
109 changed files with 11368 additions and 2876 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -2,53 +2,118 @@ name: Build packages
on: on:
push: push:
tags: tags:
- v* - '*'
permissions:
contents: write
jobs: jobs:
build: preparation:
name: Build podkop and luci-app-podkop name: Setup build version
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps: steps:
- uses: actions/checkout@v4.2.1 - uses: actions/checkout@v5.0.0
with:
fetch-depth: 0
- id: version
run: |
VERSION=$(git describe --tags --exact-match 2>/dev/null || echo "0.$(date +%d%m%Y)")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
build:
name: Builder for ${{ matrix.package_type }} podkop and luci-app-podkop
runs-on: ubuntu-latest
needs: preparation
strategy:
matrix:
include:
- { package_type: ipk }
- { package_type: apk }
steps:
- uses: actions/checkout@v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Extract version - name: Build ${{ matrix.package_type }}
id: version uses: docker/build-push-action@v6.18.0
run: |
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: with:
file: ./Dockerfile-${{ matrix.package_type }}
context: . context: .
tags: podkop:ci tags: podkop:ci-${{ matrix.package_type }}
build-args: | build-args: |
PKG_VERSION=${{ steps.version.outputs.version }} PODKOP_VERSION=${{ needs.preparation.outputs.version }}
- name: Create Docker container - name: Create ${{ matrix.package_type }} Docker container
run: docker create --name podkop podkop:ci run: docker create --name ${{ matrix.package_type }} podkop:ci-${{ matrix.package_type }}
- name: Copy file from Docker container - name: Copy files from ${{ matrix.package_type }} Docker container
run: | run: |
docker cp podkop:/builder/bin/packages/x86_64/utilites/. ./bin/ mkdir -p ./bin/${{ matrix.package_type }}
docker cp podkop:/builder/bin/packages/x86_64/luci/. ./bin/ docker cp ${{ matrix.package_type }}:/builder/bin/packages/x86_64/utilities/. ./bin/${{ matrix.package_type }}/
docker cp ${{ matrix.package_type }}:/builder/bin/packages/x86_64/luci/. ./bin/${{ matrix.package_type }}/
- name: Filter IPK files # IPK uses underscore `_` in filenames, while APK uses only dash `-`
- name: Fix naming difference between build for packages (replace _ with -)
if: matrix.package_type == 'ipk'
shell: bash
run: | run: |
# Извлекаем версию из тега, убирая префикс 'v' for f in ./bin/${{ matrix.package_type }}/*.${{ matrix.package_type }}; do
VERSION=${GITHUB_REF#refs/tags/v} [ -e "$f" ] || continue
base=$(basename "$f")
newname=$(echo "$base" | sed 's/_/-/g')
mv "$f" "./bin/${{ matrix.package_type }}/$newname"
done
mkdir -p ./filtered-bin - name: Filter files
cp ./bin/luci-i18n-podkop-ru_*.ipk "./filtered-bin/luci-i18n-podkop-ru_${VERSION}.ipk" shell: bash
cp ./bin/podkop_*.ipk ./filtered-bin/ run: |
cp ./bin/luci-app-podkop_*.ipk ./filtered-bin/ # Use version from preparation job (already without 'v' prefix)
VERSION="${{ needs.preparation.outputs.version }}"
mkdir -p ./filtered-bin/${{ matrix.package_type }}
cp ./bin/${{ matrix.package_type }}/luci-i18n-podkop-ru-*.${{ matrix.package_type }} "./filtered-bin/${{ matrix.package_type }}/luci-i18n-podkop-ru-${VERSION}.${{ matrix.package_type }}"
cp ./bin/${{ matrix.package_type }}/podkop-*.${{ matrix.package_type }} ./filtered-bin/${{ matrix.package_type }}/
cp ./bin/${{ matrix.package_type }}/luci-app-podkop-*.${{ matrix.package_type }} ./filtered-bin/${{ matrix.package_type }}/
- name: Remove Docker container - name: Remove Docker container
run: docker rm podkop run: docker rm ${{ matrix.package_type }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: release-files-${{ github.ref_name }}-${{ matrix.package_type }}
path: ./filtered-bin/${{ matrix.package_type }}/*.${{ matrix.package_type }}
retention-days: 1
if-no-files-found: error
release:
name: Create Release
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v5.0.0
- name: Create release dir
run: mkdir -p ./filtered-bin/release
- name: Download ipk artifacts
uses: actions/download-artifact@v4
with:
name: release-files-${{ github.ref_name }}-ipk
path: ./filtered-bin/release
- name: Download apk artifacts
uses: actions/download-artifact@v4
with:
name: release-files-${{ github.ref_name }}-apk
path: ./filtered-bin/release
- name: Release - name: Release
uses: softprops/action-gh-release@v2.0.8 uses: softprops/action-gh-release@v2.4.0
with: with:
files: ./filtered-bin/*.ipk files: ./filtered-bin/release/*.*
draft: false
prerelease: false
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}

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

2
.gitignore vendored
View File

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

View File

@@ -1,9 +0,0 @@
FROM itdoginfo/openwrt-sdk:24.10.1
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
RUN make defconfig && make package/podkop/compile && make package/luci-app-podkop/compile V=s -j4

View File

@@ -1,3 +0,0 @@
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/

11
Dockerfile-apk Normal file
View File

@@ -0,0 +1,11 @@
FROM itdoginfo/openwrt-sdk-apk:09102025
ARG PODKOP_VERSION
ENV PODKOP_VERSION=${PODKOP_VERSION}
COPY ./podkop /builder/package/feeds/utilities/podkop
COPY ./luci-app-podkop /builder/package/feeds/luci/luci-app-podkop
RUN make defconfig && \
make package/podkop/compile -j1 V=s && \
make package/luci-app-podkop/compile -j1 V=s

11
Dockerfile-ipk Normal file
View File

@@ -0,0 +1,11 @@
FROM itdoginfo/openwrt-sdk-ipk:24.10.3
ARG PODKOP_VERSION
COPY ./podkop /builder/package/feeds/utilities/podkop
COPY ./luci-app-podkop /builder/package/feeds/luci/luci-app-podkop
RUN export PODKOP_VERSION="v${PODKOP_VERSION}" && \
make defconfig && \
make package/podkop/compile V=s -j4 && \
make package/luci-app-podkop/compile V=s -j4

View File

@@ -1,68 +1,76 @@
# Shadowsocks ## Shadowsocks
Тут всё просто
## Shadowsocks-old
``` ```
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 # tcp
``` trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none
Может быть без `?type=tcp` 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 # WebSocket
``` trojan://G3cE9phv1g@127.0.0.1:57370?type=ws&path=%2Fwspath&host=google.com&security=none#trojan-websocket-none
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443?type=tcp&security=reality&pbk=ARQzddtXPJZHinwkPbgVpah9uwPTuzdjU9GpbUkQJkc&fp=chrome&sni=sni.server.com&sid=6cabf01472a3&spx=%2F&flow=xtls-rprx-vision#vless-reality trojan://FBok41WczO@127.0.0.1:59919?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-websocket-tls
``` trojan://bhwvndUBPA@127.0.0.1:22969?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-websocket-tls-insecur
trojan://pwiduqFUWO@127.0.0.1:46765?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCFcQYEtwrFOidJJLYHvSiN%2BljRgaAIrNHoVnio3uXAOwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-websocket-tls-ech
``` # gRPC
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@123.123.123.123:2082?security=reality&sni=sni.server.com&alpn=h2,http/1.1&allowInsecure=1&fp=chrome&pbk=ARQzddtXPJZHinwkPbgVpah9uwPTuzdjU9GpbUkQJkc&sid=6cabf01472a3&type=grpc&encryption=none#vless-reality-strange 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 # HTTPUpgrade
1. 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
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443?type=tcp&security=tls&fp=&alpn=h3%2Ch2%2Chttp%2F1.1#vless-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. # XHTTP
``` trojan://VEetltxLtw@127.0.0.1:59072?type=xhttp&path=%2Fxhttppath&host=google.com&mode=auto&security=none#trojan-xhttp
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443?security=tls&sni=sni.server.com&fp=chrome&type=tcp&flow=xtls-rprx-vision&encryption=none#vless-tls-withot-alpn
```
3.
```
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443/?type=ws&encryption=none&path=%2Fwebsocket&security=tls&sni=sni.server.com&fp=chrome#vless-tls-ws
```
4.
```
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443?security=tls&sni=sni.server.com&type=ws&path=/?ed%3D2560&host=sni.server.com&encryption=none#vless-tls-ws-2
```
5.
```
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443?security=tls&sni=sni.server.com&fp=chrome&type=ws&path=/websocket&encryption=none#vless-tls-ws-3
```
6.
```
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443/?type=ws&encryption=none&path=%2Fwebsocket&security=tls&sni=sni.server.com&fp=chrome#vless-tls-ws-4
```
7.
```
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@sub.example.com:443?type=ws&path=%2Fdir%2Fpath&host=sub.example.com&security=tls#configname
```
## No security
```
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443?type=tcp&security=none#vless-tls-no-encrypt
``` ```

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,108 @@
export const STATUS_COLORS = {
SUCCESS: '#4caf50',
ERROR: '#f44336',
WARNING: '#ff9800',
};
export const PODKOP_LUCI_APP_VERSION = '__COMPILED_VERSION_VARIABLE__';
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,17 @@
export function getClashApiUrl(): string {
const { hostname } = window.location;
return `http://${hostname}:9090`;
}
export function getClashWsUrl(): string {
const { hostname } = window.location;
return `ws://${hostname}:9090`;
}
export function getClashUIUrl(): string {
const { hostname } = window.location;
return `http://${hostname}:9090/ui`;
}

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,12 @@
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';
export * from './splitProxyString';
export * from './preserveScrollForPage';
export * from './parseQueryString';

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,22 @@
export function parseQueryString(query: string): Record<string, string> {
const clean = query.startsWith('?') ? query.slice(1) : query;
return clean
.split('&')
.filter(Boolean)
.reduce(
(acc, pair) => {
const [rawKey, rawValue = ''] = pair.split('=');
if (!rawKey) {
return acc;
}
const key = decodeURIComponent(rawKey);
const value = decodeURIComponent(rawValue);
return { ...acc, [key]: value };
},
{} as Record<string, string>,
);
}

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,9 @@
export function preserveScrollForPage(renderFn: () => void) {
const scrollY = window.scrollY;
renderFn();
requestAnimationFrame(() => {
window.scrollTo({ top: scrollY });
});
}

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,7 @@
export function splitProxyString(str: string) {
return str
.split('\n')
.map((line) => line.trim())
.filter((line) => !line.startsWith('//'))
.filter(Boolean);
}

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,158 @@
import { Podkop } from '../types';
import { getConfigSections } from './getConfigSections';
import { getClashProxies } from '../../clash';
import { getProxyUrlName, splitProxyString } 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`,
);
const activeConfigs = splitProxyString(section.proxy_string);
const proxyDisplayName =
getProxyUrlName(activeConfigs?.[0]) || outbound?.value?.name || '';
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName: proxyDisplayName,
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`,
);
const parsedOutbound = JSON.parse(section.outbound_json);
const parsedTag = parsedOutbound?.tag
? decodeURIComponent(parsedOutbound?.tag)
: undefined;
const proxyDisplayName = parsedTag || outbound?.value?.name || '';
return {
withTagSelect: false,
code: outbound?.code || section['.name'],
displayName: section['.name'],
outbounds: [
{
code: outbound?.code || section['.name'],
displayName: proxyDisplayName,
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: 10000,
});
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: 10000,
});
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,444 @@
import {
getDashboardSections,
getPodkopStatus,
getSingboxStatus,
} from '../../methods';
import {
getClashApiUrl,
getClashWsUrl,
onMount,
preserveScrollForPage,
} 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();
if (!success) {
console.log('[fetchDashboardSections]: failed to fetch', getClashApiUrl());
}
store.set({
sectionsWidget: {
latencyFetching: false,
loading: false,
failed: !success,
data,
},
});
}
async function fetchServicesInfo() {
try {
const [podkop, singbox] = await Promise.all([
getPodkopStatus(),
getSingboxStatus(),
]);
store.set({
servicesInfoWidget: {
loading: false,
failed: false,
data: { singbox: singbox.running, podkop: podkop.enabled },
},
});
} catch (err) {
console.log('[fetchServicesInfo]: failed to fetchServices', err);
store.set({
servicesInfoWidget: {
loading: false,
failed: true,
data: { singbox: 0, podkop: 0 },
},
});
}
}
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) => {
console.log(
'[fetchDashboardSections]: failed to connect',
getClashWsUrl(),
);
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) => {
console.log(
'[fetchDashboardSections]: failed to connect',
getClashWsUrl(),
);
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) {
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: true,
},
});
await triggerLatencyGroupTest(tag);
await fetchDashboardSections();
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: false,
},
});
}
async function handleTestProxyLatency(tag: string) {
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: true,
},
});
await triggerLatencyProxyTest(tag);
await fetchDashboardSections();
store.set({
sectionsWidget: {
...store.get().sectionsWidget,
latencyFetching: false,
},
});
}
// 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: () => {},
latencyFetching: sectionsWidget.latencyFetching,
});
return preserveScrollForPage(() => {
container!.replaceChildren(renderedWidget);
});
}
const renderedWidgets = sectionsWidget.data.map((section) =>
renderSections({
loading: sectionsWidget.loading,
failed: sectionsWidget.failed,
section,
latencyFetching: sectionsWidget.latencyFetching,
onTestLatency: (tag) => {
if (section.withTagSelect) {
return handleTestGroupLatency(tag);
}
return handleTestProxyLatency(tag);
},
onChooseOutbound: (selector, tag) => {
handleChooseOutbound(selector, tag);
},
}),
);
return preserveScrollForPage(() => {
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,133 @@
import { Podkop } from '../../types';
import { getClashApiUrl } from '../../../helpers';
interface IRenderSectionsProps {
loading: boolean;
failed: boolean;
section: Podkop.OutboundGroup;
onTestLatency: (tag: string) => void;
onChooseOutbound: (selector: string, tag: string) => void;
latencyFetching: boolean;
}
function renderFailedState() {
return E(
'div',
{
class: 'pdk_dashboard-page__outbound-section centered',
style: 'height: 127px',
},
E('span', {}, [
E('span', {}, _('Dashboard currently unavailable')),
E('div', { style: 'text-align: center;' }, `API: ${getClashApiUrl()}`),
]),
);
}
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,
latencyFetching,
}: 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 < 800) {
return 'pdk_dashboard-page__outbound-grid__item__latency--green';
}
if (outbound.latency < 1500) {
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,
),
latencyFetching
? E('div', { class: 'skeleton', style: 'width: 99px; height: 28px' })
: 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();

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

@@ -0,0 +1,181 @@
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[];
latencyFetching: boolean;
};
}
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,
latencyFetching: 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,63 @@
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'],
];
export const dotTLDTests = [
['Dot TLD allowed (.net)', '.net', true, true],
['Dot TLD not allowed (.net)', '.net', false, false],
['Invalid with double dot', '..net', true, false],
['Invalid single word TLD (net)', 'net', true, false],
];
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);
});
});
describe.each(dotTLDTests)(
'Dot TLD toggle: %s',
(_desc, domain, allowDotTLD, expected) => {
it(`"${domain}" with allowDotTLD=${allowDotTLD} → valid=${expected}`, () => {
const res = validateDomain(domain, allowDotTLD);
expect(res.valid).toBe(expected);
});
},
);
});

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,49 @@
import { describe, it, expect } from 'vitest';
import { validateShadowsocksUrl } from '../validateShadowsocksUrl';
const validUrls = [
[
'no-client',
'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206ZG1DbHkvWmgxNVd3OStzK0dGWGlGVElrcHc3Yy9xQ0lTYUJyYWk3V2hoWT0@127.0.0.1:25144?type=tcp#shadowsocks-no-client',
],
[
'client',
'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206S3FiWXZiNkhwb1RmTUt0N2VGcUZQSmJNNXBXaHlFU0ZKTXY2dEp1Ym1Fdz06dzRNMEx5RU9OTGQ5SWlkSGc0endTbzN2R3h4NS9aQ3hId0FpaWlxck5hcz0@127.0.0.1:26627?type=tcp#shadowsocks-client',
],
[
'plain-user',
'ss://2022-blake3-aes-256-gcm:dmCly/Zh15Ww9+s+GFXiFTIkpw7c/qCISaBrai7WhhY=@127.0.0.1:27214?type=tcp#shadowsocks-plain-user',
],
];
const invalidUrls = [
['No prefix', 'uuid@127.0.0.1:443?type=tcp'],
['No host', 'ss://password@:443?type=tcp'],
['No port', 'ss://password@127.0.0.1?type=tcp'],
['Invalid port', 'ss://password@127.0.0.1:abc?type=tcp'],
['Missing type', 'ss://password@127.0.0.1:443'],
['Contains space', 'ss://password@127.0.0.1:443?type=tcp #extra'],
];
describe('validateShadowsocksUrl', () => {
describe.each(validUrls)('Valid URL: %s', (_desc, url) => {
it(`returns valid=true for "${url}"`, () => {
const res = validateShadowsocksUrl(url);
expect(res.valid).toBe(true);
});
});
describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => {
it(`returns valid=false for "${url}"`, () => {
const res = validateShadowsocksUrl(url);
expect(res.valid).toBe(false);
});
});
it('detects invalid port range', () => {
const res = validateShadowsocksUrl(
'ss://password@127.0.0.1:99999?type=tcp',
);
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,131 @@
import { describe, it, expect } from 'vitest';
import { validateTrojanUrl } from '../validateTrojanUrl';
const validUrls = [
// TCP
[
'tcp + none',
'trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none',
],
[
'tcp + reality',
'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',
],
[
'tcp + tls',
'trojan://EJjpAj02lg@127.0.0.1:11381?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-tcp-tls',
],
[
'tcp + tls + insecure',
'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',
],
[
'tcp + tls + ech',
'trojan://90caP481ay@127.0.0.1:59708?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACC2y%2BAe4dqthLNpfvmtE6g%2BnaJ%2FciK6P%2BREbRLkR%2Fg%2FEgAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-tcp-tls-ech',
],
// mKCP
[
'mKCP + none',
'trojan://N5v7iIOe9G@127.0.0.1:36319?type=kcp&headerType=none&seed=P91wFIfjzZ&security=none#trojan-mKCP',
],
// WebSocket
[
'ws + none',
'trojan://G3cE9phv1g@127.0.0.1:57370?type=ws&path=%2Fwspath&host=google.com&security=none#trojan-websocket-none',
],
[
'ws + tls',
'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',
],
[
'ws + tls + insecure',
'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-insecure',
],
[
'ws + tls + ech',
'trojan://pwiduqFUWO@127.0.0.1:46765?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCFcQYEtwrFOidJJLYHvSiN%2BljRgaAIrNHoVnio3uXAOwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-websocket-tls-ech',
],
// gRPC
[
'grpc + none',
'trojan://WMR7qkKhsV@127.0.0.1:27897?type=grpc&serviceName=TunService&authority=authority&security=none#trojan-gRPC-none',
],
[
'grpc + reality',
'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',
],
[
'grpc + tls',
'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',
],
[
'grpc + tls + insecure',
'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',
],
[
'grpc + tls + ech',
'trojan://mbzoVKL27h@127.0.0.1:38681?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCq72Ru3VbFlDpKttl3LccmInu8R2oAsCr8wzyxB0vZZQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-gRPC-tls-ech',
],
// HTTPUpgrade
[
'httpupgrade + none',
'trojan://uc44gBwOKQ@127.0.0.1:29085?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=none#trojan-httpupgrade-none',
],
[
'httpupgrade + tls',
'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',
],
[
'httpupgrade + tls + insecure',
'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',
],
[
'httpupgrade + tls + ech',
'trojan://ou8pLSyx9N@127.0.0.1:17737?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACB%2FlkIkit%2BblFzE7PtbYDVF3NXK8olXJ5a7YwY%2Biy9QQwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-httpupgrade-tls-ech',
],
// XHTTP
[
'xhttp + none',
'trojan://VEetltxLtw@127.0.0.1:59072?type=xhttp&path=%2Fxhttppath&host=google.com&mode=auto&security=none#trojan-xhttp',
],
];
const invalidUrls = [
['No prefix', 'uuid@host:443?type=tcp&security=tls'],
['No password', 'trojan://@127.0.0.1:443?type=tcp&security=tls'],
['No host', 'trojan://pass@:443?type=tcp&security=tls'],
['No port', 'trojan://pass@127.0.0.1?type=tcp&security=tls'],
['Invalid port', 'trojan://pass@127.0.0.1:abc?type=tcp&security=tls'],
[
'tcp + reality + unexpected spaces',
'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',
],
];
describe('validateTrojanUrl', () => {
describe.each(validUrls)('Valid URL: %s', (_desc, url) => {
it(`returns valid=true for "${url}"`, () => {
const res = validateTrojanUrl(url);
expect(res.valid).toBe(true);
});
});
describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => {
it(`returns valid=false for "${url}"`, () => {
const res = validateTrojanUrl(url);
expect(res.valid).toBe(false);
});
});
it('detects invalid port range', () => {
const res = validateTrojanUrl(
'trojan://pass@127.0.0.1:99999?type=tcp&security=tls',
);
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,31 @@
import { ValidationResult } from './types';
export function validateDomain(
domain: string,
allowDotTLD = false,
): 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 (allowDotTLD) {
const dotTLD = /^\.[a-zA-Z]{2,}$/;
if (dotTLD.test(domain)) {
return { valid: true, message: _('Valid') };
}
}
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,60 @@
import { ValidationResult } from './types';
export function validateTrojanUrl(url: string): ValidationResult {
try {
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'),
};
}
const body = url.slice('trojan://'.length);
const [mainPart] = body.split('#');
const [userHostPort] = mainPart.split('?');
const [userPart, hostPortPart] = userHostPort.split('@');
if (!userHostPort)
return {
valid: false,
message: 'Invalid Trojan URL: missing credentials and host',
};
if (!userPart)
return { valid: false, message: 'Invalid Trojan URL: missing password' };
if (!hostPortPart)
return {
valid: false,
message: 'Invalid Trojan URL: missing hostname and port',
};
const [host, port] = hostPortPart.split(':');
if (!host)
return { valid: false, message: 'Invalid Trojan URL: missing hostname' };
if (!port)
return { valid: false, message: 'Invalid Trojan URL: missing port' };
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
return {
valid: false,
message: 'Invalid Trojan URL: invalid port number',
};
} 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,103 @@
import { ValidationResult } from './types';
import { parseQueryString } from '../helpers';
export function validateVlessUrl(url: string): ValidationResult {
try {
if (!url.startsWith('vless://'))
return {
valid: false,
message: 'Invalid VLESS URL: must start with vless://',
};
if (/\s/.test(url))
return {
valid: false,
message: 'Invalid VLESS URL: must not contain spaces',
};
const body = url.slice('vless://'.length);
const [mainPart] = body.split('#');
const [userHostPort, queryString] = mainPart.split('?');
if (!userHostPort)
return {
valid: false,
message: 'Invalid VLESS URL: missing host and UUID',
};
const [userPart, hostPortPart] = userHostPort.split('@');
if (!userPart)
return { valid: false, message: 'Invalid VLESS URL: missing UUID' };
if (!hostPortPart)
return { valid: false, message: 'Invalid VLESS URL: missing server' };
const [host, port] = hostPortPart.split(':');
if (!host)
return { valid: false, message: 'Invalid VLESS URL: missing hostname' };
if (!port)
return { valid: false, message: 'Invalid VLESS URL: missing port' };
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
return {
valid: false,
message: 'Invalid VLESS URL: invalid port number',
};
if (!queryString)
return {
valid: false,
message: 'Invalid VLESS URL: missing query parameters',
};
const params = parseQueryString(queryString);
const validTypes = [
'tcp',
'raw',
'udp',
'grpc',
'http',
'httpupgrade',
'xhttp',
'ws',
'kcp',
];
const validSecurities = ['tls', 'reality', 'none'];
if (!params.type || !validTypes.includes(params.type))
return {
valid: false,
message: 'Invalid VLESS URL: unsupported or missing type',
};
if (!params.security || !validSecurities.includes(params.security))
return {
valid: false,
message: 'Invalid VLESS URL: unsupported or missing security',
};
if (params.security === 'reality') {
if (!params.pbk)
return {
valid: false,
message: 'Invalid VLESS URL: missing pbk for reality',
};
if (!params.fp)
return {
valid: false,
message: 'Invalid VLESS URL: missing fp for reality',
};
}
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

@@ -4,6 +4,10 @@ REPO="https://api.github.com/repos/itdoginfo/podkop/releases/latest"
DOWNLOAD_DIR="/tmp/podkop" DOWNLOAD_DIR="/tmp/podkop"
COUNT=3 COUNT=3
# Cached flag to switch between ipk or apk package managers
PKG_IS_APK=0
command -v apk >/dev/null 2>&1 && PKG_IS_APK=1
rm -rf "$DOWNLOAD_DIR" rm -rf "$DOWNLOAD_DIR"
mkdir -p "$DOWNLOAD_DIR" mkdir -p "$DOWNLOAD_DIR"
@@ -11,13 +15,58 @@ msg() {
printf "\033[32;1m%s\033[0m\n" "$1" printf "\033[32;1m%s\033[0m\n" "$1"
} }
pkg_is_installed () {
local pkg_name="$1"
if [ "$PKG_IS_APK" -eq 1 ]; then
# grep -q should work without change based on example from documentation
# apk list --installed --providers dnsmasq
# <dnsmasq> dnsmasq-full-2.90-r3 x86_64 {feeds/base/package/network/services/dnsmasq} (GPL-2.0) [installed]
apk list --installed | grep -q "$pkg_name"
else
opkg list-installed | grep -q "$pkg_name"
fi
}
pkg_remove() {
local pkg_name="$1"
if [ "$PKG_IS_APK" -eq 1 ]; then
# TODO: check --force-depends flag
# Nothing here: https://openwrt.org/docs/guide-user/additional-software/opkg-to-apk-cheatsheet
apk del "$pkg_name"
else
opkg remove --force-depends "$pkg_name"
fi
}
pkg_list_update() {
if [ "$PKG_IS_APK" -eq 1 ]; then
apk update
else
opkg update
fi
}
pkg_install() {
local pkg_file="$1"
if [ "$PKG_IS_APK" -eq 1 ]; then
# Can't install without flag based on info from documentation
# If you're installing a non-standard (self-built) package, use the --allow-untrusted option:
apk add --allow-untrusted "$pkg_file"
else
opkg install "$pkg_file"
fi
}
main() { main() {
check_system check_system
sing_box sing_box
/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 /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; } pkg_list_update || { echo "Packages list update failed"; exit 1; }
if [ -f "/etc/init.d/podkop" ]; then if [ -f "/etc/init.d/podkop" ]; then
msg "Podkop is already installed. Upgraded..." msg "Podkop is already installed. Upgraded..."
@@ -34,6 +83,13 @@ main() {
fi fi
fi fi
local grep_url_pattern
if [ "$PKG_IS_APK" -eq 1 ]; then
grep_url_pattern='https://[^"[:space:]]*\.apk'
else
grep_url_pattern='https://[^"[:space:]]*\.ipk'
fi
download_success=0 download_success=0
while read -r url; do while read -r url; do
filename=$(basename "$url") filename=$(basename "$url")
@@ -57,7 +113,7 @@ main() {
if [ $attempt -eq $COUNT ]; then if [ $attempt -eq $COUNT ]; then
msg "Failed to download $filename after $COUNT attempts" msg "Failed to download $filename after $COUNT attempts"
fi fi
done < <(wget -qO- "$REPO" | grep -o 'https://[^"[:space:]]*\.ipk') done < <(wget -qO- "$REPO" | grep -o "$grep_url_pattern")
if [ $download_success -eq 0 ]; then if [ $download_success -eq 0 ]; then
msg "No packages were downloaded successfully" msg "No packages were downloaded successfully"
@@ -68,25 +124,25 @@ main() {
file=$(ls "$DOWNLOAD_DIR" | grep "^$pkg" | head -n 1) file=$(ls "$DOWNLOAD_DIR" | grep "^$pkg" | head -n 1)
if [ -n "$file" ]; then if [ -n "$file" ]; then
msg "Installing $file" msg "Installing $file"
opkg install "$DOWNLOAD_DIR/$file" pkg_install "$DOWNLOAD_DIR/$file"
sleep 3 sleep 3
fi fi
done done
ru=$(ls "$DOWNLOAD_DIR" | grep "luci-i18n-podkop-ru" | head -n 1) ru=$(ls "$DOWNLOAD_DIR" | grep "luci-i18n-podkop-ru" | head -n 1)
if [ -n "$ru" ]; then if [ -n "$ru" ]; then
if opkg list-installed | grep -q luci-i18n-podkop-ru; then if pkg_is_installed luci-i18n-podkop-ru; then
msg "Upgraded ru translation..." msg "Upgraded ru translation..."
opkg remove luci-i18n-podkop* pkg_remove luci-i18n-podkop*
opkg install "$DOWNLOAD_DIR/$ru" pkg_install "$DOWNLOAD_DIR/$ru"
else else
msg "Русский язык интерфейса ставим? y/n (Need a Russian translation?)" msg "Русский язык интерфейса ставим? y/n (Need a Russian translation?)"
while true; do while true; do
read -r -p '' RUS read -r -p '' RUS
case $RUS in case $RUS in
y) y)
opkg remove luci-i18n-podkop* pkg_remove luci-i18n-podkop*
opkg install "$DOWNLOAD_DIR/$ru" pkg_install "$DOWNLOAD_DIR/$ru"
break break
;; ;;
n) n)
@@ -133,15 +189,17 @@ check_system() {
exit 1 exit 1
fi fi
if opkg list-installed | grep -q https-dns-proxy; then if pkg_is_installed https-dns-proxy; then
msg "Сonflicting package detected: https-dns-proxy. Remove?" msg "Сonflicting package detected: https-dns-proxy. Remove?"
while true; do while true; do
read -r -p '' DNSPROXY read -r -p '' DNSPROXY
case $DNSPROXY in case $DNSPROXY in
yes|y|Y|yes) yes|y|Y)
opkg remove --force-depends luci-app-https-dns-proxy https-dns-proxy luci-i18n-https-dns-proxy* pkg_remove luci-app-https-dns-proxy
pkg_remove https-dns-proxy
pkg_remove luci-i18n-https-dns-proxy*
break break
;; ;;
*) *)
@@ -154,7 +212,7 @@ check_system() {
} }
sing_box() { sing_box() {
if ! opkg list-installed | grep -q "^sing-box"; then if ! pkg_is_installed "^sing-box"; then
return return
fi fi
@@ -165,7 +223,7 @@ sing_box() {
msg "sing-box version $sing_box_version is older than required $required_version" msg "sing-box version $sing_box_version is older than required $required_version"
msg "Removing old version..." msg "Removing old version..."
service podkop stop service podkop stop
opkg remove sing-box --force-depends pkg_remove sing-box
fi fi
} }

View File

@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-podkop PKG_NAME:=luci-app-podkop
PKG_VERSION := $(if $(PKG_VERSION),$(PKG_VERSION),dev_$(shell date +%d%m%Y)) PKG_VERSION := $(if $(PODKOP_VERSION),$(PODKOP_VERSION),0.$(shell date +%d%m%Y))
PKG_RELEASE:=1 PKG_RELEASE:=1
@@ -19,4 +19,12 @@ LUCI_LANGUAGES:=en ru
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)$(HTDOCS)
$(CP) $(PKG_BUILD_DIR)/htdocs/* $(1)$(HTDOCS)/
$(INSTALL_DIR) $(1)/
$(CP) $(PKG_BUILD_DIR)/root/* $(1)/
sed -i -e 's/__COMPILED_VERSION_VARIABLE__/$(PKG_VERSION)/g' $(1)$(HTDOCS)/luci-static/resources/view/podkop/main.js || true
endef
$(eval $(call BuildPackage,$(PKG_NAME)))

View File

@@ -1,36 +1,66 @@
'use strict'; 'use strict';
'require form'; 'require form';
'require baseclass'; 'require baseclass';
'require view.podkop.constants as constants';
'require tools.widgets as widgets'; 'require tools.widgets as widgets';
'require view.podkop.main as main';
function createAdditionalSection(mainSection, network) { function createAdditionalSection(mainSection) {
let o = mainSection.tab('additional', _('Additional Settings')); let o = mainSection.tab('additional', _('Additional Settings'));
o = mainSection.taboption('additional', form.Flag, 'yacd', _('Yacd enable'), '<a href="http://openwrt.lan:9090/ui" target="_blank">openwrt.lan:9090/ui</a>'); o = mainSection.taboption(
'additional',
form.Flag,
'yacd',
_('Yacd enable'),
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
);
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o = mainSection.taboption('additional', form.Flag, 'exclude_ntp', _('Exclude NTP'), _('Allows you to exclude NTP protocol traffic from the tunnel')); o = mainSection.taboption(
'additional',
form.Flag,
'exclude_ntp',
_('Exclude NTP'),
_('Allows you to exclude NTP protocol traffic from the tunnel'),
);
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o = mainSection.taboption('additional', form.Flag, 'quic_disable', _('QUIC disable'), _('For issues with the video stream')); o = mainSection.taboption(
'additional',
form.Flag,
'quic_disable',
_('QUIC disable'),
_('For issues with the video stream'),
);
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o = mainSection.taboption('additional', form.ListValue, 'update_interval', _('List Update Frequency'), _('Select how often the lists will be updated')); o = mainSection.taboption(
Object.entries(constants.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => { '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.value(key, _(label));
}); });
o.default = '1d'; o.default = '1d';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o = mainSection.taboption('additional', form.ListValue, 'dns_type', _('DNS Protocol Type'), _('Select DNS protocol to use')); 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('doh', _('DNS over HTTPS (DoH)'));
o.value('dot', _('DNS over TLS (DoT)')); o.value('dot', _('DNS over TLS (DoT)'));
o.value('udp', _('UDP (Unprotected DNS)')); o.value('udp', _('UDP (Unprotected DNS)'));
@@ -38,55 +68,61 @@ function createAdditionalSection(mainSection, network) {
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o = mainSection.taboption('additional', form.Value, 'dns_server', _('DNS Server'), _('Select or enter DNS server address')); o = mainSection.taboption(
Object.entries(constants.DNS_SERVER_OPTIONS).forEach(([key, label]) => { '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.value(key, _(label));
}); });
o.default = '8.8.8.8'; o.default = '8.8.8.8';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o.validate = function (section_id, value) { o.validate = function (section_id, value) {
if (!value) { const validation = main.validateDNS(value);
return _('DNS server address cannot be empty');
}
const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}(:[0-9]{1,5})?$/;
const domainRegex = /^(?:https:\/\/)?([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,63}(:[0-9]{1,5})?(\/[^?#\s]*)?$/;
if (!ipRegex.test(value) && !domainRegex.test(value)) {
return _('Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH');
}
if (validation.valid) {
return true; 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')); o = mainSection.taboption(
o.value('77.88.8.8', '77.88.8.8 (Yandex DNS)'); 'additional',
o.value('77.88.8.1', '77.88.8.1 (Yandex DNS)'); form.Value,
o.value('1.1.1.1', '1.1.1.1 (Cloudflare DNS)'); 'bootstrap_dns_server',
o.value('1.0.0.1', '1.0.0.1 (Cloudflare DNS)'); _('Bootstrap DNS server'),
o.value('8.8.8.8', '8.8.8.8 (Google DNS)'); _(
o.value('8.8.4.4', '8.8.4.4 (Google DNS)'); 'The DNS server used to look up the IP address of an upstream DNS server',
o.value('9.9.9.9', '9.9.9.9 (Quad9 DNS)'); ),
o.value('9.9.9.11', '9.9.9.11 (Quad9 DNS)'); );
Object.entries(main.BOOTSTRAP_DNS_SERVER_OPTIONS).forEach(([key, label]) => {
o.value(key, _(label));
});
o.default = '77.88.8.8'; o.default = '77.88.8.8';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o.validate = function (section_id, value) { o.validate = function (section_id, value) {
if (!value) { const validation = main.validateDNS(value);
return _('DNS server address cannot be empty');
}
const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}(:[0-9]{1,5})?$/;
if (!ipRegex.test(value)) {
return _('Invalid DNS server format. Example: 8.8.8.8');
}
if (validation.valid) {
return true; 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 = mainSection.taboption(
'additional',
form.Value,
'dns_rewrite_ttl',
_('DNS Rewrite TTL'),
_('Time in seconds for DNS record caching (default: 60)'),
);
o.default = '60'; o.default = '60';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
@@ -103,16 +139,35 @@ function createAdditionalSection(mainSection, network) {
return true; 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 = 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('/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.value('/tmp/sing-box/config.json', 'RAM (/tmp/sing-box/config.json)');
o.default = '/etc/sing-box/config.json'; o.default = '/etc/sing-box/config.json';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; 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 = 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('/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.value(
'/usr/share/sing-box/cache.db',
'Flash (/usr/share/sing-box/cache.db)',
);
o.default = '/tmp/sing-box/cache.db'; o.default = '/tmp/sing-box/cache.db';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
@@ -137,7 +192,13 @@ function createAdditionalSection(mainSection, network) {
return true; return true;
}; };
o = mainSection.taboption('additional', widgets.DeviceSelect, 'iface', _('Source Network Interface'), _('Select the network interface from which the traffic will originate')); o = mainSection.taboption(
'additional',
widgets.DeviceSelect,
'iface',
_('Source Network Interface'),
_('Select the network interface from which the traffic will originate'),
);
o.ucisection = 'main'; o.ucisection = 'main';
o.default = 'br-lan'; o.default = 'br-lan';
o.noaliases = true; o.noaliases = true;
@@ -145,36 +206,74 @@ function createAdditionalSection(mainSection, network) {
o.noinactive = false; o.noinactive = false;
o.multiple = true; o.multiple = true;
o.filter = function (section_id, value) { o.filter = function (section_id, value) {
if (['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan'].indexOf(value) !== -1) { // Block specific interface names from being selectable
const blocked = ['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan'];
if (blocked.includes(value)) {
return false; return false;
} }
var device = this.devices.filter(function (dev) { // Try to find the device object by its name
return dev.getName() === value; const device = this.devices.find((dev) => dev.getName() === value);
})[0];
if (device) { // If no device is found, allow the value
var type = device.getType(); if (!device) {
return type !== 'wifi' && type !== 'wireless' && !type.includes('wlan'); return true;
} }
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 = mainSection.taboption(
'additional',
form.Flag,
'mon_restart_ifaces',
_('Interface monitoring'),
_('Interface monitoring for bad WAN'),
);
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o = mainSection.taboption('additional', widgets.NetworkSelect, 'restart_ifaces', _('Interface for monitoring'), _('Select the WAN interfaces to be monitored')); o = mainSection.taboption(
'additional',
widgets.NetworkSelect,
'restart_ifaces',
_('Interface for monitoring'),
_('Select the WAN interfaces to be monitored'),
);
o.ucisection = 'main'; o.ucisection = 'main';
o.depends('mon_restart_ifaces', '1'); o.depends('mon_restart_ifaces', '1');
o.multiple = true; o.multiple = true;
o.filter = function (section_id, value) { o.filter = function (section_id, value) {
return ['lan', 'loopback'].indexOf(value) === -1 && !value.startsWith('@'); // 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 = mainSection.taboption(
'additional',
form.Value,
'procd_reload_delay',
_('Interface Monitoring Delay'),
_('Delay in milliseconds before reloading podkop after interface UP'),
);
o.ucisection = 'main'; o.ucisection = 'main';
o.depends('mon_restart_ifaces', '1'); o.depends('mon_restart_ifaces', '1');
o.default = '2000'; o.default = '2000';
@@ -186,45 +285,78 @@ function createAdditionalSection(mainSection, network) {
return true; return true;
}; };
o = mainSection.taboption('additional', form.Flag, 'dont_touch_dhcp', _('Dont touch my DHCP!'), _('Podkop will not change the DHCP config')); o = mainSection.taboption(
'additional',
form.Flag,
'dont_touch_dhcp',
_('Dont touch my DHCP!'),
_('Podkop will not change the DHCP config'),
);
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o = mainSection.taboption('additional', form.Flag, 'detour', _('Proxy download of lists'), _('Downloading all lists via main Proxy/VPN')); o = mainSection.taboption(
'additional',
form.Flag,
'detour',
_('Proxy download of lists'),
_('Downloading all lists via main Proxy/VPN'),
);
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
// Extra IPs and exclusions (main section) // 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 = 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.default = '0';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o = mainSection.taboption('basic', form.DynamicList, 'exclude_traffic_ip', _('Local IPs'), _('Enter valid IPv4 addresses')); o = mainSection.taboption(
'basic',
form.DynamicList,
'exclude_traffic_ip',
_('Local IPs'),
_('Enter valid IPv4 addresses'),
);
o.placeholder = 'IP'; o.placeholder = 'IP';
o.depends('exclude_from_ip_enabled', '1'); o.depends('exclude_from_ip_enabled', '1');
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
o.validate = function (section_id, value) { o.validate = function (section_id, value) {
if (!value || value.length === 0) return true; // Optional
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/; if (!value || value.length === 0) {
if (!ipRegex.test(value)) return _('Invalid IP format. Use format: X.X.X.X (like 192.168.1.1)');
const ipParts = value.split('.');
for (const part of ipParts) {
const num = parseInt(part);
if (num < 0 || num > 255) return _('IP address parts must be between 0 and 255');
}
return true; 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 = mainSection.taboption(
'basic',
form.Flag,
'socks5',
_('Mixed enable'),
_('Browser port: 2080'),
);
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;
o.ucisection = 'main'; o.ucisection = 'main';
} }
return baseclass.extend({ return baseclass.extend({
createAdditionalSection createAdditionalSection,
}); });

View File

@@ -1,113 +0,0 @@
'use strict';
'require baseclass';
const STATUS_COLORS = {
SUCCESS: '#4caf50',
ERROR: '#f44336',
WARNING: '#ff9800'
};
const FAKEIP_CHECK_DOMAIN = 'fakeip.podkop.fyi';
const IP_CHECK_DOMAIN = 'ip.podkop.fyi';
const REGIONAL_OPTIONS = ['russia_inside', 'russia_outside', 'ukraine_inside'];
const ALLOWED_WITH_RUSSIA_INSIDE = [
'russia_inside',
'meta',
'twitter',
'discord',
'telegram',
'cloudflare',
'google_ai',
'google_play',
'hetzner',
'ovh',
'hodca',
'digitalocean',
'cloudfront'
];
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'
};
const UPDATE_INTERVAL_OPTIONS = {
'1h': 'Every hour',
'3h': 'Every 3 hours',
'12h': 'Every 12 hours',
'1d': 'Every day',
'3d': 'Every 3 days'
};
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)'
};
const DIAGNOSTICS_UPDATE_INTERVAL = 10000; // 10 seconds
const CACHE_TIMEOUT = DIAGNOSTICS_UPDATE_INTERVAL - 1000; // 9 seconds
const ERROR_POLL_INTERVAL = 10000; // 10 seconds
const COMMAND_TIMEOUT = 10000; // 10 seconds
const FETCH_TIMEOUT = 10000; // 10 seconds
const BUTTON_FEEDBACK_TIMEOUT = 1000; // 1 second
const DIAGNOSTICS_INITIAL_DELAY = 100; // 100 milliseconds
// Интервалы планирования команд в диагностике (в миллисекундах)
const COMMAND_SCHEDULING = {
P0_PRIORITY: 0, // Наивысший приоритет (без задержки)
P1_PRIORITY: 100, // Очень высокий приоритет
P2_PRIORITY: 300, // Высокий приоритет
P3_PRIORITY: 500, // Выше среднего
P4_PRIORITY: 700, // Стандартный приоритет
P5_PRIORITY: 900, // Ниже среднего
P6_PRIORITY: 1100, // Низкий приоритет
P7_PRIORITY: 1300, // Очень низкий приоритет
P8_PRIORITY: 1500, // Фоновое выполнение
P9_PRIORITY: 1700, // Выполнение в режиме простоя
P10_PRIORITY: 1900 // Наименьший приоритет
};
return baseclass.extend({
STATUS_COLORS,
FAKEIP_CHECK_DOMAIN,
IP_CHECK_DOMAIN,
REGIONAL_OPTIONS,
ALLOWED_WITH_RUSSIA_INSIDE,
DOMAIN_LIST_OPTIONS,
UPDATE_INTERVAL_OPTIONS,
DNS_SERVER_OPTIONS,
DIAGNOSTICS_UPDATE_INTERVAL,
ERROR_POLL_INTERVAL,
COMMAND_TIMEOUT,
FETCH_TIMEOUT,
BUTTON_FEEDBACK_TIMEOUT,
DIAGNOSTICS_INITIAL_DELAY,
COMMAND_SCHEDULING,
CACHE_TIMEOUT
});

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

View File

@@ -5,51 +5,28 @@
'require view.podkop.configSection as configSection'; 'require view.podkop.configSection as configSection';
'require view.podkop.diagnosticTab as diagnosticTab'; 'require view.podkop.diagnosticTab as diagnosticTab';
'require view.podkop.additionalTab as additionalTab'; 'require view.podkop.additionalTab as additionalTab';
'require view.podkop.dashboardTab as dashboardTab';
'require view.podkop.utils as utils'; 'require view.podkop.utils as utils';
'require view.podkop.main as main';
return view.extend({ const EntryNode = {
async render() { async render() {
document.head.insertAdjacentHTML('beforeend', ` main.injectGlobalStyles();
<style>
.cbi-value {
margin-bottom: 10px !important;
}
#diagnostics-status .table > div { const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']);
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;
}
</style>
`);
const m = new form.Map('podkop', '', null, ['main', 'extra']);
// Main Section // Main Section
const mainSection = m.section(form.TypedSection, 'main'); const mainSection = podkopFormMap.section(form.TypedSection, 'main');
mainSection.anonymous = true; mainSection.anonymous = true;
configSection.createConfigSection(mainSection, m, network);
configSection.createConfigSection(mainSection);
// Additional Settings Tab (main section) // Additional Settings Tab (main section)
additionalTab.createAdditionalSection(mainSection, network); additionalTab.createAdditionalSection(mainSection);
// Diagnostics Tab (main section) // Diagnostics Tab (main section)
diagnosticTab.createDiagnosticsSection(mainSection); diagnosticTab.createDiagnosticsSection(mainSection);
const map_promise = m.render().then(node => { const podkopFormMapPromise = podkopFormMap.render().then((node) => {
// Set up diagnostics event handlers // Set up diagnostics event handlers
diagnosticTab.setupDiagnosticsEventHandlers(node); diagnosticTab.setupDiagnosticsEventHandlers(node);
@@ -81,13 +58,25 @@ return view.extend({
}); });
// Extra Section // Extra Section
const extraSection = m.section(form.TypedSection, 'extra', _('Extra configurations')); const extraSection = podkopFormMap.section(
form.TypedSection,
'extra',
_('Extra configurations'),
);
extraSection.anonymous = false; extraSection.anonymous = false;
extraSection.addremove = true; extraSection.addremove = true;
extraSection.addbtntitle = _('Add Section'); extraSection.addbtntitle = _('Add Section');
extraSection.multiple = true; extraSection.multiple = true;
configSection.createConfigSection(extraSection, m, network); configSection.createConfigSection(extraSection);
return map_promise; // Initial dashboard render
} dashboardTab.createDashboardSection(mainSection);
});
// Inject core service
main.coreService();
return podkopFormMapPromise;
},
};
return view.extend(EntryNode);

View File

@@ -2,7 +2,7 @@
'require baseclass'; 'require baseclass';
'require ui'; 'require ui';
'require fs'; 'require fs';
'require view.podkop.constants as constants'; 'require view.podkop.main as main';
// Flag to track if this is the first error check // Flag to track if this is the first error check
let isInitialCheck = true; let isInitialCheck = true;
@@ -15,14 +15,12 @@ let errorPollTimer = null;
// Helper function to fetch errors from the podkop command // Helper function to fetch errors from the podkop command
async function getPodkopErrors() { async function getPodkopErrors() {
return new Promise(resolve => { return new Promise((resolve) => {
safeExec('/usr/bin/podkop', ['check_logs'], 'P0_PRIORITY', result => { safeExec('/usr/bin/podkop', ['check_logs'], 'P0_PRIORITY', (result) => {
if (!result || !result.stdout) return resolve([]); if (!result || !result.stdout) return resolve([]);
const logs = result.stdout.split('\n'); const logs = result.stdout.split('\n');
const errors = logs.filter(log => const errors = logs.filter((log) => log.includes('[critical]'));
log.includes('[critical]')
);
resolve(errors); resolve(errors);
}); });
@@ -31,21 +29,30 @@ async function getPodkopErrors() {
// Show error notification to the user // Show error notification to the user
function showErrorNotification(error, isMultiple = false) { function showErrorNotification(error, isMultiple = false) {
const notificationContent = E('div', { 'class': 'alert-message error' }, [ const notificationContent = E('div', { class: 'alert-message error' }, [
E('pre', { 'class': 'error-log' }, error) E('pre', { class: 'error-log' }, error),
]); ]);
ui.addNotification(null, notificationContent); ui.addNotification(null, notificationContent);
} }
// Helper function for command execution with prioritization // Helper function for command execution with prioritization
function safeExec(command, args, priority, callback, timeout = constants.COMMAND_TIMEOUT) { function safeExec(
command,
args,
priority,
callback,
timeout = main.COMMAND_TIMEOUT,
) {
// Default to highest priority execution if priority is not provided or invalid // Default to highest priority execution if priority is not provided or invalid
let schedulingDelay = constants.COMMAND_SCHEDULING.P0_PRIORITY; let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY;
// If priority is a string, try to get the corresponding delay value // If priority is a string, try to get the corresponding delay value
if (typeof priority === 'string' && constants.COMMAND_SCHEDULING[priority] !== undefined) { if (
schedulingDelay = constants.COMMAND_SCHEDULING[priority]; typeof priority === 'string' &&
main.COMMAND_SCHEDULING[priority] !== undefined
) {
schedulingDelay = main.COMMAND_SCHEDULING[priority];
} }
const executeCommand = async () => { const executeCommand = async () => {
@@ -59,7 +66,7 @@ function safeExec(command, args, priority, callback, timeout = constants.COMMAND
controller.signal.addEventListener('abort', () => { controller.signal.addEventListener('abort', () => {
reject(new Error('Command execution timed out')); reject(new Error('Command execution timed out'));
}); });
}) }),
]); ]);
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -70,7 +77,9 @@ function safeExec(command, args, priority, callback, timeout = constants.COMMAND
return result; return result;
} catch (error) { } catch (error) {
console.warn(`Command execution failed or timed out: ${command} ${args.join(' ')}`); console.warn(
`Command execution failed or timed out: ${command} ${args.join(' ')}`,
);
const errorResult = { stdout: '', stderr: error.message, error: error }; const errorResult = { stdout: '', stderr: error.message, error: error };
if (callback && typeof callback === 'function') { if (callback && typeof callback === 'function') {
@@ -84,8 +93,7 @@ function safeExec(command, args, priority, callback, timeout = constants.COMMAND
if (callback && typeof callback === 'function') { if (callback && typeof callback === 'function') {
setTimeout(executeCommand, schedulingDelay); setTimeout(executeCommand, schedulingDelay);
return; return;
} } else {
else {
return executeCommand(); return executeCommand();
} }
} }
@@ -97,19 +105,19 @@ async function checkForCriticalErrors() {
if (errors && errors.length > 0) { if (errors && errors.length > 0) {
// Filter out errors we've already seen // Filter out errors we've already seen
const newErrors = errors.filter(error => !lastErrorsSet.has(error)); const newErrors = errors.filter((error) => !lastErrorsSet.has(error));
if (newErrors.length > 0) { if (newErrors.length > 0) {
// On initial check, just store errors without showing notifications // On initial check, just store errors without showing notifications
if (!isInitialCheck) { if (!isInitialCheck) {
// Show each new error as a notification // Show each new error as a notification
newErrors.forEach(error => { newErrors.forEach((error) => {
showErrorNotification(error, newErrors.length > 1); showErrorNotification(error, newErrors.length > 1);
}); });
} }
// Add new errors to our set of seen errors // Add new errors to our set of seen errors
newErrors.forEach(error => lastErrorsSet.add(error)); newErrors.forEach((error) => lastErrorsSet.add(error));
} }
} }
@@ -133,7 +141,10 @@ function startErrorPolling() {
checkForCriticalErrors(); checkForCriticalErrors();
// Then set up periodic checks // Then set up periodic checks
errorPollTimer = setInterval(checkForCriticalErrors, constants.ERROR_POLL_INTERVAL); errorPollTimer = setInterval(
checkForCriticalErrors,
main.ERROR_POLL_INTERVAL,
);
} }
// Stop polling for errors // Stop polling for errors
@@ -148,5 +159,5 @@ return baseclass.extend({
startErrorPolling, startErrorPolling,
stopErrorPolling, stopErrorPolling,
checkForCriticalErrors, checkForCriticalErrors,
safeExec safeExec,
}); });

View File

@@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
PODIR="po" PODIR="po"

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PODKOP\n" "Project-Id-Version: PODKOP\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-02 19:37+0500\n" "POT-Creation-Date: 2025-10-07 16:55+0300\n"
"PO-Revision-Date: 2025-09-30 15:18+0500\n" "PO-Revision-Date: 2025-10-07 23:45+0300\n"
"Last-Translator: Automatically generated\n" "Last-Translator: Automatically generated\n"
"Language-Team: none\n" "Language-Team: none\n"
"Language: ru\n" "Language: ru\n"
@@ -17,171 +17,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "Additional Settings"
msgstr "Дополнительные настройки"
msgid "Yacd enable"
msgstr "Включить Yacd"
msgid "Exclude NTP"
msgstr "Исключить NTP"
msgid "Allows you to exclude NTP protocol traffic from the tunnel"
msgstr "Позволяет исключить направление трафика NTP-протокола в туннель"
msgid "QUIC disable"
msgstr "Отключить QUIC"
msgid "For issues with the video stream"
msgstr "Для проблем с видеопотоком"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
msgid "Select how often the lists will be updated"
msgstr "Выберите как часто будут обновляться списки"
msgid "DNS Protocol Type"
msgstr "Тип DNS протокола"
msgid "Select DNS protocol to use"
msgstr "Выберите протокол DNS"
msgid "DNS over HTTPS (DoH)"
msgstr "DNS через HTTPS (DoH)"
msgid "DNS over TLS (DoT)"
msgstr "DNS через TLS (DoT)"
msgid "UDP (Unprotected DNS)"
msgstr "UDP (Незащищённый DNS)"
msgid "DNS Server"
msgstr "DNS-сервер"
msgid "Select or enter DNS server address"
msgstr "Выберите или введите адрес DNS-сервера"
msgid "DNS server address cannot be empty"
msgstr "Адрес DNS-сервера не может быть пустым"
msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8 или dns.example.com или dns.example.com/nicedns для DoH"
msgid "Bootstrap DNS server"
msgstr "Bootstrap DNS-сервер"
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера"
msgid "Invalid DNS server format. Example: 8.8.8.8"
msgstr "Неверный формат DNS-сервера. Пример: 8.8.8.8"
msgid "DNS Rewrite TTL"
msgstr "Перезапись TTL для DNS"
msgid "Time in seconds for DNS record caching (default: 60)"
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
msgid "TTL value cannot be empty"
msgstr "Значение TTL не может быть пустым"
msgid "TTL must be a positive number"
msgstr "TTL должно быть положительным числом"
msgid "Config File Path"
msgstr "Путь к файлу конфигурации"
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Cache File Path"
msgstr "Путь к файлу кэша"
msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing"
msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Cache file path cannot be empty"
msgstr "Путь к файлу кэша не может быть пустым"
msgid "Path must be absolute (start with /)"
msgstr "Путь должен быть абсолютным (начинаться с /)"
msgid "Path must end with cache.db"
msgstr "Путь должен заканчиваться на cache.db"
msgid "Path must contain at least one directory (like /tmp/cache.db)"
msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)"
msgid "Source Network Interface"
msgstr "Сетевой интерфейс источника"
msgid "Select the network interface from which the traffic will originate"
msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик"
msgid "Interface monitoring"
msgstr "Мониторинг интерфейсов"
msgid "Interface monitoring for bad WAN"
msgstr "Мониторинг интерфейсов для плохого WAN"
msgid "Interface for monitoring"
msgstr "Интерфейс для мониторинга"
msgid "Select the WAN interfaces to be monitored"
msgstr "Выберите WAN интерфейсы для мониторинга"
msgid "Interface Monitoring Delay"
msgstr "Задержка при мониторинге интерфейсов"
msgid "Delay in milliseconds before reloading podkop after interface UP"
msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса"
msgid "Delay value cannot be empty"
msgstr "Значение задержки не может быть пустым"
msgid "Dont touch my DHCP!"
msgstr "Не трогать мой DHCP!"
msgid "Podkop will not change the DHCP config"
msgstr "Podkop не будет изменять конфигурацию DHCP"
msgid "Proxy download of lists"
msgstr "Загрузка списков через прокси"
msgid "Downloading all lists via main Proxy/VPN"
msgstr "Загрузка всех списков через основной прокси/VPN"
msgid "IP for exclusion"
msgstr "IP для исключения"
msgid "Specify local IP addresses that will never use the configured route"
msgstr "Укажите локальные IP-адреса, которые никогда не будут использовать настроенный маршрут"
msgid "Local IPs"
msgstr "Локальные IP адреса"
msgid "Enter valid IPv4 addresses"
msgstr "Введите действительные IPv4-адреса"
msgid "Invalid IP format. Use format: X.X.X.X (like 192.168.1.1)"
msgstr "Неверный формат IP. Используйте формат: X.X.X.X (например: 192.168.1.1)"
msgid "IP address parts must be between 0 and 255"
msgstr "Части IP-адреса должны быть между 0 и 255"
msgid "Mixed enable"
msgstr "Включить смешанный режим"
msgid "Browser port: 2080"
msgstr "Порт браузера: 2080"
msgid "URL must use one of the following protocols: "
msgstr "URL должен использовать один из следующих протоколов: "
msgid "Invalid URL format"
msgstr "Неверный формат URL"
msgid "Basic Settings" msgid "Basic Settings"
msgstr "Основные настройки" msgstr "Основные настройки"
@@ -220,67 +55,16 @@ msgid ""
"configs" "configs"
msgstr "" msgstr ""
"Введите строку подключения, начинающуюся с vless:// или ss:// для настройки прокси. Добавляйте комментарии с // для " "Введите строку подключения, начинающуюся с vless:// или ss:// для настройки прокси. Добавляйте комментарии с // для "
"сохранения других конфигураций" "резервных конфигураций"
msgid "No active configuration found. At least one non-commented line is required." msgid "No active configuration found. One configuration is required."
msgstr "Активная конфигурация не найдена. Требуется хотя бы одна незакомментированная строка." msgstr "Активная конфигурация не найдена. Требуется хотя бы одна незакомментированная строка."
msgid "URL must start with vless:// or ss://" msgid "Multiply active configurations found. Please leave one configuration."
msgstr "URL должен начинаться с vless:// или ss://" msgstr "Найдено несколько активных конфигураций. Оставьте только одну."
msgid "Invalid Shadowsocks URL format: missing method and password separator \":\"" msgid "Invalid URL format:"
msgstr "Неверный формат URL Shadowsocks: отсутствует разделитель метода и пароля \":\"" msgstr "Неверный формат URL:"
msgid "Invalid Shadowsocks URL format"
msgstr "Неверный формат URL Shadowsocks"
msgid "Invalid Shadowsocks URL: missing server address"
msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера"
msgid "Invalid Shadowsocks URL: missing server"
msgstr "Неверный URL Shadowsocks: отсутствует сервер"
msgid "Invalid Shadowsocks URL: missing port"
msgstr "Неверный URL Shadowsocks: отсутствует порт"
msgid "Invalid port number. Must be between 1 and 65535"
msgstr "Неверный номер порта. Должен быть между 1 и 65535"
msgid "Invalid Shadowsocks URL: missing or invalid server/port format"
msgstr "Неверный URL Shadowsocks: отсутствует или неверный формат сервера/порта"
msgid "Invalid VLESS URL: missing UUID"
msgstr "Неверный URL VLESS: отсутствует UUID"
msgid "Invalid VLESS URL: missing server address"
msgstr "Неверный URL VLESS: отсутствует адрес сервера"
msgid "Invalid VLESS URL: missing server"
msgstr "Неверный URL VLESS: отсутствует сервер"
msgid "Invalid VLESS URL: missing port"
msgstr "Неверный URL VLESS: отсутствует порт"
msgid "Invalid VLESS URL: missing or invalid server/port format"
msgstr "Неверный URL VLESS: отсутствует или неверный формат сервера/порта"
msgid "Invalid VLESS URL: missing query parameters"
msgstr "Неверный URL VLESS: отсутствуют параметры запроса"
msgid "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws"
msgstr "Неверный URL VLESS: тип должен быть одним из tcp, raw, udp, grpc, http, ws"
msgid "Invalid VLESS URL: security must be one of tls, reality, none"
msgstr "Неверный URL VLESS: security должен быть одним из tls, reality, none"
msgid "Invalid VLESS URL: missing pbk parameter for reality security"
msgstr "Неверный URL VLESS: отсутствует параметр pbk для security reality"
msgid "Invalid VLESS URL: missing fp parameter for reality security"
msgstr "Неверный URL VLESS: отсутствует параметр fp для security reality"
msgid "Invalid URL format: "
msgstr "Неверный формат URL: "
msgid "Outbound Configuration" msgid "Outbound Configuration"
msgstr "Конфигурация исходящего соединения" msgstr "Конфигурация исходящего соединения"
@@ -288,12 +72,6 @@ msgstr "Конфигурация исходящего соединения"
msgid "Enter complete outbound configuration in JSON format" msgid "Enter complete outbound configuration in JSON format"
msgstr "Введите полную конфигурацию исходящего соединения в формате JSON" msgstr "Введите полную конфигурацию исходящего соединения в формате JSON"
msgid "JSON must contain at least type, server and server_port fields"
msgstr "JSON должен содержать как минимум поля type, server и server_port"
msgid "Invalid JSON format"
msgstr "Неверный формат JSON"
msgid "URLTest Proxy Links" msgid "URLTest Proxy Links"
msgstr "Ссылки прокси для URLTest" msgstr "Ссылки прокси для URLTest"
@@ -315,8 +93,26 @@ msgstr "Резолвер доменов"
msgid "Enable built-in DNS resolver for domains handled by this section" msgid "Enable built-in DNS resolver for domains handled by this section"
msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе" msgstr "Включить встроенный DNS-резолвер для доменов, обрабатываемых в этом разделе"
msgid "DNS Protocol Type"
msgstr "Тип протокола DNS"
msgid "Select the DNS protocol type for the domain resolver" msgid "Select the DNS protocol type for the domain resolver"
msgstr "Выберите протокол DNS для резолвера доменов" msgstr "Выберите тип протокола DNS для резолвера доменов"
msgid "DNS over HTTPS (DoH)"
msgstr "DNS через HTTPS (DoH)"
msgid "DNS over TLS (DoT)"
msgstr "DNS через TLS (DoT)"
msgid "UDP (Unprotected DNS)"
msgstr "UDP (Незащищённый DNS)"
msgid "DNS Server"
msgstr "DNS-сервер"
msgid "Select or enter DNS server address"
msgstr "Выберите или введите адрес DNS-сервера"
msgid "Community Lists" msgid "Community Lists"
msgstr "Списки сообщества" msgstr "Списки сообщества"
@@ -328,7 +124,7 @@ msgid "Select predefined service for routing"
msgstr "Выберите предустановленные сервисы для маршрутизации" msgstr "Выберите предустановленные сервисы для маршрутизации"
msgid "Regional options cannot be used together" msgid "Regional options cannot be used together"
msgstr "Нельзя использовать несколько региональных опций" msgstr "Нельзя использовать несколько региональных опций одновременно"
#, javascript-format #, javascript-format
msgid "Warning: %s cannot be used together with %s. Previous selections have been removed." msgid "Warning: %s cannot be used together with %s. Previous selections have been removed."
@@ -341,8 +137,7 @@ msgstr "Ограничения Russia inside"
msgid "" msgid ""
"Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection." "Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection."
msgstr "" msgstr ""
"Внимание: \"Russia inside\" может использоваться только с %s. %s уже находится в \"Russia inside\" и был удален из " "Внимание: «Russia inside» может использоваться только с %s. %s уже находится в «Russia inside» и был удалён из выбора."
"выбора."
msgid "User Domain List Type" msgid "User Domain List Type"
msgstr "Тип пользовательского списка доменов" msgstr "Тип пользовательского списка доменов"
@@ -363,25 +158,19 @@ msgid "User Domains"
msgstr "Пользовательские домены" msgstr "Пользовательские домены"
msgid "Enter domain names without protocols (example: sub.example.com or example.com)" msgid "Enter domain names without protocols (example: sub.example.com or example.com)"
msgstr "Введите доменные имена без указания протоколов (например: sub.example.com или example.com)" msgstr "Введите доменные имена без протоколов (например: sub.example.com или example.com)"
msgid "Invalid domain format. Enter domain without protocol (example: sub.example.com or ru)"
msgstr "Введите имена доменов без протоколов (пример: sub.example.com или example.com)"
msgid "User Domains List" msgid "User Domains List"
msgstr "Список пользовательских доменов" msgstr "Список пользовательских доменов"
msgid "Enter domain names separated by comma, space or newline. You can add comments after //" msgid "Enter domain names separated by comma, space or newline. You can add comments after //"
msgstr "" msgstr "Введите домены через запятую, пробел или с новой строки. Можно добавлять комментарии после //"
"Введите имена доменов, разделяя их запятой, пробелом или с новой строки. Вы можете добавлять комментарии после //"
#, javascript-format
msgid "Invalid domain format: %s. Enter domain without protocol"
msgstr "Неверный формат домена: %s. Введите домен без протокола"
msgid "At least one valid domain must be specified. Comments-only content is not allowed." msgid "At least one valid domain must be specified. Comments-only content is not allowed."
msgstr "" msgstr "Необходимо указать хотя бы один действительный домен. Содержимое только из комментариев не допускается."
"Должен быть указан хотя бы один действительный домен. Содержимое, состоящее только из комментариев, не допускается."
msgid "Validation errors:"
msgstr "Ошибки валидации:"
msgid "Local Domain Lists" msgid "Local Domain Lists"
msgstr "Локальные списки доменов" msgstr "Локальные списки доменов"
@@ -395,17 +184,14 @@ msgstr "Пути к локальным спискам доменов"
msgid "Enter the list file path" msgid "Enter the list file path"
msgstr "Введите путь к файлу списка" msgstr "Введите путь к файлу списка"
msgid "Invalid path format. Path must start with \"/\" and contain valid characters"
msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы"
msgid "Remote Domain Lists" msgid "Remote Domain Lists"
msgstr "Удаленные списки доменов" msgstr "Удалённые списки доменов"
msgid "Download and use domain lists from remote URLs" msgid "Download and use domain lists from remote URLs"
msgstr "Загрузка и использование списков доменов с удаленных URL" msgstr "Загружать и использовать списки доменов с удалённых URL"
msgid "Remote Domain URLs" msgid "Remote Domain URLs"
msgstr "URL удаленных доменов" msgstr "URL удалённых доменов"
msgid "Enter full URLs starting with http:// or https://" msgid "Enter full URLs starting with http:// or https://"
msgstr "Введите полные URL, начинающиеся с http:// или https://" msgstr "Введите полные URL, начинающиеся с http:// или https://"
@@ -423,22 +209,13 @@ msgid "Select how to add your custom subnets"
msgstr "Выберите способ добавления пользовательских подсетей" msgstr "Выберите способ добавления пользовательских подсетей"
msgid "Text List (comma/space/newline separated)" msgid "Text List (comma/space/newline separated)"
msgstr "Текстовый список (разделенный запятыми/пробелами/новыми строками)" msgstr "Текстовый список (через запятую, пробел или новую строку)"
msgid "User Subnets" msgid "User Subnets"
msgstr "Пользовательские подсети" msgstr "Пользовательские подсети"
msgid "Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses" msgid "Enter subnets in CIDR notation (example: 103.21.244.0/22) or single IP addresses"
msgstr "Введите подсети в нотации CIDR (пример: 103.21.244.0/22) или отдельные IP-адреса" msgstr "Введите подсети в нотации CIDR (например: 103.21.244.0/22) или отдельные IP-адреса"
msgid "Invalid format. Use format: X.X.X.X or X.X.X.X/Y"
msgstr "Неверный формат. Используйте формат: X.X.X.X или X.X.X.X/Y"
msgid "IP address 0.0.0.0 is not allowed"
msgstr "IP адрес не может быть 0.0.0.0"
msgid "CIDR must be between 0 and 32"
msgstr "CIDR должен быть между 0 и 32"
msgid "User Subnets List" msgid "User Subnets List"
msgstr "Список пользовательских подсетей" msgstr "Список пользовательских подсетей"
@@ -447,34 +224,20 @@ msgid ""
"Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments " "Enter subnets in CIDR notation or single IP addresses, separated by comma, space or newline. You can add comments "
"after //" "after //"
msgstr "" msgstr ""
"Введите подсети в нотации CIDR или отдельные IP-адреса, разделенные запятой, пробелом или новой строкой. Вы можете " "Введите подсети в нотации CIDR или IP-адреса через запятую, пробел или новую строку. Можно добавлять комментарии "
"добавлять комментарии после //" "после //"
#, javascript-format
msgid "Invalid format: %s. Use format: X.X.X.X or X.X.X.X/Y"
msgstr "Неверный формат: %s. Используйте формат: X.X.X.X или X.X.X.X/Y"
#, javascript-format
msgid "IP parts must be between 0 and 255 in: %s"
msgstr "Части IP-адреса должны быть между 0 и 255 в: %s"
#, javascript-format
msgid "CIDR must be between 0 and 32 in: %s"
msgstr "CIDR должен быть между 0 и 32 в: %s"
msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed." msgid "At least one valid subnet or IP must be specified. Comments-only content is not allowed."
msgstr "" msgstr "Необходимо указать хотя бы одну действительную подсеть или IP. Только комментарии недопустимы."
"Должна быть указана хотя бы одна действительная подсеть или IP. Содержимое, состоящее только из комментариев, не "
"допускается."
msgid "Remote Subnet Lists" msgid "Remote Subnet Lists"
msgstr "Удаленные списки подсетей" msgstr "Удалённые списки подсетей"
msgid "Download and use subnet lists from remote URLs" msgid "Download and use subnet lists from remote URLs"
msgstr "Загрузка и использование списков подсетей с удаленных URL" msgstr "Загружать и использовать списки подсетей с удалённых URL"
msgid "Remote Subnet URLs" msgid "Remote Subnet URLs"
msgstr "URL удаленных подсетей" msgstr "URL удалённых подсетей"
msgid "IP for full redirection" msgid "IP for full redirection"
msgstr "IP для полного перенаправления" msgstr "IP для полного перенаправления"
@@ -482,21 +245,219 @@ msgstr "IP для полного перенаправления"
msgid "Specify local IP addresses whose traffic will always use the configured route" msgid "Specify local IP addresses whose traffic will always use the configured route"
msgstr "Укажите локальные IP-адреса, трафик которых всегда будет использовать настроенный маршрут" msgstr "Укажите локальные IP-адреса, трафик которых всегда будет использовать настроенный маршрут"
msgid "Local IPs"
msgstr "Локальные IP-адреса"
msgid "Enter valid IPv4 addresses"
msgstr "Введите действительные IPv4-адреса"
msgid "Extra configurations"
msgstr "Дополнительные конфигурации"
msgid "Add Section"
msgstr "Добавить раздел"
msgid "Dashboard"
msgstr "Дашборд"
msgid "Valid"
msgstr "Валидно"
msgid "Invalid IP address"
msgstr "Неверный IP-адрес"
msgid "Invalid domain address"
msgstr "Неверный домен"
msgid "DNS server address cannot be empty"
msgstr "Адрес DNS-сервера не может быть пустым"
msgid "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
msgstr "Неверный формат DNS-сервера. Примеры: 8.8.8.8, dns.example.com или dns.example.com/nicedns для DoH"
msgid "URL must use one of the following protocols:"
msgstr "URL должен использовать один из следующих протоколов:"
msgid "Invalid URL format"
msgstr "Неверный формат URL"
msgid "Path cannot be empty"
msgstr "Путь не может быть пустым"
msgid "Invalid path format. Path must start with \"/\" and contain valid characters"
msgstr "Неверный формат пути. Путь должен начинаться с \"/\" и содержать допустимые символы"
msgid "Invalid format. Use X.X.X.X or X.X.X.X/Y"
msgstr "Неверный формат. Используйте X.X.X.X или X.X.X.X/Y"
msgid "IP address 0.0.0.0 is not allowed"
msgstr "IP-адрес 0.0.0.0 не допускается"
msgid "CIDR must be between 0 and 32"
msgstr "CIDR должен быть между 0 и 32"
msgid "Invalid Shadowsocks URL: must start with ss://"
msgstr "Неверный URL Shadowsocks: должен начинаться с ss://"
msgid "Invalid Shadowsocks URL: must not contain spaces"
msgstr "Неверный URL Shadowsocks: не должен содержать пробелов"
msgid "Invalid Shadowsocks URL: missing credentials"
msgstr "Неверный URL Shadowsocks: отсутствуют учетные данные"
msgid "Invalid Shadowsocks URL: decoded credentials must contain method:password"
msgstr "Неверный URL Shadowsocks: декодированные данные должны содержать method:password"
msgid "Invalid Shadowsocks URL: missing method and password separator \":\""
msgstr "Неверный URL Shadowsocks: отсутствует разделитель метода и пароля \":\""
msgid "Invalid Shadowsocks URL: missing server address"
msgstr "Неверный URL Shadowsocks: отсутствует адрес сервера"
msgid "Invalid Shadowsocks URL: missing server"
msgstr "Неверный URL Shadowsocks: отсутствует сервер"
msgid "Invalid Shadowsocks URL: missing port"
msgstr "Неверный URL Shadowsocks: отсутствует порт"
msgid "Invalid port number. Must be between 1 and 65535"
msgstr "Неверный номер порта. Допустимо от 1 до 65535"
msgid "Invalid Shadowsocks URL: parsing failed"
msgstr "Неверный URL Shadowsocks: ошибка разбора"
msgid "Invalid VLESS URL: must not contain spaces"
msgstr "Неверный URL VLESS: не должен содержать пробелов"
msgid "Invalid VLESS URL: must start with vless://"
msgstr "Неверный URL VLESS: должен начинаться с vless://"
msgid "Invalid VLESS URL: missing UUID"
msgstr "Неверный URL VLESS: отсутствует UUID"
msgid "Invalid VLESS URL: missing server"
msgstr "Неверный URL VLESS: отсутствует сервер"
msgid "Invalid VLESS URL: missing port"
msgstr "Неверный URL VLESS: отсутствует порт"
msgid "Invalid VLESS URL: invalid port number. Must be between 1 and 65535"
msgstr "Неверный URL VLESS: недопустимый порт (165535)"
msgid "Invalid VLESS URL: missing query parameters"
msgstr "Неверный URL VLESS: отсутствуют параметры запроса"
msgid "Invalid VLESS URL: type must be one of tcp, raw, udp, grpc, http, ws"
msgstr "Неверный URL VLESS: тип должен быть tcp, raw, udp, grpc, http или ws"
msgid "Invalid VLESS URL: security must be one of tls, reality, none"
msgstr "Неверный URL VLESS: параметр security должен быть tls, reality или none"
msgid "Invalid VLESS URL: missing pbk parameter for reality security"
msgstr "Неверный URL VLESS: отсутствует параметр pbk для security=reality"
msgid "Invalid VLESS URL: missing fp parameter for reality security"
msgstr "Неверный URL VLESS: отсутствует параметр fp для security=reality"
msgid "Invalid VLESS URL: parsing failed"
msgstr "Неверный URL VLESS: ошибка разбора"
msgid "Outbound JSON must contain at least \"type\", \"server\" and \"server_port\" fields"
msgstr "JSON должен содержать поля \"type\", \"server\" и \"server_port\""
msgid "Invalid JSON format"
msgstr "Неверный формат JSON"
msgid "Invalid Trojan URL: must start with trojan://"
msgstr "Неверный URL Trojan: должен начинаться с trojan://"
msgid "Invalid Trojan URL: must not contain spaces"
msgstr "Неверный URL Trojan: не должен содержать пробелов"
msgid "Invalid Trojan URL: must contain username, hostname and port"
msgstr "Неверный URL Trojan: должен содержать имя пользователя, хост и порт"
msgid "Invalid Trojan URL: parsing failed"
msgstr "Неверный URL Trojan: ошибка разбора"
msgid "URL must start with vless:// or ss:// or trojan://"
msgstr "URL должен начинаться с vless://, ss:// или trojan://"
msgid "Operation timed out"
msgstr "Время ожидания истекло"
msgid "HTTP error"
msgstr "Ошибка HTTP"
msgid "Unknown error"
msgstr "Неизвестная ошибка"
msgid "Fastest"
msgstr "Самый быстрый"
msgid "Dashboard currently unavailable"
msgstr "Дашборд сейчас недоступен"
msgid "Currently unavailable"
msgstr "Временно недоступно"
msgid "Traffic"
msgstr "Трафик"
msgid "Uplink"
msgstr "Исходящий"
msgid "Downlink"
msgstr "Входящий"
msgid "Traffic Total"
msgstr "Всего трафика"
msgid "System info"
msgstr "Системная информация"
msgid "Active Connections"
msgstr "Активные соединения"
msgid "Memory Usage"
msgstr "Использование памяти"
msgid "Services info"
msgstr "Информация о сервисах"
msgid "Podkop"
msgstr "Podkop"
msgid "✔ Enabled"
msgstr "✔ Включено"
msgid "✘ Disabled"
msgstr "✘ Отключено"
msgid "Sing-box"
msgstr "Sing-box"
msgid "✔ Running"
msgstr "✔ Работает"
msgid "✘ Stopped"
msgstr "✘ Остановлен"
msgid "Copied!" msgid "Copied!"
msgstr "Скопировано!" msgstr "Скопировано!"
msgid "Failed to copy: " msgid "Failed to copy: "
msgstr "Не удалось скопировать: " msgstr "Не удалось скопировать: "
msgid "Loading..."
msgstr "Загрузка..."
msgid "Copy to Clipboard" msgid "Copy to Clipboard"
msgstr "Копировать в буфер обмена" msgstr "Копировать в буфер"
msgid "Close" msgid "Close"
msgstr "Закрыть" msgstr "Закрыть"
msgid "Loading..."
msgstr "Загрузка..."
msgid "No output" msgid "No output"
msgstr "Нет вывода" msgstr "Нет вывода"
@@ -507,7 +468,7 @@ msgid "FakeIP is not working in browser"
msgstr "FakeIP не работает в браузере" msgstr "FakeIP не работает в браузере"
msgid "Check DNS server on current device (PC, phone)" msgid "Check DNS server on current device (PC, phone)"
msgstr "Проверьте DNS сервер на текущем устройстве (ПК, телефон)" msgstr "Проверьте DNS-сервер на текущем устройстве (ПК, телефон)"
msgid "Its must be router!" msgid "Its must be router!"
msgstr "Это должен быть роутер!" msgstr "Это должен быть роутер!"
@@ -522,7 +483,7 @@ msgid "Proxy IP: "
msgstr "Прокси IP: " msgstr "Прокси IP: "
msgid "Proxy is not working - same IP for both domains" msgid "Proxy is not working - same IP for both domains"
msgstr "Прокси не работает - одинаковый IP для обоих доменов" msgstr "Прокси не работает одинаковый IP для обоих доменов"
msgid "IP: " msgid "IP: "
msgstr "IP: " msgstr "IP: "
@@ -606,7 +567,7 @@ msgid "does not work in browser"
msgstr "не работает в браузере" msgstr "не работает в браузере"
msgid "works on router" msgid "works on router"
msgstr "не работает в браузере" msgstr "работает на роутере"
msgid "does not work on router" msgid "does not work on router"
msgstr "не работает на роутере" msgstr "не работает на роутере"
@@ -617,11 +578,122 @@ msgstr "Конфигурация: "
msgid "Diagnostics" msgid "Diagnostics"
msgstr "Диагностика" msgstr "Диагностика"
msgid "Podkop" msgid "Additional Settings"
msgstr "Podkop" msgstr "Дополнительные настройки"
msgid "Extra configurations" msgid "Yacd enable"
msgstr "Дополнительные конфигурации" msgstr "Включить YACD"
msgid "Add Section" msgid "Exclude NTP"
msgstr "Добавить раздел" msgstr "Исключить NTP"
msgid "Allows you to exclude NTP protocol traffic from the tunnel"
msgstr "Позволяет исключить направление трафика NTP-протокола в туннель"
msgid "QUIC disable"
msgstr "Отключить QUIC"
msgid "For issues with the video stream"
msgstr "Для проблем с видеопотоком"
msgid "List Update Frequency"
msgstr "Частота обновления списков"
msgid "Select how often the lists will be updated"
msgstr "Выберите как часто будут обновляться списки"
msgid "Select DNS protocol to use"
msgstr "Выберите протокол DNS"
msgid "Bootstrap DNS server"
msgstr "Bootstrap DNS-сервер"
msgid "The DNS server used to look up the IP address of an upstream DNS server"
msgstr "DNS-сервер, используемый для поиска IP-адреса вышестоящего DNS-сервера"
msgid "DNS Rewrite TTL"
msgstr "Перезапись TTL для DNS"
msgid "Time in seconds for DNS record caching (default: 60)"
msgstr "Время в секундах для кэширования DNS записей (по умолчанию: 60)"
msgid "TTL value cannot be empty"
msgstr "Значение TTL не может быть пустым"
msgid "TTL must be a positive number"
msgstr "TTL должно быть положительным числом"
msgid "Config File Path"
msgstr "Путь к файлу конфигурации"
msgid "Select path for sing-box config file. Change this ONLY if you know what you are doing"
msgstr "Выберите путь к файлу конфигурации sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Cache File Path"
msgstr "Путь к файлу кэша"
msgid "Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing"
msgstr "Выберите или введите путь к файлу кеша sing-box. Изменяйте это, ТОЛЬКО если вы знаете, что делаете"
msgid "Cache file path cannot be empty"
msgstr "Путь к файлу кэша не может быть пустым"
msgid "Path must be absolute (start with /)"
msgstr "Путь должен быть абсолютным (начинаться с /)"
msgid "Path must end with cache.db"
msgstr "Путь должен заканчиваться на cache.db"
msgid "Path must contain at least one directory (like /tmp/cache.db)"
msgstr "Путь должен содержать хотя бы одну директорию (например /tmp/cache.db)"
msgid "Source Network Interface"
msgstr "Сетевой интерфейс источника"
msgid "Select the network interface from which the traffic will originate"
msgstr "Выберите сетевой интерфейс, с которого будет исходить трафик"
msgid "Interface monitoring"
msgstr "Мониторинг интерфейсов"
msgid "Interface monitoring for bad WAN"
msgstr "Мониторинг интерфейсов для плохого WAN"
msgid "Interface for monitoring"
msgstr "Интерфейс для мониторинга"
msgid "Select the WAN interfaces to be monitored"
msgstr "Выберите WAN интерфейсы для мониторинга"
msgid "Interface Monitoring Delay"
msgstr "Задержка при мониторинге интерфейсов"
msgid "Delay in milliseconds before reloading podkop after interface UP"
msgstr "Задержка в миллисекундах перед перезагрузкой podkop после поднятия интерфейса"
msgid "Delay value cannot be empty"
msgstr "Значение задержки не может быть пустым"
msgid "Dont touch my DHCP!"
msgstr "Не трогать мой DHCP!"
msgid "Podkop will not change the DHCP config"
msgstr "Podkop не будет изменять конфигурацию DHCP"
msgid "Proxy download of lists"
msgstr "Загрузка списков через прокси"
msgid "Downloading all lists via main Proxy/VPN"
msgstr "Загрузка всех списков через основной прокси/VPN"
msgid "IP for exclusion"
msgstr "IP для исключения"
msgid "Specify local IP addresses that will never use the configured route"
msgstr "Укажите локальные IP-адреса, которые никогда не будут использовать настроенный маршрут"
msgid "Mixed enable"
msgstr "Включить смешанный режим"
msgid "Browser port: 2080"
msgstr "Порт браузера: 2080"

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