mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 09:05:01 +03:00
Compare commits
45 Commits
v3.3.1
...
37c5e0bfd2
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c5e0bfd2 | |||
| f3eba04ed8 | |||
| 9385b6c609 | |||
| d882d6aa74 | |||
| bbab83db17 | |||
| dc781b28c4 | |||
| 5b8504c756 | |||
| c1fdcd98d2 | |||
| eec030f86f | |||
| 7fe082a7f1 | |||
| f7ffe89813 | |||
| c1fbfd0510 | |||
| cbb21b7575 | |||
| cf5f37e409 | |||
| 0d87bb8b4b | |||
| f00512d12e | |||
| cdaf5f80db | |||
| ac8cb505d1 | |||
| 71616b7cf2 | |||
| 628406117e | |||
| 7605902324 | |||
| b5872af279 | |||
| 53f6ed394f | |||
| 66a9a788fc | |||
| dab0add191 | |||
| 7c737820d1 | |||
| 335470607f | |||
| 05ad7f417c | |||
| 2188830612 | |||
| d14f341b21 | |||
| f4bbaf40f0 | |||
| 1c75034957 | |||
| 7f34c306d7 | |||
| a133282fc3 | |||
| dcb923b4a1 | |||
| 7c2598fae9 | |||
| 1c0fdb4527 | |||
| 2d6dea4bf6 | |||
| 4c8d3cb625 | |||
| 9a8247fa78 | |||
| 355262e632 | |||
| 8f556fe2db | |||
| b770287995 | |||
| 3c68b039f6 | |||
| c200e248f7 |
@@ -3,3 +3,4 @@ XUI_DB_FOLDER=x-ui
|
|||||||
XUI_LOG_FOLDER=x-ui
|
XUI_LOG_FOLDER=x-ui
|
||||||
XUI_BIN_FOLDER=x-ui
|
XUI_BIN_FOLDER=x-ui
|
||||||
XUI_INIT_WEB_BASE_PATH=/
|
XUI_INIT_WEB_BASE_PATH=/
|
||||||
|
# XUI_PORT=8080
|
||||||
|
|||||||
+7
-1
@@ -3,4 +3,10 @@ DockerInit.sh text eol=lf
|
|||||||
DockerEntrypoint.sh text eol=lf
|
DockerEntrypoint.sh text eol=lf
|
||||||
frontend/src/generated/** text eol=lf
|
frontend/src/generated/** text eol=lf
|
||||||
frontend/public/openapi.json text eol=lf
|
frontend/public/openapi.json text eol=lf
|
||||||
frontend/src/test/__snapshots__/** text eol=lf
|
frontend/src/test/__snapshots__/** text eol=lf
|
||||||
|
|
||||||
|
# Cloud-image deploy assets are consumed on Linux — force LF regardless of host.
|
||||||
|
*.service text eol=lf
|
||||||
|
deploy/**/*.service text eol=lf
|
||||||
|
deploy/**/*.hcl text eol=lf
|
||||||
|
deploy/**/*.yaml text eol=lf
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||||
go test $(cat /tmp/go-packages.txt)
|
go test -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
|
||||||
|
|
||||||
codegen:
|
codegen:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -69,6 +69,39 @@ jobs:
|
|||||||
- name: Run govulncheck
|
- name: Run govulncheck
|
||||||
run: govulncheck ./...
|
run: govulncheck ./...
|
||||||
|
|
||||||
|
# Race + shuffle hygiene gate: data races and order-dependent tests fail the build.
|
||||||
|
race:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Stub internal/web/dist for go:embed
|
||||||
|
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
|
||||||
|
- name: Race + shuffle
|
||||||
|
run: |
|
||||||
|
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||||
|
go test -race -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
|
||||||
|
|
||||||
|
# Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
|
||||||
|
# generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
|
||||||
|
fuzz-smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Stub internal/web/dist for go:embed
|
||||||
|
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
|
||||||
|
- name: Fuzz critical parsers (smoke)
|
||||||
|
run: |
|
||||||
|
go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
|
||||||
|
go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
if: matrix.language == 'go'
|
if: matrix.language == 'go'
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version-file: .nvmrc
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
name: Build Cloud Images
|
||||||
|
|
||||||
|
# Build golden cloud images from a published release, for amd64 and arm64:
|
||||||
|
# * qemu -> qcow2 attached to the GitHub release (always)
|
||||||
|
# * amazon-ebs -> AWS AMI (only when AWS credentials are configured)
|
||||||
|
#
|
||||||
|
# Images contain NO database and NO baked credentials; first boot generates
|
||||||
|
# unique per-instance credentials (see deploy/firstboot + deploy/packer).
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to build images for (e.g. v3.3.1)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: image-${{ github.event.release.tag_name || inputs.tag }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Resolve the tag and wait until BOTH arch tarballs are actually published
|
||||||
|
# (the release matrix uploads assets one by one, so 'published' can fire
|
||||||
|
# before the tarballs exist).
|
||||||
|
setup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.resolve.outputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Resolve tag
|
||||||
|
id: resolve
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
|
else
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
fi
|
||||||
|
[ -n "$TAG" ] || { echo "::error::no tag resolved"; exit 1; }
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Wait for released binary assets (amd64 + arm64)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ steps.resolve.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
want="x-ui-linux-amd64.tar.gz x-ui-linux-arm64.tar.gz"
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
names=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name')
|
||||||
|
missing=""
|
||||||
|
for w in $want; do
|
||||||
|
echo "$names" | grep -qx "$w" || missing="$missing $w"
|
||||||
|
done
|
||||||
|
if [ -z "$missing" ]; then
|
||||||
|
echo "All assets present on $TAG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting for$missing on $TAG ($i/30)..."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
echo "::error::missing release assets on $TAG after 10 minutes:$missing"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# Gate the AWS AMI build so forks without secrets skip it cleanly
|
||||||
|
# (secrets cannot be referenced directly in job-level `if`).
|
||||||
|
check-aws:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
enabled: ${{ steps.c.outputs.enabled }}
|
||||||
|
use_oidc: ${{ steps.c.outputs.use_oidc }}
|
||||||
|
steps:
|
||||||
|
- id: c
|
||||||
|
env:
|
||||||
|
ROLE: ${{ secrets.AWS_ROLE_ARN }}
|
||||||
|
KEY: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$ROLE" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "use_oidc=true" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ -n "$KEY" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "use_oidc=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "use_oidc=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::No AWS credentials configured; skipping the AMI build."
|
||||||
|
fi
|
||||||
|
|
||||||
|
qemu-image:
|
||||||
|
needs: setup
|
||||||
|
timeout-minutes: 90
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
qemu_pkgs: qemu-system-x86 qemu-utils
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
qemu_pkgs: qemu-system-arm qemu-efi-aarch64 qemu-utils
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install QEMU
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends ${{ matrix.qemu_pkgs }}
|
||||||
|
|
||||||
|
- name: Setup Packer
|
||||||
|
uses: hashicorp/setup-packer@v3
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Verify released binary asset
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.setup.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
mkdir -p _asset
|
||||||
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||||
|
--pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset
|
||||||
|
ls -la _asset
|
||||||
|
|
||||||
|
- name: Select accelerator
|
||||||
|
id: accel
|
||||||
|
run: |
|
||||||
|
if [ -e /dev/kvm ]; then echo "value=kvm" >> "$GITHUB_OUTPUT"; else echo "value=tcg" >> "$GITHUB_OUTPUT"; fi
|
||||||
|
|
||||||
|
- name: Packer init
|
||||||
|
run: packer init deploy/packer/
|
||||||
|
|
||||||
|
- name: Build qcow2 image
|
||||||
|
env:
|
||||||
|
TAG: ${{ needs.setup.outputs.tag }}
|
||||||
|
ACCEL: ${{ steps.accel.outputs.value }}
|
||||||
|
run: |
|
||||||
|
packer build -only='qemu.x-ui' \
|
||||||
|
-var "xui_version=${TAG}" \
|
||||||
|
-var "xui_arch=${{ matrix.arch }}" \
|
||||||
|
-var "qemu_accelerator=${ACCEL}" \
|
||||||
|
deploy/packer/
|
||||||
|
|
||||||
|
- name: Compress qcow2
|
||||||
|
id: pack
|
||||||
|
env:
|
||||||
|
TAG: ${{ needs.setup.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
cd deploy/packer/output-qemu
|
||||||
|
src="3x-ui-ubuntu-24.04-${{ matrix.arch }}.qcow2"
|
||||||
|
out="3x-ui-ubuntu-24.04-${TAG}-${{ matrix.arch }}.qcow2.xz"
|
||||||
|
xz -T0 -6 -c "$src" > "$out"
|
||||||
|
sha256sum "$out" > "${out}.sha256"
|
||||||
|
echo "file=deploy/packer/output-qemu/${out}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha=deploy/packer/output-qemu/${out}.sha256" >> "$GITHUB_OUTPUT"
|
||||||
|
ls -la
|
||||||
|
|
||||||
|
- name: Attach qcow2 to release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.setup.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \
|
||||||
|
"${{ steps.pack.outputs.file }}" "${{ steps.pack.outputs.sha }}"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
env:
|
||||||
|
TAG: ${{ needs.setup.outputs.tag }}
|
||||||
|
ACCEL: ${{ steps.accel.outputs.value }}
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "## QEMU image (${{ matrix.arch }})"
|
||||||
|
echo "- Tag: \`${TAG}\`"
|
||||||
|
echo "- Accelerator: \`${ACCEL}\`"
|
||||||
|
echo "- Attached: \`$(basename "${{ steps.pack.outputs.file }}")\`"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
ami-image:
|
||||||
|
needs: [setup, check-aws]
|
||||||
|
if: needs.check-aws.outputs.enabled == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
instance_type: t3.small
|
||||||
|
- arch: arm64
|
||||||
|
instance_type: t4g.small
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Packer
|
||||||
|
uses: hashicorp/setup-packer@v3
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Configure AWS credentials (OIDC)
|
||||||
|
if: needs.check-aws.outputs.use_oidc == 'true'
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
|
||||||
|
aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }}
|
||||||
|
|
||||||
|
- name: Configure AWS credentials (access keys)
|
||||||
|
if: needs.check-aws.outputs.use_oidc != 'true'
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }}
|
||||||
|
|
||||||
|
- name: Verify released binary asset
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.setup.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
mkdir -p _asset
|
||||||
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||||
|
--pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset
|
||||||
|
ls -la _asset
|
||||||
|
|
||||||
|
- name: Packer init
|
||||||
|
run: packer init deploy/packer/
|
||||||
|
|
||||||
|
- name: Build AMI
|
||||||
|
env:
|
||||||
|
TAG: ${{ needs.setup.outputs.tag }}
|
||||||
|
REGION: ${{ vars.AWS_REGION || 'eu-central-1' }}
|
||||||
|
run: |
|
||||||
|
packer build -only='amazon-ebs.x-ui' \
|
||||||
|
-var "xui_version=${TAG}" \
|
||||||
|
-var "xui_arch=${{ matrix.arch }}" \
|
||||||
|
-var "instance_type=${{ matrix.instance_type }}" \
|
||||||
|
-var "region=${REGION}" \
|
||||||
|
deploy/packer/
|
||||||
|
|
||||||
|
- name: Publish AMI id to summary
|
||||||
|
env:
|
||||||
|
REGION: ${{ vars.AWS_REGION || 'eu-central-1' }}
|
||||||
|
run: |
|
||||||
|
AMI_ID=$(jq -r '.builds[] | select(.builder_type=="amazon-ebs") | .artifact_id' packer-manifest.json | tail -1 | cut -d: -f2)
|
||||||
|
{
|
||||||
|
echo "## AWS AMI (${{ matrix.arch }})"
|
||||||
|
echo "- Region: \`${REGION}\`"
|
||||||
|
echo "- Instance type: \`${{ matrix.instance_type }}\`"
|
||||||
|
echo "- AMI ID: \`${AMI_ID}\`"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: Mutation testing
|
||||||
|
|
||||||
|
# Mutation testing (gremlins) is the objective check for "fake" tests: it mutates the
|
||||||
|
# source and a surviving (LIVED) mutant means no test caught the change. It is SLOW, so it
|
||||||
|
# runs nightly / on demand and scoped per package — never per-commit. It is informational:
|
||||||
|
# no thresholds are set, so it reports survivors as artifacts without failing the build.
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *" # 03:00 UTC daily
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gremlins:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: sub
|
||||||
|
path: ./internal/sub/
|
||||||
|
exclude: ""
|
||||||
|
- name: runtime
|
||||||
|
path: ./internal/web/runtime/
|
||||||
|
exclude: ""
|
||||||
|
- name: link
|
||||||
|
path: ./internal/util/link/
|
||||||
|
exclude: ""
|
||||||
|
- name: database
|
||||||
|
path: ./internal/database/
|
||||||
|
exclude: 'dump_sqlite\.go'
|
||||||
|
- name: service
|
||||||
|
path: ./internal/web/service/
|
||||||
|
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: Stub internal/web/dist for go:embed
|
||||||
|
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
|
||||||
|
- name: Install gremlins
|
||||||
|
run: go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0
|
||||||
|
- name: Run gremlins on ${{ matrix.name }}
|
||||||
|
run: |
|
||||||
|
OUT="mutation-${{ matrix.name }}.json"
|
||||||
|
if [ -n "${{ matrix.exclude }}" ]; then
|
||||||
|
gremlins unleash -E '${{ matrix.exclude }}' -o "$OUT" ${{ matrix.path }}
|
||||||
|
else
|
||||||
|
gremlins unleash -o "$OUT" ${{ matrix.path }}
|
||||||
|
fi
|
||||||
|
- name: Upload mutation report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mutation-${{ matrix.name }}
|
||||||
|
path: mutation-${{ matrix.name }}.json
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version-file: .nvmrc
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version-file: .nvmrc
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy Smoke Tests
|
||||||
|
|
||||||
|
# Container smoke tests for the unattended install path and first-boot
|
||||||
|
# credential generation. Runs only when the install/deploy assets change.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "install.sh"
|
||||||
|
- "deploy/**"
|
||||||
|
- ".github/workflows/smoke.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "install.sh"
|
||||||
|
- "deploy/**"
|
||||||
|
- ".github/workflows/smoke.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
noninteractive-install:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Non-interactive install smoke test
|
||||||
|
run: bash deploy/test/smoke-noninteractive.sh
|
||||||
|
|
||||||
|
first-boot:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: First-boot credential smoke test
|
||||||
|
run: bash deploy/test/smoke-firstboot.sh
|
||||||
@@ -73,6 +73,7 @@ XUI_DB_FOLDER=x-ui
|
|||||||
XUI_LOG_FOLDER=x-ui
|
XUI_LOG_FOLDER=x-ui
|
||||||
XUI_BIN_FOLDER=x-ui
|
XUI_BIN_FOLDER=x-ui
|
||||||
XUI_INIT_WEB_BASE_PATH=/
|
XUI_INIT_WEB_BASE_PATH=/
|
||||||
|
# XUI_PORT=8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
|
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
|
||||||
@@ -235,6 +236,49 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
|
|||||||
| `internal/config/` | Environment-variable helpers, paths, defaults |
|
| `internal/config/` | Environment-variable helpers, paths, defaults |
|
||||||
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
|
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
|
||||||
|
|
||||||
|
### Go conventions
|
||||||
|
|
||||||
|
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
|
||||||
|
- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
|
||||||
|
- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
| Goal | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Standard run | `go test ./...` |
|
||||||
|
| Hygiene — data races + order-dependence | `go test -race -shuffle=on -count=1 ./...` (`-race` needs the C compiler from Prerequisites) |
|
||||||
|
| Coverage gaps | `go test -coverprofile=cov.out ./<pkg>/... && go tool cover -func=cov.out` |
|
||||||
|
| Fuzz a parser briefly | `go test -run '^$' -fuzz 'FuzzName$' -fuzztime=30s ./<pkg>/...` |
|
||||||
|
|
||||||
|
Frontend: `cd frontend && npm run test` (vitest), or `npm run test -- --coverage`.
|
||||||
|
|
||||||
|
### Property and fuzz tests
|
||||||
|
|
||||||
|
Input-heavy or pure logic (link builders, parsers, decoders) is also covered by **property tests** (`pgregory.net/rapid`) and **native fuzz targets** (`go test -fuzz`). A fuzz target's **seed corpus** (its inline `f.Add` cases plus any `testdata/fuzz` entries) runs as ordinary subtests under a plain `go test` — no `-fuzz` flag needed — so CI's normal test job exercises the seeds; the time-boxed *fuzzing* exploration (`-fuzz=...`) runs separately as the `fuzz-smoke` job.
|
||||||
|
|
||||||
|
### Mutation testing (optional, manual)
|
||||||
|
|
||||||
|
[gremlins](https://github.com/go-gremlins/gremlins) checks whether tests actually fail when the code is mutated — a surviving (`LIVED`) mutant means a weak test. It is **slow**, so run it **scoped per package**, never repo-wide or per-commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
|
||||||
|
gremlins unleash ./internal/sub/
|
||||||
|
gremlins unleash -E 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go' ./internal/web/service/
|
||||||
|
```
|
||||||
|
|
||||||
|
Treat each survivor as one of: a weak test (strengthen it), dead code (remove it), or an equivalent mutant (unkillable — leave it). Don't write a test purely to kill a mutant if it doesn't reflect real behavior.
|
||||||
|
|
||||||
|
CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml` — scoped per package, results uploaded as artifacts. It is **informational**, not a gate (no thresholds), so check the reports when hardening a suite rather than waiting for a red build.
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
|
||||||
|
|
||||||
## Sending a pull request
|
## Sending a pull request
|
||||||
|
|
||||||
1. Branch off `main` (e.g. `feat/short-description`).
|
1. Branch off `main` (e.g. `feat/short-description`).
|
||||||
@@ -256,9 +300,16 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
|
|||||||
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
|
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
|
||||||
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
|
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
|
||||||
| `XUI_INIT_WEB_BASE_PATH` | `/` | The initial URI path for the web panel |
|
| `XUI_INIT_WEB_BASE_PATH` | `/` | The initial URI path for the web panel |
|
||||||
|
| `XUI_PORT` | persisted `webPort` | Runtime-only web panel listener port override (`1` through `65535`) |
|
||||||
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
||||||
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
||||||
|
|
||||||
|
A valid `XUI_PORT` takes precedence over the database-backed `webPort` for the
|
||||||
|
current process without changing the stored setting. Unset, empty, whitespace-only,
|
||||||
|
malformed, or out-of-range values fall back to `webPort`; invalid configured values
|
||||||
|
also produce a warning. With Docker bridge networking, the published container port
|
||||||
|
must match the override, for example `XUI_PORT: "8080"` with `ports: ["8080:8080"]`.
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
|
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
|
||||||
|
|||||||
+6
-5
@@ -44,23 +44,24 @@ before = iptables-allports.conf
|
|||||||
[Definition]
|
[Definition]
|
||||||
actionstart = <iptables> -N f2b-<name>
|
actionstart = <iptables> -N f2b-<name>
|
||||||
<iptables> -A f2b-<name> -j <returntype>
|
<iptables> -A f2b-<name> -j <returntype>
|
||||||
<iptables> -I <chain> -p <protocol> -j f2b-<name>
|
<iptables> -I <chain> -j f2b-<name>
|
||||||
|
|
||||||
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
actionstop = <iptables> -D <chain> -j f2b-<name>
|
||||||
<actionflush>
|
<actionflush>
|
||||||
<iptables> -X f2b-<name>
|
<iptables> -X f2b-<name>
|
||||||
|
|
||||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||||
|
|
||||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
|
||||||
|
<iptables> -I f2b-<name> 1 -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
|
||||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||||
|
|
||||||
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
actionunban = <iptables> -D f2b-<name> -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
|
||||||
|
<iptables> -D f2b-<name> -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
|
||||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||||
|
|
||||||
[Init]
|
[Init]
|
||||||
name = default
|
name = default
|
||||||
protocol = tcp
|
|
||||||
chain = INPUT
|
chain = INPUT
|
||||||
exemptports = $EXEMPT_PORTS
|
exemptports = $EXEMPT_PORTS
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ During installation a random username, password, and access path are generated.
|
|||||||
|
|
||||||
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||||
|
|
||||||
|
### Unattended install & cloud images
|
||||||
|
|
||||||
|
The installer also runs **non-interactively** for cloud-init and golden images.
|
||||||
|
Set `XUI_NONINTERACTIVE=1` (or pipe with no TTY) and it installs end-to-end with
|
||||||
|
zero prompts, generating random credentials and writing them to
|
||||||
|
`/etc/x-ui/install-result.env`. See [`deploy/`](deploy/) for:
|
||||||
|
|
||||||
|
- [Cloud-init user-data](deploy/cloud-init/) — unattended install on any cloud (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
|
||||||
|
- [Packer golden image](deploy/packer/) — build an AWS EC2 AMI + qcow2 (amd64/arm64) with per-instance credentials generated on first boot
|
||||||
|
- [Amazon Lightsail](deploy/lightsail/) — launch script + reusable snapshot builder
|
||||||
|
- [AWS Marketplace checklist](deploy/marketplace/aws/)
|
||||||
|
|
||||||
## Supported Platforms
|
## Supported Platforms
|
||||||
|
|
||||||
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows.
|
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Cloud deployment & golden images
|
||||||
|
|
||||||
|
Tooling to ship the 3x-ui panel as a cloud image or via unattended install,
|
||||||
|
with **per-instance credentials generated on first boot** (never `admin/admin`,
|
||||||
|
never a shared session secret). Everything here supports **amd64 and arm64**.
|
||||||
|
|
||||||
|
| Path | What it is | Use when |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [`cloud-init/`](cloud-init/) | Generic cloud-init user-data (unattended `install.sh`) | Any cloud, no image build |
|
||||||
|
| [`packer/`](packer/) | Packer build → AWS AMI + qcow2/raw | Reusable / Marketplace images |
|
||||||
|
| [`lightsail/`](lightsail/) | Launch script + snapshot builder | Amazon Lightsail |
|
||||||
|
| [`firstboot/`](firstboot/) | First-boot unit + script that mints per-instance creds | Used by the Packer/Lightsail images |
|
||||||
|
| [`marketplace/aws/`](marketplace/aws/) | AWS Marketplace submission checklist | Publishing an EC2 AMI |
|
||||||
|
| [`marketplace/hetzner/`](marketplace/hetzner/) | Hetzner Cloud notes | Hetzner deployments |
|
||||||
|
| [`test/`](test/) | Container smoke tests | Verifying the install/firstboot paths |
|
||||||
|
|
||||||
|
## Two models
|
||||||
|
|
||||||
|
- **Non-interactive install (cloud-init):** `install.sh` runs unattended when
|
||||||
|
`XUI_NONINTERACTIVE=1` or stdin is not a TTY. Each instance installs and
|
||||||
|
configures itself with random credentials. See [`cloud-init/README.md`](cloud-init/README.md).
|
||||||
|
- **Golden image (Packer):** the image contains the panel but **no DB and no
|
||||||
|
secrets**; `firstboot` generates unique credentials on first boot. See
|
||||||
|
[`packer/README.md`](packer/README.md).
|
||||||
|
|
||||||
|
## Unattended install knobs
|
||||||
|
|
||||||
|
`install.sh` reads these env vars in non-interactive mode (all optional; unset ⇒
|
||||||
|
secure random / default):
|
||||||
|
|
||||||
|
`XUI_USERNAME`, `XUI_PASSWORD`, `XUI_PANEL_PORT`, `XUI_WEB_BASE_PATH`,
|
||||||
|
`XUI_SSL_MODE` (`none`|`ip`|`domain`, default `none`), `XUI_DOMAIN`,
|
||||||
|
`XUI_ACME_EMAIL`, `XUI_ACME_HTTP_PORT` (ACME HTTP-01 listener port, default `80`),
|
||||||
|
`XUI_SSL_IPV6` (optional IPv6 address to add to an `ip`-mode cert),
|
||||||
|
`XUI_SERVER_IP` (fallback IP for the displayed access URL when auto-detection fails),
|
||||||
|
`XUI_DB_TYPE` (`sqlite`|`postgres`), `XUI_DB_DSN`.
|
||||||
|
|
||||||
|
The resulting credentials are written to `/etc/x-ui/install-result.env` (mode 600).
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 3x-ui via cloud-init (generic, no golden image)
|
||||||
|
|
||||||
|
This is the **secondary** deployment path: a single [`cloud-init.yaml`](cloud-init.yaml)
|
||||||
|
user-data file that installs 3x-ui non-interactively on a fresh Ubuntu/Debian
|
||||||
|
VM and generates **unique random credentials per instance**. Use it when you do
|
||||||
|
not want to build a golden image — it works on any cloud-init platform.
|
||||||
|
|
||||||
|
> For AWS Marketplace / reusable images, use the Packer build in
|
||||||
|
> [`../packer/`](../packer/) instead.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. The VM boots a stock Ubuntu/Debian cloud image.
|
||||||
|
2. cloud-init writes and runs `/opt/xui-bootstrap.sh`, which exports
|
||||||
|
`XUI_NONINTERACTIVE=1` and pipes the project's `install.sh` into `bash`.
|
||||||
|
3. `install.sh` runs end-to-end with **zero prompts**, picking secure random
|
||||||
|
values for any credential you didn't pin.
|
||||||
|
4. The generated credentials are written to `/etc/x-ui/install-result.env`
|
||||||
|
(mode 600), echoed to the **serial console**, and appended to `/etc/motd`.
|
||||||
|
|
||||||
|
Retrieve them after boot with either:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cat /etc/x-ui/install-result.env # over SSH
|
||||||
|
```
|
||||||
|
|
||||||
|
…or read the provider's **serial console** output (handy before you have SSH).
|
||||||
|
|
||||||
|
## Customising
|
||||||
|
|
||||||
|
Edit the `export XUI_*` lines inside the `write_files` block of
|
||||||
|
[`cloud-init.yaml`](cloud-init.yaml). All knobs are optional; unset ⇒ random/secure default.
|
||||||
|
|
||||||
|
| Env var | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `XUI_SSL_MODE` | `none` | `none` (plain HTTP), `ip` (Let's Encrypt IP cert), `domain` |
|
||||||
|
| `XUI_USERNAME` | random | Admin username |
|
||||||
|
| `XUI_PASSWORD` | random | Admin password |
|
||||||
|
| `XUI_PANEL_PORT` | random high port | Panel listen port |
|
||||||
|
| `XUI_WEB_BASE_PATH` | random | Panel base path (obscures the URL) |
|
||||||
|
| `XUI_DOMAIN` | — | Required when `XUI_SSL_MODE=domain` |
|
||||||
|
| `XUI_ACME_EMAIL` | — | Let's Encrypt account email (domain mode) |
|
||||||
|
| `XUI_DB_TYPE` / `XUI_DB_DSN` | `sqlite` | Set `postgres` + DSN to use PostgreSQL |
|
||||||
|
|
||||||
|
> **TLS note:** `none` serves the panel over plain HTTP on a random high port —
|
||||||
|
> fine behind a reverse proxy or an SSH tunnel, but put TLS in front of it before
|
||||||
|
> exposing the panel publicly. `domain` mode needs a public DNS A record pointing
|
||||||
|
> at the box and port 80 reachable at install time.
|
||||||
|
|
||||||
|
## Per-provider usage
|
||||||
|
|
||||||
|
- **Hetzner Cloud** — *Create Server → Cloud config*: paste the file. Or CLI:
|
||||||
|
`hcloud server create --image ubuntu-24.04 --user-data-from-file cloud-init.yaml ...`
|
||||||
|
- **AWS EC2** — *Advanced details → User data*: paste the file. Or
|
||||||
|
`aws ec2 run-instances --user-data file://cloud-init.yaml ...`
|
||||||
|
(For a reusable Marketplace image use the Packer AMI build instead.)
|
||||||
|
- **DigitalOcean** — *Create Droplet → Advanced options → Add Initialization
|
||||||
|
scripts (user data)*: paste the file. Or `doctl compute droplet create --user-data-file cloud-init.yaml ...`
|
||||||
|
- **Vultr** — *Deploy → Additional Features → Cloud-Init User-Data*: paste the file.
|
||||||
|
- **Google Cloud (GCE)** — `gcloud compute instances create xui \
|
||||||
|
--image-family ubuntu-2404-lts-amd64 --image-project ubuntu-os-cloud \
|
||||||
|
--metadata-from-file user-data=cloud-init.yaml`
|
||||||
|
- **Azure** — `az vm create --image Ubuntu2404 --custom-data cloud-init.yaml ...`
|
||||||
|
- **Oracle Cloud (OCI)** — *Create Instance → Show advanced options →
|
||||||
|
Management → Cloud-init script*: paste (or base64-upload) the file.
|
||||||
|
|
||||||
|
## Validate before you deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloud-init schema --config-file deploy/cloud-init/cloud-init.yaml
|
||||||
|
```
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#cloud-config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Generic 3x-ui unattended install via cloud-init user-data.
|
||||||
|
#
|
||||||
|
# Works on any cloud-init platform: Hetzner, AWS, DigitalOcean, Vultr, GCP,
|
||||||
|
# Azure, Oracle. Paste the whole file as the instance "user data".
|
||||||
|
#
|
||||||
|
# It installs the latest 3x-ui release NON-INTERACTIVELY, generating unique
|
||||||
|
# random credentials per instance. Full credentials are surfaced ONLY on the
|
||||||
|
# serial console (owner-only); /etc/motd (world-readable) shows just the access
|
||||||
|
# URL + username. Nothing is baked in advance — every instance is unique.
|
||||||
|
#
|
||||||
|
# Requires the non-interactive install.sh (3x-ui with XUI_NONINTERACTIVE support).
|
||||||
|
# Edit the exported XUI_* knobs in /opt/xui-bootstrap.sh below to customise.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
package_update: true
|
||||||
|
package_upgrade: false
|
||||||
|
|
||||||
|
write_files:
|
||||||
|
- path: /opt/xui-bootstrap.sh
|
||||||
|
permissions: '0700'
|
||||||
|
owner: root:root
|
||||||
|
content: |
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# --- Non-interactive install knobs --------------------------------------
|
||||||
|
export XUI_NONINTERACTIVE=1
|
||||||
|
# SSL mode: none (plain HTTP, default) | ip | domain
|
||||||
|
export XUI_SSL_MODE="${XUI_SSL_MODE:-none}"
|
||||||
|
# Pin credentials instead of random (leave unset for secure random values):
|
||||||
|
# export XUI_USERNAME="admin2"
|
||||||
|
# export XUI_PASSWORD="change-me-please"
|
||||||
|
# export XUI_PANEL_PORT="2053"
|
||||||
|
# export XUI_WEB_BASE_PATH="panel"
|
||||||
|
# Let's Encrypt domain certificate instead of plain HTTP:
|
||||||
|
# export XUI_SSL_MODE="domain"
|
||||||
|
# export XUI_DOMAIN="panel.example.com"
|
||||||
|
# export XUI_ACME_EMAIL="you@example.com"
|
||||||
|
# PostgreSQL instead of SQLite:
|
||||||
|
# export XUI_DB_TYPE="postgres"
|
||||||
|
# export XUI_DB_DSN="postgres://user:pass@host:5432/db?sslmode=disable"
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
|
||||||
|
|
||||||
|
# Surface the generated credentials. Full creds (incl. password + API token)
|
||||||
|
# go ONLY to the serial console (/dev/console, owner-only). /etc/motd is
|
||||||
|
# world-readable, so it gets just the access URL + username and a pointer
|
||||||
|
# to the root-only env file.
|
||||||
|
if [ -r /etc/x-ui/install-result.env ]; then
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo "=== 3x-ui panel credentials (generated on first boot) ==="
|
||||||
|
cat /etc/x-ui/install-result.env
|
||||||
|
echo "========================================================"
|
||||||
|
echo "Change the password after first login."
|
||||||
|
} > /dev/console 2>/dev/null || true
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /etc/x-ui/install-result.env
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo "=== 3x-ui panel (generated on first boot) ==="
|
||||||
|
echo "URL: ${XUI_ACCESS_URL:-unknown}"
|
||||||
|
echo "Username: ${XUI_USERNAME:-unknown}"
|
||||||
|
echo "Password + API token: sudo cat /etc/x-ui/install-result.env"
|
||||||
|
echo "============================================="
|
||||||
|
echo "Change the password after first login."
|
||||||
|
} >> /etc/motd 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
runcmd:
|
||||||
|
- [bash, /opt/xui-bootstrap.sh]
|
||||||
|
|
||||||
|
final_message: "3x-ui installed — full credentials in /etc/x-ui/install-result.env (sudo); /etc/motd shows the URL + username only."
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=3x-ui first-boot per-instance credential generation
|
||||||
|
Documentation=https://github.com/MHSanaei/3x-ui
|
||||||
|
# Run after the network and cloud-init are up, but BEFORE the panel starts, so
|
||||||
|
# the panel never serves the default admin/admin account.
|
||||||
|
After=network-online.target cloud-init.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Before=x-ui.service
|
||||||
|
# Skip entirely once the sentinel exists (cheap guard; the script re-checks too).
|
||||||
|
ConditionPathExists=!/etc/x-ui/.firstboot-done
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
# Inherit the same DB configuration the panel uses (sqlite default / postgres).
|
||||||
|
EnvironmentFile=-/etc/default/x-ui
|
||||||
|
EnvironmentFile=-/etc/conf.d/x-ui
|
||||||
|
EnvironmentFile=-/etc/sysconfig/x-ui
|
||||||
|
ExecStart=/usr/local/x-ui/x-ui-firstboot.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# x-ui-firstboot.sh — generate per-instance 3x-ui panel credentials on first boot.
|
||||||
|
#
|
||||||
|
# A golden image (AMI / qcow2) MUST ship without an initialized x-ui.db: the
|
||||||
|
# panel seeds a hardcoded admin/admin user and generates its session secret +
|
||||||
|
# panel GUID on first start, so a baked DB would make every clone share the same
|
||||||
|
# credentials and secret. This script runs ONCE, before x-ui.service starts, and
|
||||||
|
# replaces the default admin with fresh random credentials on a random high port.
|
||||||
|
#
|
||||||
|
# Idempotent: a sentinel file guards against re-running. If a non-default admin
|
||||||
|
# already exists (operator pre-configured the box), regeneration is skipped.
|
||||||
|
#
|
||||||
|
# Wired up by deploy/packer/scripts/provision.sh; ordered Before=x-ui.service.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SENTINEL="/etc/x-ui/.firstboot-done"
|
||||||
|
CRED_FILE="/etc/x-ui/credentials.txt"
|
||||||
|
MOTD_FILE="/etc/motd"
|
||||||
|
XUI_DIR="${XUI_MAIN_FOLDER:-/usr/local/x-ui}"
|
||||||
|
XUI_BIN="${XUI_DIR}/x-ui"
|
||||||
|
|
||||||
|
log() { echo "[x-ui-firstboot] $*"; }
|
||||||
|
|
||||||
|
# Already provisioned — nothing to do (idempotent on re-run / re-image).
|
||||||
|
if [ -f "$SENTINEL" ]; then
|
||||||
|
log "sentinel $SENTINEL present; skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$XUI_BIN" ]; then
|
||||||
|
log "ERROR: x-ui binary not found at $XUI_BIN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Inherit DB configuration (sqlite default; postgres via XUI_DB_TYPE/XUI_DB_DSN)
|
||||||
|
# from the same env files the systemd unit loads, so the binary talks to the
|
||||||
|
# same database the panel will use.
|
||||||
|
for ef in /etc/default/x-ui /etc/conf.d/x-ui /etc/sysconfig/x-ui; do
|
||||||
|
if [ -r "$ef" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ef"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
install -d -m 755 /etc/x-ui 2> /dev/null || true
|
||||||
|
|
||||||
|
# Defense-in-depth: make sure the panel is not running while we mutate the DB.
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl stop x-ui > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
gen_random_string() {
|
||||||
|
local length="$1"
|
||||||
|
openssl rand -base64 $((length * 2)) | tr -dc 'a-zA-Z0-9' | head -c "$length"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Best-effort public IPv4 for the displayed access URL (cosmetic only — the
|
||||||
|
# panel binds 0.0.0.0). Falls back to the primary local IP, then a placeholder.
|
||||||
|
detect_ip() {
|
||||||
|
local ip=""
|
||||||
|
local url
|
||||||
|
for url in https://api4.ipify.org https://ipv4.icanhazip.com https://4.ident.me; do
|
||||||
|
ip=$(curl -fsS4 --max-time 3 "$url" 2> /dev/null | tr -d '[:space:]')
|
||||||
|
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "$ip"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ip=$(hostname -I 2> /dev/null | awk '{print $1}')
|
||||||
|
if [ -n "$ip" ]; then
|
||||||
|
echo "$ip"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "<server-ip>"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect whether the seeded admin/admin default is still in place.
|
||||||
|
default_creds=$("$XUI_BIN" setting -show true 2> /dev/null | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
|
||||||
|
|
||||||
|
# The parse MUST yield exactly "true" or "false". If the command failed or its
|
||||||
|
# output format changed, refuse to proceed: do NOT write the sentinel, so the
|
||||||
|
# next boot retries instead of silently leaving admin/admin in place.
|
||||||
|
if [ "$default_creds" != "true" ] && [ "$default_creds" != "false" ]; then
|
||||||
|
log "ERROR: could not determine credential state (hasDefaultCredential='${default_creds}'); not writing sentinel, will retry next boot."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$default_creds" = "false" ]; then
|
||||||
|
log "non-default admin already configured; skipping credential regeneration."
|
||||||
|
{
|
||||||
|
echo "3x-ui first-boot: a non-default admin account already exists on this"
|
||||||
|
echo "instance, so credentials were left unchanged."
|
||||||
|
} > "$MOTD_FILE" 2> /dev/null || true
|
||||||
|
: > "$SENTINEL" 2> /dev/null || true
|
||||||
|
chmod 600 "$SENTINEL" 2> /dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "generating per-instance credentials..."
|
||||||
|
|
||||||
|
NEW_USER="${XUI_USERNAME:-$(gen_random_string 10)}"
|
||||||
|
NEW_PASS="${XUI_PASSWORD:-$(gen_random_string 16)}"
|
||||||
|
NEW_PATH="${XUI_WEB_BASE_PATH:-$(gen_random_string 18)}"
|
||||||
|
NEW_PORT="${XUI_PANEL_PORT:-$(shuf -i 1024-62000 -n 1)}"
|
||||||
|
|
||||||
|
# Clean settings slate: drops any baked port/webBasePath and forces the panel
|
||||||
|
# to regenerate its session secret + panel GUID on next start (per-instance).
|
||||||
|
"$XUI_BIN" setting -reset > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Apply fresh random identity. UpdateFirstUser renames the seeded admin row and
|
||||||
|
# rehashes the password, so admin/admin no longer exists after this call.
|
||||||
|
if ! "$XUI_BIN" setting -username "$NEW_USER" -password "$NEW_PASS" -port "$NEW_PORT" -webBasePath "$NEW_PATH" > /dev/null 2>&1; then
|
||||||
|
log "ERROR: failed to apply new panel settings."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
API_TOKEN=$("$XUI_BIN" setting -getApiToken true 2> /dev/null | grep -Eo 'apiToken: .+' | awk '{print $2}')
|
||||||
|
SERVER_IP=$(detect_ip)
|
||||||
|
ACCESS_URL="http://${SERVER_IP}:${NEW_PORT}/${NEW_PATH}"
|
||||||
|
|
||||||
|
# Persist credentials for the operator (root-only). Values are shell-escaped
|
||||||
|
# with %q so the file stays safe to `source` even if a value contains shell
|
||||||
|
# metacharacters (the smoke test and operators source this file).
|
||||||
|
umask 077
|
||||||
|
{
|
||||||
|
echo "# 3x-ui per-instance credentials (generated on first boot)"
|
||||||
|
printf 'XUI_USERNAME=%q\n' "$NEW_USER"
|
||||||
|
printf 'XUI_PASSWORD=%q\n' "$NEW_PASS"
|
||||||
|
printf 'XUI_PANEL_PORT=%q\n' "$NEW_PORT"
|
||||||
|
printf 'XUI_WEB_BASE_PATH=%q\n' "$NEW_PATH"
|
||||||
|
printf 'XUI_ACCESS_URL=%q\n' "$ACCESS_URL"
|
||||||
|
printf 'XUI_API_TOKEN=%q\n' "$API_TOKEN"
|
||||||
|
} > "$CRED_FILE"
|
||||||
|
chmod 600 "$CRED_FILE" 2> /dev/null || true
|
||||||
|
|
||||||
|
# Friendly login banner shown on SSH / console before the panel is reachable.
|
||||||
|
# /etc/motd is world-readable, so it MUST NOT contain the password or API token;
|
||||||
|
# those secrets live only in ${CRED_FILE} (mode 600). Show non-secret info only.
|
||||||
|
cat > "$MOTD_FILE" 2> /dev/null << EOF
|
||||||
|
|
||||||
|
========================================================================
|
||||||
|
3x-ui panel — per-instance credentials (generated on first boot)
|
||||||
|
========================================================================
|
||||||
|
Access URL : ${ACCESS_URL}
|
||||||
|
Username : ${NEW_USER}
|
||||||
|
|
||||||
|
The password and API token are NOT shown here (this banner is
|
||||||
|
world-readable). Read them as root with:
|
||||||
|
sudo cat ${CRED_FILE}
|
||||||
|
|
||||||
|
Change the password after login. If no public IP is shown above,
|
||||||
|
replace <server-ip> with the address you reach this server on.
|
||||||
|
========================================================================
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Mark complete so we never regenerate on subsequent boots.
|
||||||
|
: > "$SENTINEL" 2> /dev/null || true
|
||||||
|
chmod 600 "$SENTINEL" 2> /dev/null || true
|
||||||
|
|
||||||
|
log "done. Panel will start on port ${NEW_PORT} with a unique admin account."
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 3x-ui on Amazon Lightsail
|
||||||
|
|
||||||
|
Two self-service ways to run 3x-ui on Lightsail, both producing **unique
|
||||||
|
per-instance credentials** (never `admin/admin`, never a shared secret).
|
||||||
|
|
||||||
|
> **Reality check.** The Lightsail *blueprint* list (WordPress, LAMP, GitLab…)
|
||||||
|
> is curated by AWS — you **cannot** self-publish your panel there, and Lightsail
|
||||||
|
> **cannot** launch from an arbitrary EC2 AMI. What you *can* do yourself is the
|
||||||
|
> two paths below. (For a public AWS listing you'd use the EC2 **AMI** +
|
||||||
|
> Marketplace path in [`../marketplace/aws/`](../marketplace/aws/), which is a
|
||||||
|
> different product from Lightsail.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path A — launch script (simplest, self-service)
|
||||||
|
|
||||||
|
Install on a fresh instance at creation time. No image to build.
|
||||||
|
|
||||||
|
1. **Create instance** → platform **Linux/Unix** → blueprint **OS Only → Ubuntu 24.04**.
|
||||||
|
2. **Add launch script** → paste [`launch-script.sh`](launch-script.sh).
|
||||||
|
3. Create the instance.
|
||||||
|
4. After it boots, read the credentials:
|
||||||
|
```bash
|
||||||
|
ssh ubuntu@<public-ip> 'sudo cat /etc/x-ui/install-result.env'
|
||||||
|
```
|
||||||
|
5. **Open the panel port** (see the firewall note below) and log in.
|
||||||
|
|
||||||
|
CLI equivalent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws lightsail create-instances \
|
||||||
|
--instance-names my-3xui \
|
||||||
|
--availability-zone eu-central-1a \
|
||||||
|
--blueprint-id ubuntu_24_04 \
|
||||||
|
--bundle-id small_3_0 \
|
||||||
|
--user-data file://deploy/lightsail/launch-script.sh \
|
||||||
|
--region eu-central-1
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the panel uses a **random** high port (in `install-result.env`). To
|
||||||
|
pin a known port so you can pre-open it, set `export XUI_PANEL_PORT=54321` inside
|
||||||
|
`launch-script.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path B — reusable snapshot (your own "ready image")
|
||||||
|
|
||||||
|
Build a Lightsail **snapshot** once; launch as many instances from it as you
|
||||||
|
like, each generating its own credentials on first boot (the golden-image model).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deploy/lightsail/build-snapshot.sh --region eu-central-1 --panel-port 54321
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does: launches a temporary Ubuntu instance with
|
||||||
|
[`snapshot-userdata.sh`](snapshot-userdata.sh) (installs the panel, **no DB**,
|
||||||
|
enables the first-boot unit), strips all state via the shared
|
||||||
|
[`cleanup.sh`](../packer/scripts/cleanup.sh), then snapshots and deletes the
|
||||||
|
build instance. Requires `awscli`, `jq`, `ssh` and Lightsail permissions.
|
||||||
|
|
||||||
|
Launch instances from the snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws lightsail create-instances-from-snapshot \
|
||||||
|
--instance-snapshot-name 3x-ui-ubuntu-24.04-<stamp> \
|
||||||
|
--instance-names my-3xui-1 --bundle-id small_3_0 \
|
||||||
|
--availability-zone eu-central-1a --region eu-central-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Each launched instance runs `x-ui-firstboot` and writes its unique credentials to
|
||||||
|
`/etc/x-ui/credentials.txt` + `/etc/motd`. With `--panel-port` the port is the
|
||||||
|
same across instances (only the credentials differ), so you can pre-open it.
|
||||||
|
|
||||||
|
> Lightsail snapshots are **private to your AWS account** (and region). To use one
|
||||||
|
> elsewhere you can export it to EC2 (`aws lightsail export-snapshot`) and share
|
||||||
|
> the resulting AMI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lightsail firewall note (important)
|
||||||
|
|
||||||
|
Lightsail's per-instance firewall only opens **22 / 80 / 443** by default. The
|
||||||
|
panel runs on a different port, so you must open it:
|
||||||
|
|
||||||
|
- Console: instance → **Networking → IPv4 Firewall → Add rule** (TCP, the panel port).
|
||||||
|
- CLI:
|
||||||
|
```bash
|
||||||
|
aws lightsail open-instance-public-ports --region eu-central-1 \
|
||||||
|
--instance-name my-3xui \
|
||||||
|
--port-info fromPort=54321,toPort=54321,protocol=TCP
|
||||||
|
```
|
||||||
|
|
||||||
|
The panel port is in `/etc/x-ui/install-result.env` (Path A) or
|
||||||
|
`/etc/x-ui/credentials.txt` (Path B), or fixed via `--panel-port` / `XUI_PANEL_PORT`.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# build-snapshot.sh — build a reusable Amazon Lightsail snapshot of 3x-ui.
|
||||||
|
#
|
||||||
|
# Flow (mirrors the Packer golden-image model, via the Lightsail API):
|
||||||
|
# 1. create an Ubuntu Lightsail instance with snapshot-userdata.sh
|
||||||
|
# (installs the panel, NO database, enables the first-boot unit)
|
||||||
|
# 2. wait for provisioning, then (optionally) pin a known panel port and run
|
||||||
|
# the shared cleanup.sh (wipes any DB/creds/keys/host-keys/cloud-init state)
|
||||||
|
# 3. stop the instance and create an instance snapshot
|
||||||
|
# 4. delete the build instance (unless --keep-instance)
|
||||||
|
#
|
||||||
|
# Every instance you later launch from the snapshot generates its OWN unique
|
||||||
|
# credentials on first boot (see deploy/firstboot/). The snapshot is private to
|
||||||
|
# your AWS account.
|
||||||
|
#
|
||||||
|
# Requirements: awscli v2, jq, ssh. AWS credentials with Lightsail permissions.
|
||||||
|
# Usage:
|
||||||
|
# deploy/lightsail/build-snapshot.sh --region eu-central-1 [options]
|
||||||
|
# Options:
|
||||||
|
# --region <r> AWS region (default: $AWS_REGION or eu-central-1)
|
||||||
|
# --blueprint-id <id> Lightsail blueprint (default: ubuntu_24_04)
|
||||||
|
# --bundle-id <id> Lightsail bundle/size (default: small_3_0)
|
||||||
|
# --availability-zone <z> AZ (default: <region>a)
|
||||||
|
# --panel-port <p> Pin the panel port in the snapshot so you can pre-open
|
||||||
|
# it in the Lightsail firewall (default: random per instance)
|
||||||
|
# --snapshot-name <n> Snapshot name (default: 3x-ui-ubuntu-24.04-<timestamp>)
|
||||||
|
# --keep-instance Do not delete the build instance afterwards
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGION="${AWS_REGION:-eu-central-1}"
|
||||||
|
BLUEPRINT="ubuntu_24_04"
|
||||||
|
BUNDLE="small_3_0"
|
||||||
|
AZ=""
|
||||||
|
PANEL_PORT=""
|
||||||
|
SNAPSHOT_NAME=""
|
||||||
|
KEEP_INSTANCE=0
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
INSTANCE_NAME="3xui-build-${STAMP}"
|
||||||
|
KEY_FILE=""
|
||||||
|
|
||||||
|
log() { echo "[build-snapshot] $*"; }
|
||||||
|
die() {
|
||||||
|
echo "[build-snapshot] ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--region) REGION="$2"; shift 2 ;;
|
||||||
|
--blueprint-id) BLUEPRINT="$2"; shift 2 ;;
|
||||||
|
--bundle-id) BUNDLE="$2"; shift 2 ;;
|
||||||
|
--availability-zone) AZ="$2"; shift 2 ;;
|
||||||
|
--panel-port) PANEL_PORT="$2"; shift 2 ;;
|
||||||
|
--snapshot-name) SNAPSHOT_NAME="$2"; shift 2 ;;
|
||||||
|
--keep-instance) KEEP_INSTANCE=1; shift ;;
|
||||||
|
-h | --help) sed -n '2,40p' "$0"; exit 0 ;;
|
||||||
|
*) die "unknown option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$AZ" ] || AZ="${REGION}a"
|
||||||
|
[ -n "$SNAPSHOT_NAME" ] || SNAPSHOT_NAME="3x-ui-ubuntu-24.04-${STAMP}"
|
||||||
|
|
||||||
|
for cmd in aws jq ssh; do
|
||||||
|
command -v "$cmd" > /dev/null 2>&1 || die "'$cmd' is required"
|
||||||
|
done
|
||||||
|
|
||||||
|
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[ -n "$KEY_FILE" ] && rm -f "$KEY_FILE"
|
||||||
|
if [ "$KEEP_INSTANCE" -eq 0 ]; then
|
||||||
|
aws lightsail delete-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
wait_state() {
|
||||||
|
local want="$1" tries="${2:-60}" st
|
||||||
|
for _ in $(seq 1 "$tries"); do
|
||||||
|
st=$(aws lightsail get-instance-state --instance-name "$INSTANCE_NAME" --region "$REGION" \
|
||||||
|
--query 'state.name' --output text 2> /dev/null || echo "")
|
||||||
|
[ "$st" = "$want" ] && return 0
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
log "creating build instance ${INSTANCE_NAME} (${BLUEPRINT}/${BUNDLE}) in ${REGION}..."
|
||||||
|
aws lightsail create-instances \
|
||||||
|
--instance-names "$INSTANCE_NAME" \
|
||||||
|
--availability-zone "$AZ" \
|
||||||
|
--blueprint-id "$BLUEPRINT" \
|
||||||
|
--bundle-id "$BUNDLE" \
|
||||||
|
--user-data "file://${SCRIPT_DIR}/snapshot-userdata.sh" \
|
||||||
|
--region "$REGION" > /dev/null
|
||||||
|
|
||||||
|
log "waiting for instance to run..."
|
||||||
|
wait_state running 60 || die "instance did not reach 'running'"
|
||||||
|
|
||||||
|
IP=$(aws lightsail get-instance --instance-name "$INSTANCE_NAME" --region "$REGION" \
|
||||||
|
--query 'instance.publicIpAddress' --output text)
|
||||||
|
if [ -z "$IP" ] || [ "$IP" = "None" ]; then die "no public IP"; fi
|
||||||
|
log "instance IP: ${IP}"
|
||||||
|
|
||||||
|
KEY_FILE="$(mktemp)"
|
||||||
|
# download-default-key-pair returns the key in 'privateKeyBase64'. Despite the
|
||||||
|
# name, the CLI historically emits the plaintext PEM (-----BEGIN...); the API
|
||||||
|
# docs describe it as base64. Handle both: write PEM as-is, else base64-decode.
|
||||||
|
KEY_RAW="$(aws lightsail download-default-key-pair --region "$REGION" \
|
||||||
|
--query 'privateKeyBase64' --output text)"
|
||||||
|
[ -n "$KEY_RAW" ] && [ "$KEY_RAW" != "None" ] || die "failed to download default key pair"
|
||||||
|
case "$KEY_RAW" in
|
||||||
|
*-----BEGIN*) printf '%s\n' "$KEY_RAW" > "$KEY_FILE" ;;
|
||||||
|
*) printf '%s' "$KEY_RAW" | base64 -d > "$KEY_FILE" 2> /dev/null \
|
||||||
|
|| die "private key is neither PEM nor valid base64" ;;
|
||||||
|
esac
|
||||||
|
grep -q -- "-----BEGIN" "$KEY_FILE" || die "downloaded key is not a valid PEM private key"
|
||||||
|
chmod 600 "$KEY_FILE"
|
||||||
|
|
||||||
|
log "waiting for provisioning to finish (this installs the panel)..."
|
||||||
|
ok=0
|
||||||
|
for _ in $(seq 1 72); do # ~12 min
|
||||||
|
if ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
|
||||||
|
'test -f /var/lib/3xui-provision-done' 2> /dev/null; then
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
[ "$ok" -eq 1 ] || die "provisioning did not complete in time"
|
||||||
|
log "provisioning complete."
|
||||||
|
|
||||||
|
if [ -n "$PANEL_PORT" ]; then
|
||||||
|
log "pinning panel port ${PANEL_PORT} (username/password stay random)..."
|
||||||
|
ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
|
||||||
|
"echo 'XUI_PANEL_PORT=${PANEL_PORT}' | sudo tee -a /etc/default/x-ui >/dev/null"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "stripping instance state (shared cleanup.sh)..."
|
||||||
|
ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
|
||||||
|
'curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/deploy/packer/scripts/cleanup.sh | sudo bash'
|
||||||
|
|
||||||
|
log "stopping instance..."
|
||||||
|
aws lightsail stop-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null
|
||||||
|
wait_state stopped 60 || die "instance did not stop"
|
||||||
|
|
||||||
|
log "creating snapshot ${SNAPSHOT_NAME}..."
|
||||||
|
aws lightsail create-instance-snapshot \
|
||||||
|
--instance-name "$INSTANCE_NAME" \
|
||||||
|
--instance-snapshot-name "$SNAPSHOT_NAME" \
|
||||||
|
--region "$REGION" > /dev/null
|
||||||
|
|
||||||
|
log "waiting for snapshot to become available..."
|
||||||
|
snap_ok=0
|
||||||
|
for _ in $(seq 1 120); do # ~20 min
|
||||||
|
state=$(aws lightsail get-instance-snapshot --instance-snapshot-name "$SNAPSHOT_NAME" \
|
||||||
|
--region "$REGION" --query 'instanceSnapshot.state' --output text 2> /dev/null || echo "")
|
||||||
|
[ "$state" = "available" ] && {
|
||||||
|
snap_ok=1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
[ "$snap_ok" -eq 1 ] || die "snapshot did not become available"
|
||||||
|
|
||||||
|
log "DONE."
|
||||||
|
echo
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Lightsail snapshot ready: ${SNAPSHOT_NAME} (region ${REGION})"
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Launch an instance from it:"
|
||||||
|
echo " aws lightsail create-instances-from-snapshot \\"
|
||||||
|
echo " --instance-snapshot-name ${SNAPSHOT_NAME} \\"
|
||||||
|
echo " --instance-names my-3xui-1 --bundle-id ${BUNDLE} \\"
|
||||||
|
echo " --availability-zone ${AZ} --region ${REGION}"
|
||||||
|
if [ -n "$PANEL_PORT" ]; then
|
||||||
|
echo
|
||||||
|
echo " Then open the panel port (pinned to ${PANEL_PORT}):"
|
||||||
|
echo " aws lightsail open-instance-public-ports --region ${REGION} \\"
|
||||||
|
echo " --instance-name my-3xui-1 \\"
|
||||||
|
echo " --port-info fromPort=${PANEL_PORT},toPort=${PANEL_PORT},protocol=TCP"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo " Each instance picks a RANDOM panel port. After it boots, read it from"
|
||||||
|
echo " sudo cat /etc/x-ui/credentials.txt"
|
||||||
|
echo " and open that TCP port in the instance's Lightsail IPv4 firewall."
|
||||||
|
fi
|
||||||
|
echo "================================================================"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Amazon Lightsail launch script for 3x-ui (self-service, per-instance creds).
|
||||||
|
#
|
||||||
|
# Use it one of two ways when creating an Ubuntu 24.04 Lightsail instance:
|
||||||
|
# * Console: "Add launch script" -> paste this file.
|
||||||
|
# * CLI: aws lightsail create-instances --user-data file://launch-script.sh ...
|
||||||
|
#
|
||||||
|
# It installs the latest 3x-ui release non-interactively and generates unique
|
||||||
|
# random credentials for THIS instance. The full credentials land in
|
||||||
|
# /etc/x-ui/install-result.env (mode 600); /etc/motd shows only the URL + username.
|
||||||
|
#
|
||||||
|
# IMPORTANT (Lightsail firewall): Lightsail only opens 22/80/443 by default. The
|
||||||
|
# panel listens on a random high port, so after boot read the port from
|
||||||
|
# /etc/x-ui/install-result.env and open it under the instance's Networking tab
|
||||||
|
# (IPv4 Firewall), or pin a known port below and pre-open it.
|
||||||
|
set -e
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# --- Non-interactive install knobs ------------------------------------------
|
||||||
|
export XUI_NONINTERACTIVE=1
|
||||||
|
export XUI_SSL_MODE="${XUI_SSL_MODE:-none}"
|
||||||
|
# Pin a known panel port so you can pre-open it in the Lightsail firewall
|
||||||
|
# (otherwise a random high port is chosen). Username/password stay random:
|
||||||
|
# export XUI_PANEL_PORT="54321"
|
||||||
|
# Other optional pins (unset => secure random):
|
||||||
|
# export XUI_USERNAME="admin2"
|
||||||
|
# export XUI_PASSWORD="change-me"
|
||||||
|
# export XUI_WEB_BASE_PATH="panel"
|
||||||
|
# Domain TLS instead of plain HTTP:
|
||||||
|
# export XUI_SSL_MODE="domain" XUI_DOMAIN="panel.example.com" XUI_ACME_EMAIL="you@example.com"
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
|
||||||
|
|
||||||
|
# /etc/motd is world-readable, so it gets ONLY non-secret info (URL + username);
|
||||||
|
# the full credentials stay in the root-only /etc/x-ui/install-result.env
|
||||||
|
# (mode 600) — read them with `sudo cat` over SSH.
|
||||||
|
if [ -r /etc/x-ui/install-result.env ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /etc/x-ui/install-result.env
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo "=== 3x-ui panel (generated on first boot) ==="
|
||||||
|
echo "URL: ${XUI_ACCESS_URL:-unknown}"
|
||||||
|
echo "Username: ${XUI_USERNAME:-unknown}"
|
||||||
|
echo "Password + API token: sudo cat /etc/x-ui/install-result.env"
|
||||||
|
echo "Open the panel port in the Lightsail IPv4 firewall, then log in."
|
||||||
|
echo "============================================="
|
||||||
|
} >> /etc/motd 2>/dev/null || true
|
||||||
|
fi
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Lightsail snapshot provisioning user-data (used by build-snapshot.sh).
|
||||||
|
#
|
||||||
|
# Installs the 3x-ui panel into a build instance but creates NO database and
|
||||||
|
# NO credentials, and enables the first-boot unit. The instance is then snapshot
|
||||||
|
# so that every instance launched from the snapshot generates its own unique
|
||||||
|
# credentials on first boot (see deploy/firstboot/).
|
||||||
|
#
|
||||||
|
# This is the Lightsail equivalent of deploy/packer/scripts/provision.sh. It is
|
||||||
|
# NOT for end users — use deploy/lightsail/launch-script.sh for a direct install.
|
||||||
|
set -e
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
REPO=MHSanaei/3x-ui
|
||||||
|
XUI_DIR=/usr/local/x-ui
|
||||||
|
RAW="https://raw.githubusercontent.com/${REPO}/main"
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl tar tzdata socat openssl cron jq
|
||||||
|
|
||||||
|
ARCH=$(dpkg --print-architecture) # amd64 | arm64
|
||||||
|
VER=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
|
||||||
|
if [ -z "$VER" ] || [ "$VER" = "null" ]; then
|
||||||
|
echo "failed to resolve 3x-ui version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
curl -fL4 --retry 3 -o "${tmp}/x.tar.gz" \
|
||||||
|
"https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"
|
||||||
|
|
||||||
|
systemctl stop x-ui > /dev/null 2>&1 || true
|
||||||
|
rm -rf "$XUI_DIR"
|
||||||
|
tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
|
||||||
|
chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh"
|
||||||
|
chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true
|
||||||
|
cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui
|
||||||
|
chmod +x /usr/bin/x-ui
|
||||||
|
mkdir -p /var/log/x-ui
|
||||||
|
|
||||||
|
# Panel + first-boot systemd units.
|
||||||
|
install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service
|
||||||
|
curl -fL4 -o "${XUI_DIR}/x-ui-firstboot.sh" "${RAW}/deploy/firstboot/x-ui-firstboot.sh"
|
||||||
|
curl -fL4 -o /etc/systemd/system/x-ui-firstboot.service "${RAW}/deploy/firstboot/x-ui-firstboot.service"
|
||||||
|
chmod 755 "${XUI_DIR}/x-ui-firstboot.sh"
|
||||||
|
chmod 644 /etc/systemd/system/x-ui-firstboot.service
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable x-ui-firstboot.service
|
||||||
|
systemctl enable x-ui.service
|
||||||
|
|
||||||
|
# No DB, no creds in the image — first boot generates them per-instance.
|
||||||
|
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true
|
||||||
|
|
||||||
|
# Marker that build-snapshot.sh polls for over SSH.
|
||||||
|
touch /var/lib/3xui-provision-done
|
||||||
|
echo "[snapshot-userdata] provisioned 3x-ui ${VER} (${ARCH}); no DB created."
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Publishing 3x-ui to the AWS Marketplace (AMI)
|
||||||
|
|
||||||
|
This is the checklist for turning the Packer-built AMI into an AWS Marketplace
|
||||||
|
listing. It assumes you have already built an AMI with
|
||||||
|
[`../../packer/`](../../packer/) (locally or via `.github/workflows/image.yml`).
|
||||||
|
|
||||||
|
> Do **not** commit AMI IDs, AWS account numbers, or credentials. The AMI ID is
|
||||||
|
> printed to the workflow job summary at build time.
|
||||||
|
|
||||||
|
## 1. Seller registration (one-time)
|
||||||
|
|
||||||
|
1. Sign in to the [AWS Marketplace Management Portal](https://aws.amazon.com/marketplace/management/)
|
||||||
|
with the AWS account that will own the listing.
|
||||||
|
2. Complete **seller registration** (legal entity, bank, tax interview). Required
|
||||||
|
before any product can be submitted.
|
||||||
|
|
||||||
|
## 2. Build a compliant AMI
|
||||||
|
|
||||||
|
Build in the seller account (or share the AMI into it):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/packer
|
||||||
|
packer init .
|
||||||
|
# amd64
|
||||||
|
packer build -only='amazon-ebs.x-ui' \
|
||||||
|
-var 'xui_version=vX.Y.Z' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' .
|
||||||
|
# arm64 (Graviton)
|
||||||
|
packer build -only='amazon-ebs.x-ui' \
|
||||||
|
-var 'xui_version=vX.Y.Z' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' .
|
||||||
|
```
|
||||||
|
|
||||||
|
You can list both AMIs (amd64 + arm64) as architectures of a single Marketplace
|
||||||
|
product, or as separate products.
|
||||||
|
|
||||||
|
The image already satisfies the Marketplace AMI policies enforced by `harden.sh`
|
||||||
|
+ `cleanup.sh`:
|
||||||
|
|
||||||
|
- ✅ `PasswordAuthentication no`, `PermitRootLogin prohibit-password`
|
||||||
|
- ✅ no default OS account passwords (all locked)
|
||||||
|
- ✅ no baked `authorized_keys`, no SSH host keys (regenerated on boot)
|
||||||
|
- ✅ base OS = current Ubuntu 24.04 LTS, patched at build time
|
||||||
|
- ✅ no application default credentials — the panel admin is generated on first
|
||||||
|
boot on a random high port (no `admin/admin`, no shipped `x-ui.db`)
|
||||||
|
|
||||||
|
## 3. Run the self-service AMI scan
|
||||||
|
|
||||||
|
1. In the Management Portal: **Server products → AMIs → Upload/scan an AMI**.
|
||||||
|
2. Share the AMI with the AWS Marketplace scanning account when prompted
|
||||||
|
(the portal gives you the exact account id and the `modify-image-attribute`
|
||||||
|
command, or share it from the EC2 console).
|
||||||
|
3. Start the scan. It checks SSH config, default credentials, open ports, and
|
||||||
|
for malware. Fix any finding and re-scan.
|
||||||
|
|
||||||
|
Common scan findings and where they're handled:
|
||||||
|
|
||||||
|
| Finding | Fix (already in the build) |
|
||||||
|
| --- | --- |
|
||||||
|
| Password authentication enabled | `harden.sh` sshd drop-in |
|
||||||
|
| Root login with password | `harden.sh` `PermitRootLogin prohibit-password` |
|
||||||
|
| Default user password set | `harden.sh` `passwd -l` on all accounts |
|
||||||
|
| Authorized keys present | `cleanup.sh` removes them |
|
||||||
|
| Out-of-date packages | base image is the latest LTS; `provision.sh` runs `apt-get update` |
|
||||||
|
|
||||||
|
## 4. Create the product (limited / private first)
|
||||||
|
|
||||||
|
1. **Server products → Create new product → AMI** (or AMI + CloudFormation).
|
||||||
|
2. Add title, description, categories, pricing (free or paid), regions, the AMI
|
||||||
|
id, recommended instance types, and the **usage instructions** (tell buyers
|
||||||
|
to read `/etc/x-ui/credentials.txt` / MOTD after first boot for the generated
|
||||||
|
admin login, then change the password).
|
||||||
|
3. Submit as a **Limited** (private) listing first. AWS publishes it with
|
||||||
|
restricted visibility so only your account / allow-listed accounts see it.
|
||||||
|
|
||||||
|
## 5. Preview & launch test
|
||||||
|
|
||||||
|
1. From the limited listing, **subscribe and launch** a test instance.
|
||||||
|
2. SSH in, `sudo cat /etc/x-ui/credentials.txt`, open the panel URL, log in,
|
||||||
|
confirm the panel works and the credentials are unique to that instance.
|
||||||
|
3. Launch a second instance and confirm its credentials differ (no shared
|
||||||
|
secrets).
|
||||||
|
|
||||||
|
## 6. Go public
|
||||||
|
|
||||||
|
1. Once the scan passes and the preview looks correct, request **public
|
||||||
|
visibility** (move from Limited to Public) in the listing.
|
||||||
|
2. AWS does a final review before the listing goes live.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- AWS Marketplace seller guide: <https://docs.aws.amazon.com/marketplace/latest/userguide/>
|
||||||
|
- AMI-based product requirements: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html>
|
||||||
|
- Self-service AMI scanning: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-submission.html>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 3x-ui on Hetzner Cloud
|
||||||
|
|
||||||
|
Hetzner Cloud does **not** have a third-party image marketplace the way AWS does.
|
||||||
|
There are two practical ways to ship 3x-ui on Hetzner.
|
||||||
|
|
||||||
|
## Option A — cloud-init (recommended, no image build)
|
||||||
|
|
||||||
|
Use the generic user-data from [`../../cloud-init/`](../../cloud-init/). It installs
|
||||||
|
3x-ui non-interactively and generates unique per-instance credentials.
|
||||||
|
|
||||||
|
Web console: **Create Server → Cloud config** → paste
|
||||||
|
[`deploy/cloud-init/cloud-init.yaml`](../../cloud-init/cloud-init.yaml).
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hcloud server create \
|
||||||
|
--name xui-1 \
|
||||||
|
--type cx22 \
|
||||||
|
--image ubuntu-24.04 \
|
||||||
|
--user-data-from-file deploy/cloud-init/cloud-init.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
After boot, fetch the generated credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@<server-ip> 'cat /etc/x-ui/install-result.env'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option B — snapshot from the qcow2 / a configured server
|
||||||
|
|
||||||
|
Hetzner lets you create a **snapshot** of a running server and launch new
|
||||||
|
servers from it. Two ways to get there:
|
||||||
|
|
||||||
|
1. **From the Packer qcow2:** Hetzner does not allow direct qcow2 upload via the
|
||||||
|
normal API, but you can boot a server, write the image to its disk in rescue
|
||||||
|
mode, then take a snapshot — or simply use Option A, which needs no image.
|
||||||
|
2. **From a configured server:** spin up a server, install via cloud-init
|
||||||
|
(Option A), verify, then **delete `/etc/x-ui/x-ui.db` and the first-boot
|
||||||
|
sentinel** before snapshotting so clones regenerate their own credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl stop x-ui
|
||||||
|
rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done /etc/x-ui/credentials.txt
|
||||||
|
# re-enable first-boot regeneration if you installed via Packer:
|
||||||
|
systemctl enable x-ui-firstboot 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ If you snapshot a server **with** its `x-ui.db`, every clone shares the
|
||||||
|
> same admin credentials and session secret. Always remove the DB first.
|
||||||
|
|
||||||
|
## "App"-style listing
|
||||||
|
|
||||||
|
Hetzner's curated apps live in the community repo
|
||||||
|
[`github.com/hetznercloud/apps`](https://github.com/hetznercloud/apps): each app
|
||||||
|
is essentially a documented cloud-init config plus metadata. To propose 3x-ui as
|
||||||
|
a Hetzner app, follow that repo's contribution pattern and base the app's
|
||||||
|
cloud-config on [`deploy/cloud-init/cloud-init.yaml`](../../cloud-init/cloud-init.yaml).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Packer build artifacts (never commit images or manifests)
|
||||||
|
output-qemu/
|
||||||
|
*.qcow2
|
||||||
|
*.raw
|
||||||
|
packer-manifest.json
|
||||||
|
packer_cache/
|
||||||
|
crash.log
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# 3x-ui golden image (Packer)
|
||||||
|
|
||||||
|
Builds a cloud image with the 3x-ui panel pre-installed but **not configured**:
|
||||||
|
the image ships with **no database and no credentials**, and generates a unique
|
||||||
|
admin account on first boot. This is the **primary** path for AWS Marketplace
|
||||||
|
and any reusable image.
|
||||||
|
|
||||||
|
Two sources, one build:
|
||||||
|
|
||||||
|
| Source | Output | For |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `amazon-ebs` | AWS AMI | AWS / Marketplace |
|
||||||
|
| `qemu` | `qcow2` (+ `raw`) | Hetzner, DigitalOcean, Vultr, GCP, Azure, Oracle, bare metal |
|
||||||
|
|
||||||
|
Both sources build for **`amd64` and `arm64`** (select with `-var xui_arch=...`).
|
||||||
|
|
||||||
|
## Why no baked DB
|
||||||
|
|
||||||
|
3x-ui seeds a hardcoded `admin/admin` user and generates its session secret +
|
||||||
|
panel GUID the first time it starts. If an image shipped an initialized
|
||||||
|
`x-ui.db`, **every clone would share the same credentials and secret**. So the
|
||||||
|
build deliberately:
|
||||||
|
|
||||||
|
- installs the panel binary + systemd unit but **never starts it** and **never
|
||||||
|
creates a DB** (`scripts/provision.sh`);
|
||||||
|
- wipes any stray DB/credentials/host-keys at the end (`scripts/cleanup.sh`);
|
||||||
|
- enables `x-ui-firstboot.service`, which on first boot resets settings, sets a
|
||||||
|
random username/password on a random high port, regenerates the secret/GUID,
|
||||||
|
and writes the credentials to `/etc/x-ui/credentials.txt` + `/etc/motd`
|
||||||
|
(`deploy/firstboot/`).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Packer](https://developer.hashicorp.com/packer) ≥ 1.9
|
||||||
|
- For `qemu` amd64: `qemu-system-x86`, `qemu-utils` (and `/dev/kvm` for acceptable speed)
|
||||||
|
- For `qemu` arm64: `qemu-system-arm`, `qemu-efi-aarch64`, `qemu-utils` — best built on an
|
||||||
|
arm64 host (native KVM); cross-building from x86 works but uses slow TCG emulation
|
||||||
|
- For `amazon-ebs`: AWS credentials with EC2 build permissions (arm64 builds on a Graviton
|
||||||
|
instance such as `t4g.small`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/packer
|
||||||
|
packer init .
|
||||||
|
packer fmt -check . # formatting
|
||||||
|
packer validate . # both sources
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Build a specific release (recommended) or `latest`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# amd64 qcow2 (no cloud account needed)
|
||||||
|
packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=amd64' .
|
||||||
|
|
||||||
|
# arm64 qcow2 (run on an arm64 host for native KVM)
|
||||||
|
packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=arm64' .
|
||||||
|
|
||||||
|
# amd64 AWS AMI
|
||||||
|
packer build -only='amazon-ebs.x-ui' \
|
||||||
|
-var 'xui_version=v3.3.1' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' .
|
||||||
|
|
||||||
|
# arm64 AWS AMI (Graviton)
|
||||||
|
packer build -only='amazon-ebs.x-ui' \
|
||||||
|
-var 'xui_version=v3.3.1' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' .
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs (per arch):
|
||||||
|
- `output-qemu/3x-ui-ubuntu-24.04-<arch>.qcow2` and `.raw`
|
||||||
|
- the AMI id (also recorded in `packer-manifest.json`)
|
||||||
|
|
||||||
|
If `/dev/kvm` is unavailable, add `-var 'qemu_accelerator=tcg'` (much slower).
|
||||||
|
|
||||||
|
## Key variables
|
||||||
|
|
||||||
|
See [`variables.pkr.hcl`](variables.pkr.hcl) for the full list.
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `xui_version` | `latest` | Release tag to install, e.g. `v3.3.1` |
|
||||||
|
| `xui_arch` | `amd64` | `amd64` or `arm64` (derives the base AMI / cloud image) |
|
||||||
|
| `region` | `eu-central-1` | AWS region (amazon-ebs) |
|
||||||
|
| `instance_type` | `t3.small` | EC2 build instance — must match the arch (`t4g.small` for arm64) |
|
||||||
|
| `qemu_accelerator` | `kvm` | `kvm` or `tcg` |
|
||||||
|
| `qemu_cpu` | `host` | arm64 `-cpu` model (`host` with KVM, `max` for TCG) |
|
||||||
|
| `ubuntu_version` | `24.04` | Base Ubuntu LTS (naming/tags) |
|
||||||
|
|
||||||
|
The CI workflow builds both arches automatically: amd64 qcow2 on a standard runner,
|
||||||
|
arm64 qcow2 on a native `ubuntu-24.04-arm` runner, and both AMIs from a single runner
|
||||||
|
(the build instance runs in AWS).
|
||||||
|
|
||||||
|
## First boot
|
||||||
|
|
||||||
|
On the first boot of any instance launched from the image:
|
||||||
|
|
||||||
|
1. `x-ui-firstboot.service` runs **before** `x-ui.service`.
|
||||||
|
2. It generates a unique admin username/password, a random panel port, a random
|
||||||
|
base path, and an API token.
|
||||||
|
3. Credentials are written to `/etc/x-ui/credentials.txt` (root-only) and shown
|
||||||
|
in `/etc/motd`. Retrieve them with `sudo cat /etc/x-ui/credentials.txt`.
|
||||||
|
4. The panel then starts on the random port. `admin/admin` never exists.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.github/workflows/image.yml` runs this build on `release: published` (and via
|
||||||
|
`workflow_dispatch`), attaching the compressed `qcow2` to the release and
|
||||||
|
building the AMI when AWS credentials are configured.
|
||||||
|
|
||||||
|
## A note on host firewalls
|
||||||
|
|
||||||
|
`scripts/harden.sh` intentionally does **not** enable a restrictive host
|
||||||
|
firewall. 3x-ui opens Xray inbound ports on admin-chosen ports at runtime, which
|
||||||
|
a host firewall would block. Use your cloud provider's security groups/firewall
|
||||||
|
instead, and open the panel port + your inbound ports there. If you still want a
|
||||||
|
host firewall, add `ufw` rules in `harden.sh` allowing SSH, the panel port and
|
||||||
|
your inbound ports.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# cleanup.sh — strip all instance-specific state and secrets from the image.
|
||||||
|
#
|
||||||
|
# Runs LAST. The output image must contain no panel database, no credentials,
|
||||||
|
# no SSH host keys, and no baked authorized_keys. Fails the build if any of
|
||||||
|
# those survive.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "[cleanup] removing panel database, credentials and first-boot sentinel..."
|
||||||
|
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* 2> /dev/null || true
|
||||||
|
rm -f /etc/x-ui/install-result.env /etc/x-ui/credentials.txt 2> /dev/null || true
|
||||||
|
rm -f /etc/x-ui/.firstboot-done 2> /dev/null || true
|
||||||
|
|
||||||
|
echo "[cleanup] removing SSH host keys (regenerated on first boot)..."
|
||||||
|
rm -f /etc/ssh/ssh_host_* 2> /dev/null || true
|
||||||
|
|
||||||
|
echo "[cleanup] removing any baked authorized_keys..."
|
||||||
|
rm -f /root/.ssh/authorized_keys 2> /dev/null || true
|
||||||
|
find /home -maxdepth 3 -name authorized_keys -type f -delete 2> /dev/null || true
|
||||||
|
|
||||||
|
echo "[cleanup] resetting machine-id..."
|
||||||
|
truncate -s 0 /etc/machine-id 2> /dev/null || true
|
||||||
|
rm -f /var/lib/dbus/machine-id 2> /dev/null || true
|
||||||
|
ln -sf /etc/machine-id /var/lib/dbus/machine-id 2> /dev/null || true
|
||||||
|
|
||||||
|
echo "[cleanup] resetting cloud-init so it re-runs on the real first boot..."
|
||||||
|
cloud-init clean --logs --seed > /dev/null 2>&1 || rm -rf /var/lib/cloud/* 2> /dev/null || true
|
||||||
|
|
||||||
|
echo "[cleanup] truncating logs, history and package caches..."
|
||||||
|
find /var/log -type f -exec truncate -s 0 {} + 2> /dev/null || true
|
||||||
|
rm -rf /var/lib/x-ui /var/log/x-ui/* 2> /dev/null || true
|
||||||
|
apt-get clean || true
|
||||||
|
rm -rf /var/lib/apt/lists/* 2> /dev/null || true
|
||||||
|
rm -f /root/.bash_history 2> /dev/null || true
|
||||||
|
find /home -maxdepth 3 -name .bash_history -type f -delete 2> /dev/null || true
|
||||||
|
rm -rf /tmp/firstboot 2> /dev/null || true
|
||||||
|
|
||||||
|
echo "[cleanup] verifying the image is clean..."
|
||||||
|
fail=0
|
||||||
|
for f in /etc/x-ui/x-ui.db /etc/x-ui/credentials.txt /etc/x-ui/install-result.env /etc/x-ui/.firstboot-done; do
|
||||||
|
if [ -e "$f" ]; then
|
||||||
|
echo "[cleanup] FATAL: $f is present in the image" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ls /etc/ssh/ssh_host_* > /dev/null 2>&1; then
|
||||||
|
echo "[cleanup] FATAL: SSH host keys present in the image" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [ -e /root/.ssh/authorized_keys ]; then
|
||||||
|
echo "[cleanup] FATAL: /root/.ssh/authorized_keys present in the image" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [ "$fail" -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[cleanup] OK — no DB, no credentials, no host keys, no authorized_keys."
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# harden.sh — baseline OS hardening for AWS Marketplace AMI scanner compliance.
|
||||||
|
#
|
||||||
|
# Focus: the controls the scanner actually checks — key-only SSH, no root
|
||||||
|
# password login, and no default OS account passwords. A restrictive host
|
||||||
|
# firewall is intentionally NOT enforced by default because 3x-ui opens Xray
|
||||||
|
# inbound ports on admin-chosen ports at runtime (see README for the rationale
|
||||||
|
# and how to add ufw rules if you want them).
|
||||||
|
set -euo pipefail
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
echo "[harden] applying SSH hardening..."
|
||||||
|
install -d -m 755 /etc/ssh/sshd_config.d
|
||||||
|
cat > /etc/ssh/sshd_config.d/99-3xui-hardening.conf << 'EOF'
|
||||||
|
# 3x-ui golden image hardening (AWS Marketplace scanner compliance)
|
||||||
|
PasswordAuthentication no
|
||||||
|
PermitRootLogin prohibit-password
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
ChallengeResponseAuthentication no
|
||||||
|
EOF
|
||||||
|
chmod 644 /etc/ssh/sshd_config.d/99-3xui-hardening.conf
|
||||||
|
|
||||||
|
echo "[harden] locking passwords on default OS accounts..."
|
||||||
|
# No account may ship with a usable password. Keys are provisioned per-instance
|
||||||
|
# by the cloud platform (EC2 metadata / cloud-init) on first boot.
|
||||||
|
# passwd -l locks the PASSWORD only; key-based login keeps working.
|
||||||
|
for u in root ubuntu admin; do
|
||||||
|
if id "$u" > /dev/null 2>&1; then
|
||||||
|
passwd -l "$u" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[harden] enabling automatic security updates..."
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends unattended-upgrades
|
||||||
|
systemctl enable unattended-upgrades > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "[harden] done."
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# provision.sh — install the 3x-ui panel into a golden image (Packer).
|
||||||
|
#
|
||||||
|
# Self-contained: mirrors install.sh's download/extract logic but DELIBERATELY
|
||||||
|
# does NOT run config_after_install and does NOT create a database. The image
|
||||||
|
# must ship without /etc/x-ui/x-ui.db so that deploy/firstboot generates unique
|
||||||
|
# per-instance credentials on first boot. Both x-ui.service and
|
||||||
|
# x-ui-firstboot.service are enabled but NOT started here.
|
||||||
|
#
|
||||||
|
# Inputs (from Packer environment_vars):
|
||||||
|
# XUI_VERSION release tag (e.g. v3.3.1) or 'latest'
|
||||||
|
# XUI_ARCH amd64 (default) or arm64
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
XUI_VERSION="${XUI_VERSION:-latest}"
|
||||||
|
XUI_ARCH="${XUI_ARCH:-amd64}"
|
||||||
|
XUI_DIR="/usr/local/x-ui"
|
||||||
|
REPO="MHSanaei/3x-ui"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
echo "[provision] installing base packages..."
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl tar tzdata socat openssl cron jq
|
||||||
|
|
||||||
|
echo "[provision] resolving 3x-ui version..."
|
||||||
|
if [ "$XUI_VERSION" = "latest" ]; then
|
||||||
|
XUI_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name')
|
||||||
|
fi
|
||||||
|
if [ -z "$XUI_VERSION" ] || [ "$XUI_VERSION" = "null" ]; then
|
||||||
|
echo "[provision] ERROR: could not resolve 3x-ui release tag" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[provision] installing 3x-ui ${XUI_VERSION} (${XUI_ARCH})"
|
||||||
|
|
||||||
|
tarball="x-ui-linux-${XUI_ARCH}.tar.gz"
|
||||||
|
url="https://github.com/${REPO}/releases/download/${XUI_VERSION}/${tarball}"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
# Download the RELEASED binary tarball (no Go build inside the image).
|
||||||
|
curl -fL4 --retry 3 -o "${tmp}/${tarball}" "$url"
|
||||||
|
|
||||||
|
# Extract into /usr/local/ (the tarball contains an x-ui/ directory).
|
||||||
|
systemctl stop x-ui > /dev/null 2>&1 || true
|
||||||
|
rm -rf "$XUI_DIR"
|
||||||
|
tar -xzf "${tmp}/${tarball}" -C /usr/local/
|
||||||
|
chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh"
|
||||||
|
chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true
|
||||||
|
|
||||||
|
# Install the x-ui management CLI.
|
||||||
|
if [ -f "${XUI_DIR}/x-ui.sh" ]; then
|
||||||
|
cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui
|
||||||
|
else
|
||||||
|
curl -fL4 -o /usr/bin/x-ui "https://raw.githubusercontent.com/${REPO}/main/x-ui.sh"
|
||||||
|
fi
|
||||||
|
chmod +x /usr/bin/x-ui
|
||||||
|
mkdir -p /var/log/x-ui
|
||||||
|
|
||||||
|
# Panel systemd unit (Ubuntu base => debian variant).
|
||||||
|
install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service
|
||||||
|
|
||||||
|
# First-boot per-instance credential unit + script (uploaded to /tmp/firstboot).
|
||||||
|
install -m 755 /tmp/firstboot/x-ui-firstboot.sh "${XUI_DIR}/x-ui-firstboot.sh"
|
||||||
|
install -m 644 /tmp/firstboot/x-ui-firstboot.service /etc/systemd/system/x-ui-firstboot.service
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
# Enable (start on next boot) but do NOT start now — there is no DB yet.
|
||||||
|
systemctl enable x-ui-firstboot.service
|
||||||
|
systemctl enable x-ui.service
|
||||||
|
|
||||||
|
# Belt-and-braces: ensure no DB / sentinel was created during provisioning.
|
||||||
|
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true
|
||||||
|
|
||||||
|
echo "[provision] done — panel installed, services enabled, NO database initialized."
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Input variables for the 3x-ui golden-image build.
|
||||||
|
// See README.md for usage. Override with -var / -var-file or env (PKR_VAR_*).
|
||||||
|
|
||||||
|
variable "xui_version" {
|
||||||
|
type = string
|
||||||
|
description = "3x-ui release tag to install, e.g. v3.3.1. 'latest' resolves the newest GitHub release at build time."
|
||||||
|
default = "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "xui_arch" {
|
||||||
|
type = string
|
||||||
|
description = "CPU architecture to build for: amd64 or arm64."
|
||||||
|
default = "amd64"
|
||||||
|
validation {
|
||||||
|
condition = contains(["amd64", "arm64"], var.xui_arch)
|
||||||
|
error_message = "The xui_arch value must be 'amd64' or 'arm64'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ubuntu_version" {
|
||||||
|
type = string
|
||||||
|
description = "Ubuntu LTS version label, used only for image naming/tags."
|
||||||
|
default = "24.04"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- amazon-ebs (AMI) ---------------------------------------------------------
|
||||||
|
|
||||||
|
variable "region" {
|
||||||
|
type = string
|
||||||
|
description = "AWS region the AMI is built in."
|
||||||
|
default = "eu-central-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "instance_type" {
|
||||||
|
type = string
|
||||||
|
description = "EC2 instance type used to build the AMI. Must match xui_arch (e.g. t3.small for amd64, t4g.small for arm64/Graviton)."
|
||||||
|
default = "t3.small"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ami_name_prefix" {
|
||||||
|
type = string
|
||||||
|
description = "Prefix for the produced AMI name."
|
||||||
|
default = "3x-ui"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "source_ami_filter_name" {
|
||||||
|
type = string
|
||||||
|
description = "Override for the Canonical Ubuntu base AMI name filter. Empty ⇒ derived from xui_arch (latest patched 24.04 LTS for that arch)."
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ssh_username" {
|
||||||
|
type = string
|
||||||
|
description = "Default SSH user on the base Ubuntu cloud image."
|
||||||
|
default = "ubuntu"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- qemu (qcow2 / raw) -------------------------------------------------------
|
||||||
|
|
||||||
|
variable "qemu_iso_url" {
|
||||||
|
type = string
|
||||||
|
description = "Override for the Ubuntu cloud image used as the qemu base disk. Empty ⇒ derived from xui_arch (amd64/arm64 cloud image)."
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "qemu_iso_checksum" {
|
||||||
|
type = string
|
||||||
|
description = "Checksum for the qemu base disk. 'file:<SHA256SUMS url>' auto-fetches; 'none' skips verification."
|
||||||
|
default = "file:https://cloud-images.ubuntu.com/releases/24.04/release/SHA256SUMS"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "qemu_accelerator" {
|
||||||
|
type = string
|
||||||
|
description = "QEMU accelerator: 'kvm' when /dev/kvm is available, else 'tcg' (slow software emulation)."
|
||||||
|
default = "kvm"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "qemu_headless" {
|
||||||
|
type = bool
|
||||||
|
description = "Run QEMU without a display (required on CI runners)."
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "qemu_build_password" {
|
||||||
|
type = string
|
||||||
|
description = "Temporary password injected via cloud-init for Packer's build-time SSH. Locked/removed before the image is finalized."
|
||||||
|
default = "packer-build-temp-pw"
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- qemu arm64-only knobs (ignored for amd64) -------------------------------
|
||||||
|
|
||||||
|
variable "qemu_cpu" {
|
||||||
|
type = string
|
||||||
|
description = "QEMU -cpu model for arm64 builds: 'host' with KVM on an arm64 host, 'max' for TCG emulation."
|
||||||
|
default = "host"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "qemu_efi_code" {
|
||||||
|
type = string
|
||||||
|
description = "Path to the arm64 UEFI code firmware (AAVMF). Only used when xui_arch=arm64."
|
||||||
|
default = "/usr/share/AAVMF/AAVMF_CODE.fd"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "qemu_efi_vars" {
|
||||||
|
type = string
|
||||||
|
description = "Path to the arm64 UEFI vars firmware template (AAVMF). Only used when xui_arch=arm64."
|
||||||
|
default = "/usr/share/AAVMF/AAVMF_VARS.fd"
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// 3x-ui golden image — one build, two sources:
|
||||||
|
// * amazon-ebs : produces an AWS AMI (Marketplace-scannable)
|
||||||
|
// * qemu : produces a qcow2 (+ raw) for Hetzner/DO/Vultr/GCP/Azure/Oracle
|
||||||
|
//
|
||||||
|
// The image ships WITHOUT an initialized x-ui.db and WITHOUT any baked
|
||||||
|
// credentials. deploy/firstboot/x-ui-firstboot.{sh,service} generates unique
|
||||||
|
// per-instance credentials on first boot, before x-ui.service starts.
|
||||||
|
//
|
||||||
|
// Provisioner order is fixed: provision.sh -> harden.sh -> cleanup.sh.
|
||||||
|
|
||||||
|
packer {
|
||||||
|
required_plugins {
|
||||||
|
amazon = {
|
||||||
|
version = ">= 1.3.0"
|
||||||
|
source = "github.com/hashicorp/amazon"
|
||||||
|
}
|
||||||
|
qemu = {
|
||||||
|
version = ">= 1.1.0"
|
||||||
|
source = "github.com/hashicorp/qemu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
build_stamp = formatdate("YYYYMMDD-hhmmss", timestamp())
|
||||||
|
image_name = "${var.ami_name_prefix}-ubuntu-${var.ubuntu_version}-${var.xui_arch}"
|
||||||
|
is_arm = var.xui_arch == "arm64"
|
||||||
|
|
||||||
|
# Base images are derived from xui_arch unless explicitly overridden.
|
||||||
|
source_ami_name = var.source_ami_filter_name != "" ? var.source_ami_filter_name : "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-${var.xui_arch}-server-*"
|
||||||
|
qemu_iso_url = var.qemu_iso_url != "" ? var.qemu_iso_url : "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-${var.xui_arch}.img"
|
||||||
|
}
|
||||||
|
|
||||||
|
source "amazon-ebs" "x-ui" {
|
||||||
|
region = var.region
|
||||||
|
instance_type = var.instance_type
|
||||||
|
ssh_username = var.ssh_username
|
||||||
|
|
||||||
|
ami_name = "${local.image_name}-${var.xui_version}-${local.build_stamp}"
|
||||||
|
ami_description = "3x-ui panel on Ubuntu ${var.ubuntu_version}. Per-instance credentials are generated on first boot."
|
||||||
|
|
||||||
|
source_ami_filter {
|
||||||
|
filters = {
|
||||||
|
name = local.source_ami_name
|
||||||
|
root-device-type = "ebs"
|
||||||
|
virtualization-type = "hvm"
|
||||||
|
}
|
||||||
|
owners = ["099720109477"] // Canonical
|
||||||
|
most_recent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_block_device_mappings {
|
||||||
|
device_name = "/dev/sda1"
|
||||||
|
volume_size = 8
|
||||||
|
volume_type = "gp3"
|
||||||
|
delete_on_termination = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = local.image_name
|
||||||
|
Project = "3x-ui"
|
||||||
|
XuiVersion = var.xui_version
|
||||||
|
BuildTool = "packer"
|
||||||
|
BaseOS = "ubuntu-${var.ubuntu_version}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source "qemu" "x-ui" {
|
||||||
|
iso_url = local.qemu_iso_url
|
||||||
|
iso_checksum = var.qemu_iso_checksum
|
||||||
|
disk_image = true
|
||||||
|
disk_size = "10G"
|
||||||
|
format = "qcow2"
|
||||||
|
|
||||||
|
accelerator = var.qemu_accelerator
|
||||||
|
headless = var.qemu_headless
|
||||||
|
cpus = 2
|
||||||
|
memory = 2048
|
||||||
|
net_device = "virtio-net"
|
||||||
|
disk_interface = "virtio"
|
||||||
|
|
||||||
|
// Arch-specific QEMU machine. amd64 uses Packer defaults (BIOS boot, x86_64);
|
||||||
|
// arm64 needs the aarch64 binary, the 'virt' machine and UEFI (AAVMF) firmware.
|
||||||
|
qemu_binary = local.is_arm ? "qemu-system-aarch64" : null
|
||||||
|
machine_type = local.is_arm ? "virt" : null
|
||||||
|
efi_boot = local.is_arm
|
||||||
|
efi_firmware_code = local.is_arm ? var.qemu_efi_code : null
|
||||||
|
efi_firmware_vars = local.is_arm ? var.qemu_efi_vars : null
|
||||||
|
qemuargs = local.is_arm ? [["-cpu", var.qemu_cpu]] : []
|
||||||
|
|
||||||
|
output_directory = "output-qemu"
|
||||||
|
vm_name = "${local.image_name}.qcow2"
|
||||||
|
|
||||||
|
// Build-time access: a NoCloud seed sets a temporary password for the default
|
||||||
|
// user so Packer can SSH in. The seed is a separate CD-ROM (not part of the
|
||||||
|
// output disk); the password is locked by harden.sh and state wiped by cleanup.sh.
|
||||||
|
cd_label = "cidata"
|
||||||
|
cd_content = {
|
||||||
|
"meta-data" = ""
|
||||||
|
"user-data" = <<-EOT
|
||||||
|
#cloud-config
|
||||||
|
password: ${var.qemu_build_password}
|
||||||
|
chpasswd: { expire: false }
|
||||||
|
ssh_pwauth: true
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_username = var.ssh_username
|
||||||
|
ssh_password = var.qemu_build_password
|
||||||
|
ssh_timeout = "20m"
|
||||||
|
boot_wait = "45s"
|
||||||
|
|
||||||
|
shutdown_command = "sudo shutdown -P now"
|
||||||
|
}
|
||||||
|
|
||||||
|
build {
|
||||||
|
name = "3x-ui"
|
||||||
|
sources = ["source.amazon-ebs.x-ui", "source.qemu.x-ui"]
|
||||||
|
|
||||||
|
// Upload the first-boot unit + script so provision.sh can install them.
|
||||||
|
provisioner "shell" {
|
||||||
|
inline = ["mkdir -p /tmp/firstboot"]
|
||||||
|
}
|
||||||
|
provisioner "file" {
|
||||||
|
source = "${path.root}/../firstboot/x-ui-firstboot.sh"
|
||||||
|
destination = "/tmp/firstboot/x-ui-firstboot.sh"
|
||||||
|
}
|
||||||
|
provisioner "file" {
|
||||||
|
source = "${path.root}/../firstboot/x-ui-firstboot.service"
|
||||||
|
destination = "/tmp/firstboot/x-ui-firstboot.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "shell" {
|
||||||
|
environment_vars = [
|
||||||
|
"XUI_VERSION=${var.xui_version}",
|
||||||
|
"XUI_ARCH=${var.xui_arch}",
|
||||||
|
"DEBIAN_FRONTEND=noninteractive",
|
||||||
|
]
|
||||||
|
execute_command = "chmod +x {{ .Path }}; sudo -E bash {{ .Path }}"
|
||||||
|
scripts = [
|
||||||
|
"${path.root}/scripts/provision.sh",
|
||||||
|
"${path.root}/scripts/harden.sh",
|
||||||
|
"${path.root}/scripts/cleanup.sh",
|
||||||
|
]
|
||||||
|
// give cloud-init time to release apt locks on the very first boot
|
||||||
|
pause_before = "10s"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the qcow2 to raw for clouds that need it (qemu source only).
|
||||||
|
post-processor "shell-local" {
|
||||||
|
only = ["qemu.x-ui"]
|
||||||
|
inline = ["qemu-img convert -p -O raw output-qemu/${local.image_name}.qcow2 output-qemu/${local.image_name}.raw"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the AMI id / artifacts for CI to surface.
|
||||||
|
post-processor "manifest" {
|
||||||
|
output = "packer-manifest.json"
|
||||||
|
strip_path = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# smoke-firstboot.sh — verify the first-boot per-instance credential script.
|
||||||
|
#
|
||||||
|
# Installs the released x-ui binary into a container WITHOUT a database, runs
|
||||||
|
# x-ui-firstboot.sh, and asserts:
|
||||||
|
# * fresh random credentials are generated (no admin/admin)
|
||||||
|
# * /etc/x-ui/credentials.txt (600) and /etc/motd are written
|
||||||
|
# * the sentinel is created and a second run is a no-op (creds unchanged)
|
||||||
|
#
|
||||||
|
# Requires Docker and network access. Usage: bash deploy/test/smoke-firstboot.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}"
|
||||||
|
|
||||||
|
if ! command -v docker > /dev/null 2>&1; then
|
||||||
|
echo "ERROR: docker is required for this smoke test." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== first-boot credential smoke test (image: $IMAGE) =="
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${REPO_ROOT}/deploy/firstboot/x-ui-firstboot.sh:/root/x-ui-firstboot.sh:ro" \
|
||||||
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
|
"$IMAGE" bash -euo pipefail -c '
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq curl tar openssl ca-certificates jq > /dev/null
|
||||||
|
|
||||||
|
echo "--- installing released x-ui binary (no DB, no systemd) ---"
|
||||||
|
REPO=MHSanaei/3x-ui
|
||||||
|
ARCH=$(dpkg --print-architecture) # amd64 | arm64
|
||||||
|
echo "container arch: $ARCH"
|
||||||
|
VER=$(curl --fail --location --silent --show-error \
|
||||||
|
--retry 5 --retry-all-errors --retry-delay 3 \
|
||||||
|
--connect-timeout 15 --max-time 60 \
|
||||||
|
"https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
|
||||||
|
[ -n "$VER" ] && [ "$VER" != "null" ] || { echo "FAIL: cannot resolve version"; exit 1; }
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
# 504s and other transient GitHub/CDN hiccups are retried; a real HTTP
|
||||||
|
# failure (e.g. missing arch asset) still aborts after the retries.
|
||||||
|
if ! curl -4 --fail --location --silent --show-error \
|
||||||
|
--retry 5 --retry-all-errors --retry-delay 3 \
|
||||||
|
--connect-timeout 15 --max-time 300 \
|
||||||
|
-o "${tmp}/x.tar.gz" \
|
||||||
|
"https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"; then
|
||||||
|
echo "FAIL: cannot download x-ui-linux-${ARCH}.tar.gz (${VER})" >&2; exit 1
|
||||||
|
fi
|
||||||
|
test -s "${tmp}/x.tar.gz" || { echo "FAIL: downloaded tarball is empty"; exit 1; }
|
||||||
|
tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
|
||||||
|
chmod +x /usr/local/x-ui/x-ui
|
||||||
|
install -m 755 /root/x-ui-firstboot.sh /usr/local/x-ui/x-ui-firstboot.sh
|
||||||
|
|
||||||
|
# Guarantee a clean slate (the image must never ship a DB).
|
||||||
|
rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done
|
||||||
|
|
||||||
|
echo "--- run 1: generate per-instance credentials ---"
|
||||||
|
/usr/local/x-ui/x-ui-firstboot.sh
|
||||||
|
|
||||||
|
test -f /etc/x-ui/.firstboot-done || { echo "FAIL: sentinel not created"; exit 1; }
|
||||||
|
test -f /etc/x-ui/credentials.txt || { echo "FAIL: credentials.txt missing"; exit 1; }
|
||||||
|
perms=$(stat -c %a /etc/x-ui/credentials.txt)
|
||||||
|
[ "$perms" = "600" ] || { echo "FAIL: credentials.txt perms=$perms (want 600)"; exit 1; }
|
||||||
|
grep -q "3x-ui" /etc/motd || { echo "FAIL: motd not written"; exit 1; }
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. /etc/x-ui/credentials.txt
|
||||||
|
[ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \
|
||||||
|
|| { echo "FAIL: username missing or still admin"; exit 1; }
|
||||||
|
first_user="$XUI_USERNAME"
|
||||||
|
|
||||||
|
/usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \
|
||||||
|
|| { echo "FAIL: hasDefaultCredential is not false"; exit 1; }
|
||||||
|
|
||||||
|
echo "--- run 2: must be a no-op (sentinel honored) ---"
|
||||||
|
/usr/local/x-ui/x-ui-firstboot.sh
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. /etc/x-ui/credentials.txt
|
||||||
|
[ "$XUI_USERNAME" = "$first_user" ] \
|
||||||
|
|| { echo "FAIL: credentials changed on re-run"; exit 1; }
|
||||||
|
|
||||||
|
echo "SMOKE_PASS: firstboot user=$first_user (stable across re-run)"
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "== first-boot smoke test PASSED =="
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# smoke-noninteractive.sh — verify the non-interactive install path.
|
||||||
|
#
|
||||||
|
# Runs install.sh inside an Ubuntu container with NO TTY (piped) and
|
||||||
|
# XUI_NONINTERACTIVE=1, then asserts:
|
||||||
|
# * /etc/x-ui/install-result.env exists (mode 600) with random, non-default creds
|
||||||
|
# * the panel reports hasDefaultCredential: false (no admin/admin remains)
|
||||||
|
# * the panel HTTP server actually serves on the generated port/base path
|
||||||
|
#
|
||||||
|
# Requires Docker and network access (install.sh downloads the released binary).
|
||||||
|
# Usage: bash deploy/test/smoke-noninteractive.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}"
|
||||||
|
|
||||||
|
if ! command -v docker > /dev/null 2>&1; then
|
||||||
|
echo "ERROR: docker is required for this smoke test." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== non-interactive install smoke test (image: $IMAGE) =="
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${REPO_ROOT}/install.sh:/root/install.sh:ro" \
|
||||||
|
-e XUI_NONINTERACTIVE=1 \
|
||||||
|
-e XUI_SSL_MODE=none \
|
||||||
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
|
"$IMAGE" bash -euo pipefail -c '
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq curl tar openssl ca-certificates > /dev/null
|
||||||
|
|
||||||
|
echo "--- running install.sh piped (no TTY) ---"
|
||||||
|
# Piping guarantees stdin is not a TTY, exercising the auto non-interactive path.
|
||||||
|
cat /root/install.sh | bash
|
||||||
|
|
||||||
|
echo "--- assertions ---"
|
||||||
|
RESULT=/etc/x-ui/install-result.env
|
||||||
|
test -f "$RESULT" || { echo "FAIL: $RESULT missing"; exit 1; }
|
||||||
|
|
||||||
|
perms=$(stat -c %a "$RESULT")
|
||||||
|
[ "$perms" = "600" ] || { echo "FAIL: $RESULT perms=$perms (want 600)"; exit 1; }
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$RESULT"
|
||||||
|
[ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \
|
||||||
|
|| { echo "FAIL: username missing or still admin"; exit 1; }
|
||||||
|
[ -n "${XUI_PASSWORD:-}" ] && [ "$XUI_PASSWORD" != "admin" ] \
|
||||||
|
|| { echo "FAIL: password missing or still admin"; exit 1; }
|
||||||
|
[ -n "${XUI_PANEL_PORT:-}" ] || { echo "FAIL: port missing"; exit 1; }
|
||||||
|
|
||||||
|
# No default admin in the DB.
|
||||||
|
/usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \
|
||||||
|
|| { echo "FAIL: hasDefaultCredential is not false"; exit 1; }
|
||||||
|
|
||||||
|
echo "--- verifying the panel serves HTTP ---"
|
||||||
|
cd /usr/local/x-ui
|
||||||
|
./x-ui > /tmp/xui.log 2>&1 &
|
||||||
|
xpid=$!
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
"http://127.0.0.1:${XUI_PANEL_PORT}/${XUI_WEB_BASE_PATH}/" 2>/dev/null || true)
|
||||||
|
case "$code" in 200|301|302|307|308) break ;; esac
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
kill "$xpid" 2>/dev/null || true
|
||||||
|
echo "panel HTTP status: ${code:-none}"
|
||||||
|
case "${code:-}" in
|
||||||
|
200|301|302|307|308) : ;;
|
||||||
|
*) echo "FAIL: panel did not serve (status ${code:-none})"; tail -n 30 /tmp/xui.log; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "SMOKE_PASS: user=$XUI_USERNAME port=$XUI_PANEL_PORT path=$XUI_WEB_BASE_PATH"
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "== non-interactive smoke test PASSED =="
|
||||||
+3
-1
@@ -19,6 +19,7 @@ services:
|
|||||||
XRAY_VMESS_AEAD_FORCED: "false"
|
XRAY_VMESS_AEAD_FORCED: "false"
|
||||||
XUI_ENABLE_FAIL2BAN: "true"
|
XUI_ENABLE_FAIL2BAN: "true"
|
||||||
# XUI_INIT_WEB_BASE_PATH: "/"
|
# XUI_INIT_WEB_BASE_PATH: "/"
|
||||||
|
# XUI_PORT: "8080"
|
||||||
# To use PostgreSQL instead of the default SQLite, run:
|
# To use PostgreSQL instead of the default SQLite, run:
|
||||||
# docker compose --profile postgres up -d
|
# docker compose --profile postgres up -d
|
||||||
# and uncomment the two lines below.
|
# and uncomment the two lines below.
|
||||||
@@ -26,6 +27,7 @@ services:
|
|||||||
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
|
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
|
||||||
tty: true
|
tty: true
|
||||||
ports:
|
ports:
|
||||||
|
# When XUI_PORT is set, publish the same container port (for example "8080:8080").
|
||||||
- "2053:2053"
|
- "2053:2053"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@@ -39,4 +41,4 @@ services:
|
|||||||
POSTGRES_DB: xui
|
POSTGRES_DB: xui
|
||||||
volumes:
|
volumes:
|
||||||
- $PWD/pgdata/:/var/lib/postgresql/data
|
- $PWD/pgdata/:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Capturing the Real Client IP
|
||||||
|
|
||||||
|
When an Xray inbound sits behind an intermediary — a CDN like Cloudflare, an L4 tunnel/relay,
|
||||||
|
or another panel — the IP that Xray sees is the **intermediary's** address, not the visitor's.
|
||||||
|
That intermediary IP is what shows up in the panel's online/IP view and what the per-client
|
||||||
|
**IP limit** counts against, which makes both useless behind a proxy.
|
||||||
|
|
||||||
|
Xray-core can recover the real visitor IP. 3x-ui exposes the two mechanisms in the inbound form
|
||||||
|
and feeds the recovered IP into the same pipeline that drives IP-limit enforcement, the online
|
||||||
|
list, and multi-node sync — so once it is set, everything downstream just works.
|
||||||
|
|
||||||
|
## Where to set it
|
||||||
|
|
||||||
|
Open an inbound → **Transport / Stream Settings** → enable **Sockopt** → use the
|
||||||
|
**Real client IP** preset selector:
|
||||||
|
|
||||||
|
| Preset | What it does | Use for |
|
||||||
|
|---|---|---|
|
||||||
|
| **Off / direct** | Clears both fields. | Inbound reachable directly by clients. |
|
||||||
|
| **Cloudflare CDN** | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). |
|
||||||
|
| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`. | An L4 tunnel/relay in front, or Cloudflare **Spectrum**. |
|
||||||
|
|
||||||
|
The raw `Proxy Protocol` switch and `Trusted X-Forwarded-For` list stay visible below the preset
|
||||||
|
selector for manual / advanced tuning — the presets just fill them in for you.
|
||||||
|
|
||||||
|
## Scenario 1 — Cloudflare CDN
|
||||||
|
|
||||||
|
Cloudflare's CDN (the orange cloud) forwards the visitor's IP in the `CF-Connecting-IP` request
|
||||||
|
header. Xray reads it when the transport is **WebSocket**, **HTTPUpgrade**, or **XHTTP** and
|
||||||
|
the header name is listed in `sockopt.trustedXForwardedFor`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "ws",
|
||||||
|
"sockopt": { "trustedXForwardedFor": ["CF-Connecting-IP"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick the **Cloudflare CDN** preset. You can add `X-Real-IP`, `True-Client-IP`, or `X-Client-IP`
|
||||||
|
to the list if a different upstream uses those.
|
||||||
|
|
||||||
|
> This is **not** the same as Cloudflare Spectrum. The free/CDN tier forwards HTTP headers — use
|
||||||
|
> this scenario. Spectrum (a TCP/L4 product) can send the PROXY protocol — use Scenario 2.
|
||||||
|
|
||||||
|
## Scenario 2 — L4 tunnel / relay or Cloudflare Spectrum (PROXY protocol)
|
||||||
|
|
||||||
|
For a TCP-level front (HAProxy, gost, nginx `stream`, an Xray dokodemo-door relay, or Cloudflare
|
||||||
|
Spectrum), the real IP is carried in the **PROXY protocol** header. Enable
|
||||||
|
`acceptProxyProtocol` and make sure the **upstream emits PROXY protocol** — otherwise the
|
||||||
|
connection will fail.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp",
|
||||||
|
"sockopt": { "acceptProxyProtocol": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick the **L4 relay / Spectrum (PROXY)** preset. Works on TCP/RAW, WebSocket, HTTPUpgrade, gRPC
|
||||||
|
and XHTTP; **not** on mKCP. The front must be configured to send the header, e.g.:
|
||||||
|
|
||||||
|
- **HAProxy**: `server backend 127.0.0.1:443 send-proxy` (or `send-proxy-v2`).
|
||||||
|
- **nginx** (`stream {}` block): `proxy_protocol on;` on the `server`, and on the upstream side
|
||||||
|
`proxy_protocol on;` in the `server` that connects to Xray.
|
||||||
|
|
||||||
|
## Transport support matrix
|
||||||
|
|
||||||
|
| Mechanism | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP |
|
||||||
|
|---|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||||
|
| `trustedXForwardedFor` (header) | – | – | ✅ | – | ✅ | ✅ |
|
||||||
|
| `acceptProxyProtocol` (PROXY) | ✅ | – | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
The form shows a warning when you select a preset that the current transport cannot honor.
|
||||||
|
|
||||||
|
> **Use one, not both.** `acceptProxyProtocol` and `trustedXForwardedFor` are independent — the
|
||||||
|
> first reads the real IP from the L4 PROXY header, the second from an HTTP request header. On
|
||||||
|
> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header *last*, so a stale
|
||||||
|
> `trustedXForwardedFor` would override (and defeat) a PROXY-protocol setup. The presets are
|
||||||
|
> mutually exclusive and clear the other field for you; only mix them by hand if you know your
|
||||||
|
> upstream chain needs it.
|
||||||
|
|
||||||
|
## Multi-node
|
||||||
|
|
||||||
|
No extra configuration is needed. The inbound's `streamSettings` (including these sockopt
|
||||||
|
fields) is pushed to child nodes verbatim, so the node's Xray records the real IP, and the
|
||||||
|
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
|
||||||
|
shows up on the parent automatically.
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
|
||||||
|
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
|
||||||
|
stripped from subscription output, so they never reach clients. Only enable
|
||||||
|
`trustedXForwardedFor` when the inbound is genuinely behind a trusted proxy that sets the
|
||||||
|
header; otherwise a client could spoof the header and forge its own source IP.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
1. Set the preset and save the inbound.
|
||||||
|
2. Inspect the generated Xray config and confirm `streamSettings.sockopt` carries the expected
|
||||||
|
field (`trustedXForwardedFor` or `acceptProxyProtocol`).
|
||||||
|
3. Connect through the intermediary, then open the client's IPs / online view in the panel — it
|
||||||
|
should show the real visitor IP rather than the CDN/relay address.
|
||||||
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
.vite/
|
.vite/
|
||||||
*.log
|
*.log
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
coverage/
|
||||||
|
|||||||
Generated
+315
-134
@@ -13,8 +13,8 @@
|
|||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-query-devtools": "^5.101.0",
|
"@tanstack/react-query-devtools": "^5.101.0",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.4",
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.18.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.16.0",
|
"react-router-dom": "^7.17.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"swagger-ui-react": "^5.32.6",
|
"swagger-ui-react": "^5.32.6",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -33,18 +33,19 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"eslint": "^10.4.1",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"eslint": "^10.5.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.60.1",
|
"typescript-eslint": "^8.61.1",
|
||||||
"vite": "8.0.16",
|
"vite": "8.0.16",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0",
|
"node": ">=22.0.0",
|
||||||
@@ -456,6 +457,16 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@bramus/specificity": {
|
"node_modules/@bramus/specificity": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
@@ -617,9 +628,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-color-parser": {
|
"node_modules/@csstools/css-color-parser": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz",
|
||||||
"integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
|
"integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1236,9 +1247,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/form": {
|
"node_modules/@rc-component/form": {
|
||||||
"version": "1.8.3",
|
"version": "1.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.4.tgz",
|
||||||
"integrity": "sha512-jNkat3uxZ246ELudKwnjQhnDI8+rSxgLxjztvQU3Mrb0G+LwDyOrPu9RNfekOjqU5GQ5QJepi225x+9LhCizJw==",
|
"integrity": "sha512-I2FHNMWoiGQNjC+hQFhAj/rQeScAIBc+AkZqvu4Zyaxe4I3WOVpQte2E5lyZhruswyT8aULYHu1clPaPwE9L2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rc-component/async-validator": "^6.0.0",
|
"@rc-component/async-validator": "^6.0.0",
|
||||||
@@ -3096,17 +3107,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
|
||||||
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
|
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.61.0",
|
"@typescript-eslint/scope-manager": "8.61.1",
|
||||||
"@typescript-eslint/type-utils": "8.61.0",
|
"@typescript-eslint/type-utils": "8.61.1",
|
||||||
"@typescript-eslint/utils": "8.61.0",
|
"@typescript-eslint/utils": "8.61.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
@@ -3119,7 +3130,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.61.0",
|
"@typescript-eslint/parser": "^8.61.1",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
@@ -3135,16 +3146,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
|
||||||
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
|
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.61.0",
|
"@typescript-eslint/scope-manager": "8.61.1",
|
||||||
"@typescript-eslint/types": "8.61.0",
|
"@typescript-eslint/types": "8.61.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3160,14 +3171,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
|
||||||
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
|
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.61.0",
|
"@typescript-eslint/tsconfig-utils": "^8.61.1",
|
||||||
"@typescript-eslint/types": "^8.61.0",
|
"@typescript-eslint/types": "^8.61.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3182,14 +3193,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
|
||||||
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
|
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.61.0",
|
"@typescript-eslint/types": "8.61.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.61.0"
|
"@typescript-eslint/visitor-keys": "8.61.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3200,9 +3211,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
|
||||||
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
|
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3217,15 +3228,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
|
||||||
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
|
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.61.0",
|
"@typescript-eslint/types": "8.61.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||||
"@typescript-eslint/utils": "8.61.0",
|
"@typescript-eslint/utils": "8.61.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
},
|
},
|
||||||
@@ -3242,9 +3253,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
|
||||||
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
|
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3256,16 +3267,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
|
||||||
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
|
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.61.0",
|
"@typescript-eslint/project-service": "8.61.1",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.61.0",
|
"@typescript-eslint/tsconfig-utils": "8.61.1",
|
||||||
"@typescript-eslint/types": "8.61.0",
|
"@typescript-eslint/types": "8.61.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -3297,16 +3308,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
|
||||||
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
|
"integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.61.0",
|
"@typescript-eslint/scope-manager": "8.61.1",
|
||||||
"@typescript-eslint/types": "8.61.0",
|
"@typescript-eslint/types": "8.61.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.61.0"
|
"@typescript-eslint/typescript-estree": "8.61.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3321,13 +3332,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
|
||||||
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
|
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.61.0",
|
"@typescript-eslint/types": "8.61.1",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3364,17 +3375,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"ast-v8-to-istanbul": "^1.0.0",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-reports": "^3.2.0",
|
||||||
|
"magicast": "^0.5.2",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vitest/browser": "4.1.9",
|
||||||
|
"vitest": "4.1.9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "4.1.8",
|
"@vitest/spy": "4.1.9",
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"chai": "^6.2.2",
|
"chai": "^6.2.2",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -3383,13 +3425,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.1.8",
|
"@vitest/spy": "4.1.9",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.21"
|
"magic-string": "^0.30.21"
|
||||||
},
|
},
|
||||||
@@ -3410,9 +3452,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3423,13 +3465,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -3437,14 +3479,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.8",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
@@ -3453,9 +3495,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -3463,13 +3505,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.8",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -3647,6 +3689,25 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.31",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"js-tokens": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -3678,9 +3739,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.17.0",
|
"version": "1.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
|
||||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
@@ -4941,6 +5002,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-property-descriptors": {
|
"node_modules/has-property-descriptors": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
@@ -5067,6 +5138,13 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
@@ -5320,6 +5398,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-file-download": {
|
"node_modules/js-file-download": {
|
||||||
"version": "0.4.12",
|
"version": "0.4.12",
|
||||||
"resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",
|
"resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",
|
||||||
@@ -5333,9 +5450,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -5832,6 +5959,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.3",
|
||||||
|
"@babel/types": "^7.29.0",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir/node_modules/semver": {
|
||||||
|
"version": "7.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||||
|
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -7033,6 +7201,19 @@
|
|||||||
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/swagger-client": {
|
"node_modules/swagger-client": {
|
||||||
"version": "3.37.4",
|
"version": "3.37.4",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz",
|
||||||
@@ -7175,22 +7356,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tldts": {
|
"node_modules/tldts": {
|
||||||
"version": "7.4.2",
|
"version": "7.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
|
||||||
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
|
"integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tldts-core": "^7.4.2"
|
"tldts-core": "^7.4.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"tldts": "bin/cli.js"
|
"tldts": "bin/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tldts-core": {
|
"node_modules/tldts-core": {
|
||||||
"version": "7.4.2",
|
"version": "7.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
|
||||||
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
|
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -7366,16 +7547,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
|
||||||
"integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==",
|
"integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||||
"@typescript-eslint/parser": "8.61.0",
|
"@typescript-eslint/parser": "8.61.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||||
"@typescript-eslint/utils": "8.61.0"
|
"@typescript-eslint/utils": "8.61.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -7390,9 +7571,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.27.2",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7566,19 +7747,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.8",
|
"@vitest/expect": "4.1.9",
|
||||||
"@vitest/mocker": "4.1.8",
|
"@vitest/mocker": "4.1.9",
|
||||||
"@vitest/pretty-format": "4.1.8",
|
"@vitest/pretty-format": "4.1.9",
|
||||||
"@vitest/runner": "4.1.8",
|
"@vitest/runner": "4.1.9",
|
||||||
"@vitest/snapshot": "4.1.8",
|
"@vitest/snapshot": "4.1.9",
|
||||||
"@vitest/spy": "4.1.8",
|
"@vitest/spy": "4.1.9",
|
||||||
"@vitest/utils": "4.1.8",
|
"@vitest/utils": "4.1.9",
|
||||||
"es-module-lexer": "^2.0.0",
|
"es-module-lexer": "^2.0.0",
|
||||||
"expect-type": "^1.3.0",
|
"expect-type": "^1.3.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
@@ -7606,12 +7787,12 @@
|
|||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
"@vitest/browser-playwright": "4.1.8",
|
"@vitest/browser-playwright": "4.1.9",
|
||||||
"@vitest/browser-preview": "4.1.8",
|
"@vitest/browser-preview": "4.1.9",
|
||||||
"@vitest/browser-webdriverio": "4.1.8",
|
"@vitest/browser-webdriverio": "4.1.9",
|
||||||
"@vitest/coverage-istanbul": "4.1.8",
|
"@vitest/coverage-istanbul": "4.1.9",
|
||||||
"@vitest/coverage-v8": "4.1.8",
|
"@vitest/coverage-v8": "4.1.9",
|
||||||
"@vitest/ui": "4.1.8",
|
"@vitest/ui": "4.1.9",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*",
|
"jsdom": "*",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
|||||||
+12
-8
@@ -26,8 +26,8 @@
|
|||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-query-devtools": "^5.101.0",
|
"@tanstack/react-query-devtools": "^5.101.0",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.4",
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.18.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.16.0",
|
"react-router-dom": "^7.17.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"swagger-ui-react": "^5.32.6",
|
"swagger-ui-react": "^5.32.6",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -46,24 +46,28 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"eslint": "^10.4.1",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"eslint": "^10.5.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.60.1",
|
"typescript-eslint": "^8.61.1",
|
||||||
"vite": "8.0.16",
|
"vite": "8.0.16",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"react-copy-to-clipboard": "^5.1.1",
|
"react-copy-to-clipboard": "^5.1.1",
|
||||||
"react-inspector": "^9.0.0",
|
"react-inspector": "^9.0.0",
|
||||||
"react-debounce-input": {
|
"react-debounce-input": {
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
|
},
|
||||||
|
"swagger-ui-react": {
|
||||||
|
"js-yaml": "^4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
@@ -73,4 +77,4 @@
|
|||||||
"core-js-pure": false,
|
"core-js-pure": false,
|
||||||
"tree-sitter-json": false
|
"tree-sitter-json": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+391
-12
@@ -138,6 +138,46 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"smtpCpu": {
|
||||||
|
"description": "CPU threshold for email notifications",
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"smtpEnable": {
|
||||||
|
"description": "Email (SMTP) notification settings\nEnable email notifications",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"smtpEnabledEvents": {
|
||||||
|
"description": "Comma-separated event types to send via email",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpEncryptionType": {
|
||||||
|
"description": "SMTP encryption: none, starttls, tls",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"description": "SMTP server host",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"description": "SMTP password",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"description": "SMTP server port",
|
||||||
|
"maximum": 65535,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"smtpTo": {
|
||||||
|
"description": "Comma-separated recipient emails",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"description": "SMTP username",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subAnnounce": {
|
"subAnnounce": {
|
||||||
"description": "Subscription announce",
|
"description": "Subscription announce",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -277,10 +317,6 @@
|
|||||||
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"tgBotLoginNotify": {
|
|
||||||
"description": "Send login notifications",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"tgBotProxy": {
|
"tgBotProxy": {
|
||||||
"description": "Proxy URL for Telegram bot",
|
"description": "Proxy URL for Telegram bot",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -295,6 +331,10 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"tgEnabledEvents": {
|
||||||
|
"description": "Comma-separated event types to send via Telegram",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"tgLang": {
|
"tgLang": {
|
||||||
"description": "Telegram bot language",
|
"description": "Telegram bot language",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -387,6 +427,15 @@
|
|||||||
"remarkModel",
|
"remarkModel",
|
||||||
"restartXrayOnClientDisable",
|
"restartXrayOnClientDisable",
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
|
"smtpCpu",
|
||||||
|
"smtpEnable",
|
||||||
|
"smtpEnabledEvents",
|
||||||
|
"smtpEncryptionType",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpPassword",
|
||||||
|
"smtpPort",
|
||||||
|
"smtpTo",
|
||||||
|
"smtpUsername",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
@@ -421,10 +470,10 @@
|
|||||||
"tgBotBackup",
|
"tgBotBackup",
|
||||||
"tgBotChatId",
|
"tgBotChatId",
|
||||||
"tgBotEnable",
|
"tgBotEnable",
|
||||||
"tgBotLoginNotify",
|
|
||||||
"tgBotProxy",
|
"tgBotProxy",
|
||||||
"tgBotToken",
|
"tgBotToken",
|
||||||
"tgCpu",
|
"tgCpu",
|
||||||
|
"tgEnabledEvents",
|
||||||
"tgLang",
|
"tgLang",
|
||||||
"tgRunTime",
|
"tgRunTime",
|
||||||
"timeLocation",
|
"timeLocation",
|
||||||
@@ -471,6 +520,9 @@
|
|||||||
"hasNordSecret": {
|
"hasNordSecret": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"hasSmtpPassword": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"hasTgBotToken": {
|
"hasTgBotToken": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -572,6 +624,46 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"smtpCpu": {
|
||||||
|
"description": "CPU threshold for email notifications",
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"smtpEnable": {
|
||||||
|
"description": "Email (SMTP) notification settings\nEnable email notifications",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"smtpEnabledEvents": {
|
||||||
|
"description": "Comma-separated event types to send via email",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpEncryptionType": {
|
||||||
|
"description": "SMTP encryption: none, starttls, tls",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"description": "SMTP server host",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"description": "SMTP password",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"description": "SMTP server port",
|
||||||
|
"maximum": 65535,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"smtpTo": {
|
||||||
|
"description": "Comma-separated recipient emails",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"description": "SMTP username",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subAnnounce": {
|
"subAnnounce": {
|
||||||
"description": "Subscription announce",
|
"description": "Subscription announce",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -711,10 +803,6 @@
|
|||||||
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"tgBotLoginNotify": {
|
|
||||||
"description": "Send login notifications",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"tgBotProxy": {
|
"tgBotProxy": {
|
||||||
"description": "Proxy URL for Telegram bot",
|
"description": "Proxy URL for Telegram bot",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -729,6 +817,10 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"tgEnabledEvents": {
|
||||||
|
"description": "Comma-separated event types to send via Telegram",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"tgLang": {
|
"tgLang": {
|
||||||
"description": "Telegram bot language",
|
"description": "Telegram bot language",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -799,6 +891,7 @@
|
|||||||
"hasApiToken",
|
"hasApiToken",
|
||||||
"hasLdapPassword",
|
"hasLdapPassword",
|
||||||
"hasNordSecret",
|
"hasNordSecret",
|
||||||
|
"hasSmtpPassword",
|
||||||
"hasTgBotToken",
|
"hasTgBotToken",
|
||||||
"hasTwoFactorToken",
|
"hasTwoFactorToken",
|
||||||
"hasWarpSecret",
|
"hasWarpSecret",
|
||||||
@@ -827,6 +920,15 @@
|
|||||||
"remarkModel",
|
"remarkModel",
|
||||||
"restartXrayOnClientDisable",
|
"restartXrayOnClientDisable",
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
|
"smtpCpu",
|
||||||
|
"smtpEnable",
|
||||||
|
"smtpEnabledEvents",
|
||||||
|
"smtpEncryptionType",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpPassword",
|
||||||
|
"smtpPort",
|
||||||
|
"smtpTo",
|
||||||
|
"smtpUsername",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
@@ -861,10 +963,10 @@
|
|||||||
"tgBotBackup",
|
"tgBotBackup",
|
||||||
"tgBotChatId",
|
"tgBotChatId",
|
||||||
"tgBotEnable",
|
"tgBotEnable",
|
||||||
"tgBotLoginNotify",
|
|
||||||
"tgBotProxy",
|
"tgBotProxy",
|
||||||
"tgBotToken",
|
"tgBotToken",
|
||||||
"tgCpu",
|
"tgCpu",
|
||||||
|
"tgEnabledEvents",
|
||||||
"tgLang",
|
"tgLang",
|
||||||
"tgRunTime",
|
"tgRunTime",
|
||||||
"timeLocation",
|
"timeLocation",
|
||||||
@@ -1617,10 +1719,21 @@
|
|||||||
"example": "de-fra-1",
|
"example": "de-fra-1",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"netDown": {
|
||||||
|
"example": 2097152,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"netUp": {
|
||||||
|
"example": 1048576,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"onlineCount": {
|
"onlineCount": {
|
||||||
"example": 3,
|
"example": 3,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"outboundTag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"panelVersion": {
|
"panelVersion": {
|
||||||
"example": "v3.x.x",
|
"example": "v3.x.x",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1658,7 +1771,8 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"verify",
|
"verify",
|
||||||
"skip",
|
"skip",
|
||||||
"pin"
|
"pin",
|
||||||
|
"mtls"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1707,7 +1821,10 @@
|
|||||||
"latencyMs",
|
"latencyMs",
|
||||||
"memPct",
|
"memPct",
|
||||||
"name",
|
"name",
|
||||||
|
"netDown",
|
||||||
|
"netUp",
|
||||||
"onlineCount",
|
"onlineCount",
|
||||||
|
"outboundTag",
|
||||||
"panelVersion",
|
"panelVersion",
|
||||||
"pinnedCertSha256",
|
"pinnedCertSha256",
|
||||||
"port",
|
"port",
|
||||||
@@ -4390,7 +4507,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Fetch one client by email, including the inbound IDs it is attached to.",
|
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
|
||||||
"operationId": "get_panel_api_clients_get_email",
|
"operationId": "get_panel_api_clients_get_email",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -4719,6 +4836,74 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/clients/{email}/externalLinks": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Clients"
|
||||||
|
],
|
||||||
|
"summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.",
|
||||||
|
"operationId": "post_panel_api_clients_email_externalLinks",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Client email (unique identifier).",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"externalLinks": [
|
||||||
|
{
|
||||||
|
"kind": "link",
|
||||||
|
"value": "vless://uuid@host:443?...#srv",
|
||||||
|
"remark": "DE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "subscription",
|
||||||
|
"value": "https://provider.example/sub/abc",
|
||||||
|
"remark": "Provider"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/resetAllTraffics": {
|
"/panel/api/clients/resetAllTraffics": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -5762,6 +5947,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/clients/clientIpsByGuid": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Clients"
|
||||||
|
],
|
||||||
|
"summary": "Per-client source IPs grouped by the panelGuid of the node that observed them. Lets the central panel attribute and enforce per-client IP limits using the real visitor IPs each node sees, instead of the address of the intermediate panel it syncs through.",
|
||||||
|
"operationId": "post_panel_api_clients_clientIpsByGuid",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"a1b2-...": {
|
||||||
|
"user1": [
|
||||||
|
{
|
||||||
|
"ip": "1.2.3.4",
|
||||||
|
"timestamp": 1700000000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/activeInbounds": {
|
"/panel/api/clients/activeInbounds": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -6049,7 +6277,10 @@
|
|||||||
"latencyMs": 42,
|
"latencyMs": 42,
|
||||||
"memPct": 45.1,
|
"memPct": 45.1,
|
||||||
"name": "de-fra-1",
|
"name": "de-fra-1",
|
||||||
|
"netDown": 2097152,
|
||||||
|
"netUp": 1048576,
|
||||||
"onlineCount": 3,
|
"onlineCount": 3,
|
||||||
|
"outboundTag": "",
|
||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"parentGuid": "",
|
"parentGuid": "",
|
||||||
"pinnedCertSha256": "",
|
"pinnedCertSha256": "",
|
||||||
@@ -6073,6 +6304,85 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/nodes/mtls/ca": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Nodes"
|
||||||
|
],
|
||||||
|
"summary": "This panel's node-auth CA certificate (public, PEM) to paste into a node's mTLS trust setting. Lazily mints the CA and the master client cert on first call. Pair with setting tlsVerifyMode=mtls on the node.",
|
||||||
|
"operationId": "post_panel_api_nodes_mtls_ca",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"caCert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/panel/api/nodes/mtls/trustCA": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Nodes"
|
||||||
|
],
|
||||||
|
"summary": "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
|
||||||
|
"operationId": "post_panel_api_nodes_mtls_trustCA",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"caCert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/nodes/get/{id}": {
|
"/panel/api/nodes/get/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -6936,6 +7246,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/setting/testSmtp": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
],
|
||||||
|
"summary": "Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.",
|
||||||
|
"operationId": "post_panel_api_setting_testSmtp",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"stage": "send",
|
||||||
|
"msg": "Test email sent successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/panel/api/setting/testTgBot": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
],
|
||||||
|
"summary": "Test Telegram bot connection by sending a test message to the configured chat.",
|
||||||
|
"operationId": "post_panel_api_setting_testTgBot",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"msg": "Test message sent to Telegram"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/setting/getDefaultJsonConfig": {
|
"/panel/api/setting/getDefaultJsonConfig": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { fetchXrayConfig } from '@/hooks/useXraySetting';
|
|||||||
// inbound's Telegram traffic to. Shares the cached xray config query so opening
|
// inbound's Telegram traffic to. Shares the cached xray config query so opening
|
||||||
// the inbound form costs no extra request when the Xray page was already
|
// the inbound form costs no extra request when the Xray page was already
|
||||||
// visited; `select` derives just the tag list without disturbing other readers.
|
// visited; `select` derives just the tag list without disturbing other readers.
|
||||||
export function useOutboundTags() {
|
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
|
||||||
|
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: keys.xray.config(),
|
queryKey: keys.xray.config(),
|
||||||
queryFn: fetchXrayConfig,
|
queryFn: fetchXrayConfig,
|
||||||
@@ -15,8 +16,10 @@ export function useOutboundTags() {
|
|||||||
select: (data): string[] => {
|
select: (data): string[] => {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
for (const o of data?.xraySetting?.outbounds ?? []) {
|
for (const o of data?.xraySetting?.outbounds ?? []) {
|
||||||
const tag = (o as { tag?: string } | null)?.tag;
|
const ob = o as { tag?: string; protocol?: string } | null;
|
||||||
if (tag) tags.add(tag);
|
if (!ob?.tag) continue;
|
||||||
|
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
||||||
|
tags.add(ob.tag);
|
||||||
}
|
}
|
||||||
for (const t of data?.subscriptionOutboundTags ?? []) {
|
for (const t of data?.subscriptionOutboundTags ?? []) {
|
||||||
if (t) tags.add(t);
|
if (t) tags.add(t);
|
||||||
@@ -31,3 +34,38 @@ export function useOutboundTags() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutboundTagGroups {
|
||||||
|
outbounds: string[];
|
||||||
|
balancers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same data as useOutboundTags, but keeps outbound and balancer tags apart so a
|
||||||
|
// picker can render them in labeled groups (like the panel-outbound selector)
|
||||||
|
// instead of one flat list.
|
||||||
|
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
|
||||||
|
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.xray.config(),
|
||||||
|
queryFn: fetchXrayConfig,
|
||||||
|
staleTime: Infinity,
|
||||||
|
select: (data): OutboundTagGroups => {
|
||||||
|
const outbounds = new Set<string>();
|
||||||
|
for (const o of data?.xraySetting?.outbounds ?? []) {
|
||||||
|
const ob = o as { tag?: string; protocol?: string } | null;
|
||||||
|
if (!ob?.tag) continue;
|
||||||
|
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
||||||
|
outbounds.add(ob.tag);
|
||||||
|
}
|
||||||
|
for (const t of data?.subscriptionOutboundTags ?? []) {
|
||||||
|
if (t) outbounds.add(t);
|
||||||
|
}
|
||||||
|
const balancers: string[] = [];
|
||||||
|
const bal = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
|
||||||
|
for (const b of bal ?? []) {
|
||||||
|
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
|
||||||
|
}
|
||||||
|
return { outbounds: [...outbounds], balancers };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.jdp-wrap {
|
.jdp-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jdp-wrap > * {
|
.jdp-wrap > * {
|
||||||
@@ -33,3 +34,38 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* persian-calendar-suite has no allowClear; overlay our own clear button so
|
||||||
|
the Jalali picker matches the Gregorian AntD DatePicker's X affordance. */
|
||||||
|
.jdp-wrap .jdp-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 11px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: rgba(0, 0, 0, 0.25);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jdp-wrap .jdp-clear:hover {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jdp-dark .jdp-clear {
|
||||||
|
color: rgba(255, 255, 255, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jdp-dark .jdp-clear:hover,
|
||||||
|
.jdp-ultra .jdp-clear:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CloseCircleFilled } from '@ant-design/icons';
|
||||||
import { DatePicker } from 'antd';
|
import { DatePicker } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
@@ -54,6 +55,10 @@ export default function DateTimePicker({
|
|||||||
}: DateTimePickerProps) {
|
}: DateTimePickerProps) {
|
||||||
const { datepicker } = useDatepicker();
|
const { datepicker } = useDatepicker();
|
||||||
const { isDark, isUltra } = useTheme();
|
const { isDark, isUltra } = useTheme();
|
||||||
|
const jalaliRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Bumped on clear: persian-calendar-suite reads `value` only on mount, so
|
||||||
|
// remounting via key is the only way to reflect an externally cleared value.
|
||||||
|
const [clearNonce, setClearNonce] = useState(0);
|
||||||
|
|
||||||
const persianTheme = useMemo(() => {
|
const persianTheme = useMemo(() => {
|
||||||
if (isUltra) return ULTRA_DARK_THEME;
|
if (isUltra) return ULTRA_DARK_THEME;
|
||||||
@@ -61,10 +66,21 @@ export default function DateTimePicker({
|
|||||||
return LIGHT_THEME;
|
return LIGHT_THEME;
|
||||||
}, [isDark, isUltra]);
|
}, [isDark, isUltra]);
|
||||||
|
|
||||||
|
// The library hardcodes a Persian placeholder and exposes no working prop to
|
||||||
|
// override it, so clear it (or apply the caller's) on the input directly so
|
||||||
|
// the empty field shows no leftover Persian text. No dep array: re-apply
|
||||||
|
// after every render (incl. clear-remounts).
|
||||||
|
useEffect(() => {
|
||||||
|
if (datepicker !== 'jalalian') return;
|
||||||
|
const input = jalaliRef.current?.querySelector('input');
|
||||||
|
if (input) input.placeholder = placeholder;
|
||||||
|
});
|
||||||
|
|
||||||
if (datepicker === 'jalalian') {
|
if (datepicker === 'jalalian') {
|
||||||
return (
|
return (
|
||||||
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
|
<div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
|
||||||
<PersianDateTimePicker
|
<PersianDateTimePicker
|
||||||
|
key={clearNonce}
|
||||||
value={value ? value.valueOf() : null}
|
value={value ? value.valueOf() : null}
|
||||||
onChange={(next: number | string | null) => {
|
onChange={(next: number | string | null) => {
|
||||||
if (next == null || next === '') {
|
if (next == null || next === '') {
|
||||||
@@ -80,6 +96,21 @@ export default function DateTimePicker({
|
|||||||
rtlCalendar
|
rtlCalendar
|
||||||
theme={persianTheme}
|
theme={persianTheme}
|
||||||
/>
|
/>
|
||||||
|
{value && !disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="jdp-clear"
|
||||||
|
aria-label="clear"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(null);
|
||||||
|
setClearNonce((n) => n + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseCircleFilled />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
|
|
||||||
interface Option {
|
interface SelectAllClearButtonsProps<T extends string | number = number> {
|
||||||
value: number;
|
options: Array<{ value: T }>;
|
||||||
}
|
value: T[];
|
||||||
|
onChange: (value: T[]) => void;
|
||||||
interface SelectAllClearButtonsProps {
|
|
||||||
options: Option[];
|
|
||||||
value: number[];
|
|
||||||
onChange: (value: number[]) => void;
|
|
||||||
/** Override the default "Select all" label (defaults to the inbound copy). */
|
/** Override the default "Select all" label (defaults to the inbound copy). */
|
||||||
selectAllLabel?: string;
|
selectAllLabel?: string;
|
||||||
/** Override the default "Clear all" label (defaults to the inbound copy). */
|
/** Override the default "Clear all" label (defaults to the inbound copy). */
|
||||||
clearLabel?: string;
|
clearLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SelectAllClearButtons({
|
export default function SelectAllClearButtons<T extends string | number = number>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
selectAllLabel,
|
selectAllLabel,
|
||||||
clearLabel,
|
clearLabel,
|
||||||
}: SelectAllClearButtonsProps) {
|
}: SelectAllClearButtonsProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const optionValues = options.map((o) => o.value);
|
const optionValues = options.map((o) => o.value);
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Checkbox, Collapse, InputNumber, Space } from 'antd';
|
||||||
|
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface EventGroup {
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
events: { value: string; labelKey: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_GROUPS: EventGroup[] = [
|
||||||
|
{
|
||||||
|
key: 'outbound',
|
||||||
|
labelKey: 'pages.settings.eventGroupOutbound',
|
||||||
|
events: [
|
||||||
|
{ value: 'outbound.down', labelKey: 'pages.settings.eventOutboundDown' },
|
||||||
|
{ value: 'outbound.up', labelKey: 'pages.settings.eventOutboundUp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'xray',
|
||||||
|
labelKey: 'pages.settings.eventGroupXray',
|
||||||
|
events: [
|
||||||
|
{ value: 'xray.crash', labelKey: 'pages.settings.eventXrayCrash' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'node',
|
||||||
|
labelKey: 'pages.settings.eventGroupNode',
|
||||||
|
events: [
|
||||||
|
{ value: 'node.down', labelKey: 'pages.settings.eventNodeDown' },
|
||||||
|
{ value: 'node.up', labelKey: 'pages.settings.eventNodeUp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
labelKey: 'pages.settings.eventGroupSystem',
|
||||||
|
events: [
|
||||||
|
{ value: 'cpu.high', labelKey: 'pages.settings.eventCPUHigh' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'security',
|
||||||
|
labelKey: 'pages.settings.eventGroupSecurity',
|
||||||
|
events: [
|
||||||
|
{ value: 'login.attempt', labelKey: 'pages.settings.eventLoginAttempt' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface EventBusCheckboxesProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
/** Maps event value → { key: setting field name, value: current value } for inline inputs */
|
||||||
|
extra?: Record<string, { key: string; value: number }>;
|
||||||
|
/** Callback when extra input changes: (settingKey, newValue) => void */
|
||||||
|
onExtraChange?: (key: string, v: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventBusCheckboxes({ value, onChange, extra, onExtraChange }: EventBusCheckboxesProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const selected = value ? value.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||||
|
|
||||||
|
function toggle(eventType: string) {
|
||||||
|
const next = selected.includes(eventType)
|
||||||
|
? selected.filter((e) => e !== eventType)
|
||||||
|
: [...selected, eventType];
|
||||||
|
onChange(next.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(group: EventGroup) {
|
||||||
|
const groupValues = group.events.map((e) => e.value);
|
||||||
|
const allSelected = groupValues.every((v) => selected.includes(v));
|
||||||
|
let next: string[];
|
||||||
|
if (allSelected) {
|
||||||
|
next = selected.filter((v) => !groupValues.includes(v));
|
||||||
|
} else {
|
||||||
|
next = [...new Set([...selected, ...groupValues])];
|
||||||
|
}
|
||||||
|
onChange(next.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = EVENT_GROUPS.map((group) => {
|
||||||
|
const count = group.events.filter((e) => selected.includes(e.value)).length;
|
||||||
|
const total = group.events.length;
|
||||||
|
const allSelected = count === total;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: group.key,
|
||||||
|
label: (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: 500 }}>{t(group.labelKey)}</span>
|
||||||
|
<span style={{ color: '#999', fontSize: 12 }}>
|
||||||
|
{count}/{total}
|
||||||
|
</span>
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={count > 0 && count < total}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={() => toggleGroup(group)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<Checkbox.Group value={selected} style={{ width: '100%' }}>
|
||||||
|
<Space wrap size={[16, 4]}>
|
||||||
|
{group.events.map((et) => {
|
||||||
|
const checked = selected.includes(et.value);
|
||||||
|
const extraConf = extra?.[et.value];
|
||||||
|
return (
|
||||||
|
<span key={et.value} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Checkbox value={et.value} onChange={() => toggle(et.value)}>
|
||||||
|
{t(et.labelKey)}
|
||||||
|
</Checkbox>
|
||||||
|
{extraConf && onExtraChange && (
|
||||||
|
<InputNumber
|
||||||
|
size="small"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={extraConf.value}
|
||||||
|
disabled={!checked}
|
||||||
|
onChange={(v) => onExtraChange(extraConf.key, v)}
|
||||||
|
style={{ width: 60 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
</Checkbox.Group>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultActiveKeys = EVENT_GROUPS
|
||||||
|
.filter((g) => g.events.some((e) => selected.includes(e.value)))
|
||||||
|
.map((g) => g.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapse
|
||||||
|
items={items}
|
||||||
|
defaultActiveKey={defaultActiveKeys.length > 0 ? defaultActiveKeys : ['outbound']}
|
||||||
|
expandIcon={({ isActive }) => isActive ? <DownOutlined /> : <RightOutlined />}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as InputAddon } from './InputAddon';
|
export { default as InputAddon } from './InputAddon';
|
||||||
export { default as InfinityIcon } from './InfinityIcon';
|
export { default as InfinityIcon } from './InfinityIcon';
|
||||||
export { default as SettingListItem } from './SettingListItem';
|
export { default as SettingListItem } from './SettingListItem';
|
||||||
|
export { EventBusCheckboxes } from './EventBusCheckboxes';
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"remarkModel": "",
|
"remarkModel": "",
|
||||||
"restartXrayOnClientDisable": false,
|
"restartXrayOnClientDisable": false,
|
||||||
"sessionMaxAge": 1,
|
"sessionMaxAge": 1,
|
||||||
|
"smtpCpu": 0,
|
||||||
|
"smtpEnable": false,
|
||||||
|
"smtpEnabledEvents": "",
|
||||||
|
"smtpEncryptionType": "",
|
||||||
|
"smtpHost": "",
|
||||||
|
"smtpPassword": "",
|
||||||
|
"smtpPort": 1,
|
||||||
|
"smtpTo": "",
|
||||||
|
"smtpUsername": "",
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
"subClashEnable": false,
|
"subClashEnable": false,
|
||||||
@@ -64,10 +73,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"tgBotBackup": false,
|
"tgBotBackup": false,
|
||||||
"tgBotChatId": "",
|
"tgBotChatId": "",
|
||||||
"tgBotEnable": false,
|
"tgBotEnable": false,
|
||||||
"tgBotLoginNotify": false,
|
|
||||||
"tgBotProxy": "",
|
"tgBotProxy": "",
|
||||||
"tgBotToken": "",
|
"tgBotToken": "",
|
||||||
"tgCpu": 0,
|
"tgCpu": 0,
|
||||||
|
"tgEnabledEvents": "",
|
||||||
"tgLang": "",
|
"tgLang": "",
|
||||||
"tgRunTime": "",
|
"tgRunTime": "",
|
||||||
"timeLocation": "",
|
"timeLocation": "",
|
||||||
@@ -91,6 +100,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"hasApiToken": false,
|
"hasApiToken": false,
|
||||||
"hasLdapPassword": false,
|
"hasLdapPassword": false,
|
||||||
"hasNordSecret": false,
|
"hasNordSecret": false,
|
||||||
|
"hasSmtpPassword": false,
|
||||||
"hasTgBotToken": false,
|
"hasTgBotToken": false,
|
||||||
"hasTwoFactorToken": false,
|
"hasTwoFactorToken": false,
|
||||||
"hasWarpSecret": false,
|
"hasWarpSecret": false,
|
||||||
@@ -119,6 +129,15 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"remarkModel": "",
|
"remarkModel": "",
|
||||||
"restartXrayOnClientDisable": false,
|
"restartXrayOnClientDisable": false,
|
||||||
"sessionMaxAge": 1,
|
"sessionMaxAge": 1,
|
||||||
|
"smtpCpu": 0,
|
||||||
|
"smtpEnable": false,
|
||||||
|
"smtpEnabledEvents": "",
|
||||||
|
"smtpEncryptionType": "",
|
||||||
|
"smtpHost": "",
|
||||||
|
"smtpPassword": "",
|
||||||
|
"smtpPort": 1,
|
||||||
|
"smtpTo": "",
|
||||||
|
"smtpUsername": "",
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
"subClashEnable": false,
|
"subClashEnable": false,
|
||||||
@@ -153,10 +172,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"tgBotBackup": false,
|
"tgBotBackup": false,
|
||||||
"tgBotChatId": "",
|
"tgBotChatId": "",
|
||||||
"tgBotEnable": false,
|
"tgBotEnable": false,
|
||||||
"tgBotLoginNotify": false,
|
|
||||||
"tgBotProxy": "",
|
"tgBotProxy": "",
|
||||||
"tgBotToken": "",
|
"tgBotToken": "",
|
||||||
"tgCpu": 0,
|
"tgCpu": 0,
|
||||||
|
"tgEnabledEvents": "",
|
||||||
"tgLang": "",
|
"tgLang": "",
|
||||||
"tgRunTime": "",
|
"tgRunTime": "",
|
||||||
"timeLocation": "",
|
"timeLocation": "",
|
||||||
@@ -353,7 +372,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"latencyMs": 42,
|
"latencyMs": 42,
|
||||||
"memPct": 45.1,
|
"memPct": 45.1,
|
||||||
"name": "de-fra-1",
|
"name": "de-fra-1",
|
||||||
|
"netDown": 2097152,
|
||||||
|
"netUp": 1048576,
|
||||||
"onlineCount": 3,
|
"onlineCount": 3,
|
||||||
|
"outboundTag": "",
|
||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"parentGuid": "",
|
"parentGuid": "",
|
||||||
"pinnedCertSha256": "",
|
"pinnedCertSha256": "",
|
||||||
|
|||||||
@@ -112,6 +112,46 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"smtpCpu": {
|
||||||
|
"description": "CPU threshold for email notifications",
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"smtpEnable": {
|
||||||
|
"description": "Email (SMTP) notification settings\nEnable email notifications",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"smtpEnabledEvents": {
|
||||||
|
"description": "Comma-separated event types to send via email",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpEncryptionType": {
|
||||||
|
"description": "SMTP encryption: none, starttls, tls",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"description": "SMTP server host",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"description": "SMTP password",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"description": "SMTP server port",
|
||||||
|
"maximum": 65535,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"smtpTo": {
|
||||||
|
"description": "Comma-separated recipient emails",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"description": "SMTP username",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subAnnounce": {
|
"subAnnounce": {
|
||||||
"description": "Subscription announce",
|
"description": "Subscription announce",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -251,10 +291,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"tgBotLoginNotify": {
|
|
||||||
"description": "Send login notifications",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"tgBotProxy": {
|
"tgBotProxy": {
|
||||||
"description": "Proxy URL for Telegram bot",
|
"description": "Proxy URL for Telegram bot",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -269,6 +305,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"tgEnabledEvents": {
|
||||||
|
"description": "Comma-separated event types to send via Telegram",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"tgLang": {
|
"tgLang": {
|
||||||
"description": "Telegram bot language",
|
"description": "Telegram bot language",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -361,6 +401,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"remarkModel",
|
"remarkModel",
|
||||||
"restartXrayOnClientDisable",
|
"restartXrayOnClientDisable",
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
|
"smtpCpu",
|
||||||
|
"smtpEnable",
|
||||||
|
"smtpEnabledEvents",
|
||||||
|
"smtpEncryptionType",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpPassword",
|
||||||
|
"smtpPort",
|
||||||
|
"smtpTo",
|
||||||
|
"smtpUsername",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
@@ -395,10 +444,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"tgBotBackup",
|
"tgBotBackup",
|
||||||
"tgBotChatId",
|
"tgBotChatId",
|
||||||
"tgBotEnable",
|
"tgBotEnable",
|
||||||
"tgBotLoginNotify",
|
|
||||||
"tgBotProxy",
|
"tgBotProxy",
|
||||||
"tgBotToken",
|
"tgBotToken",
|
||||||
"tgCpu",
|
"tgCpu",
|
||||||
|
"tgEnabledEvents",
|
||||||
"tgLang",
|
"tgLang",
|
||||||
"tgRunTime",
|
"tgRunTime",
|
||||||
"timeLocation",
|
"timeLocation",
|
||||||
@@ -445,6 +494,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"hasNordSecret": {
|
"hasNordSecret": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"hasSmtpPassword": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"hasTgBotToken": {
|
"hasTgBotToken": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -546,6 +598,46 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"smtpCpu": {
|
||||||
|
"description": "CPU threshold for email notifications",
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"smtpEnable": {
|
||||||
|
"description": "Email (SMTP) notification settings\nEnable email notifications",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"smtpEnabledEvents": {
|
||||||
|
"description": "Comma-separated event types to send via email",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpEncryptionType": {
|
||||||
|
"description": "SMTP encryption: none, starttls, tls",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"description": "SMTP server host",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"description": "SMTP password",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"description": "SMTP server port",
|
||||||
|
"maximum": 65535,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"smtpTo": {
|
||||||
|
"description": "Comma-separated recipient emails",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"description": "SMTP username",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subAnnounce": {
|
"subAnnounce": {
|
||||||
"description": "Subscription announce",
|
"description": "Subscription announce",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -685,10 +777,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"tgBotLoginNotify": {
|
|
||||||
"description": "Send login notifications",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"tgBotProxy": {
|
"tgBotProxy": {
|
||||||
"description": "Proxy URL for Telegram bot",
|
"description": "Proxy URL for Telegram bot",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -703,6 +791,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"tgEnabledEvents": {
|
||||||
|
"description": "Comma-separated event types to send via Telegram",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"tgLang": {
|
"tgLang": {
|
||||||
"description": "Telegram bot language",
|
"description": "Telegram bot language",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -773,6 +865,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"hasApiToken",
|
"hasApiToken",
|
||||||
"hasLdapPassword",
|
"hasLdapPassword",
|
||||||
"hasNordSecret",
|
"hasNordSecret",
|
||||||
|
"hasSmtpPassword",
|
||||||
"hasTgBotToken",
|
"hasTgBotToken",
|
||||||
"hasTwoFactorToken",
|
"hasTwoFactorToken",
|
||||||
"hasWarpSecret",
|
"hasWarpSecret",
|
||||||
@@ -801,6 +894,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"remarkModel",
|
"remarkModel",
|
||||||
"restartXrayOnClientDisable",
|
"restartXrayOnClientDisable",
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
|
"smtpCpu",
|
||||||
|
"smtpEnable",
|
||||||
|
"smtpEnabledEvents",
|
||||||
|
"smtpEncryptionType",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpPassword",
|
||||||
|
"smtpPort",
|
||||||
|
"smtpTo",
|
||||||
|
"smtpUsername",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
@@ -835,10 +937,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"tgBotBackup",
|
"tgBotBackup",
|
||||||
"tgBotChatId",
|
"tgBotChatId",
|
||||||
"tgBotEnable",
|
"tgBotEnable",
|
||||||
"tgBotLoginNotify",
|
|
||||||
"tgBotProxy",
|
"tgBotProxy",
|
||||||
"tgBotToken",
|
"tgBotToken",
|
||||||
"tgCpu",
|
"tgCpu",
|
||||||
|
"tgEnabledEvents",
|
||||||
"tgLang",
|
"tgLang",
|
||||||
"tgRunTime",
|
"tgRunTime",
|
||||||
"timeLocation",
|
"timeLocation",
|
||||||
@@ -1591,10 +1693,21 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": "de-fra-1",
|
"example": "de-fra-1",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"netDown": {
|
||||||
|
"example": 2097152,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"netUp": {
|
||||||
|
"example": 1048576,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"onlineCount": {
|
"onlineCount": {
|
||||||
"example": 3,
|
"example": 3,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"outboundTag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"panelVersion": {
|
"panelVersion": {
|
||||||
"example": "v3.x.x",
|
"example": "v3.x.x",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1632,7 +1745,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"enum": [
|
"enum": [
|
||||||
"verify",
|
"verify",
|
||||||
"skip",
|
"skip",
|
||||||
"pin"
|
"pin",
|
||||||
|
"mtls"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1681,7 +1795,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"latencyMs",
|
"latencyMs",
|
||||||
"memPct",
|
"memPct",
|
||||||
"name",
|
"name",
|
||||||
|
"netDown",
|
||||||
|
"netUp",
|
||||||
"onlineCount",
|
"onlineCount",
|
||||||
|
"outboundTag",
|
||||||
"panelVersion",
|
"panelVersion",
|
||||||
"pinnedCertSha256",
|
"pinnedCertSha256",
|
||||||
"port",
|
"port",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type OnlineAPISupport = number;
|
|||||||
export type ProcessState = string;
|
export type ProcessState = string;
|
||||||
export type Protocol = string;
|
export type Protocol = string;
|
||||||
export type SubLinkProvider = unknown;
|
export type SubLinkProvider = unknown;
|
||||||
|
export type staticEgressResolver = string;
|
||||||
export type transportBits = number;
|
export type transportBits = number;
|
||||||
|
|
||||||
export interface AllSetting {
|
export interface AllSetting {
|
||||||
@@ -35,6 +36,15 @@ export interface AllSetting {
|
|||||||
remarkModel: string;
|
remarkModel: string;
|
||||||
restartXrayOnClientDisable: boolean;
|
restartXrayOnClientDisable: boolean;
|
||||||
sessionMaxAge: number;
|
sessionMaxAge: number;
|
||||||
|
smtpCpu: number;
|
||||||
|
smtpEnable: boolean;
|
||||||
|
smtpEnabledEvents: string;
|
||||||
|
smtpEncryptionType: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPassword: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpTo: string;
|
||||||
|
smtpUsername: string;
|
||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
subClashEnable: boolean;
|
subClashEnable: boolean;
|
||||||
@@ -69,10 +79,10 @@ export interface AllSetting {
|
|||||||
tgBotBackup: boolean;
|
tgBotBackup: boolean;
|
||||||
tgBotChatId: string;
|
tgBotChatId: string;
|
||||||
tgBotEnable: boolean;
|
tgBotEnable: boolean;
|
||||||
tgBotLoginNotify: boolean;
|
|
||||||
tgBotProxy: string;
|
tgBotProxy: string;
|
||||||
tgBotToken: string;
|
tgBotToken: string;
|
||||||
tgCpu: number;
|
tgCpu: number;
|
||||||
|
tgEnabledEvents: string;
|
||||||
tgLang: string;
|
tgLang: string;
|
||||||
tgRunTime: string;
|
tgRunTime: string;
|
||||||
timeLocation: string;
|
timeLocation: string;
|
||||||
@@ -97,6 +107,7 @@ export interface AllSettingView {
|
|||||||
hasApiToken: boolean;
|
hasApiToken: boolean;
|
||||||
hasLdapPassword: boolean;
|
hasLdapPassword: boolean;
|
||||||
hasNordSecret: boolean;
|
hasNordSecret: boolean;
|
||||||
|
hasSmtpPassword: boolean;
|
||||||
hasTgBotToken: boolean;
|
hasTgBotToken: boolean;
|
||||||
hasTwoFactorToken: boolean;
|
hasTwoFactorToken: boolean;
|
||||||
hasWarpSecret: boolean;
|
hasWarpSecret: boolean;
|
||||||
@@ -125,6 +136,15 @@ export interface AllSettingView {
|
|||||||
remarkModel: string;
|
remarkModel: string;
|
||||||
restartXrayOnClientDisable: boolean;
|
restartXrayOnClientDisable: boolean;
|
||||||
sessionMaxAge: number;
|
sessionMaxAge: number;
|
||||||
|
smtpCpu: number;
|
||||||
|
smtpEnable: boolean;
|
||||||
|
smtpEnabledEvents: string;
|
||||||
|
smtpEncryptionType: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPassword: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpTo: string;
|
||||||
|
smtpUsername: string;
|
||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
subClashEnable: boolean;
|
subClashEnable: boolean;
|
||||||
@@ -159,10 +179,10 @@ export interface AllSettingView {
|
|||||||
tgBotBackup: boolean;
|
tgBotBackup: boolean;
|
||||||
tgBotChatId: string;
|
tgBotChatId: string;
|
||||||
tgBotEnable: boolean;
|
tgBotEnable: boolean;
|
||||||
tgBotLoginNotify: boolean;
|
|
||||||
tgBotProxy: string;
|
tgBotProxy: string;
|
||||||
tgBotToken: string;
|
tgBotToken: string;
|
||||||
tgCpu: number;
|
tgCpu: number;
|
||||||
|
tgEnabledEvents: string;
|
||||||
tgLang: string;
|
tgLang: string;
|
||||||
tgRunTime: string;
|
tgRunTime: string;
|
||||||
timeLocation: string;
|
timeLocation: string;
|
||||||
@@ -357,7 +377,10 @@ export interface Node {
|
|||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
memPct: number;
|
memPct: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
netDown: number;
|
||||||
|
netUp: number;
|
||||||
onlineCount: number;
|
onlineCount: number;
|
||||||
|
outboundTag: string;
|
||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
parentGuid?: string;
|
parentGuid?: string;
|
||||||
pinnedCertSha256: string;
|
pinnedCertSha256: string;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
|||||||
export const SubLinkProviderSchema = z.unknown();
|
export const SubLinkProviderSchema = z.unknown();
|
||||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||||
|
|
||||||
|
export const staticEgressResolverSchema = z.string();
|
||||||
|
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||||
|
|
||||||
export const transportBitsSchema = z.number().int();
|
export const transportBitsSchema = z.number().int();
|
||||||
export type transportBits = z.infer<typeof transportBitsSchema>;
|
export type transportBits = z.infer<typeof transportBitsSchema>;
|
||||||
|
|
||||||
@@ -45,6 +48,15 @@ export const AllSettingSchema = z.object({
|
|||||||
remarkModel: z.string(),
|
remarkModel: z.string(),
|
||||||
restartXrayOnClientDisable: z.boolean(),
|
restartXrayOnClientDisable: z.boolean(),
|
||||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||||
|
smtpCpu: z.number().int().min(0).max(100),
|
||||||
|
smtpEnable: z.boolean(),
|
||||||
|
smtpEnabledEvents: z.string(),
|
||||||
|
smtpEncryptionType: z.string(),
|
||||||
|
smtpHost: z.string(),
|
||||||
|
smtpPassword: z.string(),
|
||||||
|
smtpPort: z.number().int().min(1).max(65535),
|
||||||
|
smtpTo: z.string(),
|
||||||
|
smtpUsername: z.string(),
|
||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
subClashEnable: z.boolean(),
|
subClashEnable: z.boolean(),
|
||||||
@@ -79,10 +91,10 @@ export const AllSettingSchema = z.object({
|
|||||||
tgBotBackup: z.boolean(),
|
tgBotBackup: z.boolean(),
|
||||||
tgBotChatId: z.string(),
|
tgBotChatId: z.string(),
|
||||||
tgBotEnable: z.boolean(),
|
tgBotEnable: z.boolean(),
|
||||||
tgBotLoginNotify: z.boolean(),
|
|
||||||
tgBotProxy: z.string(),
|
tgBotProxy: z.string(),
|
||||||
tgBotToken: z.string(),
|
tgBotToken: z.string(),
|
||||||
tgCpu: z.number().int().min(0).max(100),
|
tgCpu: z.number().int().min(0).max(100),
|
||||||
|
tgEnabledEvents: z.string(),
|
||||||
tgLang: z.string(),
|
tgLang: z.string(),
|
||||||
tgRunTime: z.string(),
|
tgRunTime: z.string(),
|
||||||
timeLocation: z.string(),
|
timeLocation: z.string(),
|
||||||
@@ -108,6 +120,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
hasApiToken: z.boolean(),
|
hasApiToken: z.boolean(),
|
||||||
hasLdapPassword: z.boolean(),
|
hasLdapPassword: z.boolean(),
|
||||||
hasNordSecret: z.boolean(),
|
hasNordSecret: z.boolean(),
|
||||||
|
hasSmtpPassword: z.boolean(),
|
||||||
hasTgBotToken: z.boolean(),
|
hasTgBotToken: z.boolean(),
|
||||||
hasTwoFactorToken: z.boolean(),
|
hasTwoFactorToken: z.boolean(),
|
||||||
hasWarpSecret: z.boolean(),
|
hasWarpSecret: z.boolean(),
|
||||||
@@ -136,6 +149,15 @@ export const AllSettingViewSchema = z.object({
|
|||||||
remarkModel: z.string(),
|
remarkModel: z.string(),
|
||||||
restartXrayOnClientDisable: z.boolean(),
|
restartXrayOnClientDisable: z.boolean(),
|
||||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||||
|
smtpCpu: z.number().int().min(0).max(100),
|
||||||
|
smtpEnable: z.boolean(),
|
||||||
|
smtpEnabledEvents: z.string(),
|
||||||
|
smtpEncryptionType: z.string(),
|
||||||
|
smtpHost: z.string(),
|
||||||
|
smtpPassword: z.string(),
|
||||||
|
smtpPort: z.number().int().min(1).max(65535),
|
||||||
|
smtpTo: z.string(),
|
||||||
|
smtpUsername: z.string(),
|
||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
subClashEnable: z.boolean(),
|
subClashEnable: z.boolean(),
|
||||||
@@ -170,10 +192,10 @@ export const AllSettingViewSchema = z.object({
|
|||||||
tgBotBackup: z.boolean(),
|
tgBotBackup: z.boolean(),
|
||||||
tgBotChatId: z.string(),
|
tgBotChatId: z.string(),
|
||||||
tgBotEnable: z.boolean(),
|
tgBotEnable: z.boolean(),
|
||||||
tgBotLoginNotify: z.boolean(),
|
|
||||||
tgBotProxy: z.string(),
|
tgBotProxy: z.string(),
|
||||||
tgBotToken: z.string(),
|
tgBotToken: z.string(),
|
||||||
tgCpu: z.number().int().min(0).max(100),
|
tgCpu: z.number().int().min(0).max(100),
|
||||||
|
tgEnabledEvents: z.string(),
|
||||||
tgLang: z.string(),
|
tgLang: z.string(),
|
||||||
tgRunTime: z.string(),
|
tgRunTime: z.string(),
|
||||||
timeLocation: z.string(),
|
timeLocation: z.string(),
|
||||||
@@ -383,7 +405,10 @@ export const NodeSchema = z.object({
|
|||||||
latencyMs: z.number().int(),
|
latencyMs: z.number().int(),
|
||||||
memPct: z.number(),
|
memPct: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
netDown: z.number().int(),
|
||||||
|
netUp: z.number().int(),
|
||||||
onlineCount: z.number().int(),
|
onlineCount: z.number().int(),
|
||||||
|
outboundTag: z.string(),
|
||||||
panelVersion: z.string(),
|
panelVersion: z.string(),
|
||||||
parentGuid: z.string().optional(),
|
parentGuid: z.string().optional(),
|
||||||
pinnedCertSha256: z.string(),
|
pinnedCertSha256: z.string(),
|
||||||
@@ -391,7 +416,7 @@ export const NodeSchema = z.object({
|
|||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
scheme: z.enum(['http', 'https']),
|
scheme: z.enum(['http', 'https']),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||||
transitive: z.boolean().optional(),
|
transitive: z.boolean().optional(),
|
||||||
updatedAt: z.number().int(),
|
updatedAt: z.number().int(),
|
||||||
uptimeSecs: z.number().int(),
|
uptimeSecs: z.number().int(),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type ClientsSummary,
|
type ClientsSummary,
|
||||||
type ClientPageResponse,
|
type ClientPageResponse,
|
||||||
type InboundOption,
|
type InboundOption,
|
||||||
|
type ExternalLink,
|
||||||
type BulkAdjustResult,
|
type BulkAdjustResult,
|
||||||
type BulkAttachResult,
|
type BulkAttachResult,
|
||||||
type BulkCreateResult,
|
type BulkCreateResult,
|
||||||
@@ -30,7 +31,10 @@ import {
|
|||||||
} from '@/schemas/client';
|
} from '@/schemas/client';
|
||||||
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
||||||
|
|
||||||
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
|
// One row sent to POST /clients/:email/externalLinks.
|
||||||
|
export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
|
||||||
|
|
||||||
|
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
|
||||||
|
|
||||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||||
|
|
||||||
@@ -183,6 +187,9 @@ export function useClients() {
|
|||||||
queryKey: keys.clients.list(query),
|
queryKey: keys.clients.list(query),
|
||||||
queryFn: () => fetchClientPage(query),
|
queryFn: () => fetchClientPage(query),
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
|
// List is sorted/paged server-side, so the WS patch can't add new or
|
||||||
|
// re-sort rows; poll the current page to keep it live (pauses when hidden).
|
||||||
|
refetchInterval: 5000,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,6 +223,9 @@ export function useClients() {
|
|||||||
const fetched = listQuery.data !== undefined || listQuery.isError;
|
const fetched = listQuery.data !== undefined || listQuery.isError;
|
||||||
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
|
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
|
||||||
const loading = listQuery.isFetching;
|
const loading = listQuery.isFetching;
|
||||||
|
// Showing kept-previous data for a new key (filter/sort/page) — drives the
|
||||||
|
// table overlay so the 5s background poll doesn't flash it.
|
||||||
|
const transitioning = listQuery.isPlaceholderData;
|
||||||
|
|
||||||
const inbounds = inboundOptionsQuery.data ?? [];
|
const inbounds = inboundOptionsQuery.data ?? [];
|
||||||
const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
|
const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
|
||||||
@@ -344,6 +354,12 @@ export function useClients() {
|
|||||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setExternalLinksMut = useMutation({
|
||||||
|
mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
|
||||||
|
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||||
|
});
|
||||||
|
|
||||||
const bulkAttachMut = useMutation({
|
const bulkAttachMut = useMutation({
|
||||||
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
|
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
|
||||||
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
|
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
|
||||||
@@ -358,6 +374,7 @@ export function useClients() {
|
|||||||
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const bulkDetachMut = useMutation({
|
const bulkDetachMut = useMutation({
|
||||||
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
|
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
|
||||||
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
|
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
|
||||||
@@ -418,6 +435,10 @@ export function useClients() {
|
|||||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||||
return attachMut.mutateAsync({ email, inboundIds });
|
return attachMut.mutateAsync({ email, inboundIds });
|
||||||
}, [attachMut]);
|
}, [attachMut]);
|
||||||
|
const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
|
||||||
|
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||||
|
return setExternalLinksMut.mutateAsync({ email, externalLinks });
|
||||||
|
}, [setExternalLinksMut]);
|
||||||
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
|
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
|
||||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||||
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
|
||||||
@@ -528,6 +549,7 @@ export function useClients() {
|
|||||||
inbounds,
|
inbounds,
|
||||||
onlines,
|
onlines,
|
||||||
loading,
|
loading,
|
||||||
|
transitioning,
|
||||||
fetched,
|
fetched,
|
||||||
fetchError,
|
fetchError,
|
||||||
subSettings,
|
subSettings,
|
||||||
@@ -546,6 +568,7 @@ export function useClients() {
|
|||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
bulkRemoveFromGroup,
|
bulkRemoveFromGroup,
|
||||||
attach,
|
attach,
|
||||||
|
setExternalLinks,
|
||||||
bulkAttach,
|
bulkAttach,
|
||||||
detach,
|
detach,
|
||||||
bulkDetach,
|
bulkDetach,
|
||||||
|
|||||||
@@ -142,10 +142,12 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const xraySettingRef = useRef('');
|
const xraySettingRef = useRef('');
|
||||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||||
|
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
||||||
|
|
||||||
xraySettingRef.current = xraySetting;
|
xraySettingRef.current = xraySetting;
|
||||||
outboundTestUrlRef.current = outboundTestUrl;
|
outboundTestUrlRef.current = outboundTestUrl;
|
||||||
templateSettingsRef.current = templateSettings;
|
templateSettingsRef.current = templateSettings;
|
||||||
|
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||||
|
|
||||||
// Seed local editor state from the config query. Runs on first fetch and
|
// Seed local editor state from the config query. Runs on first fetch and
|
||||||
// every time the query refetches (e.g. after a successful save).
|
// every time the query refetches (e.g. after a successful save).
|
||||||
@@ -316,41 +318,61 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||||
const list = templateSettingsRef.current?.outbounds || [];
|
// Template outbounds key their results by index (outboundTestStates);
|
||||||
if (list.length === 0 || testingAll) return;
|
// subscription outbounds aren't in the template, so they key by tag
|
||||||
|
// (subscriptionTestStates). Both go through the same probe endpoint.
|
||||||
|
const templateList = templateSettingsRef.current?.outbounds || [];
|
||||||
|
const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>;
|
||||||
|
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
|
||||||
setTestingAll(true);
|
setTestingAll(true);
|
||||||
try {
|
try {
|
||||||
const tcpQueue: { index: number; outbound: unknown }[] = [];
|
type TcpEntry =
|
||||||
const httpQueue: { index: number; outbound: unknown }[] = [];
|
| { kind: 'tpl'; index: number; outbound: unknown }
|
||||||
list.forEach((ob, i) => {
|
| { kind: 'sub'; tag: string; outbound: unknown };
|
||||||
const tag = ob?.tag;
|
const tcpQueue: TcpEntry[] = [];
|
||||||
|
// HTTP batches stay homogeneous (all template or all subscription) so a
|
||||||
|
// tag shared between a template and a subscription outbound can't collide
|
||||||
|
// inside one batch, and each batch's results route to one state map.
|
||||||
|
const httpTplQueue: { index: number; outbound: unknown }[] = [];
|
||||||
|
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
|
||||||
|
const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
|
||||||
const proto = ob?.protocol;
|
const proto = ob?.protocol;
|
||||||
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
|
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
|
||||||
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
|
// freedom ("direct") and dns aren't proxies — skip them in every mode.
|
||||||
if (mode === 'http' || isUdpOutbound(ob)) {
|
if (proto === 'freedom' || proto === 'dns') return;
|
||||||
httpQueue.push({ index: i, outbound: ob });
|
if (kind === 'sub' && !tag) return;
|
||||||
|
const toHttp = mode === 'http' || isUdpOutbound(ob);
|
||||||
|
if (kind === 'tpl') {
|
||||||
|
if (toHttp) httpTplQueue.push({ index, outbound: ob });
|
||||||
|
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
|
||||||
|
} else if (toHttp) {
|
||||||
|
httpSubQueue.push({ tag, outbound: ob });
|
||||||
} else {
|
} else {
|
||||||
tcpQueue.push({ index: i, outbound: ob });
|
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
|
||||||
|
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
|
||||||
|
|
||||||
// TCP probes are dial-only and cheap server-side; per-item requests
|
// TCP probes are dial-only and cheap server-side; per-item requests
|
||||||
// keep results landing one by one.
|
// keep results landing one by one, each routed to its own state map.
|
||||||
const runTcpLane = async () => {
|
const runTcpLane = async () => {
|
||||||
const queue = [...tcpQueue];
|
const queue = [...tcpQueue];
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const item = queue.shift();
|
const item = queue.shift();
|
||||||
if (!item) break;
|
if (!item) break;
|
||||||
await testOutbound(item.index, item.outbound, mode);
|
if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode);
|
||||||
|
else await testOutbound(item.index, item.outbound, mode);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||||
};
|
};
|
||||||
// HTTP probes go out as chunked batches — one temp xray spawn per
|
// HTTP probes go out as chunked batches — one temp xray spawn per
|
||||||
// chunk instead of one per outbound, with results landing per chunk.
|
// chunk instead of one per outbound, with results landing per chunk.
|
||||||
const runHttpLane = async () => {
|
const runTplHttpLane = async () => {
|
||||||
for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) {
|
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||||
const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||||
setOutboundTestStates((prev) => {
|
setOutboundTestStates((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
|
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
|
||||||
@@ -366,11 +388,38 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const runSubHttpLane = async () => {
|
||||||
|
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||||
|
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||||
|
setSubscriptionTestStates((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: 'http' };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
|
||||||
|
setSubscriptionTestStates((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
chunk.forEach((item, i) => {
|
||||||
|
next[item.tag] = { testing: false, result: results[i] };
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// HTTP batches must not overlap: the backend serialises them with a
|
||||||
|
// non-blocking lock and rejects a second concurrent batch ("Another
|
||||||
|
// outbound test is already running"). Run the template and subscription
|
||||||
|
// HTTP lanes one after the other; TCP probes don't take that lock, so
|
||||||
|
// they still run alongside.
|
||||||
|
const runHttpLane = async () => {
|
||||||
|
await runTplHttpLane();
|
||||||
|
await runSubHttpLane();
|
||||||
|
};
|
||||||
await Promise.all([runTcpLane(), runHttpLane()]);
|
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||||
} finally {
|
} finally {
|
||||||
setTestingAll(false);
|
setTestingAll(false);
|
||||||
}
|
}
|
||||||
}, [testingAll, testOutbound, postOutboundTestBatch]);
|
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
|
MailOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MoonFilled,
|
MoonFilled,
|
||||||
@@ -153,6 +154,7 @@ export default function AppSidebar() {
|
|||||||
{ key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
|
{ key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
|
||||||
{ key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
|
{ key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
|
||||||
{ key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
|
{ key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
|
||||||
|
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
|
||||||
{ key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
|
{ key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
|
||||||
];
|
];
|
||||||
if (showSubFormats) {
|
if (showSubFormats) {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Shape of one entry in a client's IP log, as returned by
|
||||||
|
// POST /panel/api/clients/ips/:email. `node` is the name of the node the IP is
|
||||||
|
// connecting through, or '' when it is on this local panel (or unattributed).
|
||||||
|
export type ClientIpInfo = {
|
||||||
|
ip: string;
|
||||||
|
time: string;
|
||||||
|
node: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// normalizeClientIps accepts the API payload and returns typed entries. It also
|
||||||
|
// tolerates the legacy shape (a plain array of "ip (time)" strings) so the UI
|
||||||
|
// keeps working against older panels.
|
||||||
|
export function normalizeClientIps(obj: unknown): ClientIpInfo[] {
|
||||||
|
if (!Array.isArray(obj)) return [];
|
||||||
|
const out: ClientIpInfo[] = [];
|
||||||
|
for (const x of obj) {
|
||||||
|
if (typeof x === 'string') {
|
||||||
|
if (x.length > 0) out.push({ ip: x, time: '', node: '' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (x && typeof x === 'object') {
|
||||||
|
const o = x as Record<string, unknown>;
|
||||||
|
const ip = typeof o.ip === 'string' ? o.ip : '';
|
||||||
|
if (!ip) continue;
|
||||||
|
out.push({
|
||||||
|
ip,
|
||||||
|
time: typeof o.time === 'string' ? o.time : '',
|
||||||
|
node: typeof o.node === 'string' ? o.node : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import type { FormInstance } from 'antd/es/form';
|
|||||||
import type { NamePath } from 'antd/es/form/interface';
|
import type { NamePath } from 'antd/es/form/interface';
|
||||||
|
|
||||||
import { RandomUtil } from '@/utils';
|
import { RandomUtil } from '@/utils';
|
||||||
import { OutboundProtocols } from '@/schemas/primitives';
|
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||||
|
|
||||||
|
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
|
||||||
|
|
||||||
export interface FinalMaskFormProps {
|
export interface FinalMaskFormProps {
|
||||||
name: NamePath;
|
name: NamePath;
|
||||||
@@ -18,6 +20,46 @@ export interface FinalMaskFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
|
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
|
||||||
|
const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 };
|
||||||
|
// Xray-core caps the Gecko output packet size at its internal buffer (2048)
|
||||||
|
// and needs 1 <= min <= max; mirror those bounds so the panel rejects what
|
||||||
|
// core would reject at runtime (salamander/conn.go).
|
||||||
|
const GECKO_MIN_PACKET_SIZE = 1;
|
||||||
|
const GECKO_MAX_PACKET_SIZE = 2048;
|
||||||
|
|
||||||
|
export function parseGeckoPacketSize(value: unknown): { min: number; max: number } | null {
|
||||||
|
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
|
const match = /^(\d+)-(\d+)$/.exec(str);
|
||||||
|
if (!match) return null;
|
||||||
|
const min = Number(match[1]);
|
||||||
|
const max = Number(match[2]);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(min) || !Number.isSafeInteger(max)
|
||||||
|
|| min < GECKO_MIN_PACKET_SIZE || max < min || max > GECKO_MAX_PACKET_SIZE
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGeckoPacketSize(min: number, max: number): string {
|
||||||
|
return `${min}-${max}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitGeckoPacketSize(value: unknown): { min: number | null; max: number | null } {
|
||||||
|
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
|
const [minRaw = '', maxRaw = ''] = str.split('-', 2);
|
||||||
|
const min = /^\d+$/.test(minRaw) ? Number(minRaw) : null;
|
||||||
|
const max = /^\d+$/.test(maxRaw) ? Number(maxRaw) : null;
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise<void> {
|
||||||
|
if (parseGeckoPacketSize(value)) return Promise.resolve();
|
||||||
|
return Promise.reject(new Error(
|
||||||
|
`Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function asPath(name: NamePath): (string | number)[] {
|
function asPath(name: NamePath): (string | number)[] {
|
||||||
return Array.isArray(name) ? [...name] : [name];
|
return Array.isArray(name) ? [...name] : [name];
|
||||||
@@ -470,22 +512,7 @@ function UdpMaskItem({
|
|||||||
{({ getFieldValue }) => {
|
{({ getFieldValue }) => {
|
||||||
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
|
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
|
||||||
if (type === 'salamander') {
|
if (type === 'salamander') {
|
||||||
return (
|
return <SalamanderUdpMaskSettings fieldName={fieldName} form={form} absolutePath={absolutePath} />;
|
||||||
<Form.Item label="Password">
|
|
||||||
<Space.Compact block>
|
|
||||||
<Form.Item name={[fieldName, 'settings', 'password']} noStyle>
|
|
||||||
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Button
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={() => form.setFieldValue(
|
|
||||||
[...absolutePath, 'settings', 'password'],
|
|
||||||
RandomUtil.randomLowerAndNum(16),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (type === 'mkcp-legacy') {
|
if (type === 'mkcp-legacy') {
|
||||||
return (
|
return (
|
||||||
@@ -537,6 +564,35 @@ function UdpMaskItem({
|
|||||||
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
|
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
|
||||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
|
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Divider plain style={{ margin: '8px 0' }}>TLS (optional)</Divider>
|
||||||
|
<Form.Item label="Server Name" name={[fieldName, 'settings', 'tlsConfig', 'serverName']}>
|
||||||
|
<Input placeholder="SNI for the realm server (leave empty to skip TLS)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={[
|
||||||
|
{ value: 'h3', label: 'h3' },
|
||||||
|
{ value: 'h2', label: 'h2' },
|
||||||
|
{ value: 'http/1.1', label: 'http/1.1' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Fingerprint" name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={UTLS_FINGERPRINT_OPTIONS}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Allow Insecure"
|
||||||
|
name={[fieldName, 'settings', 'tlsConfig', 'allowInsecure']}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -565,6 +621,111 @@ function UdpMaskItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SalamanderUdpMaskSettings({
|
||||||
|
fieldName, form, absolutePath,
|
||||||
|
}: {
|
||||||
|
fieldName: number;
|
||||||
|
form: FormInstance;
|
||||||
|
absolutePath: (string | number)[];
|
||||||
|
}) {
|
||||||
|
const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
|
||||||
|
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
|
||||||
|
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="Mode"
|
||||||
|
extra={mode === 'gecko'
|
||||||
|
? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
|
||||||
|
: 'Scrambles each packet into random-looking bytes.'}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={mode}
|
||||||
|
onChange={(next) => {
|
||||||
|
if (next === 'gecko') {
|
||||||
|
const current = form.getFieldValue(packetSizePath);
|
||||||
|
form.setFieldValue(
|
||||||
|
packetSizePath,
|
||||||
|
parseGeckoPacketSize(current)
|
||||||
|
? current
|
||||||
|
: formatGeckoPacketSize(DEFAULT_GECKO_PACKET_SIZE.min, DEFAULT_GECKO_PACKET_SIZE.max),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue(packetSizePath, undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'salamander', label: 'Salamander' },
|
||||||
|
{ value: 'gecko', label: 'Gecko experimental' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Password">
|
||||||
|
<Space.Compact block>
|
||||||
|
<Form.Item name={[fieldName, 'settings', 'password']} noStyle>
|
||||||
|
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => form.setFieldValue(
|
||||||
|
[...absolutePath, 'settings', 'password'],
|
||||||
|
RandomUtil.randomLowerAndNum(16),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{mode === 'gecko' && (
|
||||||
|
<Form.Item
|
||||||
|
label="Packet size"
|
||||||
|
name={[fieldName, 'settings', 'packetSize']}
|
||||||
|
rules={[{ validator: validateGeckoPacketSize }]}
|
||||||
|
extra="Serialized as a string range, for example 512-1200."
|
||||||
|
>
|
||||||
|
<GeckoPacketSizeInput />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeckoPacketSizeInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const { min, max } = splitGeckoPacketSize(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space.Compact block>
|
||||||
|
<InputNumber
|
||||||
|
addonBefore="Min"
|
||||||
|
min={GECKO_MIN_PACKET_SIZE}
|
||||||
|
max={GECKO_MAX_PACKET_SIZE}
|
||||||
|
precision={0}
|
||||||
|
value={min}
|
||||||
|
placeholder={String(DEFAULT_GECKO_PACKET_SIZE.min)}
|
||||||
|
onChange={(next) => onChange?.(`${next ?? ''}-${max ?? ''}`)}
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
addonBefore="Max"
|
||||||
|
min={GECKO_MIN_PACKET_SIZE}
|
||||||
|
max={GECKO_MAX_PACKET_SIZE}
|
||||||
|
precision={0}
|
||||||
|
value={max}
|
||||||
|
placeholder={String(DEFAULT_GECKO_PACKET_SIZE.max)}
|
||||||
|
onChange={(next) => onChange?.(`${min ?? ''}-${next ?? ''}`)}
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UdpHeaderCustom({
|
function UdpHeaderCustom({
|
||||||
udpFieldName, form, absoluteSettingsPath,
|
udpFieldName, form, absoluteSettingsPath,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma
|
|||||||
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
||||||
|
|
||||||
import { getHeaderValue } from './headers';
|
import { getHeaderValue } from './headers';
|
||||||
|
import { canEnableTlsFlow } from './protocol-capabilities';
|
||||||
|
|
||||||
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
||||||
// client overrides and returns a URL (or '' when the protocol doesn't
|
// client overrides and returns a URL (or '' when the protocol doesn't
|
||||||
@@ -23,6 +24,14 @@ import { getHeaderValue } from './headers';
|
|||||||
type ForceTls = 'same' | 'tls' | 'none';
|
type ForceTls = 'same' | 'tls' | 'none';
|
||||||
const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||||
|
|
||||||
|
// Format a host for interpolation into a URL authority. IPv6 literals are
|
||||||
|
// wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is.
|
||||||
|
// Any brackets already present are first stripped so the helper is idempotent.
|
||||||
|
function formatUrlHost(address: string): string {
|
||||||
|
const bare = address.replace(/^\[|\]$/g, '');
|
||||||
|
return bare.includes(':') ? `[${bare}]` : bare;
|
||||||
|
}
|
||||||
|
|
||||||
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
|
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
|
||||||
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
|
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
|
||||||
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
||||||
@@ -178,7 +187,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
|||||||
const stream = inbound.streamSettings;
|
const stream = inbound.streamSettings;
|
||||||
if (!stream) return '';
|
if (!stream) return '';
|
||||||
|
|
||||||
const tls = forceTls === 'same' ? stream.security : forceTls;
|
const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
|
||||||
const obj: Record<string, unknown> = {
|
const obj: Record<string, unknown> = {
|
||||||
v: '2',
|
v: '2',
|
||||||
ps: remark,
|
ps: remark,
|
||||||
@@ -374,7 +383,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
|||||||
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
||||||
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
|
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
|
||||||
}
|
}
|
||||||
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
|
||||||
}
|
}
|
||||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||||
} else if (security === 'reality') {
|
} else if (security === 'reality') {
|
||||||
@@ -394,13 +402,24 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
|||||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||||
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
|
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
|
||||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||||
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
params.set('security', 'none');
|
params.set('security', 'none');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(`vless://${clientId}@${address}:${port}`);
|
// XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
|
||||||
|
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
|
||||||
|
// vlessFlowAllowed and the form's flow-field gating so panel link, share
|
||||||
|
// link and subscription agree.
|
||||||
|
if (flow.length > 0 && canEnableTlsFlow({
|
||||||
|
protocol: inbound.protocol,
|
||||||
|
settings: inbound.settings,
|
||||||
|
streamSettings: stream,
|
||||||
|
})) {
|
||||||
|
params.set('flow', flow);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
|
||||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -524,7 +543,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
|||||||
params.set('security', 'none');
|
params.set('security', 'none');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`);
|
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`);
|
||||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -583,7 +602,7 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
|
|||||||
if (isSSMultiUser) passwords.push(clientPassword);
|
if (isSSMultiUser) passwords.push(clientPassword);
|
||||||
|
|
||||||
const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
|
const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
|
||||||
const url = new URL(`ss://${userinfo}@${address}:${port}`);
|
const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`);
|
||||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -681,7 +700,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
|||||||
params.set('mport', hopPorts);
|
params.set('mport', hopPorts);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`);
|
const url = new URL(`${scheme}://${clientAuth}@${formatUrlHost(address)}:${port}`);
|
||||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -724,7 +743,7 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
|
|||||||
const peer = settings.peers[peerIndex];
|
const peer = settings.peers[peerIndex];
|
||||||
if (!peer) return '';
|
if (!peer) return '';
|
||||||
|
|
||||||
const url = new URL(`wireguard://${address}:${port}`);
|
const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
|
||||||
url.username = peer.privateKey ?? '';
|
url.username = peer.privateKey ?? '';
|
||||||
|
|
||||||
const pubKey = settings.secretKey.length > 0
|
const pubKey = settings.secretKey.length > 0
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import { Base64 } from '@/utils';
|
|||||||
// fields the common vmess:// / vless:// links carry as query params.
|
// fields the common vmess:// / vless:// links carry as query params.
|
||||||
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
|
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
|
||||||
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
|
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
|
||||||
// present in either the JSON or URL params. xmux, reality shortIds,
|
// present in either the JSON or URL params. xmux and downloadSettings
|
||||||
// padding obfs key/header/placement, hysteria udphop are still left
|
// round-trip through the `extra` JSON blob. reality shortIds, padding
|
||||||
|
// obfs key/header/placement, hysteria udphop are still left
|
||||||
// to the user to fill in after import — the legacy Outbound.fromLink
|
// to the user to fill in after import — the legacy Outbound.fromLink
|
||||||
// was ~250 lines of dense edge-case handling we don't need to
|
// was ~250 lines of dense edge-case handling we don't need to
|
||||||
// replicate verbatim for the common phone-to-panel workflow.
|
// replicate verbatim for the common phone-to-panel workflow.
|
||||||
@@ -33,6 +34,10 @@ const XHTTP_NUMBER_KEYS = [
|
|||||||
const XHTTP_BOOL_KEYS = [
|
const XHTTP_BOOL_KEYS = [
|
||||||
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
|
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
|
||||||
] as const;
|
] as const;
|
||||||
|
// Nested objects the inbound link bundles into the `extra` JSON blob
|
||||||
|
// (and vmess JSON carries inline). The outbound form adapter expands
|
||||||
|
// xmux into the XMUX sub-form (enableXmux) on load.
|
||||||
|
const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;
|
||||||
|
|
||||||
function asBool(s: string | null): boolean | undefined {
|
function asBool(s: string | null): boolean | undefined {
|
||||||
if (s === null) return undefined;
|
if (s === null) return undefined;
|
||||||
@@ -88,6 +93,10 @@ function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): vo
|
|||||||
for (const k of XHTTP_BOOL_KEYS) {
|
for (const k of XHTTP_BOOL_KEYS) {
|
||||||
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
|
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
|
||||||
}
|
}
|
||||||
|
for (const k of XHTTP_OBJECT_KEYS) {
|
||||||
|
const v = json[k];
|
||||||
|
if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStream(network: string, security: string): Raw {
|
function buildStream(network: string, security: string): Raw {
|
||||||
|
|||||||
@@ -17,12 +17,10 @@ export class AllSetting {
|
|||||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||||
tgBotEnable = false;
|
tgBotEnable = false;
|
||||||
tgBotToken = '';
|
tgBotToken = '';
|
||||||
tgBotProxy = '';
|
|
||||||
tgBotAPIServer = '';
|
tgBotAPIServer = '';
|
||||||
tgBotChatId = '';
|
tgBotChatId = '';
|
||||||
tgRunTime = '@daily';
|
tgRunTime = '@daily';
|
||||||
tgBotBackup = false;
|
tgBotBackup = false;
|
||||||
tgBotLoginNotify = true;
|
|
||||||
tgCpu = 80;
|
tgCpu = 80;
|
||||||
tgLang = 'en-US';
|
tgLang = 'en-US';
|
||||||
twoFactorEnable = false;
|
twoFactorEnable = false;
|
||||||
@@ -84,12 +82,23 @@ export class AllSetting {
|
|||||||
ldapDefaultTotalGB = 0;
|
ldapDefaultTotalGB = 0;
|
||||||
ldapDefaultExpiryDays = 0;
|
ldapDefaultExpiryDays = 0;
|
||||||
ldapDefaultLimitIP = 0;
|
ldapDefaultLimitIP = 0;
|
||||||
|
tgEnabledEvents = '';
|
||||||
|
smtpEnable = false;
|
||||||
|
smtpHost = '';
|
||||||
|
smtpPort = 587;
|
||||||
|
smtpUsername = '';
|
||||||
|
smtpPassword = '';
|
||||||
|
smtpTo = '';
|
||||||
|
smtpEncryptionType = 'starttls';
|
||||||
|
smtpEnabledEvents = '';
|
||||||
|
smtpCpu = 80;
|
||||||
hasTgBotToken = false;
|
hasTgBotToken = false;
|
||||||
hasTwoFactorToken = false;
|
hasTwoFactorToken = false;
|
||||||
hasLdapPassword = false;
|
hasLdapPassword = false;
|
||||||
hasApiToken = false;
|
hasApiToken = false;
|
||||||
hasWarpSecret = false;
|
hasWarpSecret = false;
|
||||||
hasNordSecret = false;
|
hasNordSecret = false;
|
||||||
|
hasSmtpPassword = false;
|
||||||
|
|
||||||
constructor(data?: unknown) {
|
constructor(data?: unknown) {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
|
|||||||
@@ -503,12 +503,12 @@ export const sections: readonly Section[] = [
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/clients/get/:email',
|
path: '/panel/api/clients/get/:email',
|
||||||
summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
|
summary: 'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||||
],
|
],
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
|
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -563,6 +563,17 @@ export const sections: readonly Section[] = [
|
|||||||
body: '{\n "inboundIds": [5]\n}',
|
body: '{\n "inboundIds": [5]\n}',
|
||||||
response: '{\n "success": true\n}',
|
response: '{\n "success": true\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/:email/externalLinks',
|
||||||
|
summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||||
|
{ name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
|
||||||
|
],
|
||||||
|
body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n ]\n}',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/resetAllTraffics',
|
path: '/panel/api/clients/resetAllTraffics',
|
||||||
@@ -720,6 +731,12 @@ export const sections: readonly Section[] = [
|
|||||||
summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
|
summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["user1"],\n "c3d4-...": ["user1", "user2"]\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["user1"],\n "c3d4-...": ["user1", "user2"]\n }\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/clientIpsByGuid',
|
||||||
|
summary: 'Per-client source IPs grouped by the panelGuid of the node that observed them. Lets the central panel attribute and enforce per-client IP limits using the real visitor IPs each node sees, instead of the address of the intermediate panel it syncs through.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "a1b2-...": {\n "user1": [\n { "ip": "1.2.3.4", "timestamp": 1700000000 }\n ]\n }\n }\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/activeInbounds',
|
path: '/panel/api/clients/activeInbounds',
|
||||||
@@ -779,6 +796,18 @@ export const sections: readonly Section[] = [
|
|||||||
responseSchema: 'Node',
|
responseSchema: 'Node',
|
||||||
responseSchemaArray: true,
|
responseSchemaArray: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/mtls/ca',
|
||||||
|
summary: "This panel's node-auth CA certificate (public, PEM) to paste into a node's mTLS trust setting. Lazily mints the CA and the master client cert on first call. Pair with setting tlsVerifyMode=mtls on the node.",
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/nodes/mtls/trustCA',
|
||||||
|
summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
|
||||||
|
body: '{\n "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/nodes/get/:id',
|
path: '/panel/api/nodes/get/:id',
|
||||||
@@ -931,6 +960,18 @@ export const sections: readonly Section[] = [
|
|||||||
path: '/panel/api/setting/restartPanel',
|
path: '/panel/api/setting/restartPanel',
|
||||||
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/setting/testSmtp',
|
||||||
|
summary: 'Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.',
|
||||||
|
response: '{\n "success": true,\n "stage": "send",\n "msg": "Test email sent successfully"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/setting/testTgBot',
|
||||||
|
summary: 'Test Telegram bot connection by sending a test message to the configured chat.',
|
||||||
|
response: '{\n "success": true,\n "msg": "Test message sent to Telegram"\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/setting/getDefaultJsonConfig',
|
path: '/panel/api/setting/getDefaultJsonConfig',
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { HttpUtil, RandomUtil } from '@/utils';
|
import { HttpUtil, RandomUtil } from '@/utils';
|
||||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
|
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||||
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
|
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
|
||||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||||
import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
|
import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
|
||||||
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
@@ -38,6 +40,13 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
|||||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||||
const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
|
const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
|
||||||
|
|
||||||
|
// One editable row in the Links tab. `key` is a stable client-side id for React.
|
||||||
|
interface ExternalLinkRow {
|
||||||
|
key: number;
|
||||||
|
kind: 'link' | 'subscription';
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiMsg<T = unknown> {
|
interface ApiMsg<T = unknown> {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
msg?: string;
|
msg?: string;
|
||||||
@@ -51,10 +60,13 @@ interface SaveMetaEdit {
|
|||||||
email: string;
|
email: string;
|
||||||
attach: number[];
|
attach: number[];
|
||||||
detach: number[];
|
detach: number[];
|
||||||
|
externalLinks: ExternalLinkInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SaveMetaCreate {
|
interface SaveMetaCreate {
|
||||||
isEdit: false;
|
isEdit: false;
|
||||||
|
email: string;
|
||||||
|
externalLinks: ExternalLinkInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SaveCreatePayload {
|
interface SaveCreatePayload {
|
||||||
@@ -67,6 +79,7 @@ interface ClientFormModalProps {
|
|||||||
mode: Mode;
|
mode: Mode;
|
||||||
client: ClientRecord | null;
|
client: ClientRecord | null;
|
||||||
inbounds: InboundOption[];
|
inbounds: InboundOption[];
|
||||||
|
attachedExternalLinks?: ExternalLink[];
|
||||||
attachedIds?: number[];
|
attachedIds?: number[];
|
||||||
tgBotEnable?: boolean;
|
tgBotEnable?: boolean;
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
@@ -98,6 +111,7 @@ interface FormState {
|
|||||||
comment: string;
|
comment: string;
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
inboundIds: number[];
|
inboundIds: number[];
|
||||||
|
externalLinks: ExternalLinkRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyForm(): FormState {
|
function emptyForm(): FormState {
|
||||||
@@ -121,9 +135,19 @@ function emptyForm(): FormState {
|
|||||||
comment: '',
|
comment: '',
|
||||||
enable: true,
|
enable: true,
|
||||||
inboundIds: [],
|
inboundIds: [],
|
||||||
|
externalLinks: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let externalLinkRowSeq = 0;
|
||||||
|
function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
|
||||||
|
return (links || []).map((l) => ({
|
||||||
|
key: (externalLinkRowSeq += 1),
|
||||||
|
kind: l.kind === 'subscription' ? 'subscription' : 'link',
|
||||||
|
value: l.value || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function bytesToGB(bytes: number): number {
|
function bytesToGB(bytes: number): number {
|
||||||
if (!bytes || bytes <= 0) return 0;
|
if (!bytes || bytes <= 0) return 0;
|
||||||
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||||
@@ -139,6 +163,7 @@ export default function ClientFormModal({
|
|||||||
mode,
|
mode,
|
||||||
client,
|
client,
|
||||||
inbounds,
|
inbounds,
|
||||||
|
attachedExternalLinks = [],
|
||||||
attachedIds = [],
|
attachedIds = [],
|
||||||
tgBotEnable = false,
|
tgBotEnable = false,
|
||||||
groups = [],
|
groups = [],
|
||||||
@@ -153,7 +178,7 @@ export default function ClientFormModal({
|
|||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const [clientIps, setClientIps] = useState<string[]>([]);
|
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
|
||||||
const [ipsLoading, setIpsLoading] = useState(false);
|
const [ipsLoading, setIpsLoading] = useState(false);
|
||||||
const [ipsClearing, setIpsClearing] = useState(false);
|
const [ipsClearing, setIpsClearing] = useState(false);
|
||||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||||
@@ -162,6 +187,27 @@ export default function ClientFormModal({
|
|||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExternalLinkRow(key: number, value: string) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExternalLinkRow(key: number) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
externalLinks: prev.externalLinks.filter((r) => r.key !== key),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setIpsModalOpen(false);
|
setIpsModalOpen(false);
|
||||||
@@ -186,6 +232,7 @@ export default function ClientFormModal({
|
|||||||
comment: client.comment || '',
|
comment: client.comment || '',
|
||||||
enable: !!client.enable,
|
enable: !!client.enable,
|
||||||
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
|
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
|
||||||
|
externalLinks: toExternalLinkRows(attachedExternalLinks),
|
||||||
};
|
};
|
||||||
if (et < 0) {
|
if (et < 0) {
|
||||||
next.delayedStart = true;
|
next.delayedStart = true;
|
||||||
@@ -300,14 +347,16 @@ export default function ClientFormModal({
|
|||||||
[inbounds],
|
[inbounds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
|
||||||
|
const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
|
||||||
|
|
||||||
async function loadIps() {
|
async function loadIps() {
|
||||||
if (!isEdit || !client?.email) return;
|
if (!isEdit || !client?.email) return;
|
||||||
setIpsLoading(true);
|
setIpsLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
||||||
if (!msg?.success) { setClientIps([]); return; }
|
if (!msg?.success) { setClientIps([]); return; }
|
||||||
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
setClientIps(normalizeClientIps(msg.obj));
|
||||||
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIpsLoading(false);
|
setIpsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -400,6 +449,10 @@ export default function ClientFormModal({
|
|||||||
clientPayload.reverse = { tag: reverseTag };
|
clientPayload.reverse = { tag: reverseTag };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const externalLinks: ExternalLinkInput[] = form.externalLinks
|
||||||
|
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
|
||||||
|
.filter((r) => r.value !== '');
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
let msg;
|
let msg;
|
||||||
@@ -413,11 +466,12 @@ export default function ClientFormModal({
|
|||||||
email: client.email,
|
email: client.email,
|
||||||
attach: toAttach,
|
attach: toAttach,
|
||||||
detach: toDetach,
|
detach: toDetach,
|
||||||
|
externalLinks,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
msg = await save(
|
msg = await save(
|
||||||
{ client: clientPayload, inboundIds: form.inboundIds },
|
{ client: clientPayload, inboundIds: form.inboundIds },
|
||||||
{ isEdit: false },
|
{ isEdit: false, email: clientPayload.email as string, externalLinks },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (msg?.success) close();
|
if (msg?.success) close();
|
||||||
@@ -624,71 +678,106 @@ export default function ClientFormModal({
|
|||||||
label: t('pages.clients.tabCredentials'),
|
label: t('pages.clients.tabCredentials'),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<Row gutter={16}>
|
<Form.Item label={t('pages.clients.uuid')}>
|
||||||
<Col xs={24} md={12}>
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
<Form.Item label={t('pages.clients.uuid')}>
|
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
|
||||||
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
|
</Space.Compact>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
|
</Form.Item>
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Form.Item label={t('pages.clients.password')}>
|
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
|
||||||
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Form.Item label={t('pages.clients.password')}>
|
||||||
<Col xs={24} md={12}>
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
<Form.Item label={t('pages.clients.subId')}>
|
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
||||||
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
|
</Space.Compact>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
|
</Form.Item>
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Form.Item label={t('pages.clients.hysteriaAuth')}>
|
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
|
||||||
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
|
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Form.Item label={t('pages.clients.subId')}>
|
||||||
{showFlow && (
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
<Col xs={24} md={12}>
|
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
|
||||||
<Form.Item label={t('pages.clients.flow')}>
|
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
|
||||||
<Select
|
</Space.Compact>
|
||||||
value={form.flow}
|
</Form.Item>
|
||||||
onChange={(v) => update('flow', v)}
|
|
||||||
options={[
|
<Form.Item label={t('pages.clients.hysteriaAuth')}>
|
||||||
{ value: '', label: t('none') },
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
|
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
|
||||||
]}
|
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
|
||||||
/>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
|
||||||
)}
|
{showFlow && (
|
||||||
{showSecurity && (
|
<Form.Item label={t('pages.clients.flow')}>
|
||||||
<Col xs={24} md={12}>
|
<Select
|
||||||
<Form.Item label={t('pages.clients.vmessSecurity')}>
|
value={form.flow}
|
||||||
<Select
|
onChange={(v) => update('flow', v)}
|
||||||
value={form.security}
|
options={[
|
||||||
onChange={(v) => update('security', v)}
|
{ value: '', label: t('none') },
|
||||||
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
|
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
|
||||||
/>
|
]}
|
||||||
</Form.Item>
|
/>
|
||||||
</Col>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
</Row>
|
{showSecurity && (
|
||||||
|
<Form.Item label={t('pages.clients.vmessSecurity')}>
|
||||||
|
<Select
|
||||||
|
value={form.security}
|
||||||
|
onChange={(v) => update('security', v)}
|
||||||
|
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'links',
|
||||||
|
label: t('pages.clients.tabLinks'),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||||
|
{t('pages.clients.linksHint')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
|
||||||
|
{t('pages.clients.addExternalLink')}
|
||||||
|
</Button>
|
||||||
|
<div style={{ marginTop: 12, marginBottom: 24 }}>
|
||||||
|
{linkRows.length === 0 ? (
|
||||||
|
<Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
|
||||||
|
) : linkRows.map((row) => (
|
||||||
|
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||||
|
<Input
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
|
||||||
|
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||||
|
/>
|
||||||
|
<Tooltip title={t('delete')}>
|
||||||
|
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
|
||||||
|
{t('pages.clients.addExternalSubscription')}
|
||||||
|
</Button>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{subscriptionRows.length === 0 ? (
|
||||||
|
<Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
|
||||||
|
) : subscriptionRows.map((row) => (
|
||||||
|
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||||
|
<Input
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
|
||||||
|
placeholder="https://provider.example/sub/…"
|
||||||
|
/>
|
||||||
|
<Tooltip title={t('delete')}>
|
||||||
|
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -717,7 +806,7 @@ export default function ClientFormModal({
|
|||||||
>
|
>
|
||||||
{clientIps.length > 0 ? (
|
{clientIps.length > 0 ? (
|
||||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||||
{clientIps.map((ip, idx) => (
|
{clientIps.map((entry, idx) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={idx}
|
key={idx}
|
||||||
color="blue"
|
color="blue"
|
||||||
@@ -730,7 +819,10 @@ export default function ClientFormModal({
|
|||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ip}
|
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
|
||||||
|
{entry.node ? (
|
||||||
|
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
|
||||||
|
) : null}
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-
|
|||||||
|
|
||||||
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
|
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||||
@@ -80,7 +81,7 @@ export default function ClientInfoModal({
|
|||||||
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
|
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [links, setLinks] = useState<string[]>([]);
|
const [links, setLinks] = useState<string[]>([]);
|
||||||
const [clientIps, setClientIps] = useState<string[]>([]);
|
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
|
||||||
const [ipsLoading, setIpsLoading] = useState(false);
|
const [ipsLoading, setIpsLoading] = useState(false);
|
||||||
const [ipsClearing, setIpsClearing] = useState(false);
|
const [ipsClearing, setIpsClearing] = useState(false);
|
||||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||||
@@ -144,8 +145,7 @@ export default function ClientInfoModal({
|
|||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
||||||
if (!msg?.success) { setClientIps([]); return; }
|
if (!msg?.success) { setClientIps([]); return; }
|
||||||
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
setClientIps(normalizeClientIps(msg.obj));
|
||||||
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIpsLoading(false);
|
setIpsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -503,7 +503,7 @@ export default function ClientInfoModal({
|
|||||||
>
|
>
|
||||||
{clientIps.length > 0 ? (
|
{clientIps.length > 0 ? (
|
||||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||||
{clientIps.map((ip, idx) => (
|
{clientIps.map((entry, idx) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={idx}
|
key={idx}
|
||||||
color="blue"
|
color="blue"
|
||||||
@@ -516,7 +516,10 @@ export default function ClientInfoModal({
|
|||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ip}
|
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
|
||||||
|
{entry.node ? (
|
||||||
|
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
|
||||||
|
) : null}
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
|
|||||||
import { useClients } from '@/hooks/useClients';
|
import { useClients } from '@/hooks/useClients';
|
||||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||||
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
|
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { IntlUtil, SizeFormatter } from '@/utils';
|
import { IntlUtil, SizeFormatter } from '@/utils';
|
||||||
@@ -197,9 +197,9 @@ export default function ClientsPage() {
|
|||||||
summary: serverSummary,
|
summary: serverSummary,
|
||||||
allGroups,
|
allGroups,
|
||||||
setQuery,
|
setQuery,
|
||||||
inbounds, onlines, loading, fetched, fetchError, subSettings,
|
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
|
||||||
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||||||
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
|
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
||||||
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
||||||
applyTrafficEvent, applyClientStatsEvent,
|
applyTrafficEvent, applyClientStatsEvent,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -220,6 +220,7 @@ export default function ClientsPage() {
|
|||||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||||
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
||||||
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
||||||
|
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
||||||
const [qrOpen, setQrOpen] = useState(false);
|
const [qrOpen, setQrOpen] = useState(false);
|
||||||
@@ -429,6 +430,7 @@ export default function ClientsPage() {
|
|||||||
setFormMode('add');
|
setFormMode('add');
|
||||||
setEditingClient(null);
|
setEditingClient(null);
|
||||||
setEditingAttachedIds([]);
|
setEditingAttachedIds([]);
|
||||||
|
setEditingExternalLinks([]);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +443,7 @@ export default function ClientsPage() {
|
|||||||
setEditingClient(merged);
|
setEditingClient(merged);
|
||||||
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
|
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
|
||||||
setEditingAttachedIds([...ids]);
|
setEditingAttachedIds([...ids]);
|
||||||
|
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,10 +570,18 @@ export default function ClientsPage() {
|
|||||||
|
|
||||||
const onSave = useCallback(async (
|
const onSave = useCallback(async (
|
||||||
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
|
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
|
||||||
meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
|
meta:
|
||||||
|
| { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
|
||||||
|
| { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[] },
|
||||||
) => {
|
) => {
|
||||||
if (!meta.isEdit) {
|
if (!meta.isEdit) {
|
||||||
return create(payload);
|
const createMsg = await create(payload);
|
||||||
|
if (!createMsg?.success) return createMsg;
|
||||||
|
if (meta.email && meta.externalLinks.length > 0) {
|
||||||
|
const r = await setExternalLinks(meta.email, meta.externalLinks);
|
||||||
|
if (!r?.success) return r;
|
||||||
|
}
|
||||||
|
return createMsg;
|
||||||
}
|
}
|
||||||
const updateMsg = await update(meta.email, payload);
|
const updateMsg = await update(meta.email, payload);
|
||||||
if (!updateMsg?.success) return updateMsg;
|
if (!updateMsg?.success) return updateMsg;
|
||||||
@@ -582,8 +593,11 @@ export default function ClientsPage() {
|
|||||||
const r = await detach(meta.email, meta.detach);
|
const r = await detach(meta.email, meta.detach);
|
||||||
if (!r?.success) return r;
|
if (!r?.success) return r;
|
||||||
}
|
}
|
||||||
|
// Always replace the client's external links (an empty set clears them).
|
||||||
|
const r = await setExternalLinks(meta.email, meta.externalLinks);
|
||||||
|
if (!r?.success) return r;
|
||||||
return updateMsg;
|
return updateMsg;
|
||||||
}, [create, update, attach, detach]);
|
}, [create, update, attach, detach, setExternalLinks]);
|
||||||
|
|
||||||
const pageClass = useMemo(() => {
|
const pageClass = useMemo(() => {
|
||||||
const classes = ['clients-page'];
|
const classes = ['clients-page'];
|
||||||
@@ -605,19 +619,19 @@ export default function ClientsPage() {
|
|||||||
render: (_v, record) => (
|
render: (_v, record) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
<Tooltip title={t('pages.clients.qrCode')}>
|
||||||
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('pages.clients.clientInfo')}>
|
<Tooltip title={t('pages.clients.clientInfo')}>
|
||||||
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||||
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('edit')}>
|
<Tooltip title={t('edit')}>
|
||||||
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('delete')}>
|
<Tooltip title={t('delete')}>
|
||||||
<Button size="small" type="text" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -649,7 +663,7 @@ export default function ClientsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
if (record.enable && isOnline(record.email)) return (
|
if (record.enable && isOnline(record.email)) return (
|
||||||
<Tag color="green"><span className="online-dot" />{t('pages.clients.online')}</Tag>
|
<Tag color="green" className="dot-tag"><span className="online-dot" />{t('pages.clients.online')}</Tag>
|
||||||
);
|
);
|
||||||
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
|
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
|
||||||
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
|
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
|
||||||
@@ -1100,7 +1114,7 @@ export default function ClientsPage() {
|
|||||||
<Table<ClientRecord>
|
<Table<ClientRecord>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={sortedClients}
|
dataSource={sortedClients}
|
||||||
loading={loading}
|
loading={transitioning}
|
||||||
rowKey="email"
|
rowKey="email"
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
pagination={tablePagination}
|
pagination={tablePagination}
|
||||||
@@ -1117,7 +1131,7 @@ export default function ClientsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={transitioning}>
|
||||||
<div className="client-cards">
|
<div className="client-cards">
|
||||||
{filteredClients.length > 0 && (
|
{filteredClients.length > 0 && (
|
||||||
<div className="card-bulk-bar">
|
<div className="card-bulk-bar">
|
||||||
@@ -1243,6 +1257,7 @@ export default function ClientsPage() {
|
|||||||
mode={formMode}
|
mode={formMode}
|
||||||
client={editingClient}
|
client={editingClient}
|
||||||
attachedIds={editingAttachedIds}
|
attachedIds={editingAttachedIds}
|
||||||
|
attachedExternalLinks={editingExternalLinks}
|
||||||
inbounds={inbounds}
|
inbounds={inbounds}
|
||||||
tgBotEnable={tgBotEnable}
|
tgBotEnable={tgBotEnable}
|
||||||
groups={allGroups}
|
groups={allGroups}
|
||||||
|
|||||||
@@ -407,10 +407,10 @@ export default function GroupsPage() {
|
|||||||
render: (_v, row) => (
|
render: (_v, row) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
|
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
|
||||||
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
|
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Tooltip title={t('pages.groups.rename')}>
|
<Tooltip title={t('pages.groups.rename')}>
|
||||||
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
|
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export default function InboundsPage() {
|
|||||||
clientCount,
|
clientCount,
|
||||||
onlineClients,
|
onlineClients,
|
||||||
lastOnlineMap,
|
lastOnlineMap,
|
||||||
|
inboundSpeed,
|
||||||
totals,
|
totals,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
@@ -620,6 +621,7 @@ export default function InboundsPage() {
|
|||||||
clientCount={clientCount}
|
clientCount={clientCount}
|
||||||
onlineClients={onlineClients}
|
onlineClients={onlineClients}
|
||||||
lastOnlineMap={lastOnlineMap}
|
lastOnlineMap={lastOnlineMap}
|
||||||
|
inboundSpeed={inboundSpeed}
|
||||||
expireDiff={expireDiff}
|
expireDiff={expireDiff}
|
||||||
trafficDiff={trafficDiff}
|
trafficDiff={trafficDiff}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -83,6 +84,16 @@ import type { DBInbound } from '@/models/dbinbound';
|
|||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
|
||||||
|
|
||||||
|
// Render a field label with a hover tooltip icon instead of an `extra` help line below.
|
||||||
|
const labelWithHint = (label: string, hint: string) => (
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
<Tooltip title={hint}>
|
||||||
|
<QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
|
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
|
||||||
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
|
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
|
||||||
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
|
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
|
||||||
@@ -538,16 +549,14 @@ export default function InboundFormModal({
|
|||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="listen"
|
name="listen"
|
||||||
label={t('pages.inbounds.address')}
|
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
|
||||||
extra={t('pages.inbounds.form.listenHelp')}
|
|
||||||
>
|
>
|
||||||
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="shareAddrStrategy"
|
name="shareAddrStrategy"
|
||||||
label={t('pages.inbounds.form.shareAddrStrategy')}
|
label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
|
||||||
extra={t('pages.inbounds.form.shareAddrStrategyHelp')}
|
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
options={SHARE_ADDR_STRATEGIES
|
options={SHARE_ADDR_STRATEGIES
|
||||||
@@ -562,8 +571,7 @@ export default function InboundFormModal({
|
|||||||
{shareAddrStrategy === 'custom' && (
|
{shareAddrStrategy === 'custom' && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="shareAddr"
|
name="shareAddr"
|
||||||
label={t('pages.inbounds.form.shareAddr')}
|
label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
|
||||||
extra={t('pages.inbounds.form.shareAddrHelp')}
|
|
||||||
rules={[{
|
rules={[{
|
||||||
validator: (_, value) => (
|
validator: (_, value) => (
|
||||||
isValidShareAddrInput(String(value ?? ''))
|
isValidShareAddrInput(String(value ?? ''))
|
||||||
@@ -578,8 +586,7 @@ export default function InboundFormModal({
|
|||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="subSortIndex"
|
name="subSortIndex"
|
||||||
label={t('pages.inbounds.form.subSortIndex')}
|
label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
|
||||||
extra={t('pages.inbounds.form.subSortIndexHelp')}
|
|
||||||
>
|
>
|
||||||
<InputNumber min={1} />
|
<InputNumber min={1} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -811,7 +818,7 @@ export default function InboundFormModal({
|
|||||||
<ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
|
<ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SockoptForm toggleSockopt={toggleSockopt} />
|
<SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
|
||||||
|
|
||||||
{/* Transport masks don't apply to tunnel (a transparent forwarder), so
|
{/* Transport masks don't apply to tunnel (a transparent forwarder), so
|
||||||
its stream tab is just sockopt + TProxy. */}
|
its stream tab is just sockopt + TProxy. */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
import { Alert, Button, Form, Input, InputNumber, Segmented, Select, Space, Switch } from 'antd';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Address_Port_Strategy,
|
Address_Port_Strategy,
|
||||||
@@ -8,12 +8,68 @@ import {
|
|||||||
} from '@/schemas/primitives';
|
} from '@/schemas/primitives';
|
||||||
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||||
|
|
||||||
|
// Transport key that carries its own acceptProxyProtocol field (mirrored
|
||||||
|
// alongside the sockopt-level one so the PROXY preset never silently no-ops).
|
||||||
|
const TRANSPORT_PROXY_FIELD: Record<string, string> = {
|
||||||
|
tcp: 'tcpSettings',
|
||||||
|
ws: 'wsSettings',
|
||||||
|
httpupgrade: 'httpupgradeSettings',
|
||||||
|
};
|
||||||
|
// Transports on which xray-core honors sockopt.trustedXForwardedFor.
|
||||||
|
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp'];
|
||||||
|
|
||||||
|
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
|
||||||
|
|
||||||
export default function SockoptForm({
|
export default function SockoptForm({
|
||||||
toggleSockopt,
|
toggleSockopt,
|
||||||
|
network,
|
||||||
}: {
|
}: {
|
||||||
toggleSockopt: (on: boolean) => void;
|
toggleSockopt: (on: boolean) => void;
|
||||||
|
network: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Presets write the same sockopt fields the user could set by hand below,
|
||||||
|
// picking the mechanism xray-core actually honors for the chosen transport:
|
||||||
|
// CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp) or the
|
||||||
|
// PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
|
||||||
|
const applyRealClientIpPreset = (
|
||||||
|
preset: RealClientIpPreset,
|
||||||
|
getFieldValue: (name: (string | number)[]) => unknown,
|
||||||
|
setFieldValue: (name: (string | number)[], value: unknown) => void,
|
||||||
|
) => {
|
||||||
|
const sockopt = getFieldValue(['streamSettings', 'sockopt']);
|
||||||
|
const sockoptOn =
|
||||||
|
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
|
||||||
|
if (preset !== 'off' && !sockoptOn) {
|
||||||
|
toggleSockopt(true);
|
||||||
|
}
|
||||||
|
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||||
|
|
||||||
|
if (preset === 'off') {
|
||||||
|
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
|
||||||
|
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
|
||||||
|
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'cloudflare') {
|
||||||
|
const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']);
|
||||||
|
const list = Array.isArray(current) ? [...(current as string[])] : [];
|
||||||
|
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
|
||||||
|
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list);
|
||||||
|
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
|
||||||
|
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxy — clear trustedXForwardedFor so a lingering header can't override the
|
||||||
|
// PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp).
|
||||||
|
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
|
||||||
|
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
|
||||||
|
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
noStyle
|
noStyle
|
||||||
@@ -33,6 +89,89 @@ export default function SockoptForm({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
{on && (
|
{on && (
|
||||||
<>
|
<>
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prev, curr) => {
|
||||||
|
type ProxyWatch = {
|
||||||
|
streamSettings?: {
|
||||||
|
sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown };
|
||||||
|
tcpSettings?: { acceptProxyProtocol?: unknown };
|
||||||
|
wsSettings?: { acceptProxyProtocol?: unknown };
|
||||||
|
httpupgradeSettings?: { acceptProxyProtocol?: unknown };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const pick = (v: ProxyWatch) => {
|
||||||
|
const s = v.streamSettings;
|
||||||
|
return JSON.stringify([
|
||||||
|
s?.sockopt?.trustedXForwardedFor,
|
||||||
|
s?.sockopt?.acceptProxyProtocol,
|
||||||
|
s?.tcpSettings?.acceptProxyProtocol,
|
||||||
|
s?.wsSettings?.acceptProxyProtocol,
|
||||||
|
s?.httpupgradeSettings?.acceptProxyProtocol,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ getFieldValue, setFieldValue }) => {
|
||||||
|
const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||||
|
const transportPP = transportField
|
||||||
|
? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true
|
||||||
|
: false;
|
||||||
|
const proxyOn = sockopt.acceptProxyProtocol === true || transportPP;
|
||||||
|
const trusted = Array.isArray(sockopt.trustedXForwardedFor)
|
||||||
|
? (sockopt.trustedXForwardedFor as string[])
|
||||||
|
: [];
|
||||||
|
const value: RealClientIpPreset = proxyOn
|
||||||
|
? 'proxy'
|
||||||
|
: trusted.length > 0
|
||||||
|
? 'cloudflare'
|
||||||
|
: 'off';
|
||||||
|
const trustedMismatch =
|
||||||
|
trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
|
||||||
|
const proxyMismatch = proxyOn && network === 'kcp';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.inbounds.form.realClientIp')}
|
||||||
|
tooltip={t('pages.inbounds.form.realClientIpHint')}
|
||||||
|
>
|
||||||
|
<Segmented
|
||||||
|
value={value}
|
||||||
|
onChange={(v) =>
|
||||||
|
applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue)
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
|
||||||
|
{ value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
|
||||||
|
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{trustedMismatch && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{proxyMismatch && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
|
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
|
||||||
<InputNumber min={0} />
|
<InputNumber min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -67,6 +206,7 @@ export default function SockoptForm({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
|
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
|
||||||
label={t('pages.inbounds.form.proxyProtocol')}
|
label={t('pages.inbounds.form.proxyProtocol')}
|
||||||
|
tooltip={t('pages.inbounds.form.proxyProtocolHint')}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
@@ -139,6 +279,7 @@ export default function SockoptForm({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
||||||
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
||||||
|
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
mode="tags"
|
mode="tags"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default function InboundList({
|
|||||||
dbInbounds,
|
dbInbounds,
|
||||||
clientCount,
|
clientCount,
|
||||||
lastOnlineMap: _lastOnlineMap,
|
lastOnlineMap: _lastOnlineMap,
|
||||||
|
inboundSpeed,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -124,6 +125,7 @@ export default function InboundList({
|
|||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
nodesById,
|
nodesById,
|
||||||
clientCount,
|
clientCount,
|
||||||
|
inboundSpeed,
|
||||||
subEnable,
|
subEnable,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
@@ -271,6 +273,7 @@ export default function InboundList({
|
|||||||
hasActiveNode={hasActiveNode}
|
hasActiveNode={hasActiveNode}
|
||||||
nodesById={nodesById}
|
nodesById={nodesById}
|
||||||
clientCount={clientCount}
|
clientCount={clientCount}
|
||||||
|
inboundSpeed={inboundSpeed}
|
||||||
trafficDiff={trafficDiff}
|
trafficDiff={trafficDiff}
|
||||||
expireDiff={expireDiff}
|
expireDiff={expireDiff}
|
||||||
onClose={() => setStatsRecord(null)}
|
onClose={() => setStatsRecord(null)}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Tag, Tooltip } from 'antd';
|
||||||
|
|
||||||
|
import { SizeFormatter } from '@/utils';
|
||||||
|
|
||||||
|
import type { InboundSpeedEntry } from './types';
|
||||||
|
|
||||||
|
// True when an inbound has live throughput worth showing.
|
||||||
|
export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedEntry {
|
||||||
|
return !!speed && (speed.up > 0 || speed.down > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InboundSpeedTagProps {
|
||||||
|
speed: InboundSpeedEntry;
|
||||||
|
withTooltip?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
|
||||||
|
export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
|
||||||
|
const tag = (
|
||||||
|
<Tag color="blue">
|
||||||
|
↑ {SizeFormatter.speedFormat(speed.up)}
|
||||||
|
{' / '}
|
||||||
|
↓ {SizeFormatter.speedFormat(speed.down)}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
if (!withTooltip) return tag;
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={(
|
||||||
|
<div>
|
||||||
|
<div>↑ {SizeFormatter.speedFormat(speed.up)}</div>
|
||||||
|
<div>↓ {SizeFormatter.speedFormat(speed.down)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
tunnelNetworkLabel,
|
tunnelNetworkLabel,
|
||||||
mixedNetworkLabel,
|
mixedNetworkLabel,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import type { ClientCountEntry, DBInboundRecord } from './types';
|
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||||
|
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry } from './types';
|
||||||
|
|
||||||
interface InboundStatsModalProps {
|
interface InboundStatsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -21,6 +22,7 @@ interface InboundStatsModalProps {
|
|||||||
hasActiveNode: boolean;
|
hasActiveNode: boolean;
|
||||||
nodesById: Map<number, NodeRecord>;
|
nodesById: Map<number, NodeRecord>;
|
||||||
clientCount: Record<number, ClientCountEntry>;
|
clientCount: Record<number, ClientCountEntry>;
|
||||||
|
inboundSpeed: Record<number, InboundSpeedEntry>;
|
||||||
trafficDiff: number;
|
trafficDiff: number;
|
||||||
expireDiff: number;
|
expireDiff: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -32,6 +34,7 @@ export default function InboundStatsModal({
|
|||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
nodesById,
|
nodesById,
|
||||||
clientCount,
|
clientCount,
|
||||||
|
inboundSpeed,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -109,6 +112,16 @@ export default function InboundStatsModal({
|
|||||||
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
|
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const speed = inboundSpeed[record.id];
|
||||||
|
if (!isActiveSpeed(speed)) return null;
|
||||||
|
return (
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-label">{t('pages.inbounds.speed')}</span>
|
||||||
|
<InboundSpeedTag speed={speed} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{clientCount[record.id] && (
|
{clientCount[record.id] && (
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('clients')}</span>
|
<span className="stat-label">{t('clients')}</span>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
|
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
menu={{
|
menu={{
|
||||||
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
|
|||||||
onClick: ({ key }) => onClick(key as RowAction),
|
onClick: ({ key }) => onClick(key as RowAction),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
|
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export interface ClientCountEntry {
|
|||||||
online: string[];
|
online: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InboundSpeedEntry {
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type RowAction =
|
export type RowAction =
|
||||||
| 'edit'
|
| 'edit'
|
||||||
| 'showInfo'
|
| 'showInfo'
|
||||||
@@ -63,6 +68,7 @@ export interface InboundListProps {
|
|||||||
clientCount: Record<number, ClientCountEntry>;
|
clientCount: Record<number, ClientCountEntry>;
|
||||||
onlineClients: string[];
|
onlineClients: string[];
|
||||||
lastOnlineMap: Record<string, number>;
|
lastOnlineMap: Record<string, number>;
|
||||||
|
inboundSpeed: Record<number, InboundSpeedEntry>;
|
||||||
expireDiff: number;
|
expireDiff: number;
|
||||||
trafficDiff: number;
|
trafficDiff: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useDatepicker } from '@/hooks/useDatepicker';
|
|||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
|
||||||
import { RowActionsCell } from './RowActions';
|
import { RowActionsCell } from './RowActions';
|
||||||
|
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||||
import {
|
import {
|
||||||
readStreamHints,
|
readStreamHints,
|
||||||
networkLabel,
|
networkLabel,
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
tunnelNetworkLabel,
|
tunnelNetworkLabel,
|
||||||
mixedNetworkLabel,
|
mixedNetworkLabel,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
|
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
|
||||||
|
|
||||||
interface UseInboundColumnsParams {
|
interface UseInboundColumnsParams {
|
||||||
hasAnyRemark: boolean;
|
hasAnyRemark: boolean;
|
||||||
@@ -25,6 +26,7 @@ interface UseInboundColumnsParams {
|
|||||||
hasActiveNode: boolean;
|
hasActiveNode: boolean;
|
||||||
nodesById: Map<number, NodeRecord>;
|
nodesById: Map<number, NodeRecord>;
|
||||||
clientCount: Record<number, ClientCountEntry>;
|
clientCount: Record<number, ClientCountEntry>;
|
||||||
|
inboundSpeed: Record<number, InboundSpeedEntry>;
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
expireDiff: number;
|
expireDiff: number;
|
||||||
trafficDiff: number;
|
trafficDiff: number;
|
||||||
@@ -38,6 +40,7 @@ export function useInboundColumns({
|
|||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
nodesById,
|
nodesById,
|
||||||
clientCount,
|
clientCount,
|
||||||
|
inboundSpeed,
|
||||||
subEnable,
|
subEnable,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
@@ -262,6 +265,19 @@ export function useInboundColumns({
|
|||||||
</Popover>
|
</Popover>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.inbounds.speed'),
|
||||||
|
key: 'speed',
|
||||||
|
align: 'center',
|
||||||
|
width: 90,
|
||||||
|
render: (_, record) => {
|
||||||
|
const speed = inboundSpeed[record.id];
|
||||||
|
if (!isActiveSpeed(speed)) {
|
||||||
|
return <Tag color='default'>—</Tag>;
|
||||||
|
}
|
||||||
|
return <InboundSpeedTag speed={speed} withTooltip />;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.inbounds.expireDate'),
|
title: t('pages.inbounds.expireDate'),
|
||||||
key: 'expiryTime',
|
key: 'expiryTime',
|
||||||
@@ -283,5 +299,5 @@ export function useInboundColumns({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
|
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, inboundSpeed, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from
|
|||||||
import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
|
import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
|
||||||
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
|
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
|
||||||
|
|
||||||
|
import type { InboundSpeedEntry } from './list/types';
|
||||||
|
|
||||||
export interface SubSettings {
|
export interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
@@ -26,6 +28,17 @@ export interface SubSettings {
|
|||||||
|
|
||||||
type DBInboundInstance = InstanceType<typeof DBInbound>;
|
type DBInboundInstance = InstanceType<typeof DBInbound>;
|
||||||
|
|
||||||
|
// Server-side traffic polling interval in seconds. XrayTrafficJob broadcasts
|
||||||
|
// deltas accumulated over this window, so dividing by it yields bytes/sec.
|
||||||
|
const TRAFFIC_POLL_INTERVAL_S = 5;
|
||||||
|
|
||||||
|
interface TrafficDelta {
|
||||||
|
Tag: string;
|
||||||
|
Up: number;
|
||||||
|
Down: number;
|
||||||
|
IsInbound?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ClientRollup {
|
interface ClientRollup {
|
||||||
clients: number;
|
clients: number;
|
||||||
active: string[];
|
active: string[];
|
||||||
@@ -179,6 +192,8 @@ export function useInbounds() {
|
|||||||
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
|
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
|
||||||
const [statsVersion, setStatsVersion] = useState(0);
|
const [statsVersion, setStatsVersion] = useState(0);
|
||||||
|
|
||||||
|
const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
|
||||||
|
|
||||||
const [onlineClients, setOnlineClients] = useState<string[]>([]);
|
const [onlineClients, setOnlineClients] = useState<string[]>([]);
|
||||||
const onlineClientsRef = useRef<string[]>([]);
|
const onlineClientsRef = useRef<string[]>([]);
|
||||||
onlineClientsRef.current = onlineClients;
|
onlineClientsRef.current = onlineClients;
|
||||||
@@ -383,7 +398,13 @@ export function useInbounds() {
|
|||||||
const applyTrafficEvent = useCallback(
|
const applyTrafficEvent = useCallback(
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
if (!payload || typeof payload !== 'object') return;
|
if (!payload || typeof payload !== 'object') return;
|
||||||
const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
|
const p = payload as {
|
||||||
|
traffics?: TrafficDelta[];
|
||||||
|
onlineClients?: string[];
|
||||||
|
onlineByGuid?: Record<string, string[]>;
|
||||||
|
activeInbounds?: Record<string, string[]>;
|
||||||
|
lastOnlineMap?: Record<string, number>;
|
||||||
|
};
|
||||||
if (Array.isArray(p.onlineClients)) {
|
if (Array.isArray(p.onlineClients)) {
|
||||||
onlineClientsRef.current = p.onlineClients;
|
onlineClientsRef.current = p.onlineClients;
|
||||||
setOnlineClients(p.onlineClients);
|
setOnlineClients(p.onlineClients);
|
||||||
@@ -397,6 +418,26 @@ export function useInbounds() {
|
|||||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||||
}
|
}
|
||||||
|
// Full-replace each poll so idle inbounds (and an empty array after an
|
||||||
|
// Xray stat reset) clear their speed instead of showing a stale value.
|
||||||
|
if (Array.isArray(p.traffics)) {
|
||||||
|
const byTag = new Map<string, TrafficDelta>();
|
||||||
|
for (const tr of p.traffics) {
|
||||||
|
if (!tr || typeof tr.Tag !== 'string') continue;
|
||||||
|
if (tr.IsInbound === false) continue;
|
||||||
|
byTag.set(tr.Tag, tr);
|
||||||
|
}
|
||||||
|
const nextSpeed: Record<number, InboundSpeedEntry> = {};
|
||||||
|
for (const ib of dbInboundsRef.current) {
|
||||||
|
const delta = byTag.get(ib.tag);
|
||||||
|
if (!delta) continue;
|
||||||
|
nextSpeed[ib.id] = {
|
||||||
|
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
|
||||||
|
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setInboundSpeed(nextSpeed);
|
||||||
|
}
|
||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
},
|
},
|
||||||
[rebuildClientCount],
|
[rebuildClientCount],
|
||||||
@@ -481,6 +522,7 @@ export function useInbounds() {
|
|||||||
clientCount,
|
clientCount,
|
||||||
onlineClients,
|
onlineClients,
|
||||||
lastOnlineMap,
|
lastOnlineMap,
|
||||||
|
inboundSpeed,
|
||||||
statsVersion,
|
statsVersion,
|
||||||
totals,
|
totals,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
|||||||
import type { Msg } from '@/utils';
|
import type { Msg } from '@/utils';
|
||||||
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
||||||
import { antdRule } from '@/utils/zodForm';
|
import { antdRule } from '@/utils/zodForm';
|
||||||
|
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||||
import './NodeFormModal.css';
|
import './NodeFormModal.css';
|
||||||
|
|
||||||
type Mode = 'add' | 'edit';
|
type Mode = 'add' | 'edit';
|
||||||
@@ -49,6 +50,7 @@ function defaultValues(): NodeFormValues {
|
|||||||
pinnedCertSha256: '',
|
pinnedCertSha256: '',
|
||||||
inboundSyncMode: 'all',
|
inboundSyncMode: 'all',
|
||||||
inboundTags: [],
|
inboundTags: [],
|
||||||
|
outboundTag: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,23 @@ export default function NodeFormModal({
|
|||||||
const scheme = Form.useWatch('scheme', form) ?? 'https';
|
const scheme = Form.useWatch('scheme', form) ?? 'https';
|
||||||
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
|
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
|
||||||
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
|
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
|
||||||
|
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||||
|
|
||||||
|
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
||||||
|
// when balancers exist they get a labeled group so it's clear the selection
|
||||||
|
// routes through a balancer. Empty falls back to the placeholder ("Direct
|
||||||
|
// connection") rather than a synthetic option, so it can't read as a second
|
||||||
|
// "direct" next to a real freedom outbound.
|
||||||
|
const outboundOptions = useMemo<
|
||||||
|
({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
|
||||||
|
>(() => {
|
||||||
|
const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
|
||||||
|
if (!outboundGroups?.balancers.length) return outOpts;
|
||||||
|
return [
|
||||||
|
{ label: t('pages.xray.Outbounds'), options: outOpts },
|
||||||
|
{ label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })) },
|
||||||
|
];
|
||||||
|
}, [outboundGroups, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -117,6 +136,7 @@ export default function NodeFormModal({
|
|||||||
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
||||||
inboundSyncMode: values.inboundSyncMode,
|
inboundSyncMode: values.inboundSyncMode,
|
||||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||||
|
outboundTag: values.outboundTag || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +339,7 @@ export default function NodeFormModal({
|
|||||||
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
||||||
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
||||||
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
||||||
|
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -332,6 +353,15 @@ export default function NodeFormModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tlsVerifyMode === 'mtls' && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
title={t('pages.nodes.mtlsFormHint')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{tlsVerifyMode === 'pin' && (
|
{tlsVerifyMode === 'pin' && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('pages.nodes.pinnedCert')}
|
label={t('pages.nodes.pinnedCert')}
|
||||||
@@ -356,6 +386,20 @@ export default function NodeFormModal({
|
|||||||
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
|
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('pages.nodes.outboundTag')}
|
||||||
|
name="outboundTag"
|
||||||
|
extra={t('pages.nodes.outboundTagHint')}
|
||||||
|
getValueProps={(v) => ({ value: (v as string) || undefined })}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder={t('pages.nodes.outboundTagPlaceholder')}
|
||||||
|
options={outboundOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('pages.nodes.inboundSyncMode')}
|
label={t('pages.nodes.inboundSyncMode')}
|
||||||
name="inboundSyncMode"
|
name="inboundSyncMode"
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
|||||||
const [cpuLabels, setCpuLabels] = useState<string[]>([]);
|
const [cpuLabels, setCpuLabels] = useState<string[]>([]);
|
||||||
const [memPoints, setMemPoints] = useState<number[]>([]);
|
const [memPoints, setMemPoints] = useState<number[]>([]);
|
||||||
const [memLabels, setMemLabels] = useState<string[]>([]);
|
const [memLabels, setMemLabels] = useState<string[]>([]);
|
||||||
|
const [netUpPoints, setNetUpPoints] = useState<number[]>([]);
|
||||||
|
const [netUpLabels, setNetUpLabels] = useState<string[]>([]);
|
||||||
|
const [netDownPoints, setNetDownPoints] = useState<number[]>([]);
|
||||||
|
const [netDownLabels, setNetDownLabels] = useState<string[]>([]);
|
||||||
|
|
||||||
const lastNodeId = useRef<number>(node.id);
|
const lastNodeId = useRef<number>(node.id);
|
||||||
|
|
||||||
@@ -46,7 +50,9 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
|||||||
return `${hh}:${mm}:${ss}`;
|
return `${hh}:${mm}:${ss}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSeries = async (metric: 'cpu' | 'mem') => {
|
// cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
|
||||||
|
// as KB/s (no upper clamp, the sparkline auto-scales).
|
||||||
|
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
|
||||||
try {
|
try {
|
||||||
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
|
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
|
||||||
const msg = await HttpUtil.get(url) as ApiMsg<SeriesPoint[]>;
|
const msg = await HttpUtil.get(url) as ApiMsg<SeriesPoint[]>;
|
||||||
@@ -55,7 +61,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
|||||||
const labs: string[] = [];
|
const labs: string[] = [];
|
||||||
for (const p of msg.obj) {
|
for (const p of msg.obj) {
|
||||||
labs.push(bucketLabel(p.t));
|
labs.push(bucketLabel(p.t));
|
||||||
vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
|
const n = Number(p.v) || 0;
|
||||||
|
vals.push(kind === 'pct' ? Math.max(0, Math.min(100, n)) : Math.max(0, n / 1024));
|
||||||
}
|
}
|
||||||
return { vals, labs };
|
return { vals, labs };
|
||||||
}
|
}
|
||||||
@@ -66,12 +73,21 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
|
const [cpu, mem, netUp, netDown] = await Promise.all([
|
||||||
|
fetchSeries('cpu', 'pct'),
|
||||||
|
fetchSeries('mem', 'pct'),
|
||||||
|
fetchSeries('netUp', 'rate'),
|
||||||
|
fetchSeries('netDown', 'rate'),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCpuPoints(cpu.vals);
|
setCpuPoints(cpu.vals);
|
||||||
setCpuLabels(cpu.labs);
|
setCpuLabels(cpu.labs);
|
||||||
setMemPoints(mem.vals);
|
setMemPoints(mem.vals);
|
||||||
setMemLabels(mem.labs);
|
setMemLabels(mem.labs);
|
||||||
|
setNetUpPoints(netUp.vals);
|
||||||
|
setNetUpLabels(netUp.labs);
|
||||||
|
setNetDownPoints(netDown.vals);
|
||||||
|
setNetDownLabels(netDown.labs);
|
||||||
};
|
};
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
@@ -118,6 +134,38 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
|||||||
showTooltip
|
showTooltip
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="series">
|
||||||
|
<div className="series-title">{t('pages.nodes.netUp')}</div>
|
||||||
|
<Sparkline
|
||||||
|
data={netUpPoints}
|
||||||
|
labels={netUpLabels}
|
||||||
|
height={120}
|
||||||
|
stroke="#1677ff"
|
||||||
|
showGrid
|
||||||
|
showAxes
|
||||||
|
tickCountX={4}
|
||||||
|
maxPoints={netUpPoints.length || 1}
|
||||||
|
fillOpacity={0.18}
|
||||||
|
markerRadius={2.6}
|
||||||
|
showTooltip
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="series">
|
||||||
|
<div className="series-title">{t('pages.nodes.netDown')}</div>
|
||||||
|
<Sparkline
|
||||||
|
data={netDownPoints}
|
||||||
|
labels={netDownLabels}
|
||||||
|
height={120}
|
||||||
|
stroke="#fa8c16"
|
||||||
|
showGrid
|
||||||
|
showAxes
|
||||||
|
tickCountX={4}
|
||||||
|
maxPoints={netDownPoints.length || 1}
|
||||||
|
fillOpacity={0.18}
|
||||||
|
markerRadius={2.6}
|
||||||
|
showTooltip
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ interface NodeListProps {
|
|||||||
selectedIds: number[];
|
selectedIds: number[];
|
||||||
onSelectionChange: (ids: number[]) => void;
|
onSelectionChange: (ids: number[]) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onMtls: () => void;
|
||||||
onEdit: (node: NodeRecord) => void;
|
onEdit: (node: NodeRecord) => void;
|
||||||
onDelete: (node: NodeRecord) => void;
|
onDelete: (node: NodeRecord) => void;
|
||||||
onProbe: (node: NodeRecord) => void;
|
onProbe: (node: NodeRecord) => void;
|
||||||
@@ -163,6 +165,7 @@ export default function NodeList({
|
|||||||
selectedIds,
|
selectedIds,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onMtls,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onProbe,
|
onProbe,
|
||||||
@@ -241,18 +244,18 @@ export default function NodeList({
|
|||||||
) : (
|
) : (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title={t('pages.nodes.probe')}>
|
<Tooltip title={t('pages.nodes.probe')}>
|
||||||
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isUpdateEligible(record) && (
|
{isUpdateEligible(record) && (
|
||||||
<Tooltip title={t('pages.nodes.updatePanel')}>
|
<Tooltip title={t('pages.nodes.updatePanel')}>
|
||||||
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
|
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip title={t('edit')}>
|
<Tooltip title={t('edit')}>
|
||||||
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('delete')}>
|
<Tooltip title={t('delete')}>
|
||||||
<Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -417,6 +420,9 @@ export default function NodeList({
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||||
{t('pages.nodes.addNode')}
|
{t('pages.nodes.addNode')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
|
||||||
|
{t('pages.nodes.mtls.title')}
|
||||||
|
</Button>
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
|
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
|
||||||
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
|
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
|
import { Button, Card, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
@@ -45,6 +45,45 @@ export default function NodesPage() {
|
|||||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||||
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [mtlsOpen, setMtlsOpen] = useState(false);
|
||||||
|
const [trustCa, setTrustCa] = useState('');
|
||||||
|
const [copyingCa, setCopyingCa] = useState(false);
|
||||||
|
const [savingTrustCa, setSavingTrustCa] = useState(false);
|
||||||
|
|
||||||
|
const onCopyNodeCa = useCallback(async () => {
|
||||||
|
setCopyingCa(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca');
|
||||||
|
const ca = msg?.obj?.caCert;
|
||||||
|
if (msg?.success && ca) {
|
||||||
|
await navigator.clipboard.writeText(ca);
|
||||||
|
messageApi.success(t('pages.nodes.mtls.caCopied'));
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('pages.nodes.mtls.caFailed'));
|
||||||
|
} finally {
|
||||||
|
setCopyingCa(false);
|
||||||
|
}
|
||||||
|
}, [messageApi, t]);
|
||||||
|
|
||||||
|
const onSaveTrustCa = useCallback(async () => {
|
||||||
|
setSavingTrustCa(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa });
|
||||||
|
if (msg?.success) {
|
||||||
|
messageApi.success(t('pages.nodes.mtls.saved'));
|
||||||
|
setMtlsOpen(false);
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('somethingWentWrong'));
|
||||||
|
} finally {
|
||||||
|
setSavingTrustCa(false);
|
||||||
|
}
|
||||||
|
}, [trustCa, messageApi, t]);
|
||||||
|
|
||||||
const onAdd = useCallback(() => {
|
const onAdd = useCallback(() => {
|
||||||
setFormMode('add');
|
setFormMode('add');
|
||||||
@@ -215,6 +254,7 @@ export default function NodesPage() {
|
|||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
onAdd={onAdd}
|
onAdd={onAdd}
|
||||||
|
onMtls={() => setMtlsOpen(true)}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onProbe={onProbe}
|
onProbe={onProbe}
|
||||||
@@ -239,6 +279,38 @@ export default function NodesPage() {
|
|||||||
save={onSave}
|
save={onSave}
|
||||||
onOpenChange={setFormOpen}
|
onOpenChange={setFormOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={mtlsOpen}
|
||||||
|
title={t('pages.nodes.mtls.title')}
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => setMtlsOpen(false)}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||||
|
{t('pages.nodes.mtls.intro')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Button onClick={onCopyNodeCa} loading={copyingCa} style={{ marginBottom: 4 }}>
|
||||||
|
{t('pages.nodes.mtls.copyCa')}
|
||||||
|
</Button>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{t('pages.nodes.mtls.copyCaHint')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={5}
|
||||||
|
value={trustCa}
|
||||||
|
onChange={(e) => setTrustCa(e.target.value)}
|
||||||
|
placeholder={t('pages.nodes.mtls.trustPlaceholder')}
|
||||||
|
style={{ marginTop: 4, fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||||
|
{t('pages.nodes.mtls.trustHint')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Button type="primary" onClick={onSaveTrustCa} loading={savingTrustCa} block>
|
||||||
|
{t('pages.nodes.mtls.save')}
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||||
|
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import type { AllSetting } from '@/models/setting';
|
||||||
|
import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
import { catTabLabel } from './catTabLabel';
|
||||||
|
|
||||||
|
interface EmailTabProps {
|
||||||
|
allSetting: AllSetting;
|
||||||
|
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SmtpTestResult {
|
||||||
|
success: boolean;
|
||||||
|
stage?: string;
|
||||||
|
msg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const [testLoading, setTestLoading] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<SmtpTestResult | null>(null);
|
||||||
|
|
||||||
|
const stageLabel: Record<string, string> = {
|
||||||
|
connect: t('pages.settings.smtpStageConnect'),
|
||||||
|
auth: t('pages.settings.smtpStageAuth'),
|
||||||
|
send: t('pages.settings.smtpStageSend'),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleTestSmtp() {
|
||||||
|
setTestLoading(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const res = await HttpUtil.post('/panel/api/setting/testSmtp') as SmtpTestResult;
|
||||||
|
setTestResult(res);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
|
||||||
|
} finally {
|
||||||
|
setTestLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultActiveKey="1" items={[
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.smtpEnable')} description={t('pages.settings.smtpEnableDesc')}>
|
||||||
|
<Switch checked={allSetting.smtpEnable} onChange={(v) => updateSetting({ smtpEnable: v })} />
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.smtpHost')} description={t('pages.settings.smtpHostDesc')}>
|
||||||
|
<Input value={allSetting.smtpHost} placeholder="smtp.gmail.com"
|
||||||
|
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
|
||||||
|
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
|
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
|
||||||
|
<Input value={allSetting.smtpUsername} placeholder="user@gmail.com"
|
||||||
|
onChange={(e) => updateSetting({ smtpUsername: e.target.value })} />
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
|
||||||
|
description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
|
||||||
|
<Input.Password value={allSetting.smtpPassword}
|
||||||
|
placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
|
||||||
|
onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
|
||||||
|
<Input value={allSetting.smtpTo} placeholder="admin@example.com, ops@example.com"
|
||||||
|
onChange={(e) => updateSetting({ smtpTo: e.target.value })} />
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.smtpEncryption')} description={t('pages.settings.smtpEncryptionDesc')}>
|
||||||
|
<Select
|
||||||
|
value={allSetting.smtpEncryptionType}
|
||||||
|
onChange={(v) => updateSetting({ smtpEncryptionType: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: t('pages.settings.smtpEncryptionNone') },
|
||||||
|
{ value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
|
||||||
|
{ value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
|
||||||
|
]}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
|
||||||
|
<Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestSmtp}>
|
||||||
|
{t('pages.settings.testSmtp')}
|
||||||
|
</Button>
|
||||||
|
{testResult && (
|
||||||
|
<Alert
|
||||||
|
type={testResult.success ? 'success' : 'error'}
|
||||||
|
message={
|
||||||
|
testResult.success
|
||||||
|
? t('pages.settings.' + testResult.msg)
|
||||||
|
: <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>
|
||||||
|
}
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setTestResult(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.smtpEventBusNotify')} description={t('pages.settings.smtpEventBusNotifyDesc')}>
|
||||||
|
<EventBusCheckboxes
|
||||||
|
value={allSetting.smtpEnabledEvents}
|
||||||
|
onChange={(v) => updateSetting({ smtpEnabledEvents: v })}
|
||||||
|
extra={{ 'cpu.high': { key: 'smtpCpu', value: allSetting.smtpCpu } }}
|
||||||
|
onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import AppSidebar from '@/layouts/AppSidebar';
|
|||||||
import GeneralTab from './GeneralTab';
|
import GeneralTab from './GeneralTab';
|
||||||
import SecurityTab from './SecurityTab';
|
import SecurityTab from './SecurityTab';
|
||||||
import TelegramTab from './TelegramTab';
|
import TelegramTab from './TelegramTab';
|
||||||
|
import EmailTab from './EmailTab';
|
||||||
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
||||||
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
||||||
import './SettingsPage.css';
|
import './SettingsPage.css';
|
||||||
@@ -34,7 +35,7 @@ interface ApiMsg {
|
|||||||
success?: boolean;
|
success?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
|
const tabSlugs = ['general', 'security', 'telegram', 'email', 'subscription', 'subscription-formats'];
|
||||||
|
|
||||||
function isIp(h: string): boolean {
|
function isIp(h: string): boolean {
|
||||||
if (typeof h !== 'string') return false;
|
if (typeof h !== 'string') return false;
|
||||||
@@ -197,6 +198,7 @@ export default function SettingsPage() {
|
|||||||
switch (activeSlug) {
|
switch (activeSlug) {
|
||||||
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
|
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
|
case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
|
case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
import { sanitizePath, normalizePath } from './uriPath';
|
import { sanitizePath, normalizePath } from './uriPath';
|
||||||
|
|
||||||
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
|
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'External Proxy' };
|
||||||
const REMARK_SAMPLES: Record<string, string> = { i: 'Germany', e: 'john', o: 'Relay' };
|
const REMARK_SAMPLES: Record<string, string> = { i: 'Germany', e: 'john', o: 'Relay' };
|
||||||
const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
|
const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||||
import { BellOutlined, SettingOutlined } from '@ant-design/icons';
|
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { LanguageManager } from '@/utils';
|
import { LanguageManager } from '@/utils';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||||
<Select<Mode>
|
<Select<Mode>
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={state.mode}
|
value={state.mode}
|
||||||
@@ -144,6 +145,21 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
|||||||
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
|
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
const [testLoading, setTestLoading] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
async function handleTestTgBot() {
|
||||||
|
setTestLoading(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const res = await HttpUtil.post('/panel/api/setting/testTgBot') as { success?: boolean; msg?: string };
|
||||||
|
setTestResult({ success: !!res.success, msg: res.msg || '' });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
|
||||||
|
} finally {
|
||||||
|
setTestLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const langOptions = useMemo(
|
const langOptions = useMemo(
|
||||||
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
|
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
|
||||||
@@ -172,11 +188,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
|||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.telegramToken')}
|
title={t('pages.settings.telegramToken')}
|
||||||
description={allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc')}
|
description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
|
||||||
>
|
>
|
||||||
<Input.Password
|
<Input.Password
|
||||||
value={allSetting.tgBotToken}
|
value={allSetting.tgBotToken}
|
||||||
placeholder={allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''}
|
placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
|
||||||
onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
|
onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -198,6 +214,21 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
|||||||
<Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
|
<Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
|
||||||
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
|
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
|
||||||
|
<Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestTgBot}>
|
||||||
|
{t('pages.settings.testTgBot')}
|
||||||
|
</Button>
|
||||||
|
{testResult && (
|
||||||
|
<Alert
|
||||||
|
type={testResult.success ? 'success' : 'error'}
|
||||||
|
message={testResult.msg}
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setTestResult(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -212,12 +243,14 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
|||||||
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
|
||||||
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
|
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyLogin')} description={t('pages.settings.tgNotifyLoginDesc')}>
|
|
||||||
<Switch checked={allSetting.tgBotLoginNotify} onChange={(v) => updateSetting({ tgBotLoginNotify: v })} />
|
<SettingListItem paddings="small" title={t('pages.settings.tgEventBusNotify')} description={t('pages.settings.tgEventBusNotifyDesc')}>
|
||||||
</SettingListItem>
|
<EventBusCheckboxes
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyCpu')} description={t('pages.settings.tgNotifyCpuDesc')}>
|
value={allSetting.tgEnabledEvents}
|
||||||
<InputNumber value={allSetting.tgCpu} min={0} max={100} style={{ width: '100%' }}
|
onChange={(v) => updateSetting({ tgEnabledEvents: v })}
|
||||||
onChange={(v) => updateSetting({ tgCpu: Number(v) || 0 })} />
|
extra={{ 'cpu.high': { key: 'tgCpu', value: allSetting.tgCpu } }}
|
||||||
|
onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
|
||||||
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export default function BalancersTab({
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_v, record) =>
|
render: (_v, record) =>
|
||||||
(record.selector || []).map((sel) => (
|
(record.selector || []).map((sel) => (
|
||||||
<Tag key={sel} className="info-large-tag">
|
<Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4 }}>
|
||||||
{sel}
|
{sel}
|
||||||
</Tag>
|
</Tag>
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function OutboundCardList({
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
size="small"
|
size="small"
|
||||||
loading={isTesting(outboundTestStates, index)}
|
loading={isTesting(outboundTestStates, index)}
|
||||||
disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
|
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
onClick={() => onTest(index, testMode)}
|
onClick={() => onTest(index, testMode)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function SubscriptionOutbounds({
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
size={isMobile ? 'small' : undefined}
|
size={isMobile ? 'small' : undefined}
|
||||||
loading={isTesting(subscriptionTestStates, key)}
|
loading={isTesting(subscriptionTestStates, key)}
|
||||||
disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
|
disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
|
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ export function outboundAddresses(o: OutboundRow): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUntestable(o: OutboundRow, mode: string): boolean {
|
export function isUntestable(o: OutboundRow): boolean {
|
||||||
if (!o) return true;
|
if (!o) return true;
|
||||||
if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
|
if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
|
||||||
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
|
// freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
|
||||||
|
// an HTTP probe would only measure the host's own direct reachability, so
|
||||||
|
// they're untestable in every mode.
|
||||||
|
if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function useOutboundColumns({
|
|||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
loading={isTesting(outboundTestStates, index)}
|
loading={isTesting(outboundTestStates, index)}
|
||||||
disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
|
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
onClick={() => onTest(index, testMode)}
|
onClick={() => onTest(index, testMode)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -235,3 +235,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rule-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default function RoutingTab({
|
|||||||
() =>
|
() =>
|
||||||
rules.map((rule, idx) => {
|
rules.map((rule, idx) => {
|
||||||
const r: RuleRow = { key: idx };
|
const r: RuleRow = { key: idx };
|
||||||
|
r.enabled = rule.enabled !== false;
|
||||||
r.domain = arrJoin(rule.domain);
|
r.domain = arrJoin(rule.domain);
|
||||||
r.ip = arrJoin(rule.ip);
|
r.ip = arrJoin(rule.ip);
|
||||||
r.port = rule.port;
|
r.port = rule.port;
|
||||||
@@ -185,6 +186,13 @@ export default function RoutingTab({
|
|||||||
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
|
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function toggleRule(idx: number, enabled: boolean) {
|
||||||
|
mutate((tt) => {
|
||||||
|
const list = tt.routing?.rules;
|
||||||
|
if (!list || !list[idx]) return;
|
||||||
|
list[idx].enabled = enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
|
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
|
||||||
if (ev.button != null && ev.button !== 0) return;
|
if (ev.button != null && ev.button !== 0) return;
|
||||||
@@ -247,6 +255,7 @@ export default function RoutingTab({
|
|||||||
moveUp,
|
moveUp,
|
||||||
moveDown,
|
moveDown,
|
||||||
confirmDelete,
|
confirmDelete,
|
||||||
|
toggleRule,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableScrollX = desktopColumns.reduce((sum, c) => {
|
const tableScrollX = desktopColumns.reduce((sum, c) => {
|
||||||
@@ -289,6 +298,7 @@ export default function RoutingTab({
|
|||||||
moveUp={moveUp}
|
moveUp={moveUp}
|
||||||
moveDown={moveDown}
|
moveDown={moveDown}
|
||||||
confirmDelete={confirmDelete}
|
confirmDelete={confirmDelete}
|
||||||
|
toggleRule={toggleRule}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
import { Button, Dropdown, Tag, Tooltip, Switch } from 'antd';
|
||||||
import {
|
import {
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, ruleCriteriaChips } from './helpers';
|
import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, isApiRule, ruleCriteriaChips } from './helpers';
|
||||||
import type { RuleRow } from './types';
|
import type { RuleRow } from './types';
|
||||||
|
|
||||||
interface RuleCardListProps {
|
interface RuleCardListProps {
|
||||||
@@ -25,6 +25,7 @@ interface RuleCardListProps {
|
|||||||
moveUp: (idx: number) => void;
|
moveUp: (idx: number) => void;
|
||||||
moveDown: (idx: number) => void;
|
moveDown: (idx: number) => void;
|
||||||
confirmDelete: (idx: number) => void;
|
confirmDelete: (idx: number) => void;
|
||||||
|
toggleRule: (idx: number, enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RuleCardList({
|
export default function RuleCardList({
|
||||||
@@ -36,6 +37,7 @@ export default function RuleCardList({
|
|||||||
moveUp,
|
moveUp,
|
||||||
moveDown,
|
moveDown,
|
||||||
confirmDelete,
|
confirmDelete,
|
||||||
|
toggleRule,
|
||||||
}: RuleCardListProps) {
|
}: RuleCardListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: inboundOptions } = useInboundOptions();
|
const { data: inboundOptions } = useInboundOptions();
|
||||||
@@ -50,7 +52,9 @@ export default function RuleCardList({
|
|||||||
key={rule.key}
|
key={rule.key}
|
||||||
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
|
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
|
||||||
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
|
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
|
||||||
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
|
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''} ${
|
||||||
|
rule.enabled === false ? 'rule-disabled' : ''
|
||||||
|
}`}
|
||||||
data-row-key={index}
|
data-row-key={index}
|
||||||
>
|
>
|
||||||
<div className="rule-card-head">
|
<div className="rule-card-head">
|
||||||
@@ -72,6 +76,13 @@ export default function RuleCardList({
|
|||||||
>
|
>
|
||||||
<Button shape="circle" size="small" icon={<MoreOutlined />} />
|
<Button shape="circle" size="small" icon={<MoreOutlined />} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={rule.enabled !== false}
|
||||||
|
onChange={(checked) => toggleRule(index, checked)}
|
||||||
|
disabled={isApiRule(rule)}
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rule-flow">
|
<div className="rule-flow">
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design
|
|||||||
import { InputAddon } from '@/components/ui';
|
import { InputAddon } from '@/components/ui';
|
||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
||||||
import { buildRemarkByTag, formatInboundTag } from './helpers';
|
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
|
||||||
|
|
||||||
export interface RoutingRule {
|
export interface RoutingRule {
|
||||||
|
enabled?: boolean;
|
||||||
type?: string;
|
type?: string;
|
||||||
domain?: string | string[];
|
domain?: string | string[];
|
||||||
ip?: string | string[];
|
ip?: string | string[];
|
||||||
@@ -38,6 +39,7 @@ interface RuleFormModalProps {
|
|||||||
type FormState = RuleFormValues;
|
type FormState = RuleFormValues;
|
||||||
|
|
||||||
const initialForm = (): FormState => ({
|
const initialForm = (): FormState => ({
|
||||||
|
enabled: true,
|
||||||
domain: '',
|
domain: '',
|
||||||
ip: '',
|
ip: '',
|
||||||
port: '',
|
port: '',
|
||||||
@@ -81,6 +83,7 @@ export default function RuleFormModal({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (rule) {
|
if (rule) {
|
||||||
setForm({
|
setForm({
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
|
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
|
||||||
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
|
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
|
||||||
port: rule.port || '',
|
port: rule.port || '',
|
||||||
@@ -109,6 +112,7 @@ export default function RuleFormModal({
|
|||||||
const v = validated.data;
|
const v = validated.data;
|
||||||
const built: Record<string, unknown> = {
|
const built: Record<string, unknown> = {
|
||||||
type: 'field',
|
type: 'field',
|
||||||
|
enabled: v.enabled,
|
||||||
domain: csv(v.domain),
|
domain: csv(v.domain),
|
||||||
ip: csv(v.ip),
|
ip: csv(v.ip),
|
||||||
port: v.port,
|
port: v.port,
|
||||||
@@ -151,6 +155,18 @@ export default function RuleFormModal({
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
>
|
>
|
||||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||||
|
<Form.Item label={t('enable')}>
|
||||||
|
<Select
|
||||||
|
value={form.enabled}
|
||||||
|
onChange={(v) => update('enabled', v)}
|
||||||
|
disabled={isApiRule(rule ?? {})}
|
||||||
|
options={[
|
||||||
|
{ value: true, label: t('enable') },
|
||||||
|
{ value: false, label: t('disable') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={
|
label={
|
||||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ export function inboundTagChipPreview(
|
|||||||
return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
|
return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The internal api rule (stats traffic) — its enabled state must stay locked on. */
|
||||||
|
export function isApiRule(rule: { outboundTag?: string; inboundTag?: string | string[] }): boolean {
|
||||||
|
if (rule.outboundTag !== 'api') return false;
|
||||||
|
const tags = Array.isArray(rule.inboundTag) ? rule.inboundTag : csv(rule.inboundTag);
|
||||||
|
return tags.includes('api');
|
||||||
|
}
|
||||||
|
|
||||||
export function ruleCriteriaChips(rule: RuleRow) {
|
export function ruleCriteriaChips(rule: RuleRow) {
|
||||||
const chips: { label: string; value?: string }[] = [];
|
const chips: { label: string; value?: string }[] = [];
|
||||||
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
|
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface RuleRow {
|
export interface RuleRow {
|
||||||
key: number;
|
key: number;
|
||||||
|
enabled?: boolean;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
port?: string;
|
port?: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Dropdown, Tag } from 'antd';
|
import { Button, Dropdown, Switch, Tag } from 'antd';
|
||||||
import {
|
import {
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
@@ -15,7 +15,7 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
|
|
||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
import CriterionRow from './CriterionRow';
|
import CriterionRow from './CriterionRow';
|
||||||
import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle } from './helpers';
|
import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle, isApiRule } from './helpers';
|
||||||
import type { RuleRow } from './types';
|
import type { RuleRow } from './types';
|
||||||
|
|
||||||
interface RoutingColumnsParams {
|
interface RoutingColumnsParams {
|
||||||
@@ -28,6 +28,7 @@ interface RoutingColumnsParams {
|
|||||||
moveUp: (idx: number) => void;
|
moveUp: (idx: number) => void;
|
||||||
moveDown: (idx: number) => void;
|
moveDown: (idx: number) => void;
|
||||||
confirmDelete: (idx: number) => void;
|
confirmDelete: (idx: number) => void;
|
||||||
|
toggleRule: (idx: number, enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRoutingColumns({
|
export function useRoutingColumns({
|
||||||
@@ -40,6 +41,7 @@ export function useRoutingColumns({
|
|||||||
moveUp,
|
moveUp,
|
||||||
moveDown,
|
moveDown,
|
||||||
confirmDelete,
|
confirmDelete,
|
||||||
|
toggleRule,
|
||||||
}: RoutingColumnsParams): ColumnsType<RuleRow> {
|
}: RoutingColumnsParams): ColumnsType<RuleRow> {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: inboundOptions } = useInboundOptions();
|
const { data: inboundOptions } = useInboundOptions();
|
||||||
@@ -49,44 +51,66 @@ export function useRoutingColumns({
|
|||||||
{
|
{
|
||||||
title: '#',
|
title: '#',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 100,
|
width: 60,
|
||||||
key: 'action',
|
key: 'index',
|
||||||
render: (_v, _r, index) => (
|
render: (_v, _r, index) => (
|
||||||
<div className="action-cell">
|
<div className="action-cell" style={{ justifyContent: 'center' }}>
|
||||||
<HolderOutlined
|
<HolderOutlined
|
||||||
className="drag-handle"
|
className="drag-handle"
|
||||||
title={t('pages.xray.routing.dragToReorder')}
|
title={t('pages.xray.routing.dragToReorder')}
|
||||||
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
|
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
|
||||||
/>
|
/>
|
||||||
<span className="row-index">{index + 1}</span>
|
<span className="row-index">{index + 1}</span>
|
||||||
<div className={!isMobile ? 'action-buttons' : ''}>
|
|
||||||
{!isMobile && (
|
|
||||||
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
|
|
||||||
)}
|
|
||||||
<Dropdown
|
|
||||||
trigger={['click']}
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
...(isMobile
|
|
||||||
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
|
|
||||||
: []),
|
|
||||||
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
|
|
||||||
{
|
|
||||||
key: 'down',
|
|
||||||
label: <ArrowDownOutlined />,
|
|
||||||
disabled: index === rowsLength - 1,
|
|
||||||
onClick: () => moveDown(index),
|
|
||||||
},
|
|
||||||
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button shape="circle" size="small" icon={<MoreOutlined />} />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.clients.actions'),
|
||||||
|
align: 'center',
|
||||||
|
width: 80,
|
||||||
|
key: 'action',
|
||||||
|
render: (_v, _r, index) => (
|
||||||
|
<div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
|
||||||
|
)}
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
...(isMobile
|
||||||
|
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
|
||||||
|
: []),
|
||||||
|
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
|
||||||
|
{
|
||||||
|
key: 'down',
|
||||||
|
label: <ArrowDownOutlined />,
|
||||||
|
disabled: index === rowsLength - 1,
|
||||||
|
onClick: () => moveDown(index),
|
||||||
|
},
|
||||||
|
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button shape="circle" size="small" icon={<MoreOutlined />} />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('enable'),
|
||||||
|
align: 'center',
|
||||||
|
width: 80,
|
||||||
|
key: 'enabled',
|
||||||
|
render: (_v, _r, index) => (
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={_r.enabled !== false}
|
||||||
|
onChange={(checked) => toggleRule(index, checked)}
|
||||||
|
disabled={isApiRule(_r)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.xray.rules.source'),
|
title: t('pages.xray.rules.source'),
|
||||||
align: 'left',
|
align: 'left',
|
||||||
@@ -184,6 +208,6 @@ export function useRoutingColumns({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete],
|
[t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete, toggleRule],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,9 +71,20 @@ export const ClientPageResponseSchema = z.object({
|
|||||||
groups: nullableStringArray.optional(),
|
groups: nullableStringArray.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A per-client external link surfaced in the client's subscription:
|
||||||
|
// kind=link is a single share link, kind=subscription is a remote sub URL.
|
||||||
|
export const ExternalLinkSchema = z.object({
|
||||||
|
kind: z.enum(['link', 'subscription']).default('link'),
|
||||||
|
value: z.string(),
|
||||||
|
remark: z.string().optional().default(''),
|
||||||
|
}).loose();
|
||||||
|
|
||||||
|
export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
|
||||||
|
|
||||||
export const ClientHydrateSchema = z.object({
|
export const ClientHydrateSchema = z.object({
|
||||||
client: ClientRecordSchema,
|
client: ClientRecordSchema,
|
||||||
inboundIds: nullableNumberArray,
|
inboundIds: nullableNumberArray,
|
||||||
|
externalLinks: ExternalLinkListSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const BulkAdjustResultSchema = z.object({
|
export const BulkAdjustResultSchema = z.object({
|
||||||
@@ -203,6 +214,7 @@ export const ClientBulkAddFormSchema = z.object({
|
|||||||
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
|
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
|
||||||
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
|
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
|
||||||
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
||||||
|
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
|
||||||
export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
|
export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
|
||||||
export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
|
export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
|
||||||
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
|
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ export const NodeRecordSchema = z.object({
|
|||||||
xrayState: z.string().optional(),
|
xrayState: z.string().optional(),
|
||||||
xrayError: z.string().optional(),
|
xrayError: z.string().optional(),
|
||||||
allowPrivateAddress: z.boolean().optional(),
|
allowPrivateAddress: z.boolean().optional(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']).optional(),
|
||||||
pinnedCertSha256: z.string().optional(),
|
pinnedCertSha256: z.string().optional(),
|
||||||
inboundSyncMode: z.enum(['all', 'selected']).optional(),
|
inboundSyncMode: z.enum(['all', 'selected']).optional(),
|
||||||
// Backend serializes a nil []string as null for nodes saved before #5178.
|
// Backend serializes a nil []string as null for nodes saved before #5178.
|
||||||
inboundTags: z.array(z.string()).nullish(),
|
inboundTags: z.array(z.string()).nullish(),
|
||||||
|
outboundTag: z.string().optional(),
|
||||||
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
||||||
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
||||||
guid: z.string().optional(),
|
guid: z.string().optional(),
|
||||||
@@ -61,15 +62,26 @@ export const NodeFormSchema = z.object({
|
|||||||
address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
// mTLS nodes authenticate via the client certificate, so the token is optional
|
||||||
|
// there; every other verify mode still requires one (matches remote.do()).
|
||||||
|
apiToken: z.string().trim(),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
allowPrivateAddress: z.boolean(),
|
allowPrivateAddress: z.boolean(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||||
pinnedCertSha256: z.string().optional().default(''),
|
pinnedCertSha256: z.string().optional().default(''),
|
||||||
inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
|
inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
|
||||||
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
|
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
|
||||||
// serialized as null by the backend for a nil slice — tolerate both.
|
// serialized as null by the backend for a nil slice — tolerate both.
|
||||||
inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
|
inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
|
||||||
|
outboundTag: z.string().optional(),
|
||||||
|
}).superRefine((val, ctx) => {
|
||||||
|
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['apiToken'],
|
||||||
|
message: 'pages.nodes.toasts.fillRequired',
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ export type Security = z.infer<typeof SecuritySchema>;
|
|||||||
// 'none' neither key appears. The Xray panel's StreamSettings class emits
|
// 'none' neither key appears. The Xray panel's StreamSettings class emits
|
||||||
// `undefined` for the inactive branch which strips the key during JSON
|
// `undefined` for the inactive branch which strips the key during JSON
|
||||||
// serialization, so this DU faithfully describes what's on disk.
|
// serialization, so this DU faithfully describes what's on disk.
|
||||||
export const SecuritySettingsSchema = z.discriminatedUnion('security', [
|
//
|
||||||
z.object({ security: z.literal('none') }),
|
// Tunnel (dokodemo-door / TProxy) is transportless and may carry only
|
||||||
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
|
// `sockopt` — its streamSettings has no `security` key at all. The
|
||||||
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
|
// transportless branch accepts that shape, mirroring NetworkSettingsSchema's
|
||||||
|
// `network: never().optional()` handling. A present-but-invalid security
|
||||||
|
// still fails both branches so a typo can't slip through.
|
||||||
|
export const SecuritySettingsSchema = z.union([
|
||||||
|
z.discriminatedUnion('security', [
|
||||||
|
z.object({ security: z.literal('none') }),
|
||||||
|
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
|
||||||
|
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
|
||||||
|
]),
|
||||||
|
z.object({ security: z.never().optional() }),
|
||||||
]);
|
]);
|
||||||
export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;
|
export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;
|
||||||
|
|||||||
@@ -14,28 +14,51 @@ export const RealityClientSettingsSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RealityClientSettings = z.infer<typeof RealityClientSettingsSchema>;
|
export type RealityClientSettings = z.infer<typeof RealityClientSettingsSchema>;
|
||||||
|
|
||||||
|
// xray-core accepts both `target` and `dest` as the REALITY destination —
|
||||||
|
// they are aliases (infra/conf/transport_internet.go: REALITYConfig has
|
||||||
|
// `json:"target"` and `json:"dest"`). The panel writes `target`, but configs
|
||||||
|
// produced by older panel builds, external tools, or the panel's own
|
||||||
|
// `/panel/api/inbounds` API commonly use `dest`. Map `dest` -> `target` on
|
||||||
|
// parse when `target` is absent/empty: otherwise such an inbound loads with
|
||||||
|
// an empty (required) Target field even though it runs fine, and re-saving
|
||||||
|
// it serializes the blank `target` and drops the working `dest` — silently
|
||||||
|
// breaking REALITY on the next xray restart.
|
||||||
|
const aliasRealityDest = (value: unknown): unknown => {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const hasTarget = typeof obj.target === 'string' && obj.target !== '';
|
||||||
|
if (!hasTarget && typeof obj.dest === 'string' && obj.dest !== '') {
|
||||||
|
return { ...obj, target: obj.dest };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
// Reality stream payload. `serverNames` and `shortIds` are stored as
|
// Reality stream payload. `serverNames` and `shortIds` are stored as
|
||||||
// comma-joined strings in the panel class but ship as string[] on the wire
|
// comma-joined strings in the panel class but ship as string[] on the wire
|
||||||
// — fixtures round-trip through the array form. `target` is the dest host
|
// — fixtures round-trip through the array form. `target` is the dest host
|
||||||
// Reality piggybacks on; the panel auto-generates random target+SNI when
|
// Reality piggybacks on; the panel auto-generates random target+SNI when
|
||||||
// blank.
|
// blank.
|
||||||
export const RealityStreamSettingsSchema = z.object({
|
export const RealityStreamSettingsSchema = z.preprocess(
|
||||||
show: z.boolean().default(false),
|
aliasRealityDest,
|
||||||
xver: z.number().int().min(0).default(0),
|
z.object({
|
||||||
target: z.string().default(''),
|
show: z.boolean().default(false),
|
||||||
serverNames: z.array(z.string()).default([]),
|
xver: z.number().int().min(0).default(0),
|
||||||
privateKey: z.string().default(''),
|
target: z.string().default(''),
|
||||||
minClientVer: z.string().default(''),
|
serverNames: z.array(z.string()).default([]),
|
||||||
maxClientVer: z.string().default(''),
|
privateKey: z.string().default(''),
|
||||||
maxTimediff: z.number().int().min(0).default(0),
|
minClientVer: z.string().default(''),
|
||||||
shortIds: z.array(z.string()).default([]),
|
maxClientVer: z.string().default(''),
|
||||||
mldsa65Seed: z.string().default(''),
|
maxTimediff: z.number().int().min(0).default(0),
|
||||||
settings: RealityClientSettingsSchema.default({
|
shortIds: z.array(z.string()).default([]),
|
||||||
publicKey: '',
|
mldsa65Seed: z.string().default(''),
|
||||||
fingerprint: 'chrome',
|
settings: RealityClientSettingsSchema.default({
|
||||||
serverName: '',
|
publicKey: '',
|
||||||
spiderX: '/',
|
fingerprint: 'chrome',
|
||||||
mldsa65Verify: '',
|
serverName: '',
|
||||||
|
spiderX: '/',
|
||||||
|
mldsa65Verify: '',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
export type RealityStreamSettings = z.infer<typeof RealityStreamSettingsSchema>;
|
export type RealityStreamSettings = z.infer<typeof RealityStreamSettingsSchema>;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type RuleWebhook = z.infer<typeof RuleWebhookSchema>;
|
|||||||
|
|
||||||
export const RuleObjectSchema = z.object({
|
export const RuleObjectSchema = z.object({
|
||||||
type: z.literal('field').default('field'),
|
type: z.literal('field').default('field'),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
domain: z.array(z.string()).optional(),
|
domain: z.array(z.string()).optional(),
|
||||||
ip: z.array(z.string()).optional(),
|
ip: z.array(z.string()).optional(),
|
||||||
port: PortValueSchema.optional(),
|
port: PortValueSchema.optional(),
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const AllSettingSchema = z.object({
|
|||||||
tgBotChatId: z.string().optional(),
|
tgBotChatId: z.string().optional(),
|
||||||
tgRunTime: z.string().optional(),
|
tgRunTime: z.string().optional(),
|
||||||
tgBotBackup: z.boolean().optional(),
|
tgBotBackup: z.boolean().optional(),
|
||||||
tgBotLoginNotify: z.boolean().optional(),
|
|
||||||
tgCpu: z.number().int().min(0).max(100).optional(),
|
tgCpu: z.number().int().min(0).max(100).optional(),
|
||||||
tgLang: z.string().optional(),
|
tgLang: z.string().optional(),
|
||||||
twoFactorEnable: z.boolean().optional(),
|
twoFactorEnable: z.boolean().optional(),
|
||||||
@@ -91,6 +90,7 @@ export const AllSettingSchema = z.object({
|
|||||||
hasApiToken: z.boolean().optional(),
|
hasApiToken: z.boolean().optional(),
|
||||||
hasWarpSecret: z.boolean().optional(),
|
hasWarpSecret: z.boolean().optional(),
|
||||||
hasNordSecret: z.boolean().optional(),
|
hasNordSecret: z.boolean().optional(),
|
||||||
|
hasSmtpPassword: z.boolean().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const OutboundTestResultSchema = z.object({
|
|||||||
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
|
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
|
||||||
|
|
||||||
export const RuleFormSchema = z.object({
|
export const RuleFormSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
domain: z.string(),
|
domain: z.string(),
|
||||||
ip: z.string(),
|
ip: z.string(),
|
||||||
port: z.string(),
|
port: z.string(),
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
animation: online-blink 1.1s ease-in-out infinite;
|
animation: online-blink 1.1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* For Tags that carry a status dot: inline layout aligns the dot to
|
||||||
|
baseline + half x-height (vertical-align: middle), which sits visibly
|
||||||
|
off-centre next to the label; flex centring is exact. */
|
||||||
|
.dot-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes online-blink {
|
@keyframes online-blink {
|
||||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.55); }
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.55); }
|
||||||
50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(82, 196, 26, 0); }
|
50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(82, 196, 26, 0); }
|
||||||
|
|||||||
@@ -61,6 +61,46 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`FinalMaskStreamSettingsSchema fixtures > parses realm-tls byte-stably 1`] = `
|
||||||
|
{
|
||||||
|
"tcp": [],
|
||||||
|
"udp": [
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"stunServers": [
|
||||||
|
"stun.l.google.com:19302",
|
||||||
|
],
|
||||||
|
"tlsConfig": {
|
||||||
|
"allowInsecure": false,
|
||||||
|
"alpn": [
|
||||||
|
"h3",
|
||||||
|
],
|
||||||
|
"fingerprint": "chrome",
|
||||||
|
"serverName": "example.com",
|
||||||
|
},
|
||||||
|
"url": "realm://public@example.com/my-realm",
|
||||||
|
},
|
||||||
|
"type": "realm",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`FinalMaskStreamSettingsSchema fixtures > parses salamander-gecko byte-stably 1`] = `
|
||||||
|
{
|
||||||
|
"tcp": [],
|
||||||
|
"udp": [
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"packetSize": "100-200",
|
||||||
|
"password": "swordfish",
|
||||||
|
},
|
||||||
|
"type": "salamander",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
|
exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
|
||||||
{
|
{
|
||||||
"tcp": [
|
"tcp": [
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ exports[`inbound transport forms > RawForm field structure is stable 1`] = `
|
|||||||
exports[`inbound transport forms > SockoptForm field structure is stable (enabled + happy eyeballs) 1`] = `
|
exports[`inbound transport forms > SockoptForm field structure is stable (enabled + happy eyeballs) 1`] = `
|
||||||
[
|
[
|
||||||
"Sockopt",
|
"Sockopt",
|
||||||
|
"Real client IP",
|
||||||
"Route Mark",
|
"Route Mark",
|
||||||
"TCP Keep Alive Interval",
|
"TCP Keep Alive Interval",
|
||||||
"TCP Keep Alive Idle",
|
"TCP Keep Alive Idle",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user