mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 09:05:01 +03:00
Compare commits
50 Commits
v3.3.1
..
340d0df9fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 340d0df9fc | |||
| 982595968d | |||
| 21e9b94bb4 | |||
| 5038fa1cec | |||
| 709b332d17 | |||
| 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
|
||||||
|
|||||||
@@ -4,3 +4,9 @@ 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 =="
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+11
-7
@@ -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": {
|
||||||
|
|||||||
+1336
-38
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import type { HostFormValues } from '@/schemas/api/host';
|
||||||
|
|
||||||
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||||
|
|
||||||
|
export function useHostMutations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() });
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (payload: Partial<HostFormValues>) => HttpUtil.post('/panel/api/hosts/add', payload),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: Partial<HostFormValues> }) =>
|
||||||
|
HttpUtil.post(`/panel/api/hosts/update/${id}`, payload),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMut = useMutation({
|
||||||
|
mutationFn: (id: number) => HttpUtil.post(`/panel/api/hosts/del/${id}`),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const setEnableMut = useMutation({
|
||||||
|
mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
|
||||||
|
HttpUtil.post(`/panel/api/hosts/setEnable/${id}`, { enable }),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const reorderMut = useMutation({
|
||||||
|
mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids }, JSON_HEADERS),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkEnableMut = useMutation({
|
||||||
|
mutationFn: ({ ids, enable }: { ids: number[]; enable: boolean }) =>
|
||||||
|
HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids, enable }, JSON_HEADERS),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkDelMut = useMutation({
|
||||||
|
mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids }, JSON_HEADERS),
|
||||||
|
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: (payload: Partial<HostFormValues>) => createMut.mutateAsync(payload),
|
||||||
|
update: (id: number, payload: Partial<HostFormValues>) => updateMut.mutateAsync({ id, payload }),
|
||||||
|
remove: (id: number) => removeMut.mutateAsync(id),
|
||||||
|
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
|
||||||
|
reorder: (ids: number[]) => reorderMut.mutateAsync(ids),
|
||||||
|
bulkSetEnable: (ids: number[], enable: boolean) => bulkEnableMut.mutateAsync({ ids, enable }),
|
||||||
|
bulkDel: (ids: number[]) => bulkDelMut.mutateAsync(ids),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { HostListSchema, type HostRecord } from '@/schemas/api/host';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
|
export type { HostRecord };
|
||||||
|
|
||||||
|
async function fetchHosts(): Promise<HostRecord[]> {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/hosts/list', undefined, { silent: true });
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
|
||||||
|
const validated = parseMsg(msg, HostListSchema, 'hosts/list');
|
||||||
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHostsQuery() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: keys.hosts.list(),
|
||||||
|
queryFn: fetchHosts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hosts = useMemo(() => query.data ?? [], [query.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hosts,
|
||||||
|
loading: query.isFetching,
|
||||||
|
fetched: query.data !== undefined || query.isError,
|
||||||
|
fetchError: query.error ? (query.error as Error).message : '',
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export const keys = {
|
|||||||
root: () => ['nodes'] as const,
|
root: () => ['nodes'] as const,
|
||||||
list: () => ['nodes', 'list'] as const,
|
list: () => ['nodes', 'list'] as const,
|
||||||
},
|
},
|
||||||
|
hosts: {
|
||||||
|
root: () => ['hosts'] as const,
|
||||||
|
list: () => ['hosts', 'list'] as const,
|
||||||
|
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
||||||
|
tags: () => ['hosts', 'tags'] as const,
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
root: () => ['settings'] as const,
|
root: () => ['settings'] as const,
|
||||||
all: () => ['settings', 'all'] as const,
|
all: () => ['settings', 'all'] as const,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { Button, Input, Popover, Tooltip } from 'antd';
|
||||||
|
import type { InputRef } from 'antd';
|
||||||
|
import { CodeOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
|
||||||
|
import RemarkVarPicker from './RemarkVarPicker';
|
||||||
|
|
||||||
|
interface RemarkTemplateFieldProps {
|
||||||
|
// Injected by antd Form.Item:
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
maxLength?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RemarkTemplateField is a text input augmented with a {{VAR}} template picker
|
||||||
|
* (insert-at-caret) and a live, sample-based preview of the expanded result.
|
||||||
|
* Used for the global subscription Remark Template.
|
||||||
|
*/
|
||||||
|
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
|
||||||
|
function insertToken(token: string) {
|
||||||
|
const el = inputRef.current?.input;
|
||||||
|
const start = el?.selectionStart ?? value.length;
|
||||||
|
const end = el?.selectionEnd ?? value.length;
|
||||||
|
const insert = wrapToken(token);
|
||||||
|
const next = value.slice(0, start) + insert + value.slice(end);
|
||||||
|
onChange?.(maxLength ? next.slice(0, maxLength) : next);
|
||||||
|
const caret = start + insert.length;
|
||||||
|
// The controlled value updates next render; restore the caret after it.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el?.focus();
|
||||||
|
el?.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
maxLength={maxLength}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
addonAfter={
|
||||||
|
<Popover
|
||||||
|
content={<RemarkVarPicker onPick={insertToken} />}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
title={t('pages.hosts.remarkVars.title')}
|
||||||
|
>
|
||||||
|
<Tooltip title={t('pages.hosts.remarkVars.title')}>
|
||||||
|
<Button type="text" size="small" icon={<CodeOutlined />} style={{ margin: '0 -7px' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Popover>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{hasRemarkTokens(value) && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
|
||||||
|
{t('pages.hosts.remarkVars.preview')}:{' '}
|
||||||
|
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Tag, Tooltip, Typography } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
|
||||||
|
|
||||||
|
interface RemarkVarPickerProps {
|
||||||
|
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
|
||||||
|
onPick: (token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
|
||||||
|
* the global remark-template field.
|
||||||
|
*/
|
||||||
|
export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||||
|
{t('pages.hosts.remarkVars.intro')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{REMARK_VAR_GROUPS.map((group) => (
|
||||||
|
<div key={group} style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
|
||||||
|
{t(`pages.hosts.remarkVars.groups.${group}`)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
|
||||||
|
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
|
||||||
|
<Tag
|
||||||
|
onClick={() => onPick(v.token)}
|
||||||
|
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
|
{wrapToken(v.token)}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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);
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ export { default as DateTimePicker } from './DateTimePicker';
|
|||||||
export { default as JsonEditor } from './JsonEditor';
|
export { default as JsonEditor } from './JsonEditor';
|
||||||
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
||||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||||
|
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||||
|
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||||
|
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -27,9 +27,18 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"ldapVlessField": "",
|
"ldapVlessField": "",
|
||||||
"pageSize": 0,
|
"pageSize": 0,
|
||||||
"panelOutbound": "",
|
"panelOutbound": "",
|
||||||
"remarkModel": "",
|
"remarkTemplate": "",
|
||||||
"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,
|
||||||
@@ -38,7 +47,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subClashRules": "",
|
"subClashRules": "",
|
||||||
"subClashURI": "",
|
"subClashURI": "",
|
||||||
"subDomain": "",
|
"subDomain": "",
|
||||||
"subEmailInRemark": false,
|
|
||||||
"subEnable": false,
|
"subEnable": false,
|
||||||
"subEnableRouting": false,
|
"subEnableRouting": false,
|
||||||
"subEncrypt": false,
|
"subEncrypt": false,
|
||||||
@@ -54,7 +62,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
"subShowInfo": false,
|
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
"subThemeDir": "",
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
@@ -64,10 +71,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 +98,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,
|
||||||
@@ -116,9 +124,18 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"ldapVlessField": "",
|
"ldapVlessField": "",
|
||||||
"pageSize": 0,
|
"pageSize": 0,
|
||||||
"panelOutbound": "",
|
"panelOutbound": "",
|
||||||
"remarkModel": "",
|
"remarkTemplate": "",
|
||||||
"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,
|
||||||
@@ -127,7 +144,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subClashRules": "",
|
"subClashRules": "",
|
||||||
"subClashURI": "",
|
"subClashURI": "",
|
||||||
"subDomain": "",
|
"subDomain": "",
|
||||||
"subEmailInRemark": false,
|
|
||||||
"subEnable": false,
|
"subEnable": false,
|
||||||
"subEnableRouting": false,
|
"subEnableRouting": false,
|
||||||
"subEncrypt": false,
|
"subEncrypt": false,
|
||||||
@@ -143,7 +159,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
"subShowInfo": false,
|
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
"subThemeDir": "",
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
@@ -153,10 +168,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": "",
|
||||||
@@ -258,6 +273,51 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"id": 0,
|
"id": 0,
|
||||||
"seederName": ""
|
"seederName": ""
|
||||||
},
|
},
|
||||||
|
"Host": {
|
||||||
|
"address": "cdn.example.com",
|
||||||
|
"allowInsecure": false,
|
||||||
|
"alpn": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"createdAt": 0,
|
||||||
|
"echConfigList": "",
|
||||||
|
"excludeFromSubTypes": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"finalMask": "",
|
||||||
|
"fingerprint": "",
|
||||||
|
"hostHeader": "",
|
||||||
|
"id": 1,
|
||||||
|
"inboundId": 1,
|
||||||
|
"isDisabled": false,
|
||||||
|
"isHidden": false,
|
||||||
|
"keepSniBlank": false,
|
||||||
|
"mihomoIpVersion": "dual",
|
||||||
|
"mihomoX25519": false,
|
||||||
|
"muxParams": null,
|
||||||
|
"nodeGuids": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"overrideSniFromAddress": false,
|
||||||
|
"path": "",
|
||||||
|
"pinnedPeerCertSha256": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"port": 8443,
|
||||||
|
"remark": "cdn-front",
|
||||||
|
"security": "same",
|
||||||
|
"serverDescription": "",
|
||||||
|
"shuffleHost": false,
|
||||||
|
"sni": "",
|
||||||
|
"sockoptParams": null,
|
||||||
|
"sortOrder": 0,
|
||||||
|
"tags": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"updatedAt": 0,
|
||||||
|
"verifyPeerCertByName": false,
|
||||||
|
"vlessRoute": ""
|
||||||
|
},
|
||||||
"Inbound": {
|
"Inbound": {
|
||||||
"clientStats": [
|
"clientStats": [
|
||||||
{
|
{
|
||||||
@@ -353,7 +413,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": "",
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
|
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"remarkModel": {
|
"remarkTemplate": {
|
||||||
"description": "Remark model pattern for inbounds",
|
"description": "Subscription remark template ({{VAR}} tokens) rendered per client",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"restartXrayOnClientDisable": {
|
"restartXrayOnClientDisable": {
|
||||||
@@ -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"
|
||||||
@@ -144,10 +184,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Domain for subscription server validation",
|
"description": "Domain for subscription server validation",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subEmailInRemark": {
|
|
||||||
"description": "Include email in subscription remark/name",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subEnable": {
|
"subEnable": {
|
||||||
"description": "Subscription server settings\nEnable subscription server",
|
"description": "Subscription server settings\nEnable subscription server",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -209,10 +245,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Subscription global routing rules (Only for Happ)",
|
"description": "Subscription global routing rules (Only for Happ)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subShowInfo": {
|
|
||||||
"description": "Show client information in subscriptions",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"description": "Subscription support URL",
|
"description": "Subscription support URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -251,10 +283,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 +297,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"
|
||||||
@@ -358,9 +390,18 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
"remarkModel",
|
"remarkTemplate",
|
||||||
"restartXrayOnClientDisable",
|
"restartXrayOnClientDisable",
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
|
"smtpCpu",
|
||||||
|
"smtpEnable",
|
||||||
|
"smtpEnabledEvents",
|
||||||
|
"smtpEncryptionType",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpPassword",
|
||||||
|
"smtpPort",
|
||||||
|
"smtpTo",
|
||||||
|
"smtpUsername",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
@@ -369,7 +410,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subClashRules",
|
"subClashRules",
|
||||||
"subClashURI",
|
"subClashURI",
|
||||||
"subDomain",
|
"subDomain",
|
||||||
"subEmailInRemark",
|
|
||||||
"subEnable",
|
"subEnable",
|
||||||
"subEnableRouting",
|
"subEnableRouting",
|
||||||
"subEncrypt",
|
"subEncrypt",
|
||||||
@@ -385,7 +425,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowInfo",
|
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -395,10 +434,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 +484,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"hasNordSecret": {
|
"hasNordSecret": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"hasSmtpPassword": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"hasTgBotToken": {
|
"hasTgBotToken": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -532,8 +574,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
|
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"remarkModel": {
|
"remarkTemplate": {
|
||||||
"description": "Remark model pattern for inbounds",
|
"description": "Subscription remark template ({{VAR}} tokens) rendered per client",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"restartXrayOnClientDisable": {
|
"restartXrayOnClientDisable": {
|
||||||
@@ -546,6 +588,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"
|
||||||
@@ -578,10 +660,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Domain for subscription server validation",
|
"description": "Domain for subscription server validation",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subEmailInRemark": {
|
|
||||||
"description": "Include email in subscription remark/name",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subEnable": {
|
"subEnable": {
|
||||||
"description": "Subscription server settings\nEnable subscription server",
|
"description": "Subscription server settings\nEnable subscription server",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -643,10 +721,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Subscription global routing rules (Only for Happ)",
|
"description": "Subscription global routing rules (Only for Happ)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subShowInfo": {
|
|
||||||
"description": "Show client information in subscriptions",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"description": "Subscription support URL",
|
"description": "Subscription support URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -685,10 +759,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 +773,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 +847,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"hasApiToken",
|
"hasApiToken",
|
||||||
"hasLdapPassword",
|
"hasLdapPassword",
|
||||||
"hasNordSecret",
|
"hasNordSecret",
|
||||||
|
"hasSmtpPassword",
|
||||||
"hasTgBotToken",
|
"hasTgBotToken",
|
||||||
"hasTwoFactorToken",
|
"hasTwoFactorToken",
|
||||||
"hasWarpSecret",
|
"hasWarpSecret",
|
||||||
@@ -798,9 +873,18 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
"remarkModel",
|
"remarkTemplate",
|
||||||
"restartXrayOnClientDisable",
|
"restartXrayOnClientDisable",
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
|
"smtpCpu",
|
||||||
|
"smtpEnable",
|
||||||
|
"smtpEnabledEvents",
|
||||||
|
"smtpEncryptionType",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpPassword",
|
||||||
|
"smtpPort",
|
||||||
|
"smtpTo",
|
||||||
|
"smtpUsername",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
@@ -809,7 +893,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subClashRules",
|
"subClashRules",
|
||||||
"subClashURI",
|
"subClashURI",
|
||||||
"subDomain",
|
"subDomain",
|
||||||
"subEmailInRemark",
|
|
||||||
"subEnable",
|
"subEnable",
|
||||||
"subEnableRouting",
|
"subEnableRouting",
|
||||||
"subEncrypt",
|
"subEncrypt",
|
||||||
@@ -825,7 +908,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subPort",
|
"subPort",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowInfo",
|
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
@@ -835,10 +917,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",
|
||||||
@@ -1224,6 +1306,181 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Host": {
|
||||||
|
"description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.",
|
||||||
|
"properties": {
|
||||||
|
"address": {
|
||||||
|
"example": "cdn.example.com",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"allowInsecure": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"alpn": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"echConfigList": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"excludeFromSubTypes": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"finalMask": {
|
||||||
|
"description": "FinalMask is a JSON object of xray finalmask masks (tcp/udp/quicParams),\nmerged into this host's JSON-subscription stream. Empty = no override.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hostHeader": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"inboundId": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"isDisabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"isHidden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"keepSniBlank": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"mihomoIpVersion": {
|
||||||
|
"enum": [
|
||||||
|
"dual",
|
||||||
|
"ipv4",
|
||||||
|
"ipv6",
|
||||||
|
"ipv4-prefer",
|
||||||
|
"ipv6-prefer"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mihomoX25519": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"muxParams": {},
|
||||||
|
"nodeGuids": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"overrideSniFromAddress": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pinnedPeerCertSha256": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"example": 8443,
|
||||||
|
"maximum": 65535,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"remark": {
|
||||||
|
"example": "cdn-front",
|
||||||
|
"maxLength": 256,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"enum": [
|
||||||
|
"same",
|
||||||
|
"tls",
|
||||||
|
"none",
|
||||||
|
"reality"
|
||||||
|
],
|
||||||
|
"example": "same",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"serverDescription": {
|
||||||
|
"maxLength": 64,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"shuffleHost": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sni": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sockoptParams": {},
|
||||||
|
"sortOrder": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"verifyPeerCertByName": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"vlessRoute": {
|
||||||
|
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"address",
|
||||||
|
"allowInsecure",
|
||||||
|
"alpn",
|
||||||
|
"createdAt",
|
||||||
|
"echConfigList",
|
||||||
|
"excludeFromSubTypes",
|
||||||
|
"finalMask",
|
||||||
|
"fingerprint",
|
||||||
|
"hostHeader",
|
||||||
|
"id",
|
||||||
|
"inboundId",
|
||||||
|
"isDisabled",
|
||||||
|
"isHidden",
|
||||||
|
"keepSniBlank",
|
||||||
|
"mihomoIpVersion",
|
||||||
|
"mihomoX25519",
|
||||||
|
"muxParams",
|
||||||
|
"overrideSniFromAddress",
|
||||||
|
"path",
|
||||||
|
"pinnedPeerCertSha256",
|
||||||
|
"port",
|
||||||
|
"remark",
|
||||||
|
"security",
|
||||||
|
"serverDescription",
|
||||||
|
"shuffleHost",
|
||||||
|
"sni",
|
||||||
|
"sockoptParams",
|
||||||
|
"sortOrder",
|
||||||
|
"tags",
|
||||||
|
"updatedAt",
|
||||||
|
"verifyPeerCertByName",
|
||||||
|
"vlessRoute"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Inbound": {
|
"Inbound": {
|
||||||
"description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
|
"description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1591,10 +1848,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 +1900,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"enum": [
|
"enum": [
|
||||||
"verify",
|
"verify",
|
||||||
"skip",
|
"skip",
|
||||||
"pin"
|
"pin",
|
||||||
|
"mtls"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1681,7 +1950,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 {
|
||||||
@@ -32,9 +33,18 @@ export interface AllSetting {
|
|||||||
ldapVlessField: string;
|
ldapVlessField: string;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
panelOutbound: string;
|
panelOutbound: string;
|
||||||
remarkModel: string;
|
remarkTemplate: 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;
|
||||||
@@ -43,7 +53,6 @@ export interface AllSetting {
|
|||||||
subClashRules: string;
|
subClashRules: string;
|
||||||
subClashURI: string;
|
subClashURI: string;
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEmailInRemark: boolean;
|
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
subEnableRouting: boolean;
|
subEnableRouting: boolean;
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
@@ -59,7 +68,6 @@ export interface AllSetting {
|
|||||||
subPort: number;
|
subPort: number;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
subShowInfo: boolean;
|
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
subThemeDir: string;
|
subThemeDir: string;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
@@ -69,10 +77,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 +105,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;
|
||||||
@@ -122,9 +131,18 @@ export interface AllSettingView {
|
|||||||
ldapVlessField: string;
|
ldapVlessField: string;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
panelOutbound: string;
|
panelOutbound: string;
|
||||||
remarkModel: string;
|
remarkTemplate: 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;
|
||||||
@@ -133,7 +151,6 @@ export interface AllSettingView {
|
|||||||
subClashRules: string;
|
subClashRules: string;
|
||||||
subClashURI: string;
|
subClashURI: string;
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEmailInRemark: boolean;
|
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
subEnableRouting: boolean;
|
subEnableRouting: boolean;
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
@@ -149,7 +166,6 @@ export interface AllSettingView {
|
|||||||
subPort: number;
|
subPort: number;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
subShowInfo: boolean;
|
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
subThemeDir: string;
|
subThemeDir: string;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
@@ -159,10 +175,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;
|
||||||
@@ -274,6 +290,42 @@ export interface HistoryOfSeeders {
|
|||||||
seederName: string;
|
seederName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Host {
|
||||||
|
address: string;
|
||||||
|
allowInsecure: boolean;
|
||||||
|
alpn: string[];
|
||||||
|
createdAt: number;
|
||||||
|
echConfigList: string;
|
||||||
|
excludeFromSubTypes: string[];
|
||||||
|
finalMask: string;
|
||||||
|
fingerprint: string;
|
||||||
|
hostHeader: string;
|
||||||
|
id: number;
|
||||||
|
inboundId: number;
|
||||||
|
isDisabled: boolean;
|
||||||
|
isHidden: boolean;
|
||||||
|
keepSniBlank: boolean;
|
||||||
|
mihomoIpVersion: string;
|
||||||
|
mihomoX25519: boolean;
|
||||||
|
muxParams: unknown;
|
||||||
|
nodeGuids?: string[];
|
||||||
|
overrideSniFromAddress: boolean;
|
||||||
|
path: string;
|
||||||
|
pinnedPeerCertSha256: string[];
|
||||||
|
port: number;
|
||||||
|
remark: string;
|
||||||
|
security: string;
|
||||||
|
serverDescription: string;
|
||||||
|
shuffleHost: boolean;
|
||||||
|
sni: string;
|
||||||
|
sockoptParams: unknown;
|
||||||
|
sortOrder: number;
|
||||||
|
tags: string[];
|
||||||
|
updatedAt: number;
|
||||||
|
verifyPeerCertByName: boolean;
|
||||||
|
vlessRoute: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Inbound {
|
export interface Inbound {
|
||||||
clientStats: ClientTraffic[];
|
clientStats: ClientTraffic[];
|
||||||
down: number;
|
down: number;
|
||||||
@@ -357,7 +409,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>;
|
||||||
|
|
||||||
@@ -42,9 +45,18 @@ export const AllSettingSchema = z.object({
|
|||||||
ldapVlessField: z.string(),
|
ldapVlessField: z.string(),
|
||||||
pageSize: z.number().int().min(0).max(1000),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
panelOutbound: z.string(),
|
panelOutbound: z.string(),
|
||||||
remarkModel: z.string(),
|
remarkTemplate: 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(),
|
||||||
@@ -53,7 +65,6 @@ export const AllSettingSchema = z.object({
|
|||||||
subClashRules: z.string(),
|
subClashRules: z.string(),
|
||||||
subClashURI: z.string(),
|
subClashURI: z.string(),
|
||||||
subDomain: z.string(),
|
subDomain: z.string(),
|
||||||
subEmailInRemark: z.boolean(),
|
|
||||||
subEnable: z.boolean(),
|
subEnable: z.boolean(),
|
||||||
subEnableRouting: z.boolean(),
|
subEnableRouting: z.boolean(),
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
@@ -69,7 +80,6 @@ export const AllSettingSchema = z.object({
|
|||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(1).max(65535),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowInfo: z.boolean(),
|
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
subThemeDir: z.string(),
|
subThemeDir: z.string(),
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
@@ -79,10 +89,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 +118,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(),
|
||||||
@@ -133,9 +144,18 @@ export const AllSettingViewSchema = z.object({
|
|||||||
ldapVlessField: z.string(),
|
ldapVlessField: z.string(),
|
||||||
pageSize: z.number().int().min(0).max(1000),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
panelOutbound: z.string(),
|
panelOutbound: z.string(),
|
||||||
remarkModel: z.string(),
|
remarkTemplate: 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(),
|
||||||
@@ -144,7 +164,6 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subClashRules: z.string(),
|
subClashRules: z.string(),
|
||||||
subClashURI: z.string(),
|
subClashURI: z.string(),
|
||||||
subDomain: z.string(),
|
subDomain: z.string(),
|
||||||
subEmailInRemark: z.boolean(),
|
|
||||||
subEnable: z.boolean(),
|
subEnable: z.boolean(),
|
||||||
subEnableRouting: z.boolean(),
|
subEnableRouting: z.boolean(),
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
@@ -160,7 +179,6 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(1).max(65535),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowInfo: z.boolean(),
|
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
subThemeDir: z.string(),
|
subThemeDir: z.string(),
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
@@ -170,10 +188,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(),
|
||||||
@@ -295,6 +313,43 @@ export const HistoryOfSeedersSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
|
export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
|
||||||
|
|
||||||
|
export const HostSchema = z.object({
|
||||||
|
address: z.string(),
|
||||||
|
allowInsecure: z.boolean(),
|
||||||
|
alpn: z.array(z.string()),
|
||||||
|
createdAt: z.number().int(),
|
||||||
|
echConfigList: z.string(),
|
||||||
|
excludeFromSubTypes: z.array(z.string()),
|
||||||
|
finalMask: z.string(),
|
||||||
|
fingerprint: z.string(),
|
||||||
|
hostHeader: z.string(),
|
||||||
|
id: z.number().int(),
|
||||||
|
inboundId: z.number().int(),
|
||||||
|
isDisabled: z.boolean(),
|
||||||
|
isHidden: z.boolean(),
|
||||||
|
keepSniBlank: z.boolean(),
|
||||||
|
mihomoIpVersion: z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']),
|
||||||
|
mihomoX25519: z.boolean(),
|
||||||
|
muxParams: z.unknown(),
|
||||||
|
nodeGuids: z.array(z.string()).optional(),
|
||||||
|
overrideSniFromAddress: z.boolean(),
|
||||||
|
path: z.string(),
|
||||||
|
pinnedPeerCertSha256: z.array(z.string()),
|
||||||
|
port: z.number().int().min(0).max(65535),
|
||||||
|
remark: z.string().max(256),
|
||||||
|
security: z.enum(['same', 'tls', 'none', 'reality']),
|
||||||
|
serverDescription: z.string().max(64),
|
||||||
|
shuffleHost: z.boolean(),
|
||||||
|
sni: z.string(),
|
||||||
|
sockoptParams: z.unknown(),
|
||||||
|
sortOrder: z.number().int(),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
updatedAt: z.number().int(),
|
||||||
|
verifyPeerCertByName: z.boolean(),
|
||||||
|
vlessRoute: z.string(),
|
||||||
|
});
|
||||||
|
export type Host = z.infer<typeof HostSchema>;
|
||||||
|
|
||||||
export const InboundSchema = z.object({
|
export const InboundSchema = z.object({
|
||||||
clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
|
clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
|
||||||
down: z.number().int(),
|
down: z.number().int(),
|
||||||
@@ -383,7 +438,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 +449,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(() => {
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import {
|
|||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
|
ExportOutlined,
|
||||||
GithubOutlined,
|
GithubOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
|
MailOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MoonFilled,
|
MoonFilled,
|
||||||
@@ -27,7 +30,6 @@ import {
|
|||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
UploadOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
@@ -40,7 +42,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
|
|||||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||||
const LOGOUT_KEY = '__logout__';
|
const LOGOUT_KEY = '__logout__';
|
||||||
|
|
||||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
|
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound';
|
||||||
|
|
||||||
const iconByName: Record<IconName, ComponentType> = {
|
const iconByName: Record<IconName, ComponentType> = {
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
@@ -50,9 +52,10 @@ const iconByName: Record<IconName, ComponentType> = {
|
|||||||
setting: SettingOutlined,
|
setting: SettingOutlined,
|
||||||
tool: ToolOutlined,
|
tool: ToolOutlined,
|
||||||
cluster: ClusterOutlined,
|
cluster: ClusterOutlined,
|
||||||
|
hosts: GlobalOutlined,
|
||||||
logout: LogoutOutlined,
|
logout: LogoutOutlined,
|
||||||
apidocs: ApiOutlined,
|
apidocs: ApiOutlined,
|
||||||
outbound: UploadOutlined,
|
outbound: ExportOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
function readCollapsed(): boolean {
|
function readCollapsed(): boolean {
|
||||||
@@ -138,6 +141,7 @@ export default function AppSidebar() {
|
|||||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||||
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
||||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||||
|
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
||||||
{ key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
|
{ key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
|
||||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||||
@@ -153,6 +157,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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
|
||||||
|
import type { HostFormValues } from '@/schemas/api/host';
|
||||||
|
|
||||||
|
// The subset of a host that affects its share link. Mirrors the fields the
|
||||||
|
// backend's hostToExternalProxyMap reads.
|
||||||
|
export type HostLinkInput = Pick<
|
||||||
|
HostFormValues,
|
||||||
|
| 'security'
|
||||||
|
| 'address'
|
||||||
|
| 'port'
|
||||||
|
| 'remark'
|
||||||
|
| 'sni'
|
||||||
|
| 'alpn'
|
||||||
|
| 'fingerprint'
|
||||||
|
| 'pinnedPeerCertSha256'
|
||||||
|
| 'echConfigList'
|
||||||
|
| 'overrideSniFromAddress'
|
||||||
|
| 'keepSniBlank'
|
||||||
|
>;
|
||||||
|
|
||||||
|
// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
|
||||||
|
// share-link preview generators already understand — the frontend mirror of the
|
||||||
|
// backend's hostToExternalProxyMap. security "reality"/"same" keep the inbound's
|
||||||
|
// base TLS (forceTls "same"); the preview falls back to port 443 when the host
|
||||||
|
// inherits the inbound port (port 0).
|
||||||
|
export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntry {
|
||||||
|
const forceTls = host.security === 'tls' || host.security === 'none' ? host.security : 'same';
|
||||||
|
|
||||||
|
let sni: string | undefined;
|
||||||
|
if (host.keepSniBlank) {
|
||||||
|
sni = undefined;
|
||||||
|
} else if (host.overrideSniFromAddress) {
|
||||||
|
sni = host.address || undefined;
|
||||||
|
} else {
|
||||||
|
sni = host.sni || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
forceTls,
|
||||||
|
dest: host.address || '',
|
||||||
|
port: host.port && host.port > 0 ? host.port : 443,
|
||||||
|
remark: host.remark || '',
|
||||||
|
sni,
|
||||||
|
fingerprint: host.fingerprint,
|
||||||
|
alpn: host.alpn && host.alpn.length > 0 ? host.alpn : undefined,
|
||||||
|
pinnedPeerCertSha256:
|
||||||
|
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
|
||||||
|
echConfigList: host.echConfigList || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Template variables an operator can embed in a Host's Remark. At subscription
|
||||||
|
// time the backend (internal/sub/remark_vars.go) substitutes each {{TOKEN}}
|
||||||
|
// per client. This file is the single frontend source of truth for the picker
|
||||||
|
// UI and the live preview — keep the token list in sync with remark_vars.go.
|
||||||
|
|
||||||
|
export type RemarkVarGroup = 'client' | 'traffic' | 'time';
|
||||||
|
|
||||||
|
export interface RemarkVar {
|
||||||
|
/** Bare token name, e.g. "TRAFFIC_LEFT" (rendered as {{TRAFFIC_LEFT}}). */
|
||||||
|
token: string;
|
||||||
|
group: RemarkVarGroup;
|
||||||
|
/** Example value used only for the form's live preview. */
|
||||||
|
sample: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time'];
|
||||||
|
|
||||||
|
export const REMARK_VARIABLES: RemarkVar[] = [
|
||||||
|
// Client identity
|
||||||
|
{ token: 'EMAIL', group: 'client', sample: 'john' },
|
||||||
|
{ token: 'INBOUND', group: 'client', sample: 'Germany' },
|
||||||
|
{ token: 'HOST', group: 'client', sample: 'CDN' },
|
||||||
|
{ token: 'ID', group: 'client', sample: '3f2a9c1b-aaaa-bbbb-cccc-1234567890ab' },
|
||||||
|
{ token: 'SHORT_ID', group: 'client', sample: '3f2a9c1b' },
|
||||||
|
{ token: 'TELEGRAM_ID', group: 'client', sample: '123456789' },
|
||||||
|
{ token: 'SUB_ID', group: 'client', sample: 'subABC' },
|
||||||
|
{ token: 'COMMENT', group: 'client', sample: 'vip' },
|
||||||
|
// Traffic
|
||||||
|
{ token: 'TRAFFIC_USED', group: 'traffic', sample: '8.40GB' },
|
||||||
|
{ token: 'TRAFFIC_LEFT', group: 'traffic', sample: '41.60GB' },
|
||||||
|
{ token: 'TRAFFIC_TOTAL', group: 'traffic', sample: '50.00GB' },
|
||||||
|
{ token: 'TRAFFIC_USED_BYTES', group: 'traffic', sample: '9019431321' },
|
||||||
|
{ token: 'TRAFFIC_LEFT_BYTES', group: 'traffic', sample: '44667656679' },
|
||||||
|
{ token: 'TRAFFIC_TOTAL_BYTES', group: 'traffic', sample: '53687091200' },
|
||||||
|
{ token: 'UP', group: 'traffic', sample: '5.20GB' },
|
||||||
|
{ token: 'DOWN', group: 'traffic', sample: '3.20GB' },
|
||||||
|
// Time / status
|
||||||
|
{ token: 'STATUS', group: 'time', sample: 'active' },
|
||||||
|
{ token: 'DAYS_LEFT', group: 'time', sample: '12' },
|
||||||
|
{ token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
|
||||||
|
{ token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
|
||||||
|
{ token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
|
||||||
|
{ token: 'RESET_DAYS', group: 'time', sample: '30' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
|
||||||
|
REMARK_VARIABLES.map((v) => [v.token, v.sample]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const TOKEN_RE = /\{\{([A-Z_]+)\}\}/g;
|
||||||
|
|
||||||
|
/** wrapToken("EMAIL") → "{{EMAIL}}". */
|
||||||
|
export function wrapToken(token: string): string {
|
||||||
|
return `{{${token}}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a remark string uses any {{VAR}} token at all. */
|
||||||
|
export function hasRemarkTokens(template: string): boolean {
|
||||||
|
return template.includes('{{');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* previewRemark renders a template against the sample values, mirroring the
|
||||||
|
* backend substitution closely enough for an at-a-glance preview. Unknown
|
||||||
|
* tokens collapse to empty, just like the server.
|
||||||
|
*/
|
||||||
|
export function previewRemark(template: string): string {
|
||||||
|
if (!hasRemarkTokens(template)) return template;
|
||||||
|
return template.replace(TOKEN_RE, (_m, tok: string) => SAMPLE_BY_TOKEN[tok] ?? '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button, Divider, Form, Input, Select } from 'antd';
|
||||||
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { NamePath } from 'antd/es/form/interface';
|
||||||
|
|
||||||
|
// Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each
|
||||||
|
// entry is rendered as a titled group of labeled fields (system / level / opt /
|
||||||
|
// type / value) instead of one cramped inline row, so it reads like the rest of
|
||||||
|
// the sockopt form. Shared by the inbound and outbound (and host) sockopt forms.
|
||||||
|
// Ref: https://xtls.github.io/config/transports/sockopt.html#sockoptobject
|
||||||
|
|
||||||
|
const SYSTEM_OPTIONS = [
|
||||||
|
{ value: 'linux', label: 'linux' },
|
||||||
|
{ value: 'windows', label: 'windows' },
|
||||||
|
{ value: 'darwin', label: 'darwin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: 'int', label: 'int' },
|
||||||
|
{ value: 'str', label: 'str' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CustomSockoptListProps {
|
||||||
|
name?: NamePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomSockoptList({
|
||||||
|
name = ['streamSettings', 'sockopt', 'customSockopt'],
|
||||||
|
}: CustomSockoptListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Form.List name={name}>
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
|
||||||
|
>
|
||||||
|
{t('pages.inbounds.form.addCustomOption')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<Divider plain style={{ margin: '4px 0 8px' }}>
|
||||||
|
{t('pages.inbounds.form.customSockopt')} {idx + 1}
|
||||||
|
<DeleteOutlined
|
||||||
|
className="danger-icon"
|
||||||
|
style={{ marginInlineStart: 8 }}
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
/>
|
||||||
|
</Divider>
|
||||||
|
<Form.Item label="System" name={[field.name, 'system']}>
|
||||||
|
<Select placeholder="all" allowClear options={SYSTEM_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Level" name={[field.name, 'level']}>
|
||||||
|
<Input placeholder="6 (SOL_TCP)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Opt" name={[field.name, 'opt']}>
|
||||||
|
<Input placeholder="decimal, e.g. 19" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Type" name={[field.name, 'type']}>
|
||||||
|
<Select options={TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Value" name={[field.name, 'value']}>
|
||||||
|
<Input placeholder="value" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
@@ -29,7 +71,7 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
|||||||
return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
|
return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
|
||||||
case 'sudoku':
|
case 'sudoku':
|
||||||
return {
|
return {
|
||||||
password: '', ascii: '', customTable: '', customTables: [''],
|
password: '', ascii: '', customTable: '', customTables: [],
|
||||||
paddingMin: 0, paddingMax: 0,
|
paddingMin: 0, paddingMax: 0,
|
||||||
};
|
};
|
||||||
case 'header-custom':
|
case 'header-custom':
|
||||||
@@ -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();
|
||||||
@@ -576,6 +595,18 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
|
|||||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SIP002 clients (v2rayN) ignore type/headerType/host/path and only read
|
||||||
|
// `plugin`. Re-encode a TCP http header as obfs-local so they build a
|
||||||
|
// matching tcp/http outbound (v2rayN forces request path "/").
|
||||||
|
if ((stream.network ?? 'tcp') === 'tcp' && params.get('headerType') === 'http') {
|
||||||
|
const host = params.get('host') ?? '';
|
||||||
|
params.delete('type');
|
||||||
|
params.delete('headerType');
|
||||||
|
params.delete('host');
|
||||||
|
params.delete('path');
|
||||||
|
params.set('plugin', `obfs-local;obfs=http;obfs-host=${host}`);
|
||||||
|
}
|
||||||
|
|
||||||
const isSS2022 = settings.method.substring(0, 4) === '2022';
|
const isSS2022 = settings.method.substring(0, 4) === '2022';
|
||||||
const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
|
const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
|
||||||
const passwords: string[] = [];
|
const passwords: string[] = [];
|
||||||
@@ -583,7 +614,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 +712,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 +755,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
|
||||||
@@ -964,23 +995,19 @@ export interface GenAllLinksEntry {
|
|||||||
export interface GenAllLinksInput {
|
export interface GenAllLinksInput {
|
||||||
inbound: Inbound;
|
inbound: Inbound;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
remarkModel?: string;
|
|
||||||
client: ClientShape;
|
client: ClientShape;
|
||||||
hostOverride?: string;
|
hostOverride?: string;
|
||||||
fallbackHostname: string;
|
fallbackHostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fans out a single client's link per externalProxy entry, or just one
|
// Fans out a single client's link per externalProxy entry, or just one link
|
||||||
// link when there are no external proxies. remarkModel is a 4-char
|
// when there are no external proxies. The panel copy/QR remark is the inbound
|
||||||
// string: first char is the separator, remaining chars pick which
|
// remark plus the externalProxy remark, dash-joined (the configurable
|
||||||
// pieces to compose into the per-link remark — 'i' = inbound remark,
|
// subscription remark model was removed; subscription output uses the template).
|
||||||
// 'e' = client email, 'o' = externalProxy remark. Defaults to '-io'
|
|
||||||
// (dash-separated, inbound + email + proxy).
|
|
||||||
export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
||||||
const {
|
const {
|
||||||
inbound,
|
inbound,
|
||||||
remark = '',
|
remark = '',
|
||||||
remarkModel = '-io',
|
|
||||||
client,
|
client,
|
||||||
hostOverride = '',
|
hostOverride = '',
|
||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
@@ -988,17 +1015,9 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
|||||||
|
|
||||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
const port = inbound.port;
|
const port = inbound.port;
|
||||||
const separationChar = remarkModel.charAt(0);
|
|
||||||
const orderChars = remarkModel.slice(1);
|
|
||||||
const email = client.email ?? '';
|
|
||||||
|
|
||||||
const composeRemark = (proxyRemark: string): string => {
|
const composeRemark = (proxyRemark: string): string =>
|
||||||
const orders: Record<string, string> = { i: remark, e: email, o: proxyRemark };
|
[remark, proxyRemark].filter((x) => x.length > 0).join('-');
|
||||||
return orderChars.split('')
|
|
||||||
.map((c) => orders[c] ?? '')
|
|
||||||
.filter((x) => x.length > 0)
|
|
||||||
.join(separationChar);
|
|
||||||
};
|
|
||||||
|
|
||||||
const externals = inbound.streamSettings?.externalProxy;
|
const externals = inbound.streamSettings?.externalProxy;
|
||||||
if (!externals || externals.length === 0) {
|
if (!externals || externals.length === 0) {
|
||||||
@@ -1025,7 +1044,6 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
|||||||
export interface GenInboundLinksInput {
|
export interface GenInboundLinksInput {
|
||||||
inbound: Inbound;
|
inbound: Inbound;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
remarkModel?: string;
|
|
||||||
hostOverride?: string;
|
hostOverride?: string;
|
||||||
fallbackHostname: string;
|
fallbackHostname: string;
|
||||||
}
|
}
|
||||||
@@ -1039,7 +1057,6 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
|||||||
const {
|
const {
|
||||||
inbound,
|
inbound,
|
||||||
remark = '',
|
remark = '',
|
||||||
remarkModel = '-io',
|
|
||||||
hostOverride = '',
|
hostOverride = '',
|
||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
} = input;
|
} = input;
|
||||||
@@ -1048,7 +1065,7 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
|||||||
if (clients) {
|
if (clients) {
|
||||||
const links: string[] = [];
|
const links: string[] = [];
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
const entries = genAllLinks({ inbound, remark, remarkModel, client, hostOverride, fallbackHostname });
|
const entries = genAllLinks({ inbound, remark, client, hostOverride, fallbackHostname });
|
||||||
for (const e of entries) links.push(e.link);
|
for (const e of entries) links.push(e.link);
|
||||||
}
|
}
|
||||||
return links.join('\r\n');
|
return links.join('\r\n');
|
||||||
@@ -1057,7 +1074,7 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
|||||||
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
|
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
|
||||||
}
|
}
|
||||||
if (inbound.protocol === 'wireguard') {
|
if (inbound.protocol === 'wireguard') {
|
||||||
return genWireguardConfigs({ inbound, remark, remarkModel, hostOverride, fallbackHostname });
|
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -1068,16 +1085,15 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
|||||||
export interface GenWireguardFanoutInput {
|
export interface GenWireguardFanoutInput {
|
||||||
inbound: Inbound;
|
inbound: Inbound;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
remarkModel?: string;
|
|
||||||
hostOverride?: string;
|
hostOverride?: string;
|
||||||
fallbackHostname: string;
|
fallbackHostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
||||||
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
|
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||||
if (inbound.protocol !== 'wireguard') return '';
|
if (inbound.protocol !== 'wireguard') return '';
|
||||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
const sep = remarkModel.charAt(0);
|
const sep = '-';
|
||||||
return inbound.settings.peers
|
return inbound.settings.peers
|
||||||
.map((p, i) => genWireguardLink({
|
.map((p, i) => genWireguardLink({
|
||||||
settings: inbound.settings as WireguardInboundSettings,
|
settings: inbound.settings as WireguardInboundSettings,
|
||||||
@@ -1090,10 +1106,10 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
||||||
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
|
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||||
if (inbound.protocol !== 'wireguard') return '';
|
if (inbound.protocol !== 'wireguard') return '';
|
||||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
const sep = remarkModel.charAt(0);
|
const sep = '-';
|
||||||
return inbound.settings.peers
|
return inbound.settings.peers
|
||||||
.map((p, i) => genWireguardConfig({
|
.map((p, i) => genWireguardConfig({
|
||||||
settings: inbound.settings as WireguardInboundSettings,
|
settings: inbound.settings as WireguardInboundSettings,
|
||||||
|
|||||||
@@ -47,29 +47,16 @@ const TRANSPORT_COLOR = 'gold';
|
|||||||
|
|
||||||
const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
|
const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
|
||||||
|
|
||||||
/* Strip the client email and the optional traffic/expiry decorations the
|
/* Pull protocol, transport, security plus the remark and port out of a share
|
||||||
panel appends to a remark (e.g. "5.23GB📊", "30D⏳", "⛔️N/A") together
|
link. vless/trojan carry network+security as `type`/`security` query params
|
||||||
with any separator chars left dangling, so the label shows just the
|
and the remark in the URL hash; vmess packs them into the base64 JSON as
|
||||||
inbound remark. The email is known from the client record, so it can be
|
`net`/`tls`/`ps`/`port`. Returns null when the scheme is unknown or the
|
||||||
removed even though its position in the composed remark depends on the
|
payload can't be parsed, so callers fall back to "Link N".
|
||||||
panel's remark-model settings. */
|
|
||||||
function cleanRemark(remark: string, email: string): string {
|
|
||||||
let r = remark
|
|
||||||
.replace(/⛔️?N\/A/gu, '')
|
|
||||||
.replace(/[0-9][0-9A-Za-z.,]*[📊⏳]/gu, '');
|
|
||||||
if (email) {
|
|
||||||
const esc = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
r = r.replace(new RegExp(`[\\s\\-_.|,@]*${esc}`, 'g'), '');
|
|
||||||
}
|
|
||||||
return r.replace(/^[\s\-_.|,@]+|[\s\-_.|,@]+$/gu, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pull protocol, transport, security plus the inbound remark and port out
|
The remark is shown verbatim: the panel displays the subscription's clean
|
||||||
of a share link. vless/trojan carry network+security as `type`/`security`
|
(name-only) remarks — the per-client traffic/expiry info is rendered only
|
||||||
query params and the remark in the URL hash; vmess packs them into the
|
into the body a client app imports, so there is nothing to strip here. */
|
||||||
base64 JSON as `net`/`tls`/`ps`/`port`. Returns null when the scheme is
|
export function parseLinkParts(link: string): LinkParts | null {
|
||||||
unknown or the payload can't be parsed, so callers fall back to "Link N". */
|
|
||||||
export function parseLinkParts(link: string, email = ''): LinkParts | null {
|
|
||||||
const trimmed = link.trim();
|
const trimmed = link.trim();
|
||||||
const scheme = /^([a-z0-9]+):\/\//i.exec(trimmed)?.[1]?.toLowerCase() ?? '';
|
const scheme = /^([a-z0-9]+):\/\//i.exec(trimmed)?.[1]?.toLowerCase() ?? '';
|
||||||
if (!scheme) return null;
|
if (!scheme) return null;
|
||||||
@@ -106,7 +93,7 @@ export function parseLinkParts(link: string, email = ''): LinkParts | null {
|
|||||||
protocol,
|
protocol,
|
||||||
network: network.toUpperCase(),
|
network: network.toUpperCase(),
|
||||||
security: security.toUpperCase(),
|
security: security.toUpperCase(),
|
||||||
remark: cleanRemark(remark, email),
|
remark: remark.trim(),
|
||||||
port,
|
port,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -13,16 +13,14 @@ export class AllSetting {
|
|||||||
pageSize = 25;
|
pageSize = 25;
|
||||||
expireDiff = 0;
|
expireDiff = 0;
|
||||||
trafficDiff = 0;
|
trafficDiff = 0;
|
||||||
remarkModel = '-io';
|
remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||||
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;
|
||||||
@@ -50,8 +48,6 @@ export class AllSetting {
|
|||||||
subKeyFile = '';
|
subKeyFile = '';
|
||||||
subUpdates = 12;
|
subUpdates = 12;
|
||||||
subEncrypt = true;
|
subEncrypt = true;
|
||||||
subShowInfo = true;
|
|
||||||
subEmailInRemark = true;
|
|
||||||
subURI = '';
|
subURI = '';
|
||||||
subJsonURI = '';
|
subJsonURI = '';
|
||||||
subClashURI = '';
|
subClashURI = '';
|
||||||
@@ -84,12 +80,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',
|
||||||
@@ -878,6 +907,99 @@ export const sections: readonly Section[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'hosts',
|
||||||
|
title: 'Hosts',
|
||||||
|
description:
|
||||||
|
'Per-inbound override endpoints. Each enabled host renders one extra subscription link/proxy with its own address/port/TLS, superseding the legacy externalProxy array. All endpoints under /panel/api/hosts.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/hosts/list',
|
||||||
|
summary: 'List every host across all inbounds, grouped by inbound then ordered by sort order.',
|
||||||
|
responseSchema: 'Host',
|
||||||
|
responseSchemaArray: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/hosts/get/:id',
|
||||||
|
summary: 'Fetch a single host by ID.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
|
||||||
|
],
|
||||||
|
responseSchema: 'Host',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/hosts/byInbound/:inboundId',
|
||||||
|
summary: "Fetch one inbound's hosts, ordered by sort order then id.",
|
||||||
|
params: [
|
||||||
|
{ name: 'inboundId', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
|
],
|
||||||
|
responseSchema: 'Host',
|
||||||
|
responseSchemaArray: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/hosts/tags',
|
||||||
|
summary: 'Distinct, sorted set of tags used across all hosts.',
|
||||||
|
response: '{\n "success": true,\n "obj": ["CDN", "EU", "FAST"]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/hosts/add',
|
||||||
|
summary: 'Create a host on an inbound. inboundId and remark are required; security defaults to "same" (inherit the inbound).',
|
||||||
|
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}',
|
||||||
|
responseSchema: 'Host',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/hosts/update/:id',
|
||||||
|
summary: 'Replace a host’s content. The inbound and sort order are immutable here (use /reorder for ordering).',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
|
||||||
|
],
|
||||||
|
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}',
|
||||||
|
responseSchema: 'Host',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/hosts/del/:id',
|
||||||
|
summary: 'Delete a host.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/hosts/setEnable/:id',
|
||||||
|
summary: 'Enable or disable a single host (disabled hosts are skipped in subscriptions).',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
|
||||||
|
],
|
||||||
|
body: '{\n "enable": true\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/hosts/reorder',
|
||||||
|
summary: 'Set host sort order by the position of each id in the array.',
|
||||||
|
body: '{\n "ids": [3, 1, 2]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/hosts/bulk/setEnable',
|
||||||
|
summary: 'Enable or disable many hosts in one call.',
|
||||||
|
body: '{\n "ids": [1, 2, 3],\n "enable": false\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/hosts/bulk/del',
|
||||||
|
summary: 'Delete many hosts in one call.',
|
||||||
|
body: '{\n "ids": [1, 2, 3]\n}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'backup',
|
id: 'backup',
|
||||||
title: 'Backup',
|
title: 'Backup',
|
||||||
@@ -931,6 +1053,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);
|
||||||
}
|
}
|
||||||
@@ -354,7 +354,7 @@ export default function ClientInfoModal({
|
|||||||
<>
|
<>
|
||||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||||
{links.map((link, idx) => {
|
{links.map((link, idx) => {
|
||||||
const parts = parseLinkParts(link, client.email);
|
const parts = parseLinkParts(link);
|
||||||
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
|
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
|
||||||
const rowTitle = (parts && linkMetaText(parts)) || fallback;
|
const rowTitle = (parts && linkMetaText(parts)) || fallback;
|
||||||
const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
|
const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function ClientQrModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
links.forEach((link, idx) => {
|
links.forEach((link, idx) => {
|
||||||
const parts = parseLinkParts(link, client?.email ?? '');
|
const parts = parseLinkParts(link);
|
||||||
const meta = parts ? linkMetaText(parts) : '';
|
const meta = parts ? linkMetaText(parts) : '';
|
||||||
const label: React.ReactNode = parts ? (
|
const label: React.ReactNode = parts ? (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export default function SubLinksModal({
|
|||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
message={t('pages.clients.subLinksDisabled')}
|
title={t('pages.clients.subLinksDisabled')}
|
||||||
description={t('pages.clients.subLinksDisabledHint')}
|
description={t('pages.clients.subLinksDisabledHint')}
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
@@ -174,7 +174,7 @@ export default function SubLinksModal({
|
|||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message={t('pages.clients.subLinksEmpty')}
|
title={t('pages.clients.subLinksEmpty')}
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
|
||||||
|
import {
|
||||||
|
ProfileOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
ControlOutlined,
|
||||||
|
NodeIndexOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
PartitionOutlined,
|
||||||
|
DeploymentUnitOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||||
|
import type { HostFormValues } from '@/schemas/api/host';
|
||||||
|
import type { InboundOption } from '@/schemas/client';
|
||||||
|
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||||
|
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||||
|
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
|
||||||
|
|
||||||
|
// inboundId is optional in the form so a new host starts unselected (the Select
|
||||||
|
// shows its placeholder instead of 0); the required rule enforces it on submit.
|
||||||
|
type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
|
||||||
|
|
||||||
|
interface HostFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
mode: 'add' | 'edit';
|
||||||
|
host: HostRecord | null;
|
||||||
|
inboundOptions: InboundOption[];
|
||||||
|
save: (payload: Partial<HostFormValues>) => Promise<{ success?: boolean; msg?: string } | undefined>;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||||
|
|
||||||
|
function defaultsFor(host: HostRecord | null): FormShape {
|
||||||
|
return {
|
||||||
|
inboundId: host?.inboundId,
|
||||||
|
sortOrder: host?.sortOrder ?? 0,
|
||||||
|
remark: host?.remark ?? '',
|
||||||
|
serverDescription: host?.serverDescription ?? '',
|
||||||
|
enable: host ? !host.isDisabled : true,
|
||||||
|
isHidden: host?.isHidden ?? false,
|
||||||
|
tags: host?.tags ?? [],
|
||||||
|
address: host?.address ?? '',
|
||||||
|
port: host?.port ?? 0,
|
||||||
|
security: (host?.security as HostFormValues['security']) ?? 'same',
|
||||||
|
sni: host?.sni ?? '',
|
||||||
|
hostHeader: host?.hostHeader ?? '',
|
||||||
|
path: host?.path ?? '',
|
||||||
|
alpn: (host?.alpn as HostFormValues['alpn']) ?? [],
|
||||||
|
fingerprint: host?.fingerprint as HostFormValues['fingerprint'],
|
||||||
|
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
|
||||||
|
keepSniBlank: host?.keepSniBlank ?? false,
|
||||||
|
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
|
||||||
|
verifyPeerCertByName: host?.verifyPeerCertByName ?? false,
|
||||||
|
allowInsecure: host?.allowInsecure ?? false,
|
||||||
|
echConfigList: host?.echConfigList ?? '',
|
||||||
|
muxParams: asString(host?.muxParams),
|
||||||
|
sockoptParams: asString(host?.sockoptParams),
|
||||||
|
finalMask: host?.finalMask ?? '',
|
||||||
|
vlessRoute: host?.vlessRoute ?? '',
|
||||||
|
excludeFromSubTypes: (host?.excludeFromSubTypes as HostFormValues['excludeFromSubTypes']) ?? [],
|
||||||
|
nodeGuids: host?.nodeGuids ?? [],
|
||||||
|
mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'],
|
||||||
|
mihomoX25519: host?.mihomoX25519 ?? false,
|
||||||
|
shuffleHost: host?.shuffleHost ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const [form] = Form.useForm<FormShape>();
|
||||||
|
|
||||||
|
// Drive conditional field visibility off the selected security, like the
|
||||||
|
// legacy externalProxy form: same/none inherit fully and hide every TLS/cert
|
||||||
|
// field; reality shows only the reality-relevant subset (its keys are
|
||||||
|
// inherited from the inbound); tls shows the full TLS override set.
|
||||||
|
const security = (Form.useWatch('security', form) ?? 'same') as string;
|
||||||
|
const showTls = security === 'tls' || security === 'reality';
|
||||||
|
const showTlsExtras = security === 'tls';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) form.setFieldsValue(defaultsFor(host));
|
||||||
|
}, [open, host, form]);
|
||||||
|
|
||||||
|
const { nodes } = useNodesQuery();
|
||||||
|
|
||||||
|
const inboundSelectOptions = useMemo(
|
||||||
|
() => inboundOptions.map((ib) => ({
|
||||||
|
value: ib.id,
|
||||||
|
label: ib.remark || ib.tag || `#${ib.id}`,
|
||||||
|
})),
|
||||||
|
[inboundOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeSelectOptions = useMemo(
|
||||||
|
() => nodes
|
||||||
|
.filter((n) => n.guid)
|
||||||
|
.map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
|
||||||
|
[nodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
|
||||||
|
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
let values: FormShape;
|
||||||
|
try {
|
||||||
|
values = await form.validateFields();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { enable, ...rest } = values;
|
||||||
|
const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
|
||||||
|
const res = await save(payload);
|
||||||
|
if (res?.success) {
|
||||||
|
message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
|
||||||
|
onOpenChange(false);
|
||||||
|
} else if (res?.msg) {
|
||||||
|
message.error(res.msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
okText={t('save')}
|
||||||
|
cancelText={t('cancel')}
|
||||||
|
destroyOnHidden
|
||||||
|
width={isMobile ? '95vw' : 760}
|
||||||
|
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
colon={false}
|
||||||
|
labelCol={{ sm: { span: 8 } }}
|
||||||
|
wrapperCol={{ sm: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
|
initialValues={defaultsFor(host)}
|
||||||
|
preserve={false}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="basic"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'basic',
|
||||||
|
forceRender: true,
|
||||||
|
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}>
|
||||||
|
<Input maxLength={256} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
|
||||||
|
<Input maxLength={64} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={inboundSelectOptions}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
disabled={mode === 'edit'}
|
||||||
|
placeholder={t('pages.hosts.selectInbound')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
|
||||||
|
<Input placeholder="cdn.example.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
|
||||||
|
<InputNumber min={0} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
|
||||||
|
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
|
||||||
|
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'security',
|
||||||
|
forceRender: true,
|
||||||
|
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item name="security" label={t('pages.hosts.fields.security')}>
|
||||||
|
<Select
|
||||||
|
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{showTls && (
|
||||||
|
<>
|
||||||
|
<Form.Item name="sni" label={t('pages.hosts.fields.sni')}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
|
||||||
|
<Select allowClear options={fpOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showTlsExtras && (
|
||||||
|
<>
|
||||||
|
<Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||||
|
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||||
|
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'advanced',
|
||||||
|
forceRender: true,
|
||||||
|
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
|
||||||
|
children: (
|
||||||
|
<Tabs
|
||||||
|
size="small"
|
||||||
|
defaultActiveKey="adv-general"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'adv-general',
|
||||||
|
forceRender: true,
|
||||||
|
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="path" label={t('pages.hosts.fields.path')}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
|
||||||
|
<Input placeholder="53,443,1000-2000" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adv-mux',
|
||||||
|
forceRender: true,
|
||||||
|
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
|
||||||
|
children: (
|
||||||
|
<Form.Item name="muxParams" noStyle>
|
||||||
|
<HostMuxForm />
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adv-sockopt',
|
||||||
|
forceRender: true,
|
||||||
|
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
|
||||||
|
children: (
|
||||||
|
<Form.Item name="sockoptParams" noStyle>
|
||||||
|
<HostSockoptForm />
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adv-finalmask',
|
||||||
|
forceRender: true,
|
||||||
|
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
|
||||||
|
children: (
|
||||||
|
<Form.Item name="finalMask" noStyle>
|
||||||
|
<HostFinalMaskForm />
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'clash',
|
||||||
|
forceRender: true,
|
||||||
|
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.hosts-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-remark-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-remark {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-desc {
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-endpoint {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-muted {
|
||||||
|
color: var(--ant-color-text-quaternary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* card-toolbar is shared with the Clients/Inbounds list cards, but its rules
|
||||||
|
live in a lazily-loaded page stylesheet — re-declare here so the Hosts page
|
||||||
|
renders correctly when opened directly. */
|
||||||
|
.hosts-card .card-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) and (max-width: 920px) {
|
||||||
|
.hosts-card .card-toolbar {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty-table state. The shared .card-empty rule otherwise lives only in the
|
||||||
|
lazily-loaded Clients/Inbounds/Nodes stylesheets, so a direct /hosts refresh
|
||||||
|
would render it unstyled (faint + uncentered) until another page is visited.
|
||||||
|
Re-declare it here so it's correct on first load. */
|
||||||
|
.card-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
padding: 24px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Card, Space, Switch, Table, Tag, Tooltip } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
ArrowDownOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||||
|
import type { InboundOption } from '@/schemas/client';
|
||||||
|
import './HostList.css';
|
||||||
|
|
||||||
|
interface HostListProps {
|
||||||
|
hosts: HostRecord[];
|
||||||
|
inboundOptions: InboundOption[];
|
||||||
|
loading?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
selectedIds: number[];
|
||||||
|
onSelectionChange: (ids: number[]) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (host: HostRecord) => void;
|
||||||
|
onDelete: (host: HostRecord) => void;
|
||||||
|
onToggleEnable: (host: HostRecord, next: boolean) => void;
|
||||||
|
onMove: (host: HostRecord, dir: 'up' | 'down') => void;
|
||||||
|
onBulkEnable: (enable: boolean) => void;
|
||||||
|
onBulkDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted by inbound then sort_order then id — the same order the subscription
|
||||||
|
// renderer uses, so the list mirrors the emitted link order.
|
||||||
|
function sortHosts(hosts: HostRecord[]): HostRecord[] {
|
||||||
|
return [...hosts].sort((a, b) => {
|
||||||
|
if (a.inboundId !== b.inboundId) return a.inboundId - b.inboundId;
|
||||||
|
const sa = a.sortOrder ?? 0;
|
||||||
|
const sb = b.sortOrder ?? 0;
|
||||||
|
if (sa !== sb) return sa - sb;
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HostList(props: HostListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
hosts, inboundOptions, loading, isMobile, selectedIds, onSelectionChange,
|
||||||
|
onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const inboundLabel = useMemo(() => {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
for (const ib of inboundOptions) map.set(ib.id, ib.remark || ib.tag || `#${ib.id}`);
|
||||||
|
return map;
|
||||||
|
}, [inboundOptions]);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => sortHosts(hosts), [hosts]);
|
||||||
|
|
||||||
|
// Move is bounded to neighbours within the same inbound (sort_order is per-inbound).
|
||||||
|
const movable = useMemo(() => {
|
||||||
|
const byInbound = new Map<number, number>();
|
||||||
|
const idxInGroup = new Map<number, number>();
|
||||||
|
const counters = new Map<number, number>();
|
||||||
|
for (const h of sorted) byInbound.set(h.inboundId, (byInbound.get(h.inboundId) ?? 0) + 1);
|
||||||
|
for (const h of sorted) {
|
||||||
|
const c = counters.get(h.inboundId) ?? 0;
|
||||||
|
idxInGroup.set(h.id, c);
|
||||||
|
counters.set(h.inboundId, c + 1);
|
||||||
|
}
|
||||||
|
return { byInbound, idxInGroup };
|
||||||
|
}, [sorted]);
|
||||||
|
|
||||||
|
// Column order requested: Actions, Enable, then the rest.
|
||||||
|
const columns: ColumnsType<HostRecord> = [
|
||||||
|
{
|
||||||
|
title: t('pages.hosts.fields.actions'),
|
||||||
|
key: 'actions',
|
||||||
|
width: 168,
|
||||||
|
render: (_, h) => {
|
||||||
|
const idx = movable.idxInGroup.get(h.id) ?? 0;
|
||||||
|
const count = movable.byInbound.get(h.inboundId) ?? 1;
|
||||||
|
return (
|
||||||
|
<Space size={2}>
|
||||||
|
<Tooltip title={t('pages.hosts.moveUp')}>
|
||||||
|
<Button size="small" type="text" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('pages.hosts.moveDown')}>
|
||||||
|
<Button size="small" type="text" icon={<ArrowDownOutlined />} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('edit')}>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(h)} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('delete')}>
|
||||||
|
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(h)} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.hosts.fields.enable'),
|
||||||
|
key: 'enable',
|
||||||
|
width: 90,
|
||||||
|
render: (_, h) => (
|
||||||
|
<Switch size="small" checked={!h.isDisabled} onChange={(next) => onToggleEnable(h, next)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.hosts.fields.remark'),
|
||||||
|
dataIndex: 'remark',
|
||||||
|
key: 'remark',
|
||||||
|
render: (_, h) => (
|
||||||
|
<div className="host-remark-cell">
|
||||||
|
<span className="host-remark">{h.remark}</span>
|
||||||
|
{h.serverDescription ? <span className="host-desc">{h.serverDescription}</span> : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.hosts.fields.endpoint'),
|
||||||
|
key: 'endpoint',
|
||||||
|
render: (_, h) => <span className="host-endpoint">{`${h.address || '—'}${h.port ? `:${h.port}` : ''}`}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.hosts.fields.inbound'),
|
||||||
|
key: 'inbound',
|
||||||
|
render: (_, h) => inboundLabel.get(h.inboundId) ?? `#${h.inboundId}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.hosts.fields.security'),
|
||||||
|
dataIndex: 'security',
|
||||||
|
key: 'security',
|
||||||
|
render: (security: string) => <Tag>{security || 'same'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.hosts.fields.tags'),
|
||||||
|
key: 'tags',
|
||||||
|
render: (_, h) => (h.tags && h.tags.length > 0
|
||||||
|
? <Space size={[0, 4]} wrap>{h.tags.map((tag) => <Tag key={tag} color="blue">{tag}</Tag>)}</Space>
|
||||||
|
: <span className="host-muted">—</span>),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolbar = (
|
||||||
|
<div className="card-toolbar">
|
||||||
|
{selectedIds.length === 0 ? (
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||||
|
{!isMobile && t('pages.hosts.addHost')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Tag
|
||||||
|
color="blue"
|
||||||
|
closable
|
||||||
|
onClose={() => onSelectionChange([])}
|
||||||
|
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{t('pages.hosts.selectedCount', { count: selectedIds.length })}
|
||||||
|
</Tag>
|
||||||
|
<Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
|
||||||
|
<Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
|
||||||
|
<Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>{t('pages.hosts.bulkDelete')}</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small" hoverable title={toolbar} className="hosts-card">
|
||||||
|
<Table<HostRecord>
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={sorted}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedIds,
|
||||||
|
onChange: (keys) => onSelectionChange(keys as number[]),
|
||||||
|
}}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<div className="card-empty">
|
||||||
|
<GlobalOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||||
|
<div>{t('noData')}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
|
||||||
|
import { CheckCircleOutlined, GlobalOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
import { useHostsQuery, type HostRecord } from '@/api/queries/useHostsQuery';
|
||||||
|
import { useHostMutations } from '@/api/queries/useHostMutations';
|
||||||
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
|
import type { HostFormValues } from '@/schemas/api/host';
|
||||||
|
import HostList from './HostList';
|
||||||
|
import HostFormModal from './HostFormModal';
|
||||||
|
|
||||||
|
// Hosts for one inbound in render order — used to compute a reorder payload.
|
||||||
|
function inboundHostsInOrder(hosts: HostRecord[], inboundId: number): HostRecord[] {
|
||||||
|
return hosts
|
||||||
|
.filter((h) => h.inboundId === inboundId)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const sa = a.sortOrder ?? 0;
|
||||||
|
const sb = b.sortOrder ?? 0;
|
||||||
|
if (sa !== sb) return sa - sb;
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HostsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
|
const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery();
|
||||||
|
const { create, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations();
|
||||||
|
const { data: inboundOptions = [] } = useInboundOptions();
|
||||||
|
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||||
|
const [formHost, setFormHost] = useState<HostRecord | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const onAdd = useCallback(() => {
|
||||||
|
setFormMode('add');
|
||||||
|
setFormHost(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onEdit = useCallback((host: HostRecord) => {
|
||||||
|
setFormMode('edit');
|
||||||
|
setFormHost({ ...host });
|
||||||
|
setFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSave = useCallback(async (payload: Partial<HostFormValues>) => {
|
||||||
|
if (formMode === 'edit' && formHost?.id) {
|
||||||
|
return update(formHost.id, payload);
|
||||||
|
}
|
||||||
|
return create(payload);
|
||||||
|
}, [formMode, formHost, update, create]);
|
||||||
|
|
||||||
|
const onDelete = useCallback((host: HostRecord) => {
|
||||||
|
modal.confirm({
|
||||||
|
title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }),
|
||||||
|
okText: t('delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await remove(host.id);
|
||||||
|
if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [modal, t, remove, messageApi]);
|
||||||
|
|
||||||
|
const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => {
|
||||||
|
await setEnable(host.id, next);
|
||||||
|
}, [setEnable]);
|
||||||
|
|
||||||
|
const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => {
|
||||||
|
const group = inboundHostsInOrder(hosts, host.inboundId);
|
||||||
|
const idx = group.findIndex((h) => h.id === host.id);
|
||||||
|
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
|
||||||
|
if (idx < 0 || swapWith < 0 || swapWith >= group.length) return;
|
||||||
|
const ids = group.map((h) => h.id);
|
||||||
|
[ids[idx], ids[swapWith]] = [ids[swapWith], ids[idx]];
|
||||||
|
await reorder(ids);
|
||||||
|
}, [hosts, reorder]);
|
||||||
|
|
||||||
|
const onBulkEnable = useCallback(async (enable: boolean) => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
const msg = await bulkSetEnable(selectedIds, enable);
|
||||||
|
if (msg?.success) setSelectedIds([]);
|
||||||
|
}, [selectedIds, bulkSetEnable]);
|
||||||
|
|
||||||
|
const onBulkDelete = useCallback(() => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
modal.confirm({
|
||||||
|
title: t('pages.hosts.bulkDeleteConfirm', { count: selectedIds.length }),
|
||||||
|
okText: t('delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await bulkDel(selectedIds);
|
||||||
|
if (msg?.success) {
|
||||||
|
messageApi.success(t('pages.hosts.toasts.delete'));
|
||||||
|
setSelectedIds([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [selectedIds, modal, t, bulkDel, messageApi]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const total = hosts.length;
|
||||||
|
const enabled = hosts.filter((h) => !h.isDisabled).length;
|
||||||
|
return { total, enabled, disabled: total - enabled };
|
||||||
|
}, [hosts]);
|
||||||
|
|
||||||
|
const pageClass = useMemo(() => {
|
||||||
|
const classes = ['hosts-page'];
|
||||||
|
if (isDark) classes.push('is-dark');
|
||||||
|
if (isUltra) classes.push('is-ultra');
|
||||||
|
return classes.join(' ');
|
||||||
|
}, [isDark, isUltra]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
{messageContextHolder}
|
||||||
|
{modalContextHolder}
|
||||||
|
<Layout className={pageClass}>
|
||||||
|
<AppSidebar />
|
||||||
|
<Layout className="content-shell">
|
||||||
|
<Layout.Content id="content-layout" className="content-area">
|
||||||
|
<Spin spinning={!fetched} delay={200} size="large">
|
||||||
|
{!fetched ? (
|
||||||
|
<div className="loading-spacer" />
|
||||||
|
) : fetchError ? (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={t('somethingWentWrong')}
|
||||||
|
subTitle={fetchError}
|
||||||
|
extra={<Button type="primary" loading={loading} onClick={() => refetch()}>{t('refresh')}</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Card size="small" hoverable className="summary-card">
|
||||||
|
<Row gutter={[16, 12]}>
|
||||||
|
<Col xs={8} sm={8} md={8}>
|
||||||
|
<Statistic
|
||||||
|
title={t('pages.hosts.summary.total')}
|
||||||
|
value={String(summary.total)}
|
||||||
|
prefix={<GlobalOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={8} sm={8} md={8}>
|
||||||
|
<Statistic
|
||||||
|
title={t('pages.hosts.summary.enabled')}
|
||||||
|
value={String(summary.enabled)}
|
||||||
|
prefix={<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={8} sm={8} md={8}>
|
||||||
|
<Statistic
|
||||||
|
title={t('pages.hosts.summary.disabled')}
|
||||||
|
value={String(summary.disabled)}
|
||||||
|
prefix={<StopOutlined style={{ color: 'var(--ant-color-text-quaternary)' }} />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={24}>
|
||||||
|
<HostList
|
||||||
|
hosts={hosts}
|
||||||
|
inboundOptions={inboundOptions}
|
||||||
|
loading={loading}
|
||||||
|
isMobile={isMobile}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onSelectionChange={setSelectedIds}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onToggleEnable={onToggleEnable}
|
||||||
|
onMove={onMove}
|
||||||
|
onBulkEnable={onBulkEnable}
|
||||||
|
onBulkDelete={onBulkDelete}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<HostFormModal
|
||||||
|
open={formOpen}
|
||||||
|
mode={formMode}
|
||||||
|
host={formHost}
|
||||||
|
inboundOptions={inboundOptions}
|
||||||
|
save={onSave}
|
||||||
|
onOpenChange={setFormOpen}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Form } from 'antd';
|
||||||
|
|
||||||
|
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||||
|
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
||||||
|
|
||||||
|
// Per-host Final Mask editor — same shape as the sub-JSON settings one
|
||||||
|
// (SubJsonFinalMaskForm) but reused for a host: reads/writes the host's
|
||||||
|
// finalMask JSON string. The masks are merged into this host's JSON stream.
|
||||||
|
|
||||||
|
function hasValue(v: unknown): boolean {
|
||||||
|
if (v == null) return false;
|
||||||
|
if (Array.isArray(v)) return v.some(hasValue);
|
||||||
|
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
|
||||||
|
if (typeof v === 'string') return v.length > 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFinalMask(raw: string): FinalMaskStreamSettings {
|
||||||
|
try {
|
||||||
|
if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
|
||||||
|
} catch {
|
||||||
|
return { tcp: [], udp: [] };
|
||||||
|
}
|
||||||
|
return { tcp: [], udp: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HostFinalMaskForm({ value = '', onChange }: { value?: string; onChange?: (next: string) => void }) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [initial] = useState(() => parseFinalMask(value));
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
|
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (finalmask === undefined) return;
|
||||||
|
const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
|
||||||
|
if (next !== value) onChangeRef.current?.(next);
|
||||||
|
}, [finalmask, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
component={false}
|
||||||
|
colon={false}
|
||||||
|
labelCol={{ sm: { span: 8 } }}
|
||||||
|
wrapperCol={{ sm: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
|
initialValues={{ finalmask: initial }}
|
||||||
|
>
|
||||||
|
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MuxForm } from '@/pages/xray/outbounds/transport';
|
||||||
|
|
||||||
|
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
||||||
|
import { serializeOverride } from './helpers';
|
||||||
|
|
||||||
|
// Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
|
||||||
|
// settings editor). Stored in the host's muxParams JSON string. Defaults match
|
||||||
|
// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
|
||||||
|
// when the toggle is off, an explicit mux object when on.
|
||||||
|
const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
|
||||||
|
|
||||||
|
export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
|
||||||
|
return (
|
||||||
|
<OutboundSubtreeJsonForm
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
path={['mux']}
|
||||||
|
defaultSubtree={DEFAULT_MUX}
|
||||||
|
serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
|
||||||
|
// protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
|
||||||
|
// a host's mux override is protocol-agnostic and should always be editable.
|
||||||
|
render={(form) => <MuxForm form={form} protocol="vmess" network="tcp" />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { SockoptForm } from '@/pages/xray/outbounds/transport';
|
||||||
|
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||||
|
|
||||||
|
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
||||||
|
import { serializeOverride } from './helpers';
|
||||||
|
|
||||||
|
// Sockopt override editor — reuses the outbound SockoptForm (which carries its
|
||||||
|
// own enable Switch and writes streamSettings.sockopt). Serialized to the host's
|
||||||
|
// sockoptParams JSON string.
|
||||||
|
//
|
||||||
|
// A host is the client/dialer side, so the inbound-only sockopt keys are dropped
|
||||||
|
// from the output. Verified against xray-core transport/internet/sockopt_*.go:
|
||||||
|
// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
|
||||||
|
// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
|
||||||
|
// on the outbound/dialer socket, so they stay. The outbound form no longer shows
|
||||||
|
// the inbound-only keys, but its default object still seeds them, so strip here.
|
||||||
|
const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
|
||||||
|
|
||||||
|
function serializeClientSockopt(sockopt: unknown): string {
|
||||||
|
if (!sockopt || typeof sockopt !== 'object') return serializeOverride(sockopt);
|
||||||
|
const copy = { ...(sockopt as Record<string, unknown>) };
|
||||||
|
for (const key of INBOUND_ONLY_SOCKOPT) delete copy[key];
|
||||||
|
return serializeOverride(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
|
||||||
|
// Populate the dialerProxy dropdown with the panel's outbound tags (a host can
|
||||||
|
// chain through one of the subscription's outbounds by tag). dialerProxy chains
|
||||||
|
// through a single outbound, so balancers (routing targets) are excluded — only
|
||||||
|
// the outbound group is used; blackhole is dropped too (chaining to it just
|
||||||
|
// drops the traffic).
|
||||||
|
const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||||
|
const outboundTags = tagGroups?.outbounds ?? [];
|
||||||
|
return (
|
||||||
|
<OutboundSubtreeJsonForm
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
path={['streamSettings', 'sockopt']}
|
||||||
|
serialize={serializeClientSockopt}
|
||||||
|
render={(form) => <SockoptForm form={form} outboundTags={outboundTags} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { Form, type FormInstance } from 'antd';
|
||||||
|
|
||||||
|
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||||
|
|
||||||
|
import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
|
||||||
|
|
||||||
|
interface OutboundSubtreeJsonFormProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (next: string) => void;
|
||||||
|
// Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux'].
|
||||||
|
path: (string | number)[];
|
||||||
|
// Renders the reused outbound form given this wrapper's own form instance.
|
||||||
|
render: (form: FormInstance<OutboundFormValues>) => ReactNode;
|
||||||
|
// Seeds the form when the stored value is empty, so toggling a section on
|
||||||
|
// pre-fills sensible defaults instead of blanks (used by Mux).
|
||||||
|
defaultSubtree?: Record<string, unknown>;
|
||||||
|
// Turns the edited subtree into the stored JSON string (default: prune empties).
|
||||||
|
// Mux overrides this to store '' (= inherit) when its enable flag is off.
|
||||||
|
serialize?: (subtree: unknown) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts the reused outbound transport forms (which bind to fixed form paths)
|
||||||
|
// inside an isolated antd Form, mirroring SubJsonFinalMaskForm: seed the form
|
||||||
|
// from the JSON string, watch the edited subtree, and report a JSON string back
|
||||||
|
// to the parent host form. component={false} avoids a nested <form> DOM node.
|
||||||
|
export default function OutboundSubtreeJsonForm({
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
path,
|
||||||
|
render,
|
||||||
|
defaultSubtree,
|
||||||
|
serialize = serializeOverride,
|
||||||
|
}: OutboundSubtreeJsonFormProps) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [initial] = useState<Record<string, unknown>>(() => {
|
||||||
|
const parsed = parseJsonObject(value);
|
||||||
|
return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
|
||||||
|
});
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
|
const subtree = Form.useWatch(path, form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = serialize(subtree);
|
||||||
|
if (next !== value) onChangeRef.current?.(next);
|
||||||
|
// serialize is logically stable; re-run only when the edited subtree changes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [subtree, value]);
|
||||||
|
|
||||||
|
const hasInitial = Object.keys(initial).length > 0;
|
||||||
|
const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
component={false}
|
||||||
|
colon={false}
|
||||||
|
labelCol={{ sm: { span: 8 } }}
|
||||||
|
wrapperCol={{ sm: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
|
initialValues={initialValues}
|
||||||
|
>
|
||||||
|
{render(form as unknown as FormInstance<OutboundFormValues>)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Shared helpers for the host's structured JSON-override editors. Each host
|
||||||
|
// override is persisted as a JSON string (muxParams / sockoptParams /
|
||||||
|
// finalMask); these convert between that string and the object the reused
|
||||||
|
// outbound/sub-JSON forms edit.
|
||||||
|
|
||||||
|
export function parseJsonObject(raw: string): Record<string, unknown> {
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively drop '', null, undefined, and empty arrays/objects so an override
|
||||||
|
// stays sparse — only the keys the operator actually set are emitted and merged
|
||||||
|
// into the inbound stream. 0 and false are kept (meaningful sockopt/mux values).
|
||||||
|
export function pruneEmptyDeep(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const arr = value.map(pruneEmptyDeep).filter((v) => v !== undefined);
|
||||||
|
return arr.length ? arr : undefined;
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
const pv = pruneEmptyDeep(v);
|
||||||
|
if (pv !== undefined) out[k] = pv;
|
||||||
|
}
|
||||||
|
return Object.keys(out).length ? out : undefined;
|
||||||
|
}
|
||||||
|
if (value === '' || value === null) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune then stringify; an all-empty override serializes to '' (= no override).
|
||||||
|
export function serializeOverride(value: unknown): string {
|
||||||
|
const pruned = pruneEmptyDeep(value);
|
||||||
|
return pruned === undefined ? '' : JSON.stringify(pruned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a nested object { a: { b: leaf } } from a form path ['a','b'] so the
|
||||||
|
// inner form can be seeded with initialValues at the exact path it edits.
|
||||||
|
export function nestAtPath(path: (string | number)[], leaf: unknown): Record<string, unknown> {
|
||||||
|
let acc: unknown = leaf;
|
||||||
|
for (let i = path.length - 1; i >= 0; i -= 1) {
|
||||||
|
acc = { [path[i]]: acc };
|
||||||
|
}
|
||||||
|
return acc as Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as HostMuxForm } from './HostMuxForm';
|
||||||
|
export { default as HostSockoptForm } from './HostSockoptForm';
|
||||||
|
export { default as HostFinalMaskForm } from './HostFinalMaskForm';
|
||||||
@@ -82,6 +82,7 @@ export default function InboundsPage() {
|
|||||||
clientCount,
|
clientCount,
|
||||||
onlineClients,
|
onlineClients,
|
||||||
lastOnlineMap,
|
lastOnlineMap,
|
||||||
|
inboundSpeed,
|
||||||
totals,
|
totals,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
@@ -89,7 +90,6 @@ export default function InboundsPage() {
|
|||||||
subSettings,
|
subSettings,
|
||||||
tgBotEnable,
|
tgBotEnable,
|
||||||
ipLimitEnable,
|
ipLimitEnable,
|
||||||
remarkModel,
|
|
||||||
refresh,
|
refresh,
|
||||||
hydrateInbound,
|
hydrateInbound,
|
||||||
applyTrafficEvent,
|
applyTrafficEvent,
|
||||||
@@ -264,13 +264,12 @@ export default function InboundsPage() {
|
|||||||
content: genInboundLinks({
|
content: genInboundLinks({
|
||||||
inbound: inboundFromDb(projected),
|
inbound: inboundFromDb(projected),
|
||||||
remark: projected.remark,
|
remark: projected.remark,
|
||||||
remarkModel,
|
|
||||||
hostOverride: hostOverrideFor(dbInbound),
|
hostOverride: hostOverrideFor(dbInbound),
|
||||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||||
}),
|
}),
|
||||||
fileName: projected.remark || 'inbound',
|
fileName: projected.remark || 'inbound',
|
||||||
});
|
});
|
||||||
}, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
|
}, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||||
|
|
||||||
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
||||||
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
|
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
|
||||||
@@ -302,13 +301,12 @@ export default function InboundsPage() {
|
|||||||
out.push(genInboundLinks({
|
out.push(genInboundLinks({
|
||||||
inbound: inboundFromDb(projected),
|
inbound: inboundFromDb(projected),
|
||||||
remark: projected.remark,
|
remark: projected.remark,
|
||||||
remarkModel,
|
|
||||||
hostOverride: hostOverrideFor(ib),
|
hostOverride: hostOverrideFor(ib),
|
||||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
|
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
|
||||||
}, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
|
}, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||||
|
|
||||||
const exportAllSubs = useCallback(async () => {
|
const exportAllSubs = useCallback(async () => {
|
||||||
const hydrated = await Promise.all(
|
const hydrated = await Promise.all(
|
||||||
@@ -620,6 +618,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}
|
||||||
@@ -656,7 +655,6 @@ export default function InboundsPage() {
|
|||||||
onClose={() => setInfoOpen(false)}
|
onClose={() => setInfoOpen(false)}
|
||||||
dbInbound={infoDbInbound}
|
dbInbound={infoDbInbound}
|
||||||
clientIndex={infoClientIndex}
|
clientIndex={infoClientIndex}
|
||||||
remarkModel={remarkModel}
|
|
||||||
expireDiff={expireDiff}
|
expireDiff={expireDiff}
|
||||||
trafficDiff={trafficDiff}
|
trafficDiff={trafficDiff}
|
||||||
ipLimitEnable={ipLimitEnable}
|
ipLimitEnable={ipLimitEnable}
|
||||||
@@ -672,7 +670,6 @@ export default function InboundsPage() {
|
|||||||
onClose={() => setQrOpen(false)}
|
onClose={() => setQrOpen(false)}
|
||||||
dbInbound={qrDbInbound}
|
dbInbound={qrDbInbound}
|
||||||
client={null}
|
client={null}
|
||||||
remarkModel={remarkModel}
|
|
||||||
nodeAddress={qrNodeAddress}
|
nodeAddress={qrNodeAddress}
|
||||||
subSettings={subSettings}
|
subSettings={subSettings}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -64,7 +65,6 @@ import {
|
|||||||
WireguardFields,
|
WireguardFields,
|
||||||
} from './protocols';
|
} from './protocols';
|
||||||
import {
|
import {
|
||||||
ExternalProxyForm,
|
|
||||||
GrpcForm,
|
GrpcForm,
|
||||||
HttpUpgradeForm,
|
HttpUpgradeForm,
|
||||||
KcpForm,
|
KcpForm,
|
||||||
@@ -83,6 +83,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;
|
||||||
@@ -240,23 +250,6 @@ export default function InboundFormModal({
|
|||||||
onSecurityChange,
|
onSecurityChange,
|
||||||
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
|
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
|
||||||
|
|
||||||
const toggleExternalProxy = (on: boolean) => {
|
|
||||||
if (on) {
|
|
||||||
const port = (form.getFieldValue('port') as number) ?? 443;
|
|
||||||
form.setFieldValue(['streamSettings', 'externalProxy'], [{
|
|
||||||
forceTls: 'same',
|
|
||||||
dest: typeof window !== 'undefined' ? window.location.hostname : '',
|
|
||||||
port,
|
|
||||||
remark: '',
|
|
||||||
sni: '',
|
|
||||||
fingerprint: '',
|
|
||||||
alpn: [],
|
|
||||||
pinnedPeerCertSha256: [],
|
|
||||||
}]);
|
|
||||||
} else {
|
|
||||||
form.setFieldValue(['streamSettings', 'externalProxy'], []);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSockopt = (on: boolean) => {
|
const toggleSockopt = (on: boolean) => {
|
||||||
if (on) {
|
if (on) {
|
||||||
@@ -538,16 +531,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 +553,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 +568,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>
|
||||||
@@ -696,7 +685,7 @@ export default function InboundFormModal({
|
|||||||
className="mt-12"
|
className="mt-12"
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message={t('pages.inbounds.fallbacks.needsTls')}
|
title={t('pages.inbounds.fallbacks.needsTls')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -804,14 +793,11 @@ export default function InboundFormModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* externalProxy only feeds client share links. Wireguard's per-peer
|
{/* The legacy externalProxy section is replaced by the Hosts page; the
|
||||||
.conf fanout resolves its host elsewhere, and tunnel (dokodemo-door)
|
field is still parsed/rendered for backward compatibility but is no
|
||||||
has no clients at all — the section is dead weight on both. */}
|
longer editable here. */}
|
||||||
{protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL && (
|
|
||||||
<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,209 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
|
||||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
|
||||||
|
|
||||||
import './external-proxy.css';
|
|
||||||
|
|
||||||
const newEntry = () => ({
|
|
||||||
forceTls: 'same',
|
|
||||||
dest: '',
|
|
||||||
port: 443,
|
|
||||||
remark: '',
|
|
||||||
sni: '',
|
|
||||||
fingerprint: '',
|
|
||||||
alpn: [],
|
|
||||||
pinnedPeerCertSha256: [],
|
|
||||||
echConfigList: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="ext-proxy-field">
|
|
||||||
<span className="ext-proxy-flabel">{label}</span>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExternalProxyForm({
|
|
||||||
toggleExternalProxy,
|
|
||||||
}: {
|
|
||||||
toggleExternalProxy: (on: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const form = Form.useFormInstance();
|
|
||||||
|
|
||||||
const generateRandomPin = (name: number) => {
|
|
||||||
const bytes = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
const path = ['streamSettings', 'externalProxy', name, 'pinnedPeerCertSha256'];
|
|
||||||
const current = (form.getFieldValue(path) as string[] | undefined) ?? [];
|
|
||||||
form.setFieldValue(path, [...current, hash]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form.Item
|
|
||||||
noStyle
|
|
||||||
shouldUpdate={(prev, curr) => {
|
|
||||||
const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
|
|
||||||
const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
|
|
||||||
return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ getFieldValue }) => {
|
|
||||||
const arr = getFieldValue(['streamSettings', 'externalProxy']);
|
|
||||||
const on = Array.isArray(arr) && arr.length > 0;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form.Item label={t('pages.inbounds.form.externalProxy')}>
|
|
||||||
<Switch checked={on} onChange={toggleExternalProxy} />
|
|
||||||
</Form.Item>
|
|
||||||
{on && (
|
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
|
||||||
<Form.List name={['streamSettings', 'externalProxy']}>
|
|
||||||
{(fields, { add, remove }) => (
|
|
||||||
<>
|
|
||||||
<div className="ext-proxy-list">
|
|
||||||
{fields.map((field, idx) => (
|
|
||||||
<div key={field.key} className="ext-proxy-card">
|
|
||||||
<div className="ext-proxy-card__head">
|
|
||||||
<span className="ext-proxy-card__title">#{idx + 1}</span>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={() => remove(field.name)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="ext-proxy-grid ext-proxy-grid--dest">
|
|
||||||
<Field label={t('pages.inbounds.form.forceTls')}>
|
|
||||||
<Form.Item name={[field.name, 'forceTls']} noStyle>
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: 'same', label: t('pages.inbounds.same') },
|
|
||||||
{ value: 'none', label: t('none') },
|
|
||||||
{ value: 'tls', label: 'TLS' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Field>
|
|
||||||
<Field label={t('pages.inbounds.address')}>
|
|
||||||
<Form.Item name={[field.name, 'dest']} noStyle>
|
|
||||||
<Input placeholder={t('pages.inbounds.address')} />
|
|
||||||
</Form.Item>
|
|
||||||
</Field>
|
|
||||||
<Field label={t('pages.inbounds.port')}>
|
|
||||||
<Form.Item name={[field.name, 'port']} noStyle>
|
|
||||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
|
||||||
</Form.Item>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<Field label={t('pages.inbounds.remark')}>
|
|
||||||
<Form.Item name={[field.name, 'remark']} noStyle>
|
|
||||||
<Input placeholder={t('pages.inbounds.remark')} />
|
|
||||||
</Form.Item>
|
|
||||||
</Field>
|
|
||||||
<Form.Item
|
|
||||||
noStyle
|
|
||||||
shouldUpdate={(prev, curr) =>
|
|
||||||
prev.streamSettings?.externalProxy?.[field.name]?.forceTls
|
|
||||||
!== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ getFieldValue }) => {
|
|
||||||
const ft = getFieldValue([
|
|
||||||
'streamSettings', 'externalProxy', field.name, 'forceTls',
|
|
||||||
]);
|
|
||||||
if (ft !== 'tls') return null;
|
|
||||||
return (
|
|
||||||
<div className="ext-proxy-tls">
|
|
||||||
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
|
||||||
<Field label="SNI">
|
|
||||||
<Form.Item name={[field.name, 'sni']} noStyle>
|
|
||||||
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
|
|
||||||
</Form.Item>
|
|
||||||
</Field>
|
|
||||||
<Field label={t('pages.inbounds.form.fingerprint')}>
|
|
||||||
<Form.Item name={[field.name, 'fingerprint']} noStyle>
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
placeholder={t('pages.inbounds.form.fingerprint')}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('pages.inbounds.form.defaultOption') },
|
|
||||||
...Object.values(UTLS_FINGERPRINT).map((fp) => ({
|
|
||||||
value: fp,
|
|
||||||
label: fp,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Field>
|
|
||||||
<Field label="ALPN">
|
|
||||||
<Form.Item name={[field.name, 'alpn']} noStyle>
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
placeholder="ALPN"
|
|
||||||
options={Object.values(ALPN_OPTION).map((a) => ({
|
|
||||||
value: a,
|
|
||||||
label: a,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<Field label={t('pages.inbounds.form.echConfig')}>
|
|
||||||
<Form.Item name={[field.name, 'echConfigList']} noStyle>
|
|
||||||
<Input placeholder={t('pages.inbounds.form.echConfig')} />
|
|
||||||
</Form.Item>
|
|
||||||
</Field>
|
|
||||||
<Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
|
|
||||||
<Space.Compact block>
|
|
||||||
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
tokenSeparators={[',', ' ']}
|
|
||||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
|
||||||
style={{ width: 'calc(100% - 32px)' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Button
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={() => generateRandomPin(field.name)}
|
|
||||||
title={t('pages.inbounds.form.generateRandomPin')}
|
|
||||||
/>
|
|
||||||
</Space.Compact>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="ext-proxy-add"
|
|
||||||
block
|
|
||||||
type="dashed"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => add(newEntry())}
|
|
||||||
>
|
|
||||||
{t('add')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form.Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,5 +4,4 @@ export { default as GrpcForm } from './grpc';
|
|||||||
export { default as XhttpForm } from './xhttp';
|
export { default as XhttpForm } from './xhttp';
|
||||||
export { default as HttpUpgradeForm } from './httpupgrade';
|
export { default as HttpUpgradeForm } from './httpupgrade';
|
||||||
export { default as KcpForm } from './kcp';
|
export { default as KcpForm } from './kcp';
|
||||||
export { default as ExternalProxyForm } from './external-proxy';
|
|
||||||
export { default as SockoptForm } from './sockopt';
|
export { default as SockoptForm } from './sockopt';
|
||||||
|
|||||||
@@ -1,19 +1,71 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
|
||||||
|
|
||||||
import {
|
import { CustomSockoptList } from '@/components/form';
|
||||||
Address_Port_Strategy,
|
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||||
DOMAIN_STRATEGY_OPTION,
|
|
||||||
TCP_CONGESTION_OPTION,
|
// Transport key that carries its own acceptProxyProtocol field (mirrored
|
||||||
} from '@/schemas/primitives';
|
// alongside the sockopt-level one so the PROXY preset never silently no-ops).
|
||||||
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
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 +85,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 }}
|
||||||
|
title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{proxyMismatch && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
title={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 +202,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 />
|
||||||
@@ -78,13 +214,6 @@ export default function SockoptForm({
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
|
||||||
name={['streamSettings', 'sockopt', 'tcpMptcp']}
|
|
||||||
label={t('pages.inbounds.form.multipathTcp')}
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={['streamSettings', 'sockopt', 'penetrate']}
|
name={['streamSettings', 'sockopt', 'penetrate']}
|
||||||
label={t('pages.inbounds.form.penetrate')}
|
label={t('pages.inbounds.form.penetrate')}
|
||||||
@@ -99,15 +228,6 @@ export default function SockoptForm({
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
|
||||||
name={['streamSettings', 'sockopt', 'domainStrategy']}
|
|
||||||
label={t('pages.xray.wireguard.domainStrategy')}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
style={{ width: '50%' }}
|
|
||||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((d) => ({ value: d, label: d }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||||||
label={t('pages.inbounds.form.tcpCongestion')}
|
label={t('pages.inbounds.form.tcpCongestion')}
|
||||||
@@ -127,18 +247,10 @@ export default function SockoptForm({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name={['streamSettings', 'sockopt', 'dialerProxy']} label={t('pages.inbounds.form.dialerProxy')}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name={['streamSettings', 'sockopt', 'interface']}
|
|
||||||
label={t('pages.inbounds.info.interfaceName')}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<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"
|
||||||
@@ -152,115 +264,7 @@ export default function SockoptForm({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<CustomSockoptList />
|
||||||
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
|
|
||||||
label={t('pages.inbounds.form.addressPortStrategy')}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
style={{ width: '50%' }}
|
|
||||||
options={Object.values(Address_Port_Strategy).map((v) => ({ value: v, label: v }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item shouldUpdate noStyle>
|
|
||||||
{({ getFieldValue, setFieldValue }) => {
|
|
||||||
const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']);
|
|
||||||
const hasHe = he != null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form.Item label="Happy Eyeballs">
|
|
||||||
<Switch
|
|
||||||
checked={hasHe}
|
|
||||||
onChange={(v) => {
|
|
||||||
setFieldValue(
|
|
||||||
['streamSettings', 'sockopt', 'happyEyeballs'],
|
|
||||||
v ? HappyEyeballsSchema.parse({}) : undefined,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
{hasHe && (
|
|
||||||
<>
|
|
||||||
<Form.Item
|
|
||||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
|
|
||||||
label={t('pages.inbounds.form.tryDelayMs')}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} placeholder="0 disabled — 250 recommended" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
|
|
||||||
label={t('pages.inbounds.form.prioritizeIPv6')}
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
|
|
||||||
label={t('pages.inbounds.form.interleave')}
|
|
||||||
>
|
|
||||||
<InputNumber min={1} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
|
|
||||||
label={t('pages.inbounds.form.maxConcurrentTry')}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form.Item>
|
|
||||||
<Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
|
|
||||||
{(fields, { add, remove }) => (
|
|
||||||
<>
|
|
||||||
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
size="small"
|
|
||||||
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
|
|
||||||
>
|
|
||||||
+ {t('pages.inbounds.form.addCustomOption')}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
{fields.map((field) => (
|
|
||||||
<Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
|
|
||||||
<Form.Item name={[field.name, 'system']} noStyle>
|
|
||||||
<Select
|
|
||||||
placeholder="all"
|
|
||||||
allowClear
|
|
||||||
style={{ width: 100 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'linux', label: 'linux' },
|
|
||||||
{ value: 'windows', label: 'windows' },
|
|
||||||
{ value: 'darwin', label: 'darwin' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name={[field.name, 'type']} noStyle>
|
|
||||||
<Select
|
|
||||||
style={{ width: 80 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'int', label: 'int' },
|
|
||||||
{ value: 'str', label: 'str' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name={[field.name, 'level']} noStyle>
|
|
||||||
<Input placeholder="level (6=TCP)" style={{ width: 100 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name={[field.name, 'opt']} noStyle>
|
|
||||||
<Input placeholder="opt" style={{ width: 120 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name={[field.name, 'value']} noStyle>
|
|
||||||
<Input placeholder="value" style={{ flex: 1 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Button danger onClick={() => remove(field.name)}>−</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export default function InboundInfoModal({
|
|||||||
onClose,
|
onClose,
|
||||||
dbInbound,
|
dbInbound,
|
||||||
clientIndex = 0,
|
clientIndex = 0,
|
||||||
remarkModel = '-io',
|
|
||||||
expireDiff = 0,
|
expireDiff = 0,
|
||||||
trafficDiff = 0,
|
trafficDiff = 0,
|
||||||
ipLimitEnable = false,
|
ipLimitEnable = false,
|
||||||
@@ -120,7 +119,6 @@ export default function InboundInfoModal({
|
|||||||
genWireguardConfigs({
|
genWireguardConfigs({
|
||||||
inbound: inboundForLinks,
|
inbound: inboundForLinks,
|
||||||
remark: dbInbound.remark,
|
remark: dbInbound.remark,
|
||||||
remarkModel: '-io',
|
|
||||||
hostOverride: nodeAddress,
|
hostOverride: nodeAddress,
|
||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
}).split('\r\n'),
|
}).split('\r\n'),
|
||||||
@@ -129,7 +127,6 @@ export default function InboundInfoModal({
|
|||||||
genWireguardLinks({
|
genWireguardLinks({
|
||||||
inbound: inboundForLinks,
|
inbound: inboundForLinks,
|
||||||
remark: dbInbound.remark,
|
remark: dbInbound.remark,
|
||||||
remarkModel: '-io',
|
|
||||||
hostOverride: nodeAddress,
|
hostOverride: nodeAddress,
|
||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
}).split('\r\n'),
|
}).split('\r\n'),
|
||||||
@@ -140,7 +137,6 @@ export default function InboundInfoModal({
|
|||||||
genAllLinks({
|
genAllLinks({
|
||||||
inbound: inboundForLinks,
|
inbound: inboundForLinks,
|
||||||
remark: dbInbound.remark,
|
remark: dbInbound.remark,
|
||||||
remarkModel,
|
|
||||||
client: (clientSet ?? {}) as Parameters<typeof genAllLinks>[0]['client'],
|
client: (clientSet ?? {}) as Parameters<typeof genAllLinks>[0]['client'],
|
||||||
hostOverride: nodeAddress,
|
hostOverride: nodeAddress,
|
||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
@@ -189,7 +185,7 @@ export default function InboundInfoModal({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [open, dbInbound, clientIndex, remarkModel, nodeAddress, subSettings, ipLimitEnable, t]);
|
}, [open, dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable, t]);
|
||||||
|
|
||||||
const isEnable = useMemo(() => {
|
const isEnable = useMemo(() => {
|
||||||
if (clientSettings) return !!clientSettings.enable;
|
if (clientSettings) return !!clientSettings.enable;
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export interface InboundInfoModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
dbInbound: DBInboundLike | null;
|
dbInbound: DBInboundLike | null;
|
||||||
clientIndex?: number;
|
clientIndex?: number;
|
||||||
remarkModel?: string;
|
|
||||||
expireDiff?: number;
|
expireDiff?: number;
|
||||||
trafficDiff?: number;
|
trafficDiff?: number;
|
||||||
ipLimitEnable?: boolean;
|
ipLimitEnable?: boolean;
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ interface QrCodeModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
dbInbound: (DbInboundLike & { remark?: string }) | null;
|
dbInbound: (DbInboundLike & { remark?: string }) | null;
|
||||||
client?: ClientSetting | null;
|
client?: ClientSetting | null;
|
||||||
remarkModel?: string;
|
|
||||||
nodeAddress?: string;
|
nodeAddress?: string;
|
||||||
subSettings?: SubSettings;
|
subSettings?: SubSettings;
|
||||||
}
|
}
|
||||||
@@ -43,7 +42,6 @@ export default function QrCodeModal({
|
|||||||
onClose,
|
onClose,
|
||||||
dbInbound,
|
dbInbound,
|
||||||
client = null,
|
client = null,
|
||||||
remarkModel = '-io',
|
|
||||||
nodeAddress = '',
|
nodeAddress = '',
|
||||||
subSettings,
|
subSettings,
|
||||||
}: QrCodeModalProps) {
|
}: QrCodeModalProps) {
|
||||||
@@ -67,7 +65,6 @@ export default function QrCodeModal({
|
|||||||
genWireguardConfigs({
|
genWireguardConfigs({
|
||||||
inbound,
|
inbound,
|
||||||
remark: peerRemark,
|
remark: peerRemark,
|
||||||
remarkModel: '-io',
|
|
||||||
hostOverride: nodeAddress,
|
hostOverride: nodeAddress,
|
||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
}).split('\r\n'),
|
}).split('\r\n'),
|
||||||
@@ -76,7 +73,6 @@ export default function QrCodeModal({
|
|||||||
genWireguardLinks({
|
genWireguardLinks({
|
||||||
inbound,
|
inbound,
|
||||||
remark: peerRemark,
|
remark: peerRemark,
|
||||||
remarkModel: '-io',
|
|
||||||
hostOverride: nodeAddress,
|
hostOverride: nodeAddress,
|
||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
}).split('\r\n'),
|
}).split('\r\n'),
|
||||||
@@ -87,7 +83,6 @@ export default function QrCodeModal({
|
|||||||
genAllLinks({
|
genAllLinks({
|
||||||
inbound,
|
inbound,
|
||||||
remark: dbInbound.remark || '',
|
remark: dbInbound.remark || '',
|
||||||
remarkModel,
|
|
||||||
client: client ?? {},
|
client: client ?? {},
|
||||||
hostOverride: nodeAddress,
|
hostOverride: nodeAddress,
|
||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
@@ -106,7 +101,7 @@ export default function QrCodeModal({
|
|||||||
}
|
}
|
||||||
setSubLink(nextSub);
|
setSubLink(nextSub);
|
||||||
setSubJsonLink(nextSubJson);
|
setSubJsonLink(nextSubJson);
|
||||||
}, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
|
}, [open, dbInbound, client, nodeAddress, subSettings]);
|
||||||
|
|
||||||
const qrItems = useMemo<QrItem[]>(() => {
|
const qrItems = useMemo<QrItem[]>(() => {
|
||||||
const items: QrItem[] = [];
|
const items: QrItem[] = [];
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -148,7 +161,6 @@ export function useInbounds() {
|
|||||||
const tgBotEnable = !!defaults.tgBotEnable;
|
const tgBotEnable = !!defaults.tgBotEnable;
|
||||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||||
const pageSize = defaults.pageSize ?? 0;
|
const pageSize = defaults.pageSize ?? 0;
|
||||||
const remarkModel = defaults.remarkModel || '-io';
|
|
||||||
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
|
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
|
||||||
|
|
||||||
const subSettings: SubSettings = useMemo(() => ({
|
const subSettings: SubSettings = useMemo(() => ({
|
||||||
@@ -179,6 +191,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 +397,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 +417,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,12 +521,12 @@ export function useInbounds() {
|
|||||||
clientCount,
|
clientCount,
|
||||||
onlineClients,
|
onlineClients,
|
||||||
lastOnlineMap,
|
lastOnlineMap,
|
||||||
|
inboundSpeed,
|
||||||
statsVersion,
|
statsVersion,
|
||||||
totals,
|
totals,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
subSettings,
|
subSettings,
|
||||||
remarkModel,
|
|
||||||
datepicker,
|
datepicker,
|
||||||
tgBotEnable,
|
tgBotEnable,
|
||||||
ipLimitEnable,
|
ipLimitEnable,
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user