mirror of
https://github.com/itdoginfo/podkop.git
synced 2025-12-06 19:46:52 +03:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984ae5f2a9 | ||
|
|
7a62898541 | ||
|
|
7911d1d29f | ||
|
|
bc673b7881 | ||
|
|
0493565c5f | ||
|
|
4cd1094395 | ||
|
|
e87b431d86 | ||
|
|
b9ee917abf | ||
|
|
715a278af8 | ||
|
|
9bc2b5ffef | ||
|
|
9d89258c0c | ||
|
|
52d1c5d95f | ||
|
|
587e5245d3 | ||
|
|
e7578d61bc | ||
|
|
9918b71a82 | ||
|
|
f48c4ff2bb | ||
|
|
e77bcc386a | ||
|
|
455c19ab2e | ||
|
|
914e1792f3 | ||
|
|
826245a89a | ||
|
|
b5cfc017fe | ||
|
|
267fd2b793 | ||
|
|
c0b400dfb0 | ||
|
|
752636347e | ||
|
|
28aeb29c51 | ||
|
|
6ff543d7fb | ||
|
|
b89fe33296 | ||
|
|
3d63a82815 | ||
|
|
934f802879 | ||
|
|
4d0755e4c0 | ||
|
|
88ee7b4a54 | ||
|
|
0eb575d171 | ||
|
|
9a46d731c9 | ||
|
|
a45ab62885 | ||
|
|
b7bad57299 | ||
|
|
4ac755bd36 | ||
|
|
e9a0c96882 | ||
|
|
48c8f01d2f | ||
|
|
72b2a34af9 | ||
|
|
ae4a3781e6 | ||
|
|
1bce7c0c98 | ||
|
|
a8b2001cc1 | ||
|
|
d6481675e0 | ||
|
|
2ba1c2f740 | ||
|
|
5d0f8ce5bf | ||
|
|
ddad137fc1 | ||
|
|
7b2e5d2838 | ||
|
|
9a72785fa7 | ||
|
|
e0874c3775 | ||
|
|
1e6c827f2b | ||
|
|
c8c0025470 | ||
|
|
c78f97d64f | ||
|
|
7cb43ffb65 | ||
|
|
1e4cda9400 | ||
|
|
caf82b096f | ||
|
|
6117b0ef9b | ||
|
|
5418187dd3 | ||
|
|
31b09cc3d2 | ||
|
|
b2a473573b | ||
|
|
aad6d8c002 | ||
|
|
c75dd3e78b | ||
|
|
341f260fcf | ||
|
|
c5e19a0f2d | ||
|
|
d50b6dbab6 | ||
|
|
99c8ead148 | ||
|
|
d605094a9d | ||
|
|
eb60e6edec | ||
|
|
08f5b31d58 | ||
|
|
f69e3478c8 | ||
|
|
d9a4f50f62 | ||
|
|
eb52d52eb4 | ||
|
|
3f4a0cf094 | ||
|
|
b0a8526c90 | ||
|
|
e9d5b18816 | ||
|
|
7b06f422af | ||
|
|
96bcc36cf1 | ||
|
|
db8e8e8298 | ||
|
|
eb0617eef1 | ||
|
|
8f9bff9a64 | ||
|
|
65d3a9253f | ||
|
|
b99116fbf3 | ||
|
|
8f19f31e7a | ||
|
|
327c3d2b68 | ||
|
|
260b7b9558 | ||
|
|
df9dba9742 | ||
|
|
547feb0e06 | ||
|
|
77e141b305 | ||
|
|
cfc5d995a8 | ||
|
|
e84233a10c | ||
|
|
b71c7b379d | ||
|
|
3988588c9f | ||
|
|
cd133838cb | ||
|
|
f58472a53d | ||
|
|
5e95148492 | ||
|
|
df9400514b | ||
|
|
14eec8e600 | ||
|
|
294cb21e91 | ||
|
|
4ef15f7340 | ||
|
|
41563a5828 | ||
|
|
2e99ee3a17 | ||
|
|
a8db33dd28 | ||
|
|
1295e0dcb2 | ||
|
|
b6bec0fc51 | ||
|
|
769d263be2 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
123
.github/workflows/build.yml
vendored
123
.github/workflows/build.yml
vendored
@@ -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
78
.github/workflows/frontend-ci.yml
vendored
Normal 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 "" >> $GITHUB_STEP_SUMMARY
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
.idea
|
.idea
|
||||||
|
fe-app-podkop/node_modules
|
||||||
|
fe-app-podkop/.env
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
11
Dockerfile-apk
Normal 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
11
Dockerfile-ipk
Normal 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
|
||||||
@@ -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
|
|
||||||
```
|
```
|
||||||
8
fe-app-podkop/.prettierrc
Normal file
8
fe-app-podkop/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
||||||
27
fe-app-podkop/eslint.config.js
Normal file
27
fe-app-podkop/eslint.config.js
Normal 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,
|
||||||
|
];
|
||||||
31
fe-app-podkop/package.json
Normal file
31
fe-app-podkop/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
fe-app-podkop/src/clash/index.ts
Normal file
2
fe-app-podkop/src/clash/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './methods';
|
||||||
28
fe-app-podkop/src/clash/methods/createBaseApiRequest.ts
Normal file
28
fe-app-podkop/src/clash/methods/createBaseApiRequest.ts
Normal 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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
fe-app-podkop/src/clash/methods/getConfig.ts
Normal file
14
fe-app-podkop/src/clash/methods/getConfig.ts
Normal 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' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
20
fe-app-podkop/src/clash/methods/getGroupDelay.ts
Normal file
20
fe-app-podkop/src/clash/methods/getGroupDelay.ts
Normal 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' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
14
fe-app-podkop/src/clash/methods/getProxies.ts
Normal file
14
fe-app-podkop/src/clash/methods/getProxies.ts
Normal 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' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
14
fe-app-podkop/src/clash/methods/getVersion.ts
Normal file
14
fe-app-podkop/src/clash/methods/getVersion.ts
Normal 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' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
7
fe-app-podkop/src/clash/methods/index.ts
Normal file
7
fe-app-podkop/src/clash/methods/index.ts
Normal 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';
|
||||||
35
fe-app-podkop/src/clash/methods/triggerLatencyTest.ts
Normal file
35
fe-app-podkop/src/clash/methods/triggerLatencyTest.ts
Normal 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' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
16
fe-app-podkop/src/clash/methods/triggerProxySelector.ts
Normal file
16
fe-app-podkop/src/clash/methods/triggerProxySelector.ts
Normal 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 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
53
fe-app-podkop/src/clash/types.ts
Normal file
53
fe-app-podkop/src/clash/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
108
fe-app-podkop/src/constants.ts
Normal file
108
fe-app-podkop/src/constants.ts
Normal 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;
|
||||||
32
fe-app-podkop/src/helpers/executeShellCommand.ts
Normal file
32
fe-app-podkop/src/helpers/executeShellCommand.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
4
fe-app-podkop/src/helpers/getBaseUrl.ts
Normal file
4
fe-app-podkop/src/helpers/getBaseUrl.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function getBaseUrl(): string {
|
||||||
|
const { protocol, hostname } = window.location;
|
||||||
|
return `${protocol}//${hostname}`;
|
||||||
|
}
|
||||||
17
fe-app-podkop/src/helpers/getClashApiUrl.ts
Normal file
17
fe-app-podkop/src/helpers/getClashApiUrl.ts
Normal 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`;
|
||||||
|
}
|
||||||
13
fe-app-podkop/src/helpers/getProxyUrlName.ts
Normal file
13
fe-app-podkop/src/helpers/getProxyUrlName.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function getProxyUrlName(url: string) {
|
||||||
|
try {
|
||||||
|
const [_link, hash] = url.split('#');
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeURIComponent(hash);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
12
fe-app-podkop/src/helpers/index.ts
Normal file
12
fe-app-podkop/src/helpers/index.ts
Normal 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';
|
||||||
12
fe-app-podkop/src/helpers/injectGlobalStyles.ts
Normal file
12
fe-app-podkop/src/helpers/injectGlobalStyles.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { GlobalStyles } from '../styles';
|
||||||
|
|
||||||
|
export function injectGlobalStyles() {
|
||||||
|
document.head.insertAdjacentHTML(
|
||||||
|
'beforeend',
|
||||||
|
`
|
||||||
|
<style>
|
||||||
|
${GlobalStyles}
|
||||||
|
</style>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
5
fe-app-podkop/src/helpers/maskIP.ts
Normal file
5
fe-app-podkop/src/helpers/maskIP.ts
Normal 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}`);
|
||||||
|
}
|
||||||
30
fe-app-podkop/src/helpers/onMount.ts
Normal file
30
fe-app-podkop/src/helpers/onMount.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
22
fe-app-podkop/src/helpers/parseQueryString.ts
Normal file
22
fe-app-podkop/src/helpers/parseQueryString.ts
Normal 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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
9
fe-app-podkop/src/helpers/parseValueList.ts
Normal file
9
fe-app-podkop/src/helpers/parseValueList.ts
Normal 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
|
||||||
|
}
|
||||||
9
fe-app-podkop/src/helpers/preserveScrollForPage.ts
Normal file
9
fe-app-podkop/src/helpers/preserveScrollForPage.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function preserveScrollForPage(renderFn: () => void) {
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
|
||||||
|
renderFn();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: scrollY });
|
||||||
|
});
|
||||||
|
}
|
||||||
12
fe-app-podkop/src/helpers/prettyBytes.ts
Normal file
12
fe-app-podkop/src/helpers/prettyBytes.ts
Normal 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;
|
||||||
|
}
|
||||||
7
fe-app-podkop/src/helpers/splitProxyString.ts
Normal file
7
fe-app-podkop/src/helpers/splitProxyString.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function splitProxyString(str: string) {
|
||||||
|
return str
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => !line.startsWith('//'))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
42
fe-app-podkop/src/helpers/tests/maskIp.test.js
Normal file
42
fe-app-podkop/src/helpers/tests/maskIp.test.js
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
21
fe-app-podkop/src/helpers/withTimeout.ts
Normal file
21
fe-app-podkop/src/helpers/withTimeout.ts
Normal 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
40
fe-app-podkop/src/luci.d.ts
vendored
Normal 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
10
fe-app-podkop/src/main.ts
Normal 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';
|
||||||
3
fe-app-podkop/src/podkop/index.ts
Normal file
3
fe-app-podkop/src/podkop/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './methods';
|
||||||
|
export * from './services';
|
||||||
|
export * from './tabs';
|
||||||
5
fe-app-podkop/src/podkop/methods/getConfigSections.ts
Normal file
5
fe-app-podkop/src/podkop/methods/getConfigSections.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Podkop } from '../types';
|
||||||
|
|
||||||
|
export async function getConfigSections(): Promise<Podkop.ConfigSection[]> {
|
||||||
|
return uci.load('podkop').then(() => uci.sections('podkop'));
|
||||||
|
}
|
||||||
158
fe-app-podkop/src/podkop/methods/getDashboardSections.ts
Normal file
158
fe-app-podkop/src/podkop/methods/getDashboardSections.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
21
fe-app-podkop/src/podkop/methods/getPodkopStatus.ts
Normal file
21
fe-app-podkop/src/podkop/methods/getPodkopStatus.ts
Normal 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' };
|
||||||
|
}
|
||||||
23
fe-app-podkop/src/podkop/methods/getSingboxStatus.ts
Normal file
23
fe-app-podkop/src/podkop/methods/getSingboxStatus.ts
Normal 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' };
|
||||||
|
}
|
||||||
4
fe-app-podkop/src/podkop/methods/index.ts
Normal file
4
fe-app-podkop/src/podkop/methods/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './getConfigSections';
|
||||||
|
export * from './getDashboardSections';
|
||||||
|
export * from './getPodkopStatus';
|
||||||
|
export * from './getSingboxStatus';
|
||||||
13
fe-app-podkop/src/podkop/services/core.service.ts
Normal file
13
fe-app-podkop/src/podkop/services/core.service.ts
Normal 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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
2
fe-app-podkop/src/podkop/services/index.ts
Normal file
2
fe-app-podkop/src/podkop/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './tab.service';
|
||||||
|
export * from './core.service';
|
||||||
92
fe-app-podkop/src/podkop/services/tab.service.ts
Normal file
92
fe-app-podkop/src/podkop/services/tab.service.ts
Normal 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();
|
||||||
2
fe-app-podkop/src/podkop/tabs/dashboard/index.ts
Normal file
2
fe-app-podkop/src/podkop/tabs/dashboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './renderDashboard';
|
||||||
|
export * from './initDashboardController';
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
54
fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts
Normal file
54
fe-app-podkop/src/podkop/tabs/dashboard/renderDashboard.ts
Normal 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: () => {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
133
fe-app-podkop/src/podkop/tabs/dashboard/renderSections.ts
Normal file
133
fe-app-podkop/src/podkop/tabs/dashboard/renderSections.ts
Normal 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);
|
||||||
|
}
|
||||||
78
fe-app-podkop/src/podkop/tabs/dashboard/renderWidget.ts
Normal file
78
fe-app-podkop/src/podkop/tabs/dashboard/renderWidget.ts
Normal 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);
|
||||||
|
}
|
||||||
1
fe-app-podkop/src/podkop/tabs/index.ts
Normal file
1
fe-app-podkop/src/podkop/tabs/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './dashboard';
|
||||||
56
fe-app-podkop/src/podkop/types.ts
Normal file
56
fe-app-podkop/src/podkop/types.ts
Normal 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
121
fe-app-podkop/src/socket.ts
Normal 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
181
fe-app-podkop/src/store.ts
Normal 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
177
fe-app-podkop/src/styles.ts
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
13
fe-app-podkop/src/validators/bulkValidate.ts
Normal file
13
fe-app-podkop/src/validators/bulkValidate.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
fe-app-podkop/src/validators/index.ts
Normal file
12
fe-app-podkop/src/validators/index.ts
Normal 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';
|
||||||
24
fe-app-podkop/src/validators/tests/validateDns.test.js
Normal file
24
fe-app-podkop/src/validators/tests/validateDns.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
63
fe-app-podkop/src/validators/tests/validateDomain.test.js
Normal file
63
fe-app-podkop/src/validators/tests/validateDomain.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
38
fe-app-podkop/src/validators/tests/validateIp.test.js
Normal file
38
fe-app-podkop/src/validators/tests/validateIp.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
fe-app-podkop/src/validators/tests/validatePath.test.js
Normal file
39
fe-app-podkop/src/validators/tests/validatePath.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
fe-app-podkop/src/validators/tests/validateSubnet.test.js
Normal file
41
fe-app-podkop/src/validators/tests/validateSubnet.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
131
fe-app-podkop/src/validators/tests/validateTrojanUrl.test.js
Normal file
131
fe-app-podkop/src/validators/tests/validateTrojanUrl.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
fe-app-podkop/src/validators/tests/validateUrl.test.js
Normal file
40
fe-app-podkop/src/validators/tests/validateUrl.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
fe-app-podkop/src/validators/tests/validateVlessUrl.test.js
Normal file
100
fe-app-podkop/src/validators/tests/validateVlessUrl.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
fe-app-podkop/src/validators/types.ts
Normal file
13
fe-app-podkop/src/validators/types.ts
Normal 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>[];
|
||||||
|
}
|
||||||
24
fe-app-podkop/src/validators/validateDns.ts
Normal file
24
fe-app-podkop/src/validators/validateDns.ts
Normal 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',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
31
fe-app-podkop/src/validators/validateDomain.ts
Normal file
31
fe-app-podkop/src/validators/validateDomain.ts
Normal 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') };
|
||||||
|
}
|
||||||
12
fe-app-podkop/src/validators/validateIp.ts
Normal file
12
fe-app-podkop/src/validators/validateIp.ts
Normal 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') };
|
||||||
|
}
|
||||||
21
fe-app-podkop/src/validators/validateOutboundJson.ts
Normal file
21
fe-app-podkop/src/validators/validateOutboundJson.ts
Normal 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') };
|
||||||
|
}
|
||||||
|
}
|
||||||
26
fe-app-podkop/src/validators/validatePath.ts
Normal file
26
fe-app-podkop/src/validators/validatePath.ts
Normal 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',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
24
fe-app-podkop/src/validators/validateProxyUrl.ts
Normal file
24
fe-app-podkop/src/validators/validateProxyUrl.ts
Normal 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://'),
|
||||||
|
};
|
||||||
|
}
|
||||||
96
fe-app-podkop/src/validators/validateShadowsocksUrl.ts
Normal file
96
fe-app-podkop/src/validators/validateShadowsocksUrl.ts
Normal 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') };
|
||||||
|
}
|
||||||
39
fe-app-podkop/src/validators/validateSubnet.ts
Normal file
39
fe-app-podkop/src/validators/validateSubnet.ts
Normal 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') };
|
||||||
|
}
|
||||||
60
fe-app-podkop/src/validators/validateTrojanUrl.ts
Normal file
60
fe-app-podkop/src/validators/validateTrojanUrl.ts
Normal 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') };
|
||||||
|
}
|
||||||
20
fe-app-podkop/src/validators/validateUrl.ts
Normal file
20
fe-app-podkop/src/validators/validateUrl.ts
Normal 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') };
|
||||||
|
}
|
||||||
|
}
|
||||||
103
fe-app-podkop/src/validators/validateVlessUrl.ts
Normal file
103
fe-app-podkop/src/validators/validateVlessUrl.ts
Normal 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') };
|
||||||
|
}
|
||||||
|
}
|
||||||
2
fe-app-podkop/tests/setup/global-mocks.ts
Normal file
2
fe-app-podkop/tests/setup/global-mocks.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// tests/setup/global-mocks.ts
|
||||||
|
globalThis._ = (key: string) => key;
|
||||||
13
fe-app-podkop/tsconfig.json
Normal file
13
fe-app-podkop/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
35
fe-app-podkop/tsup.config.ts
Normal file
35
fe-app-podkop/tsup.config.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
9
fe-app-podkop/vitest.config.js
Normal file
9
fe-app-podkop/vitest.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
setupFiles: ['./tests/setup/global-mocks.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
82
fe-app-podkop/watch-upload.js
Normal file
82
fe-app-podkop/watch-upload.js
Normal 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
2025
fe-app-podkop/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
84
install.sh
84
install.sh
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
});
|
|
||||||
@@ -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
2016
luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js
Normal file
2016
luci-app-podkop/htdocs/luci-static/resources/view/podkop/main.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PODIR="po"
|
PODIR="po"
|
||||||
|
|||||||
@@ -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: недопустимый порт (1–65535)"
|
||||||
|
|
||||||
|
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
Reference in New Issue
Block a user