mirror of
https://github.com/itdoginfo/podkop.git
synced 2025-12-06 19:46:52 +03:00
Compare commits
144 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 | ||
|
|
470f11699c | ||
|
|
852b6c043a | ||
|
|
f5cafd5573 | ||
|
|
3562b913a2 | ||
|
|
f4ac9dcc77 | ||
|
|
f5a629afcf | ||
|
|
aea201bf24 | ||
|
|
1313c3b26f | ||
|
|
a3f4e942c3 | ||
|
|
4d8e4c1c13 | ||
|
|
0cb5c2daae | ||
|
|
19fbfff555 | ||
|
|
75a2ed1e29 | ||
|
|
759b6748c6 | ||
|
|
0a27784f85 | ||
|
|
3b95ac2bc3 | ||
|
|
5c51d99d73 | ||
|
|
904b90e012 | ||
|
|
5fb8343cf8 | ||
|
|
014f0f4bdf | ||
|
|
dd44e0156e | ||
|
|
927b8a53b0 | ||
|
|
7ba20905d5 | ||
|
|
5b15a56502 | ||
|
|
c31df68bec | ||
|
|
0a5229f4f6 | ||
|
|
5ecb6ef997 | ||
|
|
340c2b3505 | ||
|
|
515c0be38b | ||
|
|
59c59bcb17 | ||
|
|
e5eff41a0f | ||
|
|
bb1c06951c | ||
|
|
4999840340 | ||
|
|
6c5a271105 | ||
|
|
e336bb831c | ||
|
|
00db99723c | ||
|
|
5439504de7 | ||
|
|
c3072162de | ||
|
|
d021636f85 | ||
|
|
a06aac0613 |
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:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build podkop and luci-app-podkop
|
||||
preparation:
|
||||
name: Setup build version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
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:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
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
|
||||
- name: Build ${{ matrix.package_type }}
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
with:
|
||||
file: ./Dockerfile-${{ matrix.package_type }}
|
||||
context: .
|
||||
tags: podkop:ci
|
||||
tags: podkop:ci-${{ matrix.package_type }}
|
||||
build-args: |
|
||||
PKG_VERSION=${{ steps.version.outputs.version }}
|
||||
PODKOP_VERSION=${{ needs.preparation.outputs.version }}
|
||||
|
||||
- name: Create Docker container
|
||||
run: docker create --name podkop podkop:ci
|
||||
- name: Create ${{ matrix.package_type }} Docker container
|
||||
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: |
|
||||
docker cp podkop:/builder/bin/packages/x86_64/utilites/. ./bin/
|
||||
docker cp podkop:/builder/bin/packages/x86_64/luci/. ./bin/
|
||||
mkdir -p ./bin/${{ matrix.package_type }}
|
||||
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: |
|
||||
# Извлекаем версию из тега, убирая префикс 'v'
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
for f in ./bin/${{ matrix.package_type }}/*.${{ matrix.package_type }}; do
|
||||
[ -e "$f" ] || continue
|
||||
base=$(basename "$f")
|
||||
newname=$(echo "$base" | sed 's/_/-/g')
|
||||
mv "$f" "./bin/${{ matrix.package_type }}/$newname"
|
||||
done
|
||||
|
||||
mkdir -p ./filtered-bin
|
||||
cp ./bin/luci-i18n-podkop-ru_*.ipk "./filtered-bin/luci-i18n-podkop-ru_${VERSION}.ipk"
|
||||
cp ./bin/podkop_*.ipk ./filtered-bin/
|
||||
cp ./bin/luci-app-podkop_*.ipk ./filtered-bin/
|
||||
- name: Filter files
|
||||
shell: bash
|
||||
run: |
|
||||
# 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
|
||||
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
|
||||
uses: softprops/action-gh-release@v2.0.8
|
||||
uses: softprops/action-gh-release@v2.4.0
|
||||
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
|
||||
4
.gitignore
vendored
4
.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-old
|
||||
## Shadowsocks
|
||||
```
|
||||
ss://YWVzLTI1Ni1nY206RmJwUDJnSStPczJKK1kzdkVhTnVuOUZ2ZjJZYUhNUlN1L1BBdEVqMks1VT0@example.com:80?type=tcp#example-ss-old
|
||||
ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206ZG1DbHkvWmgxNVd3OStzK0dGWGlGVElrcHc3Yy9xQ0lTYUJyYWk3V2hoWT0@127.0.0.1:25144?type=tcp#shadowsocks-no-client
|
||||
ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206S3FiWXZiNkhwb1RmTUt0N2VGcUZQSmJNNXBXaHlFU0ZKTXY2dEp1Ym1Fdz06dzRNMEx5RU9OTGQ5SWlkSGc0endTbzN2R3h4NS9aQ3hId0FpaWlxck5hcz0@127.0.0.1:26627?type=tcp#shadowsocks-client
|
||||
ss://2022-blake3-aes-256-gcm:dmCly/Zh15Ww9+s+GFXiFTIkpw7c/qCISaBrai7WhhY=@127.0.0.1:27214?type=tcp#shadowsocks-plain-user
|
||||
```
|
||||
|
||||
## Shadowsocks-2022
|
||||
## VLESS
|
||||
```
|
||||
ss://2022-blake3-aes-128-gcm:5NgF%2B9eM8h4OnrTbHp%2B8UA%3D%3D%3Am8tbs5aKLYG7dN9f3xsiKA%3D%3D@example.com:80#example-ss2022
|
||||
# tcp
|
||||
vless://94792286-7bbe-4f33-8b36-18d1bbf70723@127.0.0.1:34520?type=tcp&encryption=none&security=none#vless-tcp-none
|
||||
vless://e95163dc-905e-480a-afe5-20b146288679@127.0.0.1:16399?type=tcp&encryption=none&security=reality&pbk=tqhSkeDR6jsqC-BYCnZWBrdL33g705ba8tV5-ZboWTM&fp=chrome&sni=google.com&sid=f6&spx=%2F#vless-tcp-reality
|
||||
vless://2e9e8288-060e-4da2-8b9f-a1c81826feb7@127.0.0.1:19316?type=tcp&encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-tcp-tls
|
||||
vless://0235c833-dc29-4202-8a7b-1bbba5b516a2@127.0.0.1:22993?type=tcp&encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#vless-tcp-tls-insecure
|
||||
vless://17776137-e747-4268-a84d-99fd798accac@127.0.0.1:48076?type=tcp&encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com&ech=AFP%2BDQBPAAAgACDJXiKG5eoCHfd1MbMxgccxgrbGisBPPe3bz1KVIETUXQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAAAAA%3D%3D#vless-tcp-tls-ech
|
||||
|
||||
# mKCP
|
||||
vless://72e201d7-7841-4a32-b266-4aa3eb776d51@127.0.0.1:17270?type=kcp&encryption=none&headerType=none&seed=AirziWi4ng&security=none#vless-mKCP
|
||||
|
||||
# WebSocket
|
||||
vless://d86daef7-565b-4ecd-a9ee-bac847ad38e6@127.0.0.1:12928?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=none#vless-websocket-none
|
||||
vless://fe0f0941-09a9-4e46-bc69-e00190d7bb9c@127.0.0.1:10156?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-websocket-tls
|
||||
vless://599e8659-e2ef-47d9-bf72-2f9b4b673474@127.0.0.1:36567?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#vless-websocket-tls-insecure
|
||||
vless://4d21ce62-8723-4c4d-93e3-d586b107aa40@127.0.0.1:51394?type=ws&encryption=none&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com&ech=AF3%2BDQBZAAAgACD7fjrtDMlcigKXFBKoLn6UDB9%2BWR6HBZpY96DlBiD%2BIwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D#vless-websocket-tls-ech
|
||||
|
||||
# gRPC
|
||||
vless://974b39e3-f7bf-42b9-933c-16699c635e77@127.0.0.1:15633?type=grpc&encryption=none&serviceName=TunService&authority=&security=none#vless-gRPC-none
|
||||
vless://651e7eca-5152-46f1-baf2-d502e0af7b27@127.0.0.1:28535?type=grpc&encryption=none&serviceName=TunService&authority=authority&security=reality&pbk=nhZ7NiKfcqESa5ZeBFfsq9o18W-OWOAHLln9UmuVXSk&fp=chrome&sni=google.com&sid=11cbaeaa&spx=%2F#vless-gRPC-reality
|
||||
vless://af1f8b5f-26c9-4fe8-8ce7-6d6366c5c9ce@127.0.0.1:47904?type=grpc&encryption=none&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-gRPC-tls
|
||||
vless://95f2c4bb-abcb-47ba-bfad-e181c03e4659@127.0.0.1:34530?type=grpc&encryption=none&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#vless-gRPC-tls-insecure
|
||||
vless://bd39490f-9a4f-49b2-96b6-824190cf89e9@127.0.0.1:27779?type=grpc&encryption=none&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com&ech=AF3%2BDQBZAAAgACBc%2FiNdo4QkTt9eQCQgkOiJVSfA9G6UWAyipaBFtBD%2FVQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D#vless-gRPC-tls-ech
|
||||
|
||||
# HTTPUpgrade
|
||||
vless://2b98f144-847f-42f7-8798-e1a32d27bdc7@127.0.0.1:47154?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=none#vless-httpupgrade-none
|
||||
vless://76dbd0ff-1a35-4f0c-a9ba-3c5890b7dea6@127.0.0.1:50639?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#vless-httpupgrade-tls
|
||||
vless://6d229881-50ed-4f3f-995d-bd3e725fdbff@127.0.0.1:57616?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#vless-httpupgrade-tls-insecure
|
||||
vless://1897e9e4-6f5d-4a85-9512-9192e76c3f04@127.0.0.1:38658?type=httpupgrade&encryption=none&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com&ech=AF3%2BDQBZAAAgACCmXTMzlrdcCk2FyINAWKZ4DBxq4%2BCgmJ69v%2BmH4EMlEQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D#vless-httpupgrade-tls-ech
|
||||
|
||||
# XHTTP
|
||||
vless://c2841505-ec32-4b8d-b6dd-3e19d648c321@127.0.0.1:45507?type=xhttp&encryption=none&path=%2Fxhttppath&host=xhttp&mode=auto&security=none#vless-xhttp
|
||||
```
|
||||
|
||||
## Trojan
|
||||
```
|
||||
ss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206Y21lZklCdDhwMTJaZm1QWUplMnNCNThRd3R3NXNKeVpUV0Z6ZENKV2taOD06eEJHZUxiMWNPTjFIeE9CenF6UlN0VFdhUUh6YWM2cFhRVFNZd2dVV2R1RT0@example.com:81?type=tcp#example-ss2022
|
||||
```
|
||||
Может быть без `?type=tcp`
|
||||
# tcp
|
||||
trojan://04agAQapcl@127.0.0.1:33641?type=tcp&security=none#trojan-tcp-none
|
||||
trojan://cME3ZlUrYF@127.0.0.1:43772?type=tcp&security=reality&pbk=DckTwU6p6pTX9QxFXOi6vH4Vzt_RCE1vMCnj2c6hvjw&fp=chrome&sni=google.com&sid=221a80cf94&spx=%2F#trojan-tcp-reality
|
||||
trojan://EJjpAj02lg@127.0.0.1:11381?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-tcp-tls
|
||||
trojan://ZP2Ik5sxN3@127.0.0.1:16247?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-tcp-tls-insecure
|
||||
trojan://90caP481ay@127.0.0.1:59708?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACC2y%2BAe4dqthLNpfvmtE6g%2BnaJ%2FciK6P%2BREbRLkR%2Fg%2FEgAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-tcp-tls-ech
|
||||
|
||||
# VLESS
|
||||
# mKCP
|
||||
trojan://N5v7iIOe9G@127.0.0.1:36319?type=kcp&headerType=none&seed=P91wFIfjzZ&security=none#trojan-mKCP
|
||||
|
||||
## Reality
|
||||
```
|
||||
vless://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
|
||||
```
|
||||
# WebSocket
|
||||
trojan://G3cE9phv1g@127.0.0.1:57370?type=ws&path=%2Fwspath&host=google.com&security=none#trojan-websocket-none
|
||||
trojan://FBok41WczO@127.0.0.1:59919?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-websocket-tls
|
||||
trojan://bhwvndUBPA@127.0.0.1:22969?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-websocket-tls-insecur
|
||||
trojan://pwiduqFUWO@127.0.0.1:46765?type=ws&path=%2Fwspath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCFcQYEtwrFOidJJLYHvSiN%2BljRgaAIrNHoVnio3uXAOwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-websocket-tls-ech
|
||||
|
||||
```
|
||||
vless://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
|
||||
```
|
||||
# gRPC
|
||||
trojan://WMR7qkKhsV@127.0.0.1:27897?type=grpc&serviceName=TunService&authority=authority&security=none#trojan-gRPC-none
|
||||
trojan://KVuRNsu6KG@127.0.0.1:46077?type=grpc&serviceName=TunService&authority=authority&security=reality&pbk=Xn59i4gum3ppCICS6-_NuywrhHIVVAH54b2mjd5CFkE&fp=chrome&sni=google.com&sid=e5be&spx=%2F#trojan-gRPC-reality
|
||||
trojan://7BJtbywy8h@127.0.0.1:10627?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-gRPC-tls
|
||||
trojan://TI3PakvtP4@127.0.0.1:10435?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-gRPC-tls-insecure
|
||||
trojan://mbzoVKL27h@127.0.0.1:38681?type=grpc&serviceName=TunService&authority=authority&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACCq72Ru3VbFlDpKttl3LccmInu8R2oAsCr8wzyxB0vZZQAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-gRPC-tls-ech
|
||||
|
||||
## TLS
|
||||
1.
|
||||
```
|
||||
vless://8100b6eb-3fd1-4e73-8ccf-b4ac961232d6@example.com:443?type=tcp&security=tls&fp=&alpn=h3%2Ch2%2Chttp%2F1.1#vless-tls
|
||||
```
|
||||
# HTTPUpgrade
|
||||
trojan://uc44gBwOKQ@127.0.0.1:29085?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=none#trojan-httpupgrade-none
|
||||
trojan://MhNxbcVB14@127.0.0.1:32700?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=google.com#trojan-httpupgrade-tls
|
||||
trojan://7SOQFUpLob@127.0.0.1:28474?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&allowInsecure=1&sni=google.com#trojan-httpupgrade-tls-insecure
|
||||
trojan://ou8pLSyx9N@127.0.0.1:17737?type=httpupgrade&path=%2Fhttpupgradepath&host=google.com&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&ech=AF3%2BDQBZAAAgACB%2FlkIkit%2BblFzE7PtbYDVF3NXK8olXJ5a7YwY%2Biy9QQwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAApnb29nbGUuY29tAAA%3D&sni=google.com#trojan-httpupgrade-tls-ech
|
||||
|
||||
2.
|
||||
```
|
||||
vless://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
|
||||
# XHTTP
|
||||
trojan://VEetltxLtw@127.0.0.1:59072?type=xhttp&path=%2Fxhttppath&host=google.com&mode=auto&security=none#trojan-xhttp
|
||||
```
|
||||
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
99
install.sh
99
install.sh
@@ -4,6 +4,10 @@ REPO="https://api.github.com/repos/itdoginfo/podkop/releases/latest"
|
||||
DOWNLOAD_DIR="/tmp/podkop"
|
||||
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"
|
||||
mkdir -p "$DOWNLOAD_DIR"
|
||||
|
||||
@@ -11,20 +15,65 @@ msg() {
|
||||
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() {
|
||||
check_system
|
||||
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
|
||||
|
||||
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
|
||||
msg "Podkop is already installed. Upgraded..."
|
||||
else
|
||||
msg "Installed podkop..."
|
||||
fi
|
||||
|
||||
|
||||
if command -v curl &> /dev/null; then
|
||||
check_response=$(curl -s "https://api.github.com/repos/itdoginfo/podkop/releases/latest")
|
||||
|
||||
@@ -34,11 +83,18 @@ main() {
|
||||
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
|
||||
while read -r url; do
|
||||
filename=$(basename "$url")
|
||||
filepath="$DOWNLOAD_DIR/$filename"
|
||||
|
||||
|
||||
attempt=0
|
||||
while [ $attempt -lt $COUNT ]; do
|
||||
msg "Download $filename (count $((attempt+1)))..."
|
||||
@@ -53,40 +109,40 @@ main() {
|
||||
rm -f "$filepath"
|
||||
attempt=$((attempt+1))
|
||||
done
|
||||
|
||||
|
||||
if [ $attempt -eq $COUNT ]; then
|
||||
msg "Failed to download $filename after $COUNT attempts"
|
||||
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
|
||||
msg "No packages were downloaded successfully"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
for pkg in podkop luci-app-podkop; do
|
||||
file=$(ls "$DOWNLOAD_DIR" | grep "^$pkg" | head -n 1)
|
||||
if [ -n "$file" ]; then
|
||||
msg "Installing $file"
|
||||
opkg install "$DOWNLOAD_DIR/$file"
|
||||
pkg_install "$DOWNLOAD_DIR/$file"
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
|
||||
ru=$(ls "$DOWNLOAD_DIR" | grep "luci-i18n-podkop-ru" | head -n 1)
|
||||
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..."
|
||||
opkg remove luci-i18n-podkop*
|
||||
opkg install "$DOWNLOAD_DIR/$ru"
|
||||
pkg_remove luci-i18n-podkop*
|
||||
pkg_install "$DOWNLOAD_DIR/$ru"
|
||||
else
|
||||
msg "Русский язык интерфейса ставим? y/n (Need a Russian translation?)"
|
||||
while true; do
|
||||
read -r -p '' RUS
|
||||
case $RUS in
|
||||
y)
|
||||
opkg remove luci-i18n-podkop*
|
||||
opkg install "$DOWNLOAD_DIR/$ru"
|
||||
pkg_remove luci-i18n-podkop*
|
||||
pkg_install "$DOWNLOAD_DIR/$ru"
|
||||
break
|
||||
;;
|
||||
n)
|
||||
@@ -133,15 +189,17 @@ check_system() {
|
||||
exit 1
|
||||
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?"
|
||||
|
||||
while true; do
|
||||
read -r -p '' DNSPROXY
|
||||
case $DNSPROXY in
|
||||
|
||||
yes|y|Y|yes)
|
||||
opkg remove --force-depends luci-app-https-dns-proxy https-dns-proxy luci-i18n-https-dns-proxy*
|
||||
yes|y|Y)
|
||||
pkg_remove luci-app-https-dns-proxy
|
||||
pkg_remove https-dns-proxy
|
||||
pkg_remove luci-i18n-https-dns-proxy*
|
||||
break
|
||||
;;
|
||||
*)
|
||||
@@ -154,7 +212,7 @@ check_system() {
|
||||
}
|
||||
|
||||
sing_box() {
|
||||
if ! opkg list-installed | grep -q "^sing-box"; then
|
||||
if ! pkg_is_installed "^sing-box"; then
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -164,8 +222,9 @@ sing_box() {
|
||||
if [ "$(echo -e "$sing_box_version\n$required_version" | sort -V | head -n 1)" != "$required_version" ]; then
|
||||
msg "sing-box version $sing_box_version is older than required $required_version"
|
||||
msg "Removing old version..."
|
||||
opkg remove sing-box
|
||||
service podkop stop
|
||||
pkg_remove sing-box
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
main
|
||||
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
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
|
||||
|
||||
@@ -19,4 +19,12 @@ LUCI_LANGUAGES:=en ru
|
||||
|
||||
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,242 +1,362 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require baseclass';
|
||||
'require view.podkop.constants as constants';
|
||||
'require tools.widgets as widgets';
|
||||
'require view.podkop.main as main';
|
||||
|
||||
function createAdditionalSection(mainSection, network) {
|
||||
let o = mainSection.tab('additional', _('Additional Settings'));
|
||||
function createAdditionalSection(mainSection) {
|
||||
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.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Flag,
|
||||
'yacd',
|
||||
_('Yacd enable'),
|
||||
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
|
||||
);
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
o = mainSection.taboption('additional', form.Flag, 'exclude_ntp', _('Exclude NTP'), _('For issues with open connections sing-box'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Flag,
|
||||
'exclude_ntp',
|
||||
_('Exclude NTP'),
|
||||
_('Allows you to exclude NTP protocol traffic from the tunnel'),
|
||||
);
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
o = mainSection.taboption('additional', form.Flag, 'quic_disable', _('QUIC disable'), _('For issues with the video stream'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Flag,
|
||||
'quic_disable',
|
||||
_('QUIC disable'),
|
||||
_('For issues with the video stream'),
|
||||
);
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
o = mainSection.taboption('additional', form.ListValue, 'update_interval', _('List Update Frequency'), _('Select how often the lists will be updated'));
|
||||
Object.entries(constants.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => {
|
||||
o.value(key, _(label));
|
||||
});
|
||||
o.default = '1d';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.ListValue,
|
||||
'update_interval',
|
||||
_('List Update Frequency'),
|
||||
_('Select how often the lists will be updated'),
|
||||
);
|
||||
Object.entries(main.UPDATE_INTERVAL_OPTIONS).forEach(([key, label]) => {
|
||||
o.value(key, _(label));
|
||||
});
|
||||
o.default = '1d';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
o = mainSection.taboption('additional', form.ListValue, 'dns_type', _('DNS Protocol Type'), _('Select DNS protocol to use'));
|
||||
o.value('doh', _('DNS over HTTPS (DoH)'));
|
||||
o.value('dot', _('DNS over TLS (DoT)'));
|
||||
o.value('udp', _('UDP (Unprotected DNS)'));
|
||||
o.default = 'udp';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.ListValue,
|
||||
'dns_type',
|
||||
_('DNS Protocol Type'),
|
||||
_('Select DNS protocol to use'),
|
||||
);
|
||||
o.value('doh', _('DNS over HTTPS (DoH)'));
|
||||
o.value('dot', _('DNS over TLS (DoT)'));
|
||||
o.value('udp', _('UDP (Unprotected DNS)'));
|
||||
o.default = 'udp';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
o = mainSection.taboption('additional', form.Value, 'dns_server', _('DNS Server'), _('Select or enter DNS server address'));
|
||||
Object.entries(constants.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
|
||||
o.value(key, _(label));
|
||||
});
|
||||
o.default = '8.8.8.8';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value) {
|
||||
return _('DNS server address cannot be empty');
|
||||
}
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Value,
|
||||
'dns_server',
|
||||
_('DNS Server'),
|
||||
_('Select or enter DNS server address'),
|
||||
);
|
||||
Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
|
||||
o.value(key, _(label));
|
||||
});
|
||||
o.default = '8.8.8.8';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
const validation = main.validateDNS(value);
|
||||
|
||||
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 (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
return true;
|
||||
};
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Value,
|
||||
'bootstrap_dns_server',
|
||||
_('Bootstrap DNS server'),
|
||||
_(
|
||||
'The DNS server used to look up the IP address of an upstream DNS server',
|
||||
),
|
||||
);
|
||||
Object.entries(main.BOOTSTRAP_DNS_SERVER_OPTIONS).forEach(([key, label]) => {
|
||||
o.value(key, _(label));
|
||||
});
|
||||
o.default = '77.88.8.8';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
const validation = main.validateDNS(value);
|
||||
|
||||
o = mainSection.taboption('additional', form.Flag, 'split_dns_enabled', _('Split DNS'), _('DNS for the list via proxy'));
|
||||
o.default = '1';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
if (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
o = mainSection.taboption('additional', form.ListValue, 'split_dns_type', _('Split DNS Protocol Type'), _('Select DNS protocol for split'));
|
||||
o.value('doh', _('DNS over HTTPS (DoH)'));
|
||||
o.value('dot', _('DNS over TLS (DoT)'));
|
||||
o.value('udp', _('UDP (Unprotected DNS)'));
|
||||
o.default = 'udp';
|
||||
o.rmempty = false;
|
||||
o.depends('split_dns_enabled', '1');
|
||||
o.ucisection = 'main';
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = mainSection.taboption('additional', form.Value, 'split_dns_server', _('Split DNS Server'), _('Select or enter DNS server address'));
|
||||
Object.entries(constants.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
|
||||
o.value(key, _(label));
|
||||
});
|
||||
o.default = '1.1.1.1';
|
||||
o.rmempty = false;
|
||||
o.depends('split_dns_enabled', '1');
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value) {
|
||||
return _('DNS server address cannot be empty');
|
||||
}
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Value,
|
||||
'dns_rewrite_ttl',
|
||||
_('DNS Rewrite TTL'),
|
||||
_('Time in seconds for DNS record caching (default: 60)'),
|
||||
);
|
||||
o.default = '60';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value) {
|
||||
return _('TTL value cannot be empty');
|
||||
}
|
||||
|
||||
const 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]*)?$/;
|
||||
const ttl = parseInt(value);
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
return _('TTL must be a positive number');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
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.value('/etc/sing-box/config.json', 'Flash (/etc/sing-box/config.json)');
|
||||
o.value('/tmp/sing-box/config.json', 'RAM (/tmp/sing-box/config.json)');
|
||||
o.default = '/etc/sing-box/config.json';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
o = mainSection.taboption('additional', form.Value, 'dns_rewrite_ttl', _('DNS Rewrite TTL'), _('Time in seconds for DNS record caching (default: 60)'));
|
||||
o.default = '60';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value) {
|
||||
return _('TTL value cannot be empty');
|
||||
}
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Value,
|
||||
'cache_path',
|
||||
_('Cache File Path'),
|
||||
_(
|
||||
'Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing',
|
||||
),
|
||||
);
|
||||
o.value('/tmp/sing-box/cache.db', 'RAM (/tmp/sing-box/cache.db)');
|
||||
o.value(
|
||||
'/usr/share/sing-box/cache.db',
|
||||
'Flash (/usr/share/sing-box/cache.db)',
|
||||
);
|
||||
o.default = '/tmp/sing-box/cache.db';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value) {
|
||||
return _('Cache file path cannot be empty');
|
||||
}
|
||||
|
||||
const ttl = parseInt(value);
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
return _('TTL must be a positive number');
|
||||
}
|
||||
if (!value.startsWith('/')) {
|
||||
return _('Path must be absolute (start with /)');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
if (!value.endsWith('cache.db')) {
|
||||
return _('Path must end with cache.db');
|
||||
}
|
||||
|
||||
o = mainSection.taboption('additional', form.ListValue, 'config_path', _('Config File Path'), _('Select path for sing-box config file. Change this ONLY if you know what you are doing'));
|
||||
o.value('/etc/sing-box/config.json', 'Flash (/etc/sing-box/config.json)');
|
||||
o.value('/tmp/sing-box/config.json', 'RAM (/tmp/sing-box/config.json)');
|
||||
o.default = '/etc/sing-box/config.json';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
const parts = value.split('/').filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return _('Path must contain at least one directory (like /tmp/cache.db)');
|
||||
}
|
||||
|
||||
o = mainSection.taboption('additional', form.Value, 'cache_path', _('Cache File Path'), _('Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing'));
|
||||
o.value('/tmp/sing-box/cache.db', 'RAM (/tmp/sing-box/cache.db)');
|
||||
o.value('/usr/share/sing-box/cache.db', 'Flash (/usr/share/sing-box/cache.db)');
|
||||
o.default = '/tmp/sing-box/cache.db';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value) {
|
||||
return _('Cache file path cannot be empty');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!value.startsWith('/')) {
|
||||
return _('Path must be absolute (start with /)');
|
||||
}
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
widgets.DeviceSelect,
|
||||
'iface',
|
||||
_('Source Network Interface'),
|
||||
_('Select the network interface from which the traffic will originate'),
|
||||
);
|
||||
o.ucisection = 'main';
|
||||
o.default = 'br-lan';
|
||||
o.noaliases = true;
|
||||
o.nobridges = false;
|
||||
o.noinactive = false;
|
||||
o.multiple = true;
|
||||
o.filter = function (section_id, value) {
|
||||
// Block specific interface names from being selectable
|
||||
const blocked = ['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan'];
|
||||
if (blocked.includes(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!value.endsWith('cache.db')) {
|
||||
return _('Path must end with cache.db');
|
||||
}
|
||||
// Try to find the device object by its name
|
||||
const device = this.devices.find((dev) => dev.getName() === value);
|
||||
|
||||
const parts = value.split('/').filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return _('Path must contain at least one directory (like /tmp/cache.db)');
|
||||
}
|
||||
// If no device is found, allow the value
|
||||
if (!device) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
// Check the type of the device
|
||||
const type = device.getType();
|
||||
|
||||
o = mainSection.taboption('additional', widgets.DeviceSelect, 'iface', _('Source Network Interface'), _('Select the network interface from which the traffic will originate'));
|
||||
o.ucisection = 'main';
|
||||
o.default = 'br-lan';
|
||||
o.noaliases = true;
|
||||
o.nobridges = false;
|
||||
o.noinactive = false;
|
||||
o.multiple = true;
|
||||
o.filter = function (section_id, value) {
|
||||
if (['wan', 'phy0-ap0', 'phy1-ap0', 'pppoe-wan'].indexOf(value) !== -1) {
|
||||
return false;
|
||||
}
|
||||
// Consider any Wi-Fi / wireless / wlan device as invalid
|
||||
const isWireless =
|
||||
type === 'wifi' || type === 'wireless' || type.includes('wlan');
|
||||
|
||||
var device = this.devices.filter(function (dev) {
|
||||
return dev.getName() === value;
|
||||
})[0];
|
||||
// Allow only non-wireless devices
|
||||
return !isWireless;
|
||||
};
|
||||
|
||||
if (device) {
|
||||
var type = device.getType();
|
||||
return type !== 'wifi' && type !== 'wireless' && !type.includes('wlan');
|
||||
}
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Flag,
|
||||
'mon_restart_ifaces',
|
||||
_('Interface monitoring'),
|
||||
_('Interface monitoring for bad WAN'),
|
||||
);
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
return true;
|
||||
};
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
widgets.NetworkSelect,
|
||||
'restart_ifaces',
|
||||
_('Interface for monitoring'),
|
||||
_('Select the WAN interfaces to be monitored'),
|
||||
);
|
||||
o.ucisection = 'main';
|
||||
o.depends('mon_restart_ifaces', '1');
|
||||
o.multiple = true;
|
||||
o.filter = function (section_id, value) {
|
||||
// Reject if the value is in the blocked list ['lan', 'loopback']
|
||||
if (['lan', 'loopback'].includes(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
o = mainSection.taboption('additional', form.Flag, 'mon_restart_ifaces', _('Interface monitoring'), _('Interface monitoring for bad WAN'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
// Reject if the value starts with '@' (means it's an alias/reference)
|
||||
if (value.startsWith('@')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
o = mainSection.taboption('additional', widgets.NetworkSelect, 'restart_ifaces', _('Interface for monitoring'), _('Select the WAN interfaces to be monitored'));
|
||||
o.ucisection = 'main';
|
||||
o.depends('mon_restart_ifaces', '1');
|
||||
o.multiple = true;
|
||||
o.filter = function (section_id, value) {
|
||||
return ['lan', 'loopback'].indexOf(value) === -1 && !value.startsWith('@');
|
||||
};
|
||||
// Otherwise allow it
|
||||
return true;
|
||||
};
|
||||
|
||||
o = mainSection.taboption('additional', form.Value, 'procd_reload_delay', _('Interface Monitoring Delay'), _('Delay in milliseconds before reloading podkop after interface UP'));
|
||||
o.ucisection = 'main';
|
||||
o.depends('mon_restart_ifaces', '1');
|
||||
o.default = '2000';
|
||||
o.rmempty = false;
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value) {
|
||||
return _('Delay value cannot be empty');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Value,
|
||||
'procd_reload_delay',
|
||||
_('Interface Monitoring Delay'),
|
||||
_('Delay in milliseconds before reloading podkop after interface UP'),
|
||||
);
|
||||
o.ucisection = 'main';
|
||||
o.depends('mon_restart_ifaces', '1');
|
||||
o.default = '2000';
|
||||
o.rmempty = false;
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value) {
|
||||
return _('Delay value cannot be empty');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
o = mainSection.taboption('additional', form.Flag, 'dont_touch_dhcp', _('Dont touch my DHCP!'), _('Podkop will not change the DHCP config'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Flag,
|
||||
'dont_touch_dhcp',
|
||||
_('Dont touch my DHCP!'),
|
||||
_('Podkop will not change the DHCP config'),
|
||||
);
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
o = mainSection.taboption('additional', form.Flag, 'detour', _('Proxy download of lists'), _('Downloading all lists via main Proxy/VPN'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o = mainSection.taboption(
|
||||
'additional',
|
||||
form.Flag,
|
||||
'detour',
|
||||
_('Proxy download of lists'),
|
||||
_('Downloading all lists via main Proxy/VPN'),
|
||||
);
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
// TODO(ampetelin): Can be moved to advanced settings in luci
|
||||
// Extra IPs and exclusions (main section)
|
||||
o = mainSection.taboption('basic', form.Flag, 'exclude_from_ip_enabled', _('IP for exclusion'), _('Specify local IP addresses that will never use the configured route'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
// Extra IPs and exclusions (main section)
|
||||
o = mainSection.taboption(
|
||||
'basic',
|
||||
form.Flag,
|
||||
'exclude_from_ip_enabled',
|
||||
_('IP for exclusion'),
|
||||
_('Specify local IP addresses that will never use the configured route'),
|
||||
);
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
|
||||
o = mainSection.taboption('basic', form.DynamicList, 'exclude_traffic_ip', _('Local IPs'), _('Enter valid IPv4 addresses'));
|
||||
o.placeholder = 'IP';
|
||||
o.depends('exclude_from_ip_enabled', '1');
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
if (!value || value.length === 0) return true;
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
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;
|
||||
};
|
||||
o = mainSection.taboption(
|
||||
'basic',
|
||||
form.DynamicList,
|
||||
'exclude_traffic_ip',
|
||||
_('Local IPs'),
|
||||
_('Enter valid IPv4 addresses'),
|
||||
);
|
||||
o.placeholder = 'IP';
|
||||
o.depends('exclude_from_ip_enabled', '1');
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
o.validate = function (section_id, value) {
|
||||
// Optional
|
||||
if (!value || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
o = mainSection.taboption('basic', form.Flag, 'socks5', _('Mixed enable'), _('Browser port: 2080'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
const validation = main.validateIPV4(value);
|
||||
|
||||
if (validation.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validation.message;
|
||||
};
|
||||
|
||||
o = mainSection.taboption(
|
||||
'basic',
|
||||
form.Flag,
|
||||
'socks5',
|
||||
_('Mixed enable'),
|
||||
_('Browser port: 2080'),
|
||||
);
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
o.ucisection = 'main';
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
createAdditionalSection
|
||||
});
|
||||
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': 'Cloudflare (1.1.1.1)',
|
||||
'8.8.8.8': 'Google (8.8.8.8)',
|
||||
'9.9.9.9': 'Quad9 (9.9.9.9)',
|
||||
'dns.adguard-dns.com': 'AdGuard Default (dns.adguard-dns.com)',
|
||||
'unfiltered.adguard-dns.com': 'AdGuard Unfiltered (unfiltered.adguard-dns.com)',
|
||||
'family.adguard-dns.com': 'AdGuard Family (family.adguard-dns.com)'
|
||||
};
|
||||
|
||||
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,89 +5,78 @@
|
||||
'require view.podkop.configSection as configSection';
|
||||
'require view.podkop.diagnosticTab as diagnosticTab';
|
||||
'require view.podkop.additionalTab as additionalTab';
|
||||
'require view.podkop.dashboardTab as dashboardTab';
|
||||
'require view.podkop.utils as utils';
|
||||
'require view.podkop.main as main';
|
||||
|
||||
return view.extend({
|
||||
async render() {
|
||||
document.head.insertAdjacentHTML('beforeend', `
|
||||
<style>
|
||||
.cbi-value {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
const EntryNode = {
|
||||
async render() {
|
||||
main.injectGlobalStyles();
|
||||
|
||||
#diagnostics-status .table > div {
|
||||
background: var(--background-color-primary);
|
||||
border: 1px solid var(--border-color-medium);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
const podkopFormMap = new form.Map('podkop', '', null, ['main', 'extra']);
|
||||
|
||||
#diagnostics-status .table > div pre,
|
||||
#diagnostics-status .table > div div[style*="monospace"] {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
// Main Section
|
||||
const mainSection = podkopFormMap.section(form.TypedSection, 'main');
|
||||
mainSection.anonymous = true;
|
||||
|
||||
#diagnostics-status .alert-message {
|
||||
background: var(--background-color-primary);
|
||||
border-color: var(--border-color-medium);
|
||||
}
|
||||
configSection.createConfigSection(mainSection);
|
||||
|
||||
#cbi-podkop:has(.cbi-tab-disabled[data-tab="basic"]) #cbi-podkop-extra {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
// Additional Settings Tab (main section)
|
||||
additionalTab.createAdditionalSection(mainSection);
|
||||
|
||||
const m = new form.Map('podkop', _(''), null, ['main', 'extra']);
|
||||
// Diagnostics Tab (main section)
|
||||
diagnosticTab.createDiagnosticsSection(mainSection);
|
||||
const podkopFormMapPromise = podkopFormMap.render().then((node) => {
|
||||
// Set up diagnostics event handlers
|
||||
diagnosticTab.setupDiagnosticsEventHandlers(node);
|
||||
|
||||
// Main Section
|
||||
const mainSection = m.section(form.TypedSection, 'main');
|
||||
mainSection.anonymous = true;
|
||||
configSection.createConfigSection(mainSection, m, network);
|
||||
// Start critical error polling for all tabs
|
||||
utils.startErrorPolling();
|
||||
|
||||
// Additional Settings Tab (main section)
|
||||
additionalTab.createAdditionalSection(mainSection, network);
|
||||
|
||||
// Diagnostics Tab (main section)
|
||||
diagnosticTab.createDiagnosticsSection(mainSection);
|
||||
const map_promise = m.render().then(node => {
|
||||
// Set up diagnostics event handlers
|
||||
diagnosticTab.setupDiagnosticsEventHandlers(node);
|
||||
|
||||
// Start critical error polling for all tabs
|
||||
// Add event listener to keep error polling active when switching tabs
|
||||
const tabs = node.querySelectorAll('.cbi-tabmenu');
|
||||
if (tabs.length > 0) {
|
||||
tabs[0].addEventListener('click', function (e) {
|
||||
const tab = e.target.closest('.cbi-tab');
|
||||
if (tab) {
|
||||
// Ensure error polling continues when switching tabs
|
||||
utils.startErrorPolling();
|
||||
|
||||
// Add event listener to keep error polling active when switching tabs
|
||||
const tabs = node.querySelectorAll('.cbi-tabmenu');
|
||||
if (tabs.length > 0) {
|
||||
tabs[0].addEventListener('click', function (e) {
|
||||
const tab = e.target.closest('.cbi-tab');
|
||||
if (tab) {
|
||||
// Ensure error polling continues when switching tabs
|
||||
utils.startErrorPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add visibility change handler to manage error polling
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
utils.stopErrorPolling();
|
||||
} else {
|
||||
utils.startErrorPolling();
|
||||
}
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extra Section
|
||||
const extraSection = m.section(form.TypedSection, 'extra', _('Extra configurations'));
|
||||
extraSection.anonymous = false;
|
||||
extraSection.addremove = true;
|
||||
extraSection.addbtntitle = _('Add Section');
|
||||
extraSection.multiple = true;
|
||||
configSection.createConfigSection(extraSection, m, network);
|
||||
// Add visibility change handler to manage error polling
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
utils.stopErrorPolling();
|
||||
} else {
|
||||
utils.startErrorPolling();
|
||||
}
|
||||
});
|
||||
|
||||
return map_promise;
|
||||
}
|
||||
});
|
||||
return node;
|
||||
});
|
||||
|
||||
// Extra Section
|
||||
const extraSection = podkopFormMap.section(
|
||||
form.TypedSection,
|
||||
'extra',
|
||||
_('Extra configurations'),
|
||||
);
|
||||
extraSection.anonymous = false;
|
||||
extraSection.addremove = true;
|
||||
extraSection.addbtntitle = _('Add Section');
|
||||
extraSection.multiple = true;
|
||||
configSection.createConfigSection(extraSection);
|
||||
|
||||
// Initial dashboard render
|
||||
dashboardTab.createDashboardSection(mainSection);
|
||||
|
||||
// Inject core service
|
||||
main.coreService();
|
||||
|
||||
return podkopFormMapPromise;
|
||||
},
|
||||
};
|
||||
|
||||
return view.extend(EntryNode);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require fs';
|
||||
'require view.podkop.constants as constants';
|
||||
'require view.podkop.main as main';
|
||||
|
||||
// Flag to track if this is the first error check
|
||||
let isInitialCheck = true;
|
||||
@@ -15,138 +15,149 @@ let errorPollTimer = null;
|
||||
|
||||
// Helper function to fetch errors from the podkop command
|
||||
async function getPodkopErrors() {
|
||||
return new Promise(resolve => {
|
||||
safeExec('/usr/bin/podkop', ['check_logs'], 'P0_PRIORITY', result => {
|
||||
if (!result || !result.stdout) return resolve([]);
|
||||
return new Promise((resolve) => {
|
||||
safeExec('/usr/bin/podkop', ['check_logs'], 'P0_PRIORITY', (result) => {
|
||||
if (!result || !result.stdout) return resolve([]);
|
||||
|
||||
const logs = result.stdout.split('\n');
|
||||
const errors = logs.filter(log =>
|
||||
log.includes('[critical]')
|
||||
);
|
||||
const logs = result.stdout.split('\n');
|
||||
const errors = logs.filter((log) => log.includes('[critical]'));
|
||||
|
||||
resolve(errors);
|
||||
});
|
||||
resolve(errors);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Show error notification to the user
|
||||
function showErrorNotification(error, isMultiple = false) {
|
||||
const notificationContent = E('div', { 'class': 'alert-message error' }, [
|
||||
E('pre', { 'class': 'error-log' }, error)
|
||||
]);
|
||||
const notificationContent = E('div', { class: 'alert-message error' }, [
|
||||
E('pre', { class: 'error-log' }, error),
|
||||
]);
|
||||
|
||||
ui.addNotification(null, notificationContent);
|
||||
ui.addNotification(null, notificationContent);
|
||||
}
|
||||
|
||||
// Helper function for command execution with prioritization
|
||||
function safeExec(command, args, priority, callback, timeout = constants.COMMAND_TIMEOUT) {
|
||||
// Default to highest priority execution if priority is not provided or invalid
|
||||
let schedulingDelay = constants.COMMAND_SCHEDULING.P0_PRIORITY;
|
||||
function safeExec(
|
||||
command,
|
||||
args,
|
||||
priority,
|
||||
callback,
|
||||
timeout = main.COMMAND_TIMEOUT,
|
||||
) {
|
||||
// Default to highest priority execution if priority is not provided or invalid
|
||||
let schedulingDelay = main.COMMAND_SCHEDULING.P0_PRIORITY;
|
||||
|
||||
// If priority is a string, try to get the corresponding delay value
|
||||
if (typeof priority === 'string' && constants.COMMAND_SCHEDULING[priority] !== undefined) {
|
||||
schedulingDelay = constants.COMMAND_SCHEDULING[priority];
|
||||
// If priority is a string, try to get the corresponding delay value
|
||||
if (
|
||||
typeof priority === 'string' &&
|
||||
main.COMMAND_SCHEDULING[priority] !== undefined
|
||||
) {
|
||||
schedulingDelay = main.COMMAND_SCHEDULING[priority];
|
||||
}
|
||||
|
||||
const executeCommand = async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const result = await Promise.race([
|
||||
fs.exec(command, args),
|
||||
new Promise((_, reject) => {
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
reject(new Error('Command execution timed out'));
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Command execution failed or timed out: ${command} ${args.join(' ')}`,
|
||||
);
|
||||
const errorResult = { stdout: '', stderr: error.message, error: error };
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback(errorResult);
|
||||
}
|
||||
|
||||
return errorResult;
|
||||
}
|
||||
};
|
||||
|
||||
const executeCommand = async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const result = await Promise.race([
|
||||
fs.exec(command, args),
|
||||
new Promise((_, reject) => {
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
reject(new Error('Command execution timed out'));
|
||||
});
|
||||
})
|
||||
]);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn(`Command execution failed or timed out: ${command} ${args.join(' ')}`);
|
||||
const errorResult = { stdout: '', stderr: error.message, error: error };
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback(errorResult);
|
||||
}
|
||||
|
||||
return errorResult;
|
||||
}
|
||||
};
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
setTimeout(executeCommand, schedulingDelay);
|
||||
return;
|
||||
}
|
||||
else {
|
||||
return executeCommand();
|
||||
}
|
||||
if (callback && typeof callback === 'function') {
|
||||
setTimeout(executeCommand, schedulingDelay);
|
||||
return;
|
||||
} else {
|
||||
return executeCommand();
|
||||
}
|
||||
}
|
||||
|
||||
// Check for critical errors and show notifications
|
||||
async function checkForCriticalErrors() {
|
||||
try {
|
||||
const errors = await getPodkopErrors();
|
||||
try {
|
||||
const errors = await getPodkopErrors();
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
// Filter out errors we've already seen
|
||||
const newErrors = errors.filter(error => !lastErrorsSet.has(error));
|
||||
if (errors && errors.length > 0) {
|
||||
// Filter out errors we've already seen
|
||||
const newErrors = errors.filter((error) => !lastErrorsSet.has(error));
|
||||
|
||||
if (newErrors.length > 0) {
|
||||
// On initial check, just store errors without showing notifications
|
||||
if (!isInitialCheck) {
|
||||
// Show each new error as a notification
|
||||
newErrors.forEach(error => {
|
||||
showErrorNotification(error, newErrors.length > 1);
|
||||
});
|
||||
}
|
||||
|
||||
// Add new errors to our set of seen errors
|
||||
newErrors.forEach(error => lastErrorsSet.add(error));
|
||||
}
|
||||
if (newErrors.length > 0) {
|
||||
// On initial check, just store errors without showing notifications
|
||||
if (!isInitialCheck) {
|
||||
// Show each new error as a notification
|
||||
newErrors.forEach((error) => {
|
||||
showErrorNotification(error, newErrors.length > 1);
|
||||
});
|
||||
}
|
||||
|
||||
// After first check, mark as no longer initial
|
||||
isInitialCheck = false;
|
||||
} catch (error) {
|
||||
console.error('Error checking for critical messages:', error);
|
||||
// Add new errors to our set of seen errors
|
||||
newErrors.forEach((error) => lastErrorsSet.add(error));
|
||||
}
|
||||
}
|
||||
|
||||
// After first check, mark as no longer initial
|
||||
isInitialCheck = false;
|
||||
} catch (error) {
|
||||
console.error('Error checking for critical messages:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling for errors at regular intervals
|
||||
function startErrorPolling() {
|
||||
if (errorPollTimer) {
|
||||
clearInterval(errorPollTimer);
|
||||
}
|
||||
if (errorPollTimer) {
|
||||
clearInterval(errorPollTimer);
|
||||
}
|
||||
|
||||
// Reset initial check flag to make sure we show errors
|
||||
isInitialCheck = false;
|
||||
// Reset initial check flag to make sure we show errors
|
||||
isInitialCheck = false;
|
||||
|
||||
// Immediately check for errors on start
|
||||
checkForCriticalErrors();
|
||||
// Immediately check for errors on start
|
||||
checkForCriticalErrors();
|
||||
|
||||
// Then set up periodic checks
|
||||
errorPollTimer = setInterval(checkForCriticalErrors, constants.ERROR_POLL_INTERVAL);
|
||||
// Then set up periodic checks
|
||||
errorPollTimer = setInterval(
|
||||
checkForCriticalErrors,
|
||||
main.ERROR_POLL_INTERVAL,
|
||||
);
|
||||
}
|
||||
|
||||
// Stop polling for errors
|
||||
function stopErrorPolling() {
|
||||
if (errorPollTimer) {
|
||||
clearInterval(errorPollTimer);
|
||||
errorPollTimer = null;
|
||||
}
|
||||
if (errorPollTimer) {
|
||||
clearInterval(errorPollTimer);
|
||||
errorPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
startErrorPolling,
|
||||
stopErrorPolling,
|
||||
checkForCriticalErrors,
|
||||
safeExec
|
||||
});
|
||||
startErrorPolling,
|
||||
stopErrorPolling,
|
||||
checkForCriticalErrors,
|
||||
safeExec,
|
||||
});
|
||||
|
||||
30
luci-app-podkop/msgmerge.sh
Normal file
30
luci-app-podkop/msgmerge.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PODIR="po"
|
||||
POTFILE="$PODIR/templates/podkop.pot"
|
||||
WIDTH=120
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <language_code> (e.g., ru, de, fr)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LANG="$1"
|
||||
POFILE="$PODIR/$LANG/podkop.po"
|
||||
|
||||
if [ ! -f "$POTFILE" ]; then
|
||||
echo "Template $POTFILE not found. Run xgettext first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$POFILE" ]; then
|
||||
echo "Updating $POFILE"
|
||||
msgmerge --update --width="$WIDTH" --no-location "$POFILE" "$POTFILE"
|
||||
else
|
||||
echo "Creating new $POFILE using msginit"
|
||||
mkdir -p "$PODIR/$LANG"
|
||||
msginit --no-translator --no-location --locale="$LANG" --width="$WIDTH" --input="$POTFILE" --output-file="$POFILE"
|
||||
fi
|
||||
|
||||
echo "Translation file for $LANG updated."
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user