mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 15:55:02 +03:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c621ba9014 | |||
| 7f82daa42f | |||
| f992694712 | |||
| c3d4a153b3 | |||
| 8eb58a4a14 | |||
| 952c1c751a | |||
| be25314157 | |||
| 56ac8d0fc7 | |||
| 4d6db99be8 | |||
| 39935ed9ef | |||
| 96069e9393 | |||
| 64a7697f56 | |||
| 73fad424ed | |||
| 8da3b3c344 | |||
| 14c9bdff89 | |||
| 746bdbe9f4 | |||
| 2c18cbe1cd | |||
| e8feb9d641 | |||
| 180cc37503 | |||
| f105132866 | |||
| de21c128fc | |||
| a46bed383c | |||
| 15fee2a035 | |||
| bc1b750e00 | |||
| d103c0c012 | |||
| e66d18fe05 | |||
| 052b11d2bc | |||
| e0d2a0c58c | |||
| 136c1c4d50 | |||
| a69ae2b0ea | |||
| 0b90dc2bed | |||
| 2b759f3916 | |||
| 3af0f81181 | |||
| 35e09191de | |||
| 207c705300 | |||
| 07910bf6c0 | |||
| 49c0600f35 | |||
| 0729fc6dbb | |||
| f43645656c | |||
| 41bafb3fd5 | |||
| a403faee23 | |||
| 7d45456feb | |||
| d2a0216e01 | |||
| ad470681ef | |||
| 40cea0c2fe | |||
| f6a233220a | |||
| 9f0f2b3802 | |||
| 5a135d2adb | |||
| 0516a1f87c | |||
| 3051aebb28 | |||
| 864cbd234a | |||
| 2f62dadd25 | |||
| c3037fc716 | |||
| 1dbb37432c | |||
| c3394fefed | |||
| 3acdfd9dea | |||
| d50c419e28 | |||
| 1df8ab29f8 | |||
| 0faad4f4f3 | |||
| 57fd61790c | |||
| aedd8fac1c | |||
| 47be06358e | |||
| f172bc3f10 | |||
| e2184777a0 | |||
| 775b2b0b26 | |||
| 61b129d5b3 | |||
| a8d0700bf2 | |||
| a1f2589eb5 |
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
# LF in every checkout, Windows included: format-check, the msw worker check and
|
# LF in every checkout, Windows included: format-check and tests that parse repo
|
||||||
# tests that parse repo files compare bytes, so a CRLF working copy fails them.
|
# files compare bytes, so a CRLF working copy fails them.
|
||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ question it already answers.
|
|||||||
`cd docs && pnpm gen:api`, because `docs-ci.yml` fires only on `docs/**`.
|
`cd docs && pnpm gen:api`, because `docs-ci.yml` fires only on `docs/**`.
|
||||||
Step 4 is the one that reaches production wrong.
|
Step 4 is the one that reaches production wrong.
|
||||||
- **i18n.** A new English key goes in EVERY locale JSON in
|
- **i18n.** A new English key goes in EVERY locale JSON in
|
||||||
`internal/web/translation/` (13 files) AND must be referenced from
|
`internal/web/translation/` (14 files) AND must be referenced from
|
||||||
`frontend/src` or Go in the SAME change.
|
`frontend/src` or Go in the SAME change.
|
||||||
`frontend/src/test/i18n-dead-keys.test.ts` fails on a missing locale file and
|
`frontend/src/test/i18n-dead-keys.test.ts` fails on a missing locale file and
|
||||||
on an orphan key alike.
|
on an orphan key alike.
|
||||||
@@ -152,7 +152,7 @@ It is paths-filtered, so a docs-only or workflow-only change produces no run.
|
|||||||
| `golangci` | `golangci-lint` |
|
| `golangci` | `golangci-lint` |
|
||||||
| `fuzz-smoke` | 30s each on `FuzzParseLink` and `FuzzDecodeCertPin` |
|
| `fuzz-smoke` | 30s each on `FuzzParseLink` and `FuzzDecodeCertPin` |
|
||||||
| `codegen` | `npm run gen` then `git diff --exit-code` on the generated files |
|
| `codegen` | `npm run gen` then `git diff --exit-code` on the generated files |
|
||||||
| `frontend` | MSW worker drift, lint, format:check, typecheck, `npm test` (Vitest + headless-Chromium Storybook), build, build-storybook, `npm audit` |
|
| `frontend` | lint, format:check, typecheck, `npm test` (Vitest + headless-Chromium Storybook), build, build-storybook, `npm audit` |
|
||||||
|
|
||||||
**What CI does NOT prove.** These test families `t.Skip` unless an environment
|
**What CI does NOT prove.** These test families `t.Skip` unless an environment
|
||||||
variable is set, and CI sets only the PostgreSQL ones above:
|
variable is set, and CI sets only the PostgreSQL ones above:
|
||||||
|
|||||||
+135
-10
@@ -71,24 +71,124 @@ jobs:
|
|||||||
- name: PostgreSQL durable-first tests
|
- name: PostgreSQL durable-first tests
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
go test ./internal/web/service -run 'PostgresCommitFailure' -count=1 -v | tee /tmp/postgres-durable-first.log
|
go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$' -count=1 -v | tee /tmp/postgres-durable-first.log
|
||||||
# Count passes rather than assert no SKIP: a renamed or deleted test
|
# Count passes rather than assert no SKIP: a renamed or deleted test
|
||||||
# prints "no tests to run" and exits 0, leaving the step green for nothing.
|
# prints "no tests to run" and exits 0, leaving the step green for nothing.
|
||||||
passed=$(grep -c -- '--- PASS' /tmp/postgres-durable-first.log || true)
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-durable-first.log || true)
|
||||||
|
if [ "$passed" -lt 4 ]; then
|
||||||
|
echo "expected at least 4 passing durable-first tests, got $passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PostgreSQL batched stats identities
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
go test ./internal/web/service -run '^TestClientStatsIdentitiesPostgres$' -count=1 -v | tee /tmp/postgres-stats-identities.log
|
||||||
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-stats-identities.log || true)
|
||||||
|
if [ "$passed" -ne 1 ]; then
|
||||||
|
echo "expected 1 passing PostgreSQL stats identities test, got $passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PostgreSQL client IP sync regressions
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
go test -p=1 ./internal/web/service ./internal/web/job -run '^TestClientIpSyncPostgres(Scanner)?$' -count=1 -v | tee /tmp/postgres-client-ip-sync.log
|
||||||
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-client-ip-sync.log || true)
|
||||||
|
if [ "$passed" -ne 2 ]; then
|
||||||
|
echo "expected 2 passing PostgreSQL client IP sync tests, got $passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PostgreSQL global-traffic heartbeat regressions
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
go test ./internal/web/service -run '^TestGlobalTrafficHeartbeatPostgres$' -count=1 -v | tee /tmp/postgres-global-traffic-heartbeat.log
|
||||||
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-global-traffic-heartbeat.log || true)
|
||||||
|
if [ "$passed" -ne 1 ]; then
|
||||||
|
echo "expected 1 passing global-traffic heartbeat test, got $passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PostgreSQL deferred-commit test
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
go test ./internal/web/service/outbound -run '^TestAddTrafficReturnsDeferredCommitFailure$' -count=1 -v | tee /tmp/postgres-outbound.log
|
||||||
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-outbound.log || true)
|
||||||
if [ "$passed" -lt 1 ]; then
|
if [ "$passed" -lt 1 ]; then
|
||||||
echo "expected at least 1 passing durable-first test, got $passed" >&2
|
echo "expected at least 1 passing deferred-commit test, got $passed" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: PostgreSQL schema and migration tests
|
- name: PostgreSQL schema and migration tests
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres|TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
|
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres|TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked_Postgres|TestClientKeepAlivePostgresMigrationPreservesLegacyScalars|TestPostgresModelSettled_TracksSchemaPresence)$' -count=1 -v | tee /tmp/postgres-schema.log
|
||||||
# All must pass. Counting, not SKIP-matching: renaming a test would
|
# All must pass. Counting, not SKIP-matching: renaming a test would
|
||||||
# otherwise leave this step green while testing nothing.
|
# otherwise leave this step green while testing nothing.
|
||||||
passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
|
||||||
|
if [ "$passed" -lt 6 ]; then
|
||||||
|
echo "expected at least 6 passing PostgreSQL schema tests, got $passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PostgreSQL subscription Host tests
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
go test ./internal/sub -run '^TestSubHosts_Postgres$' -count=1 -v | tee /tmp/postgres-hosts.log
|
||||||
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-hosts.log || true)
|
||||||
|
if [ "$passed" -ne 1 ]; then
|
||||||
|
echo "expected passing PostgreSQL Host coverage, got $passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PostgreSQL Telegram lookup and legacy migration tests
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
go test ./internal/web/service -run '^(TestGetClientTrafficTgBot_IndexedPostgres|TestGetClientTrafficTgBot_SettingsSerializationStyles)$' -count=1 -v | tee /tmp/postgres-tgbot.log
|
||||||
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-tgbot.log || true)
|
||||||
|
if [ "$passed" -ne 2 ]; then
|
||||||
|
echo "expected 2 passing PostgreSQL Telegram lookup tests, got $passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The migration tests write and drop their own tables, so they need a
|
||||||
|
# scratch database instead of the durable one the panel tests use.
|
||||||
|
postgres-migration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: xui_scratch
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres -d xui_scratch"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
env:
|
||||||
|
XUI_TEST_PG_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=xui_scratch sslmode=disable"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
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: PostgreSQL migration tests
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
go test ./internal/database -run '^(TestMigrateData_CompositeKeyTableLargerThanBatch|TestMigrateData_PreservesFalseDefaultedColumns|TestMigrateData_FailedCopyLeavesDestinationUntouched|TestClientHwidSchemaPostgres)$' -count=1 -v | tee /tmp/postgres-migration.log
|
||||||
|
# Count passes rather than assert no SKIP: a renamed or deleted test
|
||||||
|
# prints "no tests to run" and exits 0, leaving the step green for nothing.
|
||||||
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-migration.log || true)
|
||||||
if [ "$passed" -lt 4 ]; then
|
if [ "$passed" -lt 4 ]; then
|
||||||
echo "expected at least 4 passing PostgreSQL schema tests, got $passed" >&2
|
echo "expected at least 4 passing PostgreSQL migration tests, got $passed" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -108,6 +208,8 @@ jobs:
|
|||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Fail if generated files are stale (run 'npm run gen' and commit)
|
- name: Fail if generated files are stale (run 'npm run gen' and commit)
|
||||||
run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
|
run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
|
||||||
|
- name: Fail if the docs spec is not the panel's
|
||||||
|
run: cmp frontend/public/openapi.json docs/public/openapi.json
|
||||||
|
|
||||||
govulncheck:
|
govulncheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -187,7 +289,33 @@ jobs:
|
|||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: latest
|
# Pinned: "latest" let the lint result drift between runs, so the same
|
||||||
|
# commit could pass today and fail tomorrow on a new upstream release.
|
||||||
|
version: v2.15.0
|
||||||
|
|
||||||
|
# Lint the shell scripts and the workflows themselves. The three legacy
|
||||||
|
# scripts carry a warning backlog (unquoted expansions, masked return codes)
|
||||||
|
# and are gated on errors only; the scripts that are clean today are gated on
|
||||||
|
# everything.
|
||||||
|
shell-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- name: actionlint
|
||||||
|
# The shellcheck integration is off: it reports the stylistic rules
|
||||||
|
# (SC2086, SC2046) that main's own workflows trip. The shell scripts
|
||||||
|
# the panel ships are linted by the shellcheck step below.
|
||||||
|
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -color -shellcheck=
|
||||||
|
- name: Install shellcheck
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||||
|
- name: shellcheck
|
||||||
|
run: |
|
||||||
|
shellcheck --severity=style DockerInit.sh DockerEntrypoint.sh deploy/test/smoke-noninteractive.sh
|
||||||
|
shellcheck --severity=error install.sh update.sh x-ui.sh
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -201,9 +329,6 @@ jobs:
|
|||||||
- name: Install
|
- name: Install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Verify generated MSW worker is current
|
|
||||||
run: git diff --exit-code -- public/mockServiceWorker.js package-lock.json
|
|
||||||
working-directory: frontend
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -226,5 +351,5 @@ jobs:
|
|||||||
run: npm run build-storybook
|
run: npm run build-storybook
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Audit
|
- name: Audit
|
||||||
run: npm audit --omit=dev --audit-level=high
|
run: npm run audit
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
|
- 'frontend/public/openapi.json'
|
||||||
|
- 'frontend/src/pages/api-docs/endpoints.ts'
|
||||||
- '.github/workflows/docs-ci.yml'
|
- '.github/workflows/docs-ci.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
|
- 'frontend/public/openapi.json'
|
||||||
|
- 'frontend/src/pages/api-docs/endpoints.ts'
|
||||||
- '.github/workflows/docs-ci.yml'
|
- '.github/workflows/docs-ci.yml'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -48,5 +52,18 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
|
# The API reference is generated from the panel's own spec. The copy into
|
||||||
|
# docs/public and the generated pages both have to be in step with it.
|
||||||
|
- name: The docs spec must be the panel's
|
||||||
|
run: cmp frontend/public/openapi.json docs/public/openapi.json
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Regenerate the API reference
|
||||||
|
run: pnpm gen:api
|
||||||
|
|
||||||
|
- name: Fail if the generated API reference is stale
|
||||||
|
run: git diff --exit-code -- docs/content/docs/en/reference/api
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
cd x-ui/bin
|
cd x-ui/bin
|
||||||
|
|
||||||
# Download dependencies
|
# Download dependencies
|
||||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.10/"
|
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
|
||||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||||
fetch ${Xray_URL}Xray-linux-64.zip
|
fetch ${Xray_URL}Xray-linux-64.zip
|
||||||
unzip Xray-linux-64.zip
|
unzip Xray-linux-64.zip
|
||||||
@@ -283,7 +283,7 @@ jobs:
|
|||||||
cd x-ui\bin
|
cd x-ui\bin
|
||||||
|
|
||||||
# Download Xray for Windows
|
# Download Xray for Windows
|
||||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/"
|
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/"
|
||||||
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||||
Remove-Item "Xray-windows-64.zip"
|
Remove-Item "Xray-windows-64.zip"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
FROM node:26-trixie-slim AS node
|
FROM node:26-trixie-slim AS node
|
||||||
|
|
||||||
FROM golang:1.27.2-trixie
|
FROM golang:1.27.3-trixie
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
CGO_ENABLED=1 \
|
CGO_ENABLED=1 \
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ responses for limited tokens, or node sync payloads.
|
|||||||
- Frontend: `cd /src/frontend && npx vitest run --project unit --project components`.
|
- Frontend: `cd /src/frontend && npx vitest run --project unit --project components`.
|
||||||
The Storybook browser project needs Chromium and is not provisioned.
|
The Storybook browser project needs Chromium and is not provisioned.
|
||||||
- Running a live panel: `XUI_DB_FOLDER=/tmp/xui XUI_LOG_FOLDER=/tmp/xui
|
- Running a live panel: `XUI_DB_FOLDER=/tmp/xui XUI_LOG_FOLDER=/tmp/xui
|
||||||
XUI_BIN_FOLDER=/src/build/bin x-ui` (creates `admin`/`admin` on first start;
|
XUI_BIN_FOLDER=/src/build/bin x-ui` (logs a generated username and password on
|
||||||
`x-ui setting -h` changes port, base path and credentials).
|
first start; `x-ui setting -h` changes port, base path and credentials).
|
||||||
- Baseline logs from the image build: `/src/.oss-scanner-go-test.log` and
|
- Baseline logs from the image build: `/src/.oss-scanner-go-test.log` and
|
||||||
`/src/.oss-scanner-vitest.log`. A built image does not prove the tests passed.
|
`/src/.oss-scanner-vitest.log`. A built image does not prove the tests passed.
|
||||||
- PostgreSQL is not installed: tests gated on `XUI_TEST_PG_DSN` skip, so a
|
- PostgreSQL is not installed: tests gated on `XUI_TEST_PG_DSN` skip, so a
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ file locations when it can answer in one hop.
|
|||||||
CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
|
CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
|
||||||
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
|
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
|
||||||
`runtime/` (master/sub-node over mTLS), `websocket/`.
|
`runtime/` (master/sub-node over mTLS), `websocket/`.
|
||||||
- `locale/` + `translation/` — i18n, 13 embedded locale JSON files.
|
- `locale/` + `translation/` — i18n, 14 embedded locale JSON files.
|
||||||
- `frontend/` — React + TS source (see `frontend/CLAUDE.md`).
|
- `frontend/` — React + TS source (see `frontend/CLAUDE.md`).
|
||||||
- `tools/openapigen/` — Go generator that emits frontend types + Zod/JSON schemas
|
- `tools/openapigen/` — Go generator that emits frontend types + Zod/JSON schemas
|
||||||
into `frontend/src/generated/` from Go structs. The OpenAPI doc itself
|
into `frontend/src/generated/` from Go structs. The OpenAPI doc itself
|
||||||
@@ -102,12 +102,13 @@ file locations when it can answer in one hop.
|
|||||||
never hand-write them. A new struct must be added to openapigen's `StructAllow`
|
never hand-write them. A new struct must be added to openapigen's `StructAllow`
|
||||||
allowlist (`tools/openapigen/main.go`) or it is silently omitted from
|
allowlist (`tools/openapigen/main.go`) or it is silently omitted from
|
||||||
schemas/examples (and `build-openapi.mjs` then fails on the missing schema).
|
schemas/examples (and `build-openapi.mjs` then fails on the missing schema).
|
||||||
- A new or renamed endpoint has a FOURTH step nothing checks: copy
|
- A new or renamed endpoint has a FOURTH step: copy
|
||||||
`frontend/public/openapi.json` → `docs/public/openapi.json`, then
|
`frontend/public/openapi.json` → `docs/public/openapi.json`, then
|
||||||
`cd docs && pnpm gen:api` to refresh the MDX under
|
`cd docs && pnpm gen:api` to refresh the MDX under
|
||||||
`docs/content/docs/en/reference/api/`. `docs-ci.yml` fires only on `docs/**`.
|
`docs/content/docs/en/reference/api/`. The `codegen` job and `make verify`
|
||||||
|
fail on a stale copy, and `docs-ci.yml` fails on stale MDX.
|
||||||
- A new English i18n key goes in EVERY locale JSON in `internal/web/translation/`
|
- A new English i18n key goes in EVERY locale JSON in `internal/web/translation/`
|
||||||
(13 files) AND must be referenced from `frontend/src` or Go in the SAME commit —
|
(14 files) AND must be referenced from `frontend/src` or Go in the SAME commit —
|
||||||
`frontend/src/test/i18n-dead-keys.test.ts` fails both ways. It is a frontend
|
`frontend/src/test/i18n-dead-keys.test.ts` fails both ways. It is a frontend
|
||||||
test, so run `npm test`, not just `make test-go`. At runtime the frontend falls
|
test, so run `npm test`, not just `make test-go`. At runtime the frontend falls
|
||||||
back to en-US; Go (`internal/web/locale/`) returns "" for an unknown key.
|
back to en-US; Go (`internal/web/locale/`) returns "" for an unknown key.
|
||||||
@@ -189,7 +190,7 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every
|
|||||||
`make` Go target already depends on it, which is why `make test-go` beats
|
`make` Go target already depends on it, which is why `make test-go` beats
|
||||||
`go test ./...`. Run `make help` for all targets. The local gate:
|
`go test ./...`. Run `make help` for all targets. The local gate:
|
||||||
|
|
||||||
make verify # gen-check + lint + typecheck + test + build + build-storybook
|
make verify # gen-check + lint + typecheck + test + build + build-storybook + docs-openapi-check
|
||||||
|
|
||||||
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
|
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
|
||||||
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure),
|
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure),
|
||||||
|
|||||||
+2
-2
@@ -84,7 +84,7 @@ Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on
|
|||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. Credentials must be changed on first login.
|
Open [http://localhost:2053](http://localhost:2053) and log in with the username and password the first start prints to the terminal (`No panel user existed, so one was generated…`). Lost them? Set new ones with `go run . setting -username <user> -password <pass>`.
|
||||||
|
|
||||||
### Inside VS Code
|
### Inside VS Code
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
|
|||||||
- **Function components + hooks** everywhere. No class components.
|
- **Function components + hooks** everywhere. No class components.
|
||||||
- **Comments in committed Go/TS/TSX: 2 lines MAX per comment block**, spent on the *why* a name cannot hold — an invariant, an issue number, a non-obvious constraint. Names should carry the meaning; rename rather than annotate. Compiler and tool directives (`//go:build`, `//go:generate`, `//nolint:`) are exempt, and HTML `<!-- ... -->` is fine for template structure.
|
- **Comments in committed Go/TS/TSX: 2 lines MAX per comment block**, spent on the *why* a name cannot hold — an invariant, an issue number, a non-obvious constraint. Names should carry the meaning; rename rather than annotate. Compiler and tool directives (`//go:build`, `//go:generate`, `//nolint:`) are exempt, and HTML `<!-- ... -->` is fine for template structure.
|
||||||
- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
|
- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
|
||||||
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
|
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `rules={{ validate: rhfZodValidate(Schema.shape.field) }}` rather than inline `z.string()` in rules.
|
||||||
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
|
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
|
||||||
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
|
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
|
||||||
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
|
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
|
||||||
|
|||||||
+43
-29
@@ -1,29 +1,48 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
# $1 is Docker's TARGETARCH and $2 its TARGETVARIANT (v6/v7 on arm). The
|
||||||
|
# platform names match the release archives and the mtg-multi packages.
|
||||||
case $1 in
|
case $1 in
|
||||||
amd64)
|
amd64 | "")
|
||||||
ARCH="64"
|
PLATFORM="amd64"
|
||||||
FNAME="amd64"
|
|
||||||
;;
|
;;
|
||||||
i386)
|
arm64 | armv8 | aarch64)
|
||||||
ARCH="32"
|
PLATFORM="arm64"
|
||||||
FNAME="i386"
|
|
||||||
;;
|
;;
|
||||||
armv8 | arm64 | aarch64)
|
arm | arm32 | armv7)
|
||||||
ARCH="arm64-v8a"
|
PLATFORM="armv7"
|
||||||
FNAME="arm64"
|
if [ "$2" = "v6" ]; then
|
||||||
;;
|
PLATFORM="armv6"
|
||||||
armv7 | arm | arm32)
|
fi
|
||||||
ARCH="arm32-v7a"
|
|
||||||
FNAME="arm32"
|
|
||||||
;;
|
;;
|
||||||
armv6)
|
armv6)
|
||||||
ARCH="arm32-v6"
|
PLATFORM="armv6"
|
||||||
FNAME="armv6"
|
;;
|
||||||
|
386 | i386)
|
||||||
|
PLATFORM="386"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
ARCH="64"
|
echo "DockerInit: unsupported architecture '$1'" >&2
|
||||||
FNAME="amd64"
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case $PLATFORM in
|
||||||
|
amd64) XRAY_ZIP="64" ;;
|
||||||
|
arm64) XRAY_ZIP="arm64-v8a" ;;
|
||||||
|
armv7) XRAY_ZIP="arm32-v7a" ;;
|
||||||
|
armv6) XRAY_ZIP="arm32-v6" ;;
|
||||||
|
386) XRAY_ZIP="32" ;;
|
||||||
|
esac
|
||||||
|
# The panel looks binaries up by GOARCH; on arm Xray's is "arm32"
|
||||||
|
# (internal/xray/process.go) and mtg's plain "arm", as install.sh renames them.
|
||||||
|
case $PLATFORM in
|
||||||
|
armv7 | armv6)
|
||||||
|
XRAY_BIN="xray-linux-arm32"
|
||||||
|
MTG_BIN="mtg-linux-arm"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
XRAY_BIN="xray-linux-${PLATFORM}"
|
||||||
|
MTG_BIN="mtg-linux-${PLATFORM}"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
MTG_MULTI_VER=$(curl -sfL "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
|
MTG_MULTI_VER=$(curl -sfL "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
|
||||||
@@ -33,23 +52,18 @@ if [ -z "$MTG_MULTI_VER" ]; then
|
|||||||
fi
|
fi
|
||||||
mkdir -p build/bin
|
mkdir -p build/bin
|
||||||
cd build/bin
|
cd build/bin
|
||||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/Xray-linux-${ARCH}.zip"
|
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/Xray-linux-${XRAY_ZIP}.zip"
|
||||||
unzip "Xray-linux-${ARCH}.zip"
|
unzip "Xray-linux-${XRAY_ZIP}.zip"
|
||||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
rm -f "Xray-linux-${XRAY_ZIP}.zip" geoip.dat geosite.dat
|
||||||
mv xray "xray-linux-${FNAME}"
|
mv xray "${XRAY_BIN}"
|
||||||
# mtg-multi (MTProto sidecar) ships prebuilt release binaries for every target
|
# mtg-multi (MTProto sidecar) ships prebuilt release binaries for every target
|
||||||
# we package, so download and unpack the matching one instead of compiling.
|
# we package, so download and unpack the matching one instead of compiling.
|
||||||
case $FNAME in
|
MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${PLATFORM}"
|
||||||
i386) MTGARCH="386" ;;
|
|
||||||
arm32) MTGARCH="armv7" ;;
|
|
||||||
*) MTGARCH="$FNAME" ;;
|
|
||||||
esac
|
|
||||||
MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${MTGARCH}"
|
|
||||||
curl -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
|
curl -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
|
||||||
tar -xzf "${MTG_PKG}.tar.gz"
|
tar -xzf "${MTG_PKG}.tar.gz"
|
||||||
mv "${MTG_PKG}/mtg-multi" "mtg-linux-${FNAME}"
|
mv "${MTG_PKG}/mtg-multi" "${MTG_BIN}"
|
||||||
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
|
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
|
||||||
chmod +x "mtg-linux-${FNAME}"
|
chmod +x "${MTG_BIN}"
|
||||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||||
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||||
|
|||||||
+2
-1
@@ -15,6 +15,7 @@ RUN npm run build
|
|||||||
FROM golang:1.27-alpine AS builder
|
FROM golang:1.27-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
|
|
||||||
RUN apk --no-cache --update add \
|
RUN apk --no-cache --update add \
|
||||||
build-base \
|
build-base \
|
||||||
@@ -28,7 +29,7 @@ COPY --from=frontend /src/internal/web/dist ./internal/web/dist
|
|||||||
ENV CGO_ENABLED=1
|
ENV CGO_ENABLED=1
|
||||||
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
|
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
|
||||||
RUN go build -ldflags "-w -s" -o build/x-ui main.go
|
RUN go build -ldflags "-w -s" -o build/x-ui main.go
|
||||||
RUN ./DockerInit.sh "$TARGETARCH"
|
RUN ./DockerInit.sh "$TARGETARCH" "$TARGETVARIANT"
|
||||||
|
|
||||||
# ========================================================
|
# ========================================================
|
||||||
# Stage: Final Image of 3x-ui
|
# Stage: Final Image of 3x-ui
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ lint-go: dist-stub ## golangci-lint on Go sources
|
|||||||
lint-fe: ## oxlint on frontend sources
|
lint-fe: ## oxlint on frontend sources
|
||||||
cd $(FRONTEND) && npm run lint
|
cd $(FRONTEND) && npm run lint
|
||||||
|
|
||||||
|
.PHONY: audit-fe
|
||||||
|
audit-fe: ## Audit the frontend's production dependencies (needs the network)
|
||||||
|
cd $(FRONTEND) && npm run audit
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go lint-fe ## All linters
|
lint: lint-go lint-fe ## All linters
|
||||||
|
|
||||||
@@ -45,10 +49,6 @@ format-check: ## oxfmt in check mode on frontend sources
|
|||||||
typecheck: ## tsc --noEmit
|
typecheck: ## tsc --noEmit
|
||||||
cd $(FRONTEND) && npm run typecheck
|
cd $(FRONTEND) && npm run typecheck
|
||||||
|
|
||||||
.PHONY: msw-worker-check
|
|
||||||
msw-worker-check: ## Verify the tracked worker matches the installed MSW runtime
|
|
||||||
cmp $(FRONTEND)/public/mockServiceWorker.js $(FRONTEND)/node_modules/msw/lib/mockServiceWorker.js
|
|
||||||
|
|
||||||
.PHONY: test-go
|
.PHONY: test-go
|
||||||
test-go: dist-stub ## Go tests (shuffle, no cache)
|
test-go: dist-stub ## Go tests (shuffle, no cache)
|
||||||
go test -shuffle=on -count=1 $(GO_PKGS)
|
go test -shuffle=on -count=1 $(GO_PKGS)
|
||||||
@@ -76,6 +76,10 @@ test: test-go test-fe ## All tests
|
|||||||
vulncheck: dist-stub ## govulncheck
|
vulncheck: dist-stub ## govulncheck
|
||||||
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||||
|
|
||||||
|
.PHONY: docs-openapi-check
|
||||||
|
docs-openapi-check: ## Fail if the docs OpenAPI spec is not the panel's
|
||||||
|
cmp frontend/public/openapi.json docs/public/openapi.json
|
||||||
|
|
||||||
.PHONY: build-fe
|
.PHONY: build-fe
|
||||||
build-fe: ## Build the Vite bundles into internal/web/dist
|
build-fe: ## Build the Vite bundles into internal/web/dist
|
||||||
cd $(FRONTEND) && npm run build
|
cd $(FRONTEND) && npm run build
|
||||||
@@ -89,7 +93,10 @@ build-storybook: ## Build the static Storybook (compile-checks all stories)
|
|||||||
cd $(FRONTEND) && npm run build-storybook
|
cd $(FRONTEND) && npm run build-storybook
|
||||||
|
|
||||||
# The PR gate. Matches ci.yml: codegen freshness, both linters, the formatter,
|
# The PR gate. Matches ci.yml: codegen freshness, both linters, the formatter,
|
||||||
# typecheck, both test suites, a full build, and the Storybook compile-check.
|
# typecheck, both test suites, a full build, the Storybook compile-check and the
|
||||||
|
# docs spec check. The dependency audit is a separate target: it needs the
|
||||||
|
# network and a live advisory database, so an unchanged commit could pass today
|
||||||
|
# and fail tomorrow.
|
||||||
.PHONY: verify
|
.PHONY: verify
|
||||||
verify: gen-check lint format-check typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI)
|
verify: gen-check lint format-check typecheck test build build-storybook docs-openapi-check ## Full local gate (mirrors CI)
|
||||||
@echo "verify: OK"
|
@echo "verify: OK"
|
||||||
|
|||||||
+3
-3
@@ -41,7 +41,7 @@
|
|||||||
- **واجهة RESTful API** مع رموز وصول محدودة النطاق وقابلة لانتهاء الصلاحية، ومرجع API داخل اللوحة.
|
- **واجهة RESTful API** مع رموز وصول محدودة النطاق وقابلة لانتهاء الصلاحية، ومرجع API داخل اللوحة.
|
||||||
- **لوحة قابلة للتثبيت (PWA)** — ثبّت 3X-UI على سطح المكتب أو شاشة هاتفك الرئيسية.
|
- **لوحة قابلة للتثبيت (PWA)** — ثبّت 3X-UI على سطح المكتب أو شاشة هاتفك الرئيسية.
|
||||||
- **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.
|
- **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.
|
||||||
- **13 لغة لواجهة المستخدم** مع سمات داكنة وفاتحة.
|
- **14 لغة لواجهة المستخدم** مع سمات داكنة وفاتحة.
|
||||||
- **تكامل مع Fail2ban** لفرض حدود IP لكل عميل.
|
- **تكامل مع Fail2ban** لفرض حدود IP لكل عميل.
|
||||||
|
|
||||||
## لقطات الشاشة
|
## لقطات الشاشة
|
||||||
@@ -177,9 +177,9 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
|||||||
|
|
||||||
## اللغات المدعومة
|
## اللغات المدعومة
|
||||||
|
|
||||||
تتوفر واجهة اللوحة بـ 13 لغة:
|
تتوفر واجهة اللوحة بـ 14 لغة:
|
||||||
|
|
||||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
|
||||||
|
|
||||||
## المساهمة
|
## المساهمة
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -41,7 +41,7 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
|
|||||||
- **API RESTful** con tokens de alcance limitado y caducidad opcional, y una referencia de la API dentro del panel.
|
- **API RESTful** con tokens de alcance limitado y caducidad opcional, y una referencia de la API dentro del panel.
|
||||||
- **Panel instalable (PWA)** — ancla 3X-UI al escritorio o a la pantalla de inicio del móvil.
|
- **Panel instalable (PWA)** — ancla 3X-UI al escritorio o a la pantalla de inicio del móvil.
|
||||||
- **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.
|
- **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.
|
||||||
- **13 idiomas de interfaz** con temas oscuro y claro.
|
- **14 idiomas de interfaz** con temas oscuro y claro.
|
||||||
- **Integración con Fail2ban** para aplicar límites de IP por cliente.
|
- **Integración con Fail2ban** para aplicar límites de IP por cliente.
|
||||||
|
|
||||||
## Capturas de pantalla
|
## Capturas de pantalla
|
||||||
@@ -177,9 +177,9 @@ La lista completa está en la [referencia de variables de entorno](https://docs.
|
|||||||
|
|
||||||
## Idiomas Compatibles
|
## Idiomas Compatibles
|
||||||
|
|
||||||
La interfaz del panel está disponible en 13 idiomas:
|
La interfaz del panel está disponible en 14 idiomas:
|
||||||
|
|
||||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
|
||||||
|
|
||||||
## Contribuir
|
## Contribuir
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -41,7 +41,7 @@
|
|||||||
- **RESTful API** با توکنهای محدودشده (scoped) و دارای انقضای اختیاری، بههمراه مرجع API درونپنل.
|
- **RESTful API** با توکنهای محدودشده (scoped) و دارای انقضای اختیاری، بههمراه مرجع API درونپنل.
|
||||||
- **پنل قابل نصب (PWA)** — 3X-UI را به دسکتاپ یا صفحهی اصلی گوشی خود سنجاق کنید.
|
- **پنل قابل نصب (PWA)** — 3X-UI را به دسکتاپ یا صفحهی اصلی گوشی خود سنجاق کنید.
|
||||||
- **ذخیرهسازی منعطف** — SQLite (پیشفرض) یا PostgreSQL.
|
- **ذخیرهسازی منعطف** — SQLite (پیشفرض) یا PostgreSQL.
|
||||||
- **۱۳ زبان رابط کاربری** با تمهای تیره و روشن.
|
- **۱۴ زبان رابط کاربری** با تمهای تیره و روشن.
|
||||||
- **یکپارچگی با Fail2ban** برای اعمال محدودیت IP بهازای هر کلاینت.
|
- **یکپارچگی با Fail2ban** برای اعمال محدودیت IP بهازای هر کلاینت.
|
||||||
|
|
||||||
## اسکرینشاتها
|
## اسکرینشاتها
|
||||||
@@ -177,9 +177,9 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
|||||||
|
|
||||||
## زبانهای پشتیبانیشده
|
## زبانهای پشتیبانیشده
|
||||||
|
|
||||||
رابط کاربری پنل به ۱۳ زبان در دسترس است:
|
رابط کاربری پنل به ۱۴ زبان در دسترس است:
|
||||||
|
|
||||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
|
||||||
|
|
||||||
## مشارکت
|
## مشارکت
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
|
|||||||
- **RESTful API** with scoped, optionally expiring tokens and an in-panel API reference.
|
- **RESTful API** with scoped, optionally expiring tokens and an in-panel API reference.
|
||||||
- **Installable panel (PWA)** — pin 3X-UI to a desktop or phone home screen.
|
- **Installable panel (PWA)** — pin 3X-UI to a desktop or phone home screen.
|
||||||
- **Flexible storage** — SQLite (default) or PostgreSQL.
|
- **Flexible storage** — SQLite (default) or PostgreSQL.
|
||||||
- **13 UI languages** with dark and light themes.
|
- **14 UI languages** with dark and light themes.
|
||||||
- **Fail2ban integration** for enforcing per-client IP limits.
|
- **Fail2ban integration** for enforcing per-client IP limits.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -177,9 +177,9 @@ The complete list is on the [environment variables reference](https://docs.sanae
|
|||||||
|
|
||||||
## Supported Languages
|
## Supported Languages
|
||||||
|
|
||||||
The panel UI is available in 13 languages:
|
The panel UI is available in 14 languages:
|
||||||
|
|
||||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -41,7 +41,7 @@
|
|||||||
- **RESTful API** с токенами ограниченной области действия и необязательным сроком действия, а также справочником API внутри панели.
|
- **RESTful API** с токенами ограниченной области действия и необязательным сроком действия, а также справочником API внутри панели.
|
||||||
- **Устанавливаемая панель (PWA)** — закрепите 3X-UI на рабочем столе или главном экране телефона.
|
- **Устанавливаемая панель (PWA)** — закрепите 3X-UI на рабочем столе или главном экране телефона.
|
||||||
- **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.
|
- **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.
|
||||||
- **13 языков интерфейса** с тёмной и светлой темами.
|
- **14 языков интерфейса** с тёмной и светлой темами.
|
||||||
- **Интеграция с Fail2ban** для применения лимитов IP по каждому клиенту.
|
- **Интеграция с Fail2ban** для применения лимитов IP по каждому клиенту.
|
||||||
|
|
||||||
## Скриншоты
|
## Скриншоты
|
||||||
@@ -177,9 +177,9 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
|||||||
|
|
||||||
## Поддерживаемые языки
|
## Поддерживаемые языки
|
||||||
|
|
||||||
Интерфейс панели доступен на 13 языках:
|
Интерфейс панели доступен на 14 языках:
|
||||||
|
|
||||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
|
||||||
|
|
||||||
## Участие в разработке
|
## Участие в разработке
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -41,7 +41,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
|
|||||||
- Kapsamı sınırlanmış, isteğe bağlı olarak süresi dolan token'lar ve panel içi API referansı sunan **RESTful API**.
|
- Kapsamı sınırlanmış, isteğe bağlı olarak süresi dolan token'lar ve panel içi API referansı sunan **RESTful API**.
|
||||||
- **Kurulabilir panel (PWA)** — 3X-UI'yi masaüstüne veya telefon ana ekranına sabitleyin.
|
- **Kurulabilir panel (PWA)** — 3X-UI'yi masaüstüne veya telefon ana ekranına sabitleyin.
|
||||||
- **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
|
- **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
|
||||||
- Koyu ve açık tema seçenekleriyle **13 farklı UI dili**.
|
- Koyu ve açık tema seçenekleriyle **14 farklı UI dili**.
|
||||||
- Kullanıcı başına IP limitlerini zorunlu kılmak için **Fail2ban entegrasyonu**.
|
- Kullanıcı başına IP limitlerini zorunlu kılmak için **Fail2ban entegrasyonu**.
|
||||||
|
|
||||||
## Ekran Görüntüleri
|
## Ekran Görüntüleri
|
||||||
@@ -177,9 +177,9 @@ Tam liste [ortam değişkenleri referansında](https://docs.sanaei.dev/docs/refe
|
|||||||
|
|
||||||
## Desteklenen Diller
|
## Desteklenen Diller
|
||||||
|
|
||||||
Panel arayüzü 13 farklı dilde mevcuttur:
|
Panel arayüzü 14 farklı dilde mevcuttur:
|
||||||
|
|
||||||
İngilizce · Farsça · Arapça · Çince (Basitleştirilmiş) · Çince (Geleneksel) · İspanyolca · Rusça · Ukraynaca · Türkçe · Vietnamca · Japonca · Endonezce · Portekizce (Brezilya)
|
İngilizce · Farsça · Arapça · Çince (Basitleştirilmiş) · Çince (Geleneksel) · İspanyolca · Rusça · Ukraynaca · Türkçe · Vietnamca · Japonca · Endonezce · Portekizce (Brezilya) · Slovakça
|
||||||
|
|
||||||
## Katkıda Bulunma
|
## Katkıda Bulunma
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -41,7 +41,7 @@
|
|||||||
- **RESTful API**,支持带作用域、可设置有效期的令牌,并提供面板内置的 API 参考文档。
|
- **RESTful API**,支持带作用域、可设置有效期的令牌,并提供面板内置的 API 参考文档。
|
||||||
- **可安装面板 (PWA)** — 将 3X-UI 固定到桌面或手机主屏幕。
|
- **可安装面板 (PWA)** — 将 3X-UI 固定到桌面或手机主屏幕。
|
||||||
- **灵活的存储** — SQLite(默认)或 PostgreSQL。
|
- **灵活的存储** — SQLite(默认)或 PostgreSQL。
|
||||||
- **13 种界面语言**,支持深色和浅色主题。
|
- **14 种界面语言**,支持深色和浅色主题。
|
||||||
- **Fail2ban 集成**,用于强制执行按客户端的 IP 限制。
|
- **Fail2ban 集成**,用于强制执行按客户端的 IP 限制。
|
||||||
|
|
||||||
## 截图
|
## 截图
|
||||||
@@ -177,9 +177,9 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
|||||||
|
|
||||||
## 支持的语言
|
## 支持的语言
|
||||||
|
|
||||||
面板界面提供 13 种语言:
|
面板界面提供 14 种语言:
|
||||||
|
|
||||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil) · Slovenčina
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ the code already handles is not a finding at all.
|
|||||||
- Missing tests for getters, constants, renames or pure map lookups —
|
- Missing tests for getters, constants, renames or pure map lookups —
|
||||||
`CLAUDE.md` rejects such tests outright.
|
`CLAUDE.md` rejects such tests outright.
|
||||||
- A missing or unreferenced i18n key.
|
- A missing or unreferenced i18n key.
|
||||||
`frontend/src/test/i18n-dead-keys.test.ts` pins the 13 locale files in
|
`frontend/src/test/i18n-dead-keys.test.ts` pins the 14 locale files in
|
||||||
`internal/web/translation/` in both directions, so the `frontend` job is
|
`internal/web/translation/` in both directions, so the `frontend` job is
|
||||||
already red. Report the failing check, not the key.
|
already red. Report the failing check, not the key.
|
||||||
|
|
||||||
|
|||||||
+36
-3
@@ -231,7 +231,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
|
|||||||
│ │ ├── network/ # Custom net listeners (e.g. proxy-protocol aware)
|
│ │ ├── network/ # Custom net listeners (e.g. proxy-protocol aware)
|
||||||
│ │ ├── session/ # Session/cookie helpers
|
│ │ ├── session/ # Session/cookie helpers
|
||||||
│ │ ├── websocket/ # WS hub implementation
|
│ │ ├── websocket/ # WS hub implementation
|
||||||
│ │ ├── locale/ + translation/ # i18n middleware + 13 locale JSON catalogs
|
│ │ ├── locale/ + translation/ # i18n middleware + 14 locale JSON catalogs
|
||||||
│ │ ├── entity/ # Shared request/response DTOs
|
│ │ ├── entity/ # Shared request/response DTOs
|
||||||
│ │ └── dist/ # ⚠️ Vite build output, embedded via go:embed (generated — do not hand-edit)
|
│ │ └── dist/ # ⚠️ Vite build output, embedded via go:embed (generated — do not hand-edit)
|
||||||
│ │
|
│ │
|
||||||
@@ -320,6 +320,12 @@ Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` /
|
|||||||
**Key files:** `service/xray.go` (orchestration), `xray/hot_diff.go` (the diff algorithm),
|
**Key files:** `service/xray.go` (orchestration), `xray/hot_diff.go` (the diff algorithm),
|
||||||
`xray/process.go` (process lifecycle), `xray/api.go` (gRPC calls), `xray/config.go` (config model).
|
`xray/process.go` (process lifecycle), `xray/api.go` (gRPC calls), `xray/config.go` (config model).
|
||||||
|
|
||||||
|
Inbound-list statistics read only UUID, email and subscription ID from `clients`, joined
|
||||||
|
through `client_inbounds` in batches of up to 400 distinct inbound IDs. Full client records
|
||||||
|
remain the source for Xray and subscriptions. Missing shared-client traffic is fetched by
|
||||||
|
email, including rows owned by an inbound outside the requested list; enrichment preserves
|
||||||
|
the stored traffic, lifecycle and last-online fields.
|
||||||
|
|
||||||
### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important
|
### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important
|
||||||
|
|
||||||
A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing
|
A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing
|
||||||
@@ -340,6 +346,22 @@ code_ works whether the target is the local Xray or a remote node.
|
|||||||
- **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
|
- **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
|
||||||
otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
|
otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
|
||||||
|
|
||||||
|
Bearer tokens use the narrowest scope that matches the caller's duties:
|
||||||
|
|
||||||
|
| Scope | Access |
|
||||||
|
| ------------ | ---------------------------------------------------------------------- |
|
||||||
|
| `monitor` | Read-only status, version, and metric endpoints. |
|
||||||
|
| `node-sync` | Node configuration, inbound/client synchronization, traffic, and Xray. |
|
||||||
|
| `node-admin` | All `node-sync` access plus `POST /panel/api/server/updatePanel`. |
|
||||||
|
| `admin` | Every panel API endpoint; intended for fully trusted automation. |
|
||||||
|
|
||||||
|
`node-admin` remains a node synchronization identity when inbound rules are applied. This
|
||||||
|
preserves the node-specific validation path while keeping software-update authority separate
|
||||||
|
from ordinary `node-sync` credentials. On an mTLS connection, only a valid `node-admin` bearer
|
||||||
|
elevates the request; other bearer scopes retain the historical mTLS `node-sync` behavior.
|
||||||
|
Existing tokens retain their stored scope, and legacy tokens without one continue to migrate to
|
||||||
|
`admin`.
|
||||||
|
|
||||||
**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` _and_ an
|
**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` _and_ an
|
||||||
`OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and
|
`OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and
|
||||||
online clients back to the originating panel using **stable GUIDs** rather than local IDs.
|
online clients back to the originating panel using **stable GUIDs** rather than local IDs.
|
||||||
@@ -370,6 +392,17 @@ A client reset is queued per hosting node in `model.NodePendingReset` (`service/
|
|||||||
and replayed by the node sync until the node accepts it.
|
and replayed by the node sync until the node accepts it.
|
||||||
Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.TrafficReset`).
|
Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.TrafficReset`).
|
||||||
|
|
||||||
|
Cross-panel totals are stored per master GUID and hosted email, separately from local
|
||||||
|
`client_traffics`. Directional counter changes, including lower values and resets, persist
|
||||||
|
on the next push. Identical totals refresh only when the persisted timestamp is at least
|
||||||
|
one hour old; pushes still send the same batches. Display and quota reads retain the
|
||||||
|
inclusive 24-hour persisted-row freshness window. Full-list snapshots remain local-only,
|
||||||
|
while slim/detail reads and downstream forwarding retain their global overlays.
|
||||||
|
|
||||||
|
The heartbeat coarsens liveness, not counter propagation: an idle master can expire up to
|
||||||
|
one hour earlier relative to its final unchanged push. Active hourly refresh leaves roughly
|
||||||
|
23 hours of missed-push margin. No cache, external service or configurable interval is added.
|
||||||
|
|
||||||
### 5.4 Background jobs (cron)
|
### 5.4 Background jobs (cron)
|
||||||
|
|
||||||
All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
|
All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
|
||||||
@@ -381,7 +414,7 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
|
|||||||
| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) |
|
| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) |
|
||||||
| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) |
|
| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) |
|
||||||
| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation |
|
| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation |
|
||||||
| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits |
|
| `@every 10s` | `check_client_ip_job` | Record client IPs (30-min window) and enforce per-client IP limits |
|
||||||
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
|
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
|
||||||
| `@every 10s` | `amneziawg_job` | Reconcile embedded AmneziaWG interfaces against enabled local inbounds |
|
| `@every 10s` | `amneziawg_job` | Reconcile embedded AmneziaWG interfaces against enabled local inbounds |
|
||||||
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
||||||
@@ -607,7 +640,7 @@ them with a comment starting `@claude review` on a PR or `@claude analyze` on an
|
|||||||
(`FetchCertFingerprint`), `service/node_mtls.go`, and `runtime/tls_client.go`.
|
(`FetchCertFingerprint`), `service/node_mtls.go`, and `runtime/tls_client.go`.
|
||||||
- **Restart is signal-driven.** `main.go` traps SIGHUP to restart panel+sub servers; the
|
- **Restart is signal-driven.** `main.go` traps SIGHUP to restart panel+sub servers; the
|
||||||
in-process restart hook (`global.SetRestartHook`) funnels into the same path.
|
in-process restart hook (`global.SetRestartHook`) funnels into the same path.
|
||||||
- **i18n:** backend catalogs in `internal/web/translation/` (13 locales, shared with the
|
- **i18n:** backend catalogs in `internal/web/translation/` (14 locales, shared with the
|
||||||
frontend); frontend wiring in `frontend/src/i18n/`. Persian (`fa_IR`) is a first-class
|
frontend); frontend wiring in `frontend/src/i18n/`. Persian (`fa_IR`) is a first-class
|
||||||
locale (Jalali calendar via `persian-calendar-suite`).
|
locale (Jalali calendar via `persian-calendar-suite`).
|
||||||
- **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
|
- **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function TelegramSetupHelper() {
|
|||||||
/>
|
/>
|
||||||
{adminIds ? (
|
{adminIds ? (
|
||||||
idsV.invalid.length > 0 ? (
|
idsV.invalid.length > 0 ? (
|
||||||
<Status ok={false}>Not numeric: {idsV.invalid.join(', ')}</Status>
|
<Status ok={false}>Invalid: {idsV.invalid.join(', ')}</Status>
|
||||||
) : (
|
) : (
|
||||||
<Status ok>
|
<Status ok>
|
||||||
{idsV.ids.length} admin id{idsV.ids.length === 1 ? '' : 's'}
|
{idsV.ids.length} admin id{idsV.ids.length === 1 ? '' : 's'}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: AmneziaWG
|
title: AmneziaWG
|
||||||
description: Set up an AmneziaWG inbound in 3x-ui — obfuscation parameters, native IPv6, per-client port-forwarding, and routing client traffic through Xray.
|
description: Configure embedded or optional Linux kernel AmneziaWG inbounds in 3x-ui.
|
||||||
icon: Lock
|
icon: Lock
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,13 +116,61 @@ the server.
|
|||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
Every AmneziaWG inbound's traffic already goes through Xray — each peer
|
In the **embedded** backend, each peer relays into its own loopback SOCKS5
|
||||||
relays into its own loopback SOCKS5 inbound, tagged with the AmneziaWG
|
inbound, tagged with the AmneziaWG inbound's tag. Its traffic therefore uses
|
||||||
inbound's own tag, so it shows up as a normal source on the
|
Xray routing and appears on the [Routing](/docs/operations/outbounds-routing)
|
||||||
[Routing](/docs/operations/outbounds-routing) page like any other protocol's
|
page, like other Xray inbounds.
|
||||||
inbound. There is no separate toggle for this: unlike a kernel tunnel,
|
|
||||||
there's no other way for a peer's traffic to reach the internet once it's
|
## Optional Linux kernel backend
|
||||||
decapsulated.
|
|
||||||
|
The embedded backend remains the default. On a supported Linux host, an
|
||||||
|
administrator can opt an individual inbound into the kernel backend with
|
||||||
|
**Kernel mode**. The panel then creates an `xui-awg<id>` interface on the
|
||||||
|
AmneziaWG kernel module, configures it with `awg setconf`, and masquerades
|
||||||
|
peer traffic out of the host NIC with an nftables table of its own
|
||||||
|
(`xui_awg<id>`). Peer traffic bypasses Xray. Nothing runs through a shell:
|
||||||
|
every step is a direct `ip`, `awg` or `nft` command, and the interface's
|
||||||
|
config lives in a panel-private directory (`/etc/x-ui/amneziawg` on a standard
|
||||||
|
install), never next to your own `awg-quick` configs.
|
||||||
|
|
||||||
|
Kernel mode requires a Linux host running the panel as root, with the
|
||||||
|
`amneziawg` module installed and `awg`, `ip` and `nft` available. The
|
||||||
|
readiness panel reports missing host requirements and provides the install
|
||||||
|
command. On a standard install, run
|
||||||
|
`sudo /usr/local/x-ui/x-ui install-awg-kernel`. The bundled command builds a
|
||||||
|
pinned, commit-verified release of the module and tools on Debian and Ubuntu;
|
||||||
|
kernel headers and, when Secure Boot is enabled, module signing must be
|
||||||
|
available. Review the script and host changes before running it.
|
||||||
|
|
||||||
|
The panel enables IPv4 forwarding and, when `iptables` is installed, adds two
|
||||||
|
`FORWARD` accept rules per interface (commented `3x-ui:xui-awg<id>`), so a host
|
||||||
|
whose `FORWARD` policy is `DROP` — as Docker and ufw set it — still forwards
|
||||||
|
the tunnel. A firewall that drops forwarded traffic in its own nftables table,
|
||||||
|
such as firewalld, still needs a rule from you.
|
||||||
|
|
||||||
|
Kernel mode is not supported in Docker. Embedded mode continues to work in
|
||||||
|
containers. The kernel backend does not support Xray routing or sniffing,
|
||||||
|
per-client IPv6, client port forwarding, or a pinned listen address; the panel
|
||||||
|
rejects saves that request those features. On a multi-node setup the master
|
||||||
|
checks that the target node supports kernel mode before pushing such an
|
||||||
|
inbound, and the node checks its own host when it applies it.
|
||||||
|
|
||||||
|
Stopping the panel records the interfaces' final traffic and then tears them
|
||||||
|
down, exactly as the embedded backend stops: a stopped panel enforces no quota
|
||||||
|
or expiry, so nothing keeps forwarding. A panel-only restart (`SIGHUP`) leaves
|
||||||
|
them running. If the panel process dies instead, the next start adopts the
|
||||||
|
surviving interfaces; traffic between the crash and the first counter read
|
||||||
|
after it is not charged. Interfaces and configs the panel cannot prove are its
|
||||||
|
own — a same-named link with a different key, or an unmarked, non-regular or
|
||||||
|
symlinked config file — are never changed or removed.
|
||||||
|
|
||||||
|
## Keepalive ranges
|
||||||
|
|
||||||
|
AmneziaWG clients accept `keepAlive` as a number or an ordered range from 0 to 65535, such as `15-25`. Omitted values keep the existing setting; explicit `0` disables keepalive. Native `.conf` files preserve ranges, while Clash/Mihomo scalar-only output uses the lower bound (`0-25` therefore disables keepalive). Plain WireGuard clients support only a scalar value. Upgrade sub-nodes before sending them range values because older nodes parse keepalive as an integer.
|
||||||
|
|
||||||
|
Parser support is present in the native tools and the panel embedded engine: [the native `awg-tools` v3.1.20260812 config parser](https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/config.c#L596-L599) sends `PersistentKeepalive` to its [ordered-range parser](https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/type.c#L40-L59), and this panel pins [amneziawg-go v3.1.20260828](https://github.com/amnezia-vpn/amneziawg-go/tree/v3.1.20260828) in `go.mod`; its [UAPI parser](https://github.com/amnezia-vpn/amneziawg-go/blob/v3.1.20260828/device/uapi.go#L663-L674) reads the interval as a `UintRange`. This confirms parser support for these code paths, not interoperability with every third-party client. The panel enforces the documented 0-65535 limit on new values; the native parser's syntax support is not a substitute for that validation.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## What the configuration looks like
|
## What the configuration looks like
|
||||||
|
|
||||||
|
|||||||
@@ -131,9 +131,11 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||||||
with Mihomo's uTLS v1.8.7); enabling the flag cannot upgrade an old fingerprint.
|
with Mihomo's uTLS v1.8.7); enabling the flag cannot upgrade an old fingerprint.
|
||||||
Raw `vless://` links carry the equivalent `support-x25519mlkem768=true` hint;
|
Raw `vless://` links carry the equivalent `support-x25519mlkem768=true` hint;
|
||||||
clients that support this URI extension, including current Mihomo builds, apply
|
clients that support this URI extension, including current Mihomo builds, apply
|
||||||
it on import. Very old REALITY servers that reject ML-KEM require a per-node
|
it on import. Servers on Xray-core older than 26.9.8 accept a plain `X25519`
|
||||||
client override setting this option to `false`, or a server upgrade. Clearing
|
hello, and very old ones reject ML-KEM outright. For those, or where a network
|
||||||
the version limit alone does not fix the handshake.
|
drops the larger ML-KEM ClientHello, enable **Mihomo X25519** on a Host: its
|
||||||
|
Clash proxy gets `support-x25519mlkem768: false` and its `vless://` link drops
|
||||||
|
the hint. Clearing the version limit alone does not fix the handshake.
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ The **format is chosen by path**, each with its own enable toggle:
|
|||||||
| **Clash for Windows (legacy)** | `/clash-legacy/` | `subClashEnable` | YAML limited to proxy types, transports, and ciphers supported by the legacy Clash core. |
|
| **Clash for Windows (legacy)** | `/clash-legacy/` | `subClashEnable` | YAML limited to proxy types, transports, and ciphers supported by the legacy Clash core. |
|
||||||
|
|
||||||
Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, MTProto, TUIC, or Hysteria2**
|
Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, MTProto, TUIC, or Hysteria2**
|
||||||
appear in a subscription, ordered by their sub-sort index (TUIC and AmneziaWG are included in raw links and Clash/Mihomo profiles, but omitted from JSON endpoints; MTProto is included in raw links). Requesting `subPath`
|
appear in a subscription, ordered by their sub-sort index by default (TUIC and AmneziaWG are included in raw links and Clash/Mihomo profiles, but omitted from JSON endpoints; MTProto is included in raw links). Requesting `subPath`
|
||||||
with an `Accept: text/html` header (or `?html=1`) returns a human-readable info
|
with an `Accept: text/html` header (or `?html=1`) returns a human-readable info
|
||||||
page instead of the raw body.
|
page instead of the raw body.
|
||||||
|
|
||||||
@@ -74,6 +74,42 @@ default matcher so existing subscription URLs continue returning YAML.
|
|||||||
It does not distinguish legacy clients from Mihomo-based clients; Clash for
|
It does not distinguish legacy clients from Mihomo-based clients; Clash for
|
||||||
Windows users must use `/clash-legacy/<sub-id>` for a compatible profile.
|
Windows users must use `/clash-legacy/<sub-id>` for a compatible profile.
|
||||||
|
|
||||||
|
### Subscription node ordering
|
||||||
|
|
||||||
|
In **Settings → Subscription → Panel settings**, select **Subscription Node Ordering**
|
||||||
|
(`subHostSortMode`). **Inbound order (default)** preserves the existing subscription
|
||||||
|
sort index, then inbound ID, with each inbound's Hosts ordered inside that block.
|
||||||
|
|
||||||
|
**Hosts order** (`hosts-first`) uses the global order on **Hosts**, including Host
|
||||||
|
groups attached to different inbounds. Drag a group's grip above or below another
|
||||||
|
row to save its position, or use the existing Up/Down buttons. A failed save restores
|
||||||
|
the previous displayed order. The next subscription fetch uses the saved order;
|
||||||
|
no inbound edit or Xray restart is needed.
|
||||||
|
|
||||||
|
For example, these rows start with Host A (Inbound #5) above Host B (Inbound #20):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Dragging Host B above Host A saves the order across both inbounds:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
These screenshots use example Hosts and synthetic API responses.
|
||||||
|
|
||||||
|
Host groups sort by their persisted sort order. Ties use the group remark and group
|
||||||
|
ID, matching the Hosts table. Within a group, inbound subscription sort index,
|
||||||
|
inbound ID and Host row ID provide deterministic ordering for attachments and
|
||||||
|
addresses. Inbounds without applicable Hosts and legacy externalProxy entries
|
||||||
|
follow all Host entries in their previous relative order. External client links
|
||||||
|
remain appended, and the subscription info node remains first.
|
||||||
|
|
||||||
|
This applies to Raw/Base64, Clash/Mihomo and JSON subscriptions. JSON balancer
|
||||||
|
documents keep their slots from the existing numeric balancer/inbound sort; only
|
||||||
|
the endpoint slots are rearranged, preserving the relative order of Host entries.
|
||||||
|
Eligibility, exclusions, credentials and protocol settings are unchanged.
|
||||||
|
Standalone share-link and QR previews retain their existing behavior. Client apps
|
||||||
|
may apply their own sorting after importing a subscription.
|
||||||
|
|
||||||
### Base64 vs JSON
|
### Base64 vs JSON
|
||||||
|
|
||||||
The **Base64** body is just the newline-joined share links, standard-base64
|
The **Base64** body is just the newline-joined share links, standard-base64
|
||||||
|
|||||||
@@ -125,9 +125,14 @@ that folder instead of accepting connections on its port. Both sides must use th
|
|||||||
**same** service, folder and secrets, so the JSON subscription ships them to every
|
**same** service, folder and secrets, so the JSON subscription ships them to every
|
||||||
client; XDRIVE inbounds get no share link or Clash entry.
|
client; XDRIVE inbounds get no share link or Clash entry.
|
||||||
|
|
||||||
|
Since Xray 26.10.17 XDRIVE carries only **VLESS** inside **VLESS encryption**, with
|
||||||
|
no flow: an inbound needs a `decryption` value, an outbound its `encryption` and Mux
|
||||||
|
enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound without
|
||||||
|
`decryption`, and leaves one stored before the upgrade out of the running config.
|
||||||
|
|
||||||
| Field | Default | Meaning |
|
| Field | Default | Meaning |
|
||||||
| -------------- | -------------- | ----------------------------------------------------------------------- |
|
| -------------- | -------------- | ----------------------------------------------------------------------- |
|
||||||
| `service` | `Google Drive` | `Google Drive`, `local` (a folder on disk) or `template` (any HTTP storage API). |
|
| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) or `template` (any HTTP storage API). The old `Google Drive` spelling is still read. |
|
||||||
| `remoteFolder` | — | Folder that holds the session files. |
|
| `remoteFolder` | — | Folder that holds the session files. |
|
||||||
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. |
|
| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. |
|
||||||
| `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. |
|
| `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. |
|
||||||
@@ -147,9 +152,10 @@ TLS. Masks are configured per direction:
|
|||||||
|
|
||||||
- **TCP masks** — `fragment`, `sudoku`, `header-custom`, `xmc` (disguises the
|
- **TCP masks** — `fragment`, `sudoku`, `header-custom`, `xmc` (disguises the
|
||||||
stream as Minecraft protocol traffic; requires a password, with optional
|
stream as Minecraft protocol traffic; requires a password, with optional
|
||||||
hostname and player usernames).
|
hostname and player usernames), plus the inbound-only `filter`.
|
||||||
- **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
|
- **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
|
||||||
`noise`, `sudoku`, `realm`, plus the client-only `udphop`. (`mkcp-legacy`
|
`noise`, `sudoku`, `realm`, plus the client-only `udphop` and the inbound-only
|
||||||
|
`filter`. (`mkcp-legacy`
|
||||||
reproduces the old mKCP header obfuscation; `udphop` rotates the remote port —
|
reproduces the old mKCP header obfuscation; `udphop` rotates the remote port —
|
||||||
and optionally the remote IP — on an interval or per connection to dodge port
|
and optionally the remote IP — on an interval or per connection to dodge port
|
||||||
blocking.)
|
blocking.)
|
||||||
@@ -160,6 +166,11 @@ TLS. Masks are configured per direction:
|
|||||||
Xray 26.9.9 a client hops only through the `udphop` mask, so the panel converts
|
Xray 26.9.9 a client hops only through the `udphop` mask, so the panel converts
|
||||||
an older client `quicParams.udpHop` to it.
|
an older client `quicParams.udpHop` to it.
|
||||||
|
|
||||||
|
`filter` (Xray 26.10.21) drops an inbound's connections by source IP:
|
||||||
|
`allowedSourceIPs` admits only the listed sources, `blockedSourceIPs` drops its
|
||||||
|
sources, and a blocked match wins. Both take CIDRs or `geoip:` entries. Share links
|
||||||
|
and JSON subscriptions leave it out, since it only acts on the listener.
|
||||||
|
|
||||||
FinalMask replaces the per-transport `header`/`seed` obfuscation that older Xray
|
FinalMask replaces the per-transport `header`/`seed` obfuscation that older Xray
|
||||||
builds exposed.
|
builds exposed.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ and custom congestion control algorithms to maintain stable connections over los
|
|||||||
unstable networks.
|
unstable networks.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
TUIC runs as an **in-process native Go server** inside 3x-ui. Decrypted traffic is bridged into Xray-core via a loopback SOCKS5 tunnel, enabling full Xray routing rules, cascading outbounds (e.g. TUIC → VLESS / WARP), per-client traffic quotas (`totalGB`), and zero-downtime hot user updates without restarting the port.
|
TUIC runs as an **in-process native Go server** inside 3x-ui. Decrypted traffic is bridged into Xray-core via a loopback SOCKS5 tunnel, enabling full Xray routing rules, cascading outbounds (e.g. TUIC → VLESS / WARP), per-client traffic quotas (`totalGB`), and in-memory client management.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Key settings
|
## Key settings
|
||||||
@@ -55,7 +55,7 @@ The panel fills recommended defaults (`bbr`, `h3`, `native` UDP relay). Adjust t
|
|||||||
<Step>
|
<Step>
|
||||||
### Add clients
|
### Add clients
|
||||||
|
|
||||||
Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. The panel automatically generates secure random credentials when creating clients.
|
Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. TUIC email identifiers must contain 1–255 UTF-8 bytes. Existing clients with invalid identifiers are excluded from TUIC service with a warning; shorten their identifiers to restore access. The panel automatically generates secure random credentials when creating clients.
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step>
|
<Step>
|
||||||
@@ -103,10 +103,10 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
|
|||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
|
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
|
||||||
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
|
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. The client **Email** is preserved for `user` rules on TCP and both UDP modes. Inbound tags (`in-<port>-udp`), domain/IP rules, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.) are supported. Put specific `inboundTag` + `user` rules before broader `user` rules; Xray uses the first matching rule.
|
||||||
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
|
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
|
||||||
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay; TUIC's client-speed event does not add inbound totals again.
|
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay. A separate relay policy disables Xray's per-user traffic and loopback online-IP counters; native TUIC remains the source for client accounting, speed, and activity.
|
||||||
- **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
|
- **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
|
||||||
- **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
|
- **Client updates**: Native TUIC enforces client enable/disable, quotas, and expiration; resetting a quota restores access without restarting Xray. Adding, removing, or renaming clients replaces only that inbound's internal SOCKS relay. Accounts are validated before replacement and checked again against the running relay afterward. A failed update restores the previous relay and schedules a retry. Failures are isolated to related relays: healthy TUIC inbounds and independent Xray updates continue to apply. Routing changes that depend on a failed tag change wait for that relay to recover. Related client and inbound changes also wait; new TUIC sessions are admitted only after their routing policy is applied. New relay connections wait up to 10 seconds while replacement or rollback is in progress; existing TCP streams and UDP associations continue running. New UDP associations use a bounded packet queue so they do not block existing flows on the same QUIC connection. Waiting is canceled on disconnect. Queue overflow or an unavailable relay can still reject a new request; other protocols keep running. TUIC password-only changes and client-profile edits do not change relay accounts.
|
||||||
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
|
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Import a client's share link or QR code, or use a
|
|||||||
|
|
||||||
3x-ui is an enhanced fork of the original X-UI project. It adds broader protocol
|
3x-ui is an enhanced fork of the original X-UI project. It adds broader protocol
|
||||||
support, improved stability, per-client traffic accounting, multi-node
|
support, improved stability, per-client traffic accounting, multi-node
|
||||||
management, a 13-language UI, and many quality-of-life features.
|
management, a 14-language UI, and many quality-of-life features.
|
||||||
|
|
||||||
## How do I update?
|
## How do I update?
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ and it just works:
|
|||||||
|
|
||||||
```text title="Caddyfile"
|
```text title="Caddyfile"
|
||||||
panel.example.com {
|
panel.example.com {
|
||||||
reverse_proxy 127.0.0.1:2053
|
reverse_proxy 127.0.0.1:2053 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ connected). This is your **admin** ID.
|
|||||||
|
|
||||||
In Panel Settings, enable the Telegram bot and set the **token** and **admin chat
|
In Panel Settings, enable the Telegram bot and set the **token** and **admin chat
|
||||||
ID(s)** (comma-separated). Save, then message your bot.
|
ID(s)** (comma-separated). Save, then message your bot.
|
||||||
|
|
||||||
|
To send notifications into a specific topic of a forum supergroup, append the
|
||||||
|
topic ID after a colon, e.g. `-1001234567890:42`. The topic ID is the number
|
||||||
|
after the chat in a topic message link (`https://t.me/c/1234567890/42/…`). Leave
|
||||||
|
the topic off (or use `1`) for the General topic.
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
@@ -101,7 +106,7 @@ The warning lead times come from `expireDiff` (days before expiry) and
|
|||||||
| -------------- | -------------------------- | ---------------------------------------------- |
|
| -------------- | -------------------------- | ---------------------------------------------- |
|
||||||
| `tgBotEnable` | `false` | Master on/off. |
|
| `tgBotEnable` | `false` | Master on/off. |
|
||||||
| `tgBotToken` | _(secret)_ | Bot API token. |
|
| `tgBotToken` | _(secret)_ | Bot API token. |
|
||||||
| `tgBotChatId` | _(none)_ | Comma-separated **admin** Telegram IDs. |
|
| `tgBotChatId` | _(none)_ | Comma-separated **admin** Telegram IDs; `chatId:topicId` targets a forum topic. |
|
||||||
| `tgBotProxy` | _(none)_ | `socks5://`, `http://`, or `https://` proxy. |
|
| `tgBotProxy` | _(none)_ | `socks5://`, `http://`, or `https://` proxy. |
|
||||||
| `tgBotAPIServer` | _(default)_ | Custom Telegram Bot API server. |
|
| `tgBotAPIServer` | _(default)_ | Custom Telegram Bot API server. |
|
||||||
| `tgRunTime` | `@daily` | Report schedule (cron / `@daily` / `@every …`).|
|
| `tgRunTime` | `@daily` | Report schedule (cron / `@daily` / `@every …`).|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
---
|
---
|
||||||
title: API Tokens
|
title: API Tokens
|
||||||
description: Manage scoped Bearer tokens for programmatic auth. Tokens grant
|
description: Manage scoped Bearer tokens for programmatic auth. admin grants the
|
||||||
admin, monitor, or node-sync access, may expire, and are stored as SHA-256
|
full panel API; monitor is read-only for status and metrics; node-sync manages
|
||||||
hashes. The plaintext is returned only once at creation.
|
node configuration without panel updates; node-admin adds panel self-update to
|
||||||
|
node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext
|
||||||
|
is returned only once at creation.
|
||||||
full: true
|
full: true
|
||||||
_openapi:
|
_openapi:
|
||||||
preload:
|
preload:
|
||||||
@@ -44,13 +46,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/apiTokens","method":"get"},{"path":"/panel/api/setting/apiTokens/create","method":"post"},{"path":"/panel/api/setting/apiTokens/delete/{id}","method":"post"},{"path":"/panel/api/setting/apiTokens/setEnabled/{id}","method":"post"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/apiTokens","method":"get"},{"path":"/panel/api/setting/apiTokens/create","method":"post"},{"path":"/panel/api/setting/apiTokens/delete/{id}","method":"post"},{"path":"/panel/api/setting/apiTokens/setEnabled/{id}","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,13 +59,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -21,13 +21,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/backuptotgbot","method":"post"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/backuptotgbot","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,9 @@ _openapi:
|
|||||||
title: Fetch one client by email, including the inbound IDs and external config
|
title: Fetch one client by email, including the inbound IDs and external config
|
||||||
IDs it is attached to.
|
IDs it is attached to.
|
||||||
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
|
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
|
||||||
|
- depth: 2
|
||||||
|
title: Fetch clients by subscription ID.
|
||||||
|
url: '#fetch-clients-by-subscription-id'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Fetch clients by Telegram user ID. Returns an array since multiple
|
title: Fetch clients by Telegram user ID. Returns an array since multiple
|
||||||
clients can share the same Telegram ID.
|
clients can share the same Telegram ID.
|
||||||
@@ -319,6 +322,8 @@ _openapi:
|
|||||||
- content: Fetch one client by email, including the inbound IDs and external
|
- content: Fetch one client by email, including the inbound IDs and external
|
||||||
config IDs it is attached to.
|
config IDs it is attached to.
|
||||||
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
|
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
|
||||||
|
- content: Fetch clients by subscription ID.
|
||||||
|
id: fetch-clients-by-subscription-id
|
||||||
- content: Fetch clients by Telegram user ID. Returns an array since multiple
|
- content: Fetch clients by Telegram user ID. Returns an array since multiple
|
||||||
clients can share the same Telegram ID.
|
clients can share the same Telegram ID.
|
||||||
id: fetch-clients-by-telegram-user-id-returns-an-array-since-multiple-clients-can-share-the-same-telegram-id
|
id: fetch-clients-by-telegram-user-id-returns-an-array-since-multiple-clients-can-share-the-same-telegram-id
|
||||||
@@ -555,6 +560,13 @@ _openapi:
|
|||||||
tunnel) contribute nothing.'
|
tunnel) contribute nothing.'
|
||||||
id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
|
id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
|
||||||
contents:
|
contents:
|
||||||
|
- content: Matches the exact subscription ID and orders results by client ID.
|
||||||
|
Returns an array of the same payloads as GET /clients/get/:email,
|
||||||
|
since multiple clients can share a subscription ID. Includes disabled,
|
||||||
|
expired and unattached clients. An unknown ID returns HTTP 200 with
|
||||||
|
success:false, obj:null and a record-not-found message, matching the
|
||||||
|
email lookup. Requires an admin token or a panel login session.
|
||||||
|
heading: fetch-clients-by-subscription-id
|
||||||
- content: >-
|
- content: >-
|
||||||
Fields the server fills in when they are omitted — a valid value sent
|
Fields the server fills in when they are omitted — a valid value sent
|
||||||
by the caller is never overwritten. Re-adding an email that already
|
by the caller is never overwritten. Re-adding an email that already
|
||||||
@@ -663,13 +675,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/subId/{subId}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,10 @@ _openapi:
|
|||||||
title: List every host across all inbounds, grouped by inbound then ordered by
|
title: List every host across all inbounds, grouped by inbound then ordered by
|
||||||
sort order.
|
sort order.
|
||||||
url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order'
|
url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order'
|
||||||
|
- depth: 2
|
||||||
|
title: List every host row with its numeric id, for id-based pickers such as the
|
||||||
|
per-client host rule override.
|
||||||
|
url: '#list-every-host-row-with-its-numeric-id-for-id-based-pickers-such-as-the-per-client-host-rule-override'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Fetch a single host group by Group ID.
|
title: Fetch a single host group by Group ID.
|
||||||
url: '#fetch-a-single-host-group-by-group-id'
|
url: '#fetch-a-single-host-group-by-group-id'
|
||||||
@@ -35,7 +39,9 @@ _openapi:
|
|||||||
url: '#enable-or-disable-a-host-group'
|
url: '#enable-or-disable-a-host-group'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Set host group sort order by the position of each groupId in the array.
|
title: Set host group sort order by the position of each groupId in the array.
|
||||||
url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array'
|
IDs must be unique and refer to existing groups; omitted groups follow
|
||||||
|
in their previous order.
|
||||||
|
url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array-ids-must-be-unique-and-refer-to-existing-groups-omitted-groups-follow-in-their-previous-order'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Add a host group to inbounds (same as /add).
|
title: Add a host group to inbounds (same as /add).
|
||||||
url: '#add-a-host-group-to-inbounds-same-as-add'
|
url: '#add-a-host-group-to-inbounds-same-as-add'
|
||||||
@@ -50,6 +56,9 @@ _openapi:
|
|||||||
- content: List every host across all inbounds, grouped by inbound then ordered by
|
- content: List every host across all inbounds, grouped by inbound then ordered by
|
||||||
sort order.
|
sort order.
|
||||||
id: list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order
|
id: list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order
|
||||||
|
- content: List every host row with its numeric id, for id-based pickers such as
|
||||||
|
the per-client host rule override.
|
||||||
|
id: list-every-host-row-with-its-numeric-id-for-id-based-pickers-such-as-the-per-client-host-rule-override
|
||||||
- content: Fetch a single host group by Group ID.
|
- content: Fetch a single host group by Group ID.
|
||||||
id: fetch-a-single-host-group-by-group-id
|
id: fetch-a-single-host-group-by-group-id
|
||||||
- content: Fetch one inbound's hosts, grouped by host group.
|
- content: Fetch one inbound's hosts, grouped by host group.
|
||||||
@@ -65,7 +74,9 @@ _openapi:
|
|||||||
- content: Enable or disable a host group.
|
- content: Enable or disable a host group.
|
||||||
id: enable-or-disable-a-host-group
|
id: enable-or-disable-a-host-group
|
||||||
- content: Set host group sort order by the position of each groupId in the array.
|
- content: Set host group sort order by the position of each groupId in the array.
|
||||||
id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array
|
IDs must be unique and refer to existing groups; omitted groups follow
|
||||||
|
in their previous order.
|
||||||
|
id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array-ids-must-be-unique-and-refer-to-existing-groups-omitted-groups-follow-in-their-previous-order
|
||||||
- content: Add a host group to inbounds (same as /add).
|
- content: Add a host group to inbounds (same as /add).
|
||||||
id: add-a-host-group-to-inbounds-same-as-add
|
id: add-a-host-group-to-inbounds-same-as-add
|
||||||
- content: Enable or disable many host groups in one call.
|
- content: Enable or disable many host groups in one call.
|
||||||
@@ -78,13 +89,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/add","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/raw","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/add","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,13 @@ _openapi:
|
|||||||
clientStats so the payload stays small even on panels with thousands of
|
clientStats so the payload stays small even on panels with thousands of
|
||||||
clients.
|
clients.
|
||||||
url: '#lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients'
|
url: '#lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients'
|
||||||
|
- depth: 2
|
||||||
|
title: Whether this host can run an AmneziaWG inbound on the kernel module.
|
||||||
|
Returns ready, host blocker codes (root/tools/module/nft), and the
|
||||||
|
command that fixes them. Saving an inbound also checks its features and
|
||||||
|
rejects IPv6, port forwarding, routeThroughXray, or a pinned listen
|
||||||
|
address that the kernel backend cannot honor.
|
||||||
|
url: '#whether-this-host-can-run-an-amneziawg-inbound-on-the-kernel-module-returns-ready-host-blocker-codes-roottoolsmodulenft-and-the-command-that-fixes-them-saving-an-inbound-also-checks-its-features-and-rejects-ipv6-port-forwarding-routethroughxray-or-a-pinned-listen-address-that-the-kernel-backend-cannot-honor'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Return every protocol URL (vless://, vmess://, trojan://, ss://,
|
title: Return every protocol URL (vless://, vmess://, trojan://, ss://,
|
||||||
hysteria://, mtproto) across all inbounds and all of their clients.
|
hysteria://, mtproto) across all inbounds and all of their clients.
|
||||||
@@ -131,6 +138,12 @@ _openapi:
|
|||||||
clientStats so the payload stays small even on panels with thousands
|
clientStats so the payload stays small even on panels with thousands
|
||||||
of clients.
|
of clients.
|
||||||
id: lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients
|
id: lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients
|
||||||
|
- content: Whether this host can run an AmneziaWG inbound on the kernel module.
|
||||||
|
Returns ready, host blocker codes (root/tools/module/nft), and the
|
||||||
|
command that fixes them. Saving an inbound also checks its features
|
||||||
|
and rejects IPv6, port forwarding, routeThroughXray, or a pinned
|
||||||
|
listen address that the kernel backend cannot honor.
|
||||||
|
id: whether-this-host-can-run-an-amneziawg-inbound-on-the-kernel-module-returns-ready-host-blocker-codes-roottoolsmodulenft-and-the-command-that-fixes-them-saving-an-inbound-also-checks-its-features-and-rejects-ipv6-port-forwarding-routethroughxray-or-a-pinned-listen-address-that-the-kernel-backend-cannot-honor
|
||||||
- content: Return every protocol URL (vless://, vmess://, trojan://, ss://,
|
- content: Return every protocol URL (vless://, vmess://, trojan://, ss://,
|
||||||
hysteria://, mtproto) across all inbounds and all of their clients.
|
hysteria://, mtproto) across all inbounds and all of their clients.
|
||||||
Links are rendered through the subscription engine, so the configured
|
Links are rendered through the subscription engine, so the configured
|
||||||
@@ -197,13 +210,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/allLinks","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/amneziawgKernelReadiness","method":"get"},{"path":"/panel/api/inbounds/allLinks","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,13 +145,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,8 +114,8 @@ _openapi:
|
|||||||
url: '#download-and-install-the-specified-xray-version-pass-latest-for-the-newest-release'
|
url: '#download-and-install-the-specified-xray-version-pass-latest-for-the-newest-release'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Self-update the panel to the latest version. The server restarts on
|
title: Self-update the panel to the latest version. The server restarts on
|
||||||
success.
|
success. Bearer-token callers need admin or node-admin scope.
|
||||||
url: '#self-update-the-panel-to-the-latest-version-the-server-restarts-on-success'
|
url: '#self-update-the-panel-to-the-latest-version-the-server-restarts-on-success-bearer-token-callers-need-admin-or-node-admin-scope'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Toggle the panel update channel between stable and the rolling per-commit
|
title: Toggle the panel update channel between stable and the rolling per-commit
|
||||||
dev release. Only effective on dev builds.
|
dev release. Only effective on dev builds.
|
||||||
@@ -263,8 +263,8 @@ _openapi:
|
|||||||
newest release.
|
newest release.
|
||||||
id: download-and-install-the-specified-xray-version-pass-latest-for-the-newest-release
|
id: download-and-install-the-specified-xray-version-pass-latest-for-the-newest-release
|
||||||
- content: Self-update the panel to the latest version. The server restarts on
|
- content: Self-update the panel to the latest version. The server restarts on
|
||||||
success.
|
success. Bearer-token callers need admin or node-admin scope.
|
||||||
id: self-update-the-panel-to-the-latest-version-the-server-restarts-on-success
|
id: self-update-the-panel-to-the-latest-version-the-server-restarts-on-success-bearer-token-callers-need-admin-or-node-admin-scope
|
||||||
- content: Toggle the panel update channel between stable and the rolling
|
- content: Toggle the panel update channel between stable and the rolling
|
||||||
per-commit dev release. Only effective on dev builds.
|
per-commit dev release. Only effective on dev builds.
|
||||||
id: toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds
|
id: toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds
|
||||||
@@ -321,13 +321,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/openapi.json","method":"get"},{"path":"/panel/api/server/status","method":"get"},{"path":"/panel/api/server/fail2banStatus","method":"get"},{"path":"/panel/api/server/cpuHistory/{bucket}","method":"get"},{"path":"/panel/api/server/history/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayMetricsState","method":"get"},{"path":"/panel/api/server/xrayMetricsHistory/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayObservatory","method":"get"},{"path":"/panel/api/server/xrayObservatoryHistory/{tag}/{bucket}","method":"get"},{"path":"/panel/api/server/getXrayVersion","method":"get"},{"path":"/panel/api/server/getPanelUpdateInfo","method":"get"},{"path":"/panel/api/server/getUpdateStatus","method":"get"},{"path":"/panel/api/server/getConfigJson","method":"get"},{"path":"/panel/api/server/getDb","method":"get"},{"path":"/panel/api/server/getMigration","method":"get"},{"path":"/panel/api/server/getNewUUID","method":"get"},{"path":"/panel/api/server/getWebCertFiles","method":"get"},{"path":"/panel/api/server/descendants","method":"get"},{"path":"/panel/api/server/getNewX25519Cert","method":"get"},{"path":"/panel/api/server/getNewmldsa65","method":"get"},{"path":"/panel/api/server/getNewmlkem768","method":"get"},{"path":"/panel/api/server/getNewVlessEnc","method":"get"},{"path":"/panel/api/server/stopXrayService","method":"post"},{"path":"/panel/api/server/restartXrayService","method":"post"},{"path":"/panel/api/server/installXray/{version}","method":"post"},{"path":"/panel/api/server/updatePanel","method":"post"},{"path":"/panel/api/server/setUpdateChannel","method":"post"},{"path":"/panel/api/server/updateGeofile","method":"post"},{"path":"/panel/api/server/updateGeofile/{fileName}","method":"post"},{"path":"/panel/api/server/logs/{count}","method":"post"},{"path":"/panel/api/server/xraylogs/{count}","method":"post"},{"path":"/panel/api/server/amneziawglogs/{count}","method":"post"},{"path":"/panel/api/server/importDB","method":"post"},{"path":"/panel/api/server/getNewEchCert","method":"post"},{"path":"/panel/api/server/getCertHash","method":"post"},{"path":"/panel/api/server/getRemoteCertHash","method":"post"},{"path":"/panel/api/server/scanRealityTarget","method":"post"},{"path":"/panel/api/server/scanRealityTargets","method":"post"},{"path":"/panel/api/server/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/openapi.json","method":"get"},{"path":"/panel/api/server/status","method":"get"},{"path":"/panel/api/server/fail2banStatus","method":"get"},{"path":"/panel/api/server/cpuHistory/{bucket}","method":"get"},{"path":"/panel/api/server/history/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayMetricsState","method":"get"},{"path":"/panel/api/server/xrayMetricsHistory/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayObservatory","method":"get"},{"path":"/panel/api/server/xrayObservatoryHistory/{tag}/{bucket}","method":"get"},{"path":"/panel/api/server/getXrayVersion","method":"get"},{"path":"/panel/api/server/getPanelUpdateInfo","method":"get"},{"path":"/panel/api/server/getUpdateStatus","method":"get"},{"path":"/panel/api/server/getConfigJson","method":"get"},{"path":"/panel/api/server/getDb","method":"get"},{"path":"/panel/api/server/getMigration","method":"get"},{"path":"/panel/api/server/getNewUUID","method":"get"},{"path":"/panel/api/server/getWebCertFiles","method":"get"},{"path":"/panel/api/server/descendants","method":"get"},{"path":"/panel/api/server/getNewX25519Cert","method":"get"},{"path":"/panel/api/server/getNewmldsa65","method":"get"},{"path":"/panel/api/server/getNewmlkem768","method":"get"},{"path":"/panel/api/server/getNewVlessEnc","method":"get"},{"path":"/panel/api/server/stopXrayService","method":"post"},{"path":"/panel/api/server/restartXrayService","method":"post"},{"path":"/panel/api/server/installXray/{version}","method":"post"},{"path":"/panel/api/server/updatePanel","method":"post"},{"path":"/panel/api/server/setUpdateChannel","method":"post"},{"path":"/panel/api/server/updateGeofile","method":"post"},{"path":"/panel/api/server/updateGeofile/{fileName}","method":"post"},{"path":"/panel/api/server/logs/{count}","method":"post"},{"path":"/panel/api/server/xraylogs/{count}","method":"post"},{"path":"/panel/api/server/amneziawglogs/{count}","method":"post"},{"path":"/panel/api/server/importDB","method":"post"},{"path":"/panel/api/server/getNewEchCert","method":"post"},{"path":"/panel/api/server/getCertHash","method":"post"},{"path":"/panel/api/server/getRemoteCertHash","method":"post"},{"path":"/panel/api/server/scanRealityTarget","method":"post"},{"path":"/panel/api/server/scanRealityTargets","method":"post"},{"path":"/panel/api/server/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,10 @@ _openapi:
|
|||||||
title: Test Telegram bot connection by sending a test message to the configured
|
title: Test Telegram bot connection by sending a test message to the configured
|
||||||
chat.
|
chat.
|
||||||
url: '#test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat'
|
url: '#test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat'
|
||||||
|
- depth: 2
|
||||||
|
title: Report what the configured bot can do. Inline mode is a BotFather switch,
|
||||||
|
so the panel warns when it is off.
|
||||||
|
url: '#report-what-the-configured-bot-can-do-inline-mode-is-a-botfather-switch-so-the-panel-warns-when-it-is-off'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Test Discord bot connection by sending a test embed to the configured
|
title: Test Discord bot connection by sending a test embed to the configured
|
||||||
channel.
|
channel.
|
||||||
@@ -90,6 +94,9 @@ _openapi:
|
|||||||
- content: Test Telegram bot connection by sending a test message to the
|
- content: Test Telegram bot connection by sending a test message to the
|
||||||
configured chat.
|
configured chat.
|
||||||
id: test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat
|
id: test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat
|
||||||
|
- content: Report what the configured bot can do. Inline mode is a BotFather
|
||||||
|
switch, so the panel warns when it is off.
|
||||||
|
id: report-what-the-configured-bot-can-do-inline-mode-is-a-botfather-switch-so-the-panel-warns-when-it-is-off
|
||||||
- content: Test Discord bot connection by sending a test embed to the configured
|
- content: Test Discord bot connection by sending a test embed to the configured
|
||||||
channel.
|
channel.
|
||||||
id: test-discord-bot-connection-by-sending-a-test-embed-to-the-configured-channel
|
id: test-discord-bot-connection-by-sending-a-test-embed-to-the-configured-channel
|
||||||
@@ -102,13 +109,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/tgBotCapabilities","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,13 +50,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/sub-balancers","method":"get"},{"path":"/panel/api/sub-balancers","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"delete"},{"path":"/panel/api/sub-balancers/{id}/del","method":"post"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/sub-balancers","method":"get"},{"path":"/panel/api/sub-balancers","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"delete"},{"path":"/panel/api/sub-balancers/{id}/del","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,13 +98,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{subPath}{subid}","method":"head"},{"path":"/{subPath}{subid}/hwid-status","method":"get"},{"path":"/{subPath}{subid}/hwid-status","method":"head"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"head"},{"path":"/{clashPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"head"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{subPath}{subid}","method":"head"},{"path":"/{subPath}{subid}/hwid-status","method":"get"},{"path":"/{subPath}{subid}/hwid-status","method":"head"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"head"},{"path":"/{clashPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"head"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,13 +29,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/ws","method":"get"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/ws","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -213,13 +213,11 @@ _openapi:
|
|||||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
export default function Layout(props) {
|
export default function Layout(props) {
|
||||||
const { APIPage, OpenAPIPage } = props.components ?? {};
|
const { OpenAPIPage } = props.components ?? {};
|
||||||
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
|
||||||
const Comp = OpenAPIPage ?? APIPage;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/geodata/files","method":"get"},{"path":"/panel/api/xray/geodata/categories","method":"get"},{"path":"/panel/api/xray/geodata/entries","method":"get"},{"path":"/panel/api/xray/geodata/validate","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
|
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/geodata/files","method":"get"},{"path":"/panel/api/xray/geodata/categories","method":"get"},{"path":"/panel/api/xray/geodata/entries","method":"get"},{"path":"/panel/api/xray/geodata/validate","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,9 +140,15 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||||||
راهنمای معادل
|
راهنمای معادل
|
||||||
`support-x25519mlkem768=true`
|
`support-x25519mlkem768=true`
|
||||||
را منتقل میکند؛ کلاینتهایی که این افزونهٔ URI را پشتیبانی میکنند، از جمله نسخههای فعلی Mihomo، آن را هنگام ورود اعمال میکنند.
|
را منتقل میکند؛ کلاینتهایی که این افزونهٔ URI را پشتیبانی میکنند، از جمله نسخههای فعلی Mihomo، آن را هنگام ورود اعمال میکنند.
|
||||||
برای سرورهای بسیار قدیمی که ML-KEM را رد میکنند، گزینه را برای همان گره در کلاینت روی
|
سرورهای Xray-core قدیمیتر از 26.9.8 دستدهی سادهٔ
|
||||||
`false`
|
`X25519`
|
||||||
بگذارید یا سرور را ارتقا دهید. حذف محدودیت نسخه بهتنهایی دستدهی را اصلاح نمیکند.
|
را میپذیرند و سرورهای بسیار قدیمی ML-KEM را رد میکنند. برای آنها، یا اگر شبکه ClientHello بزرگتر ML-KEM را حذف میکند، گزینهٔ
|
||||||
|
**Mihomo X25519**
|
||||||
|
را در میزبان (Host) فعال کنید: پروکسی Clash آن مقدار
|
||||||
|
`support-x25519mlkem768: false`
|
||||||
|
را میگیرد و لینک
|
||||||
|
`vless://`
|
||||||
|
آن راهنما را ندارد. حذف محدودیت نسخه بهتنهایی دستدهی را اصلاح نمیکند.
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,14 @@ XDRIVE جریان را بهصورت فایلهایی در پوشهای
|
|||||||
و مقادیر محرمانهٔ **یکسان** داشته باشند، بنابراین اشتراک JSON آنها را به همهٔ کلاینتها
|
و مقادیر محرمانهٔ **یکسان** داشته باشند، بنابراین اشتراک JSON آنها را به همهٔ کلاینتها
|
||||||
میفرستد؛ inboundهای XDRIVE لینک اشتراکگذاری یا ورودی Clash ندارند.
|
میفرستد؛ inboundهای XDRIVE لینک اشتراکگذاری یا ورودی Clash ندارند.
|
||||||
|
|
||||||
|
از Xray 26.10.17، XDRIVE فقط **VLESS** را درون **رمزنگاری VLESS** و بدون flow حمل میکند:
|
||||||
|
inbound به مقدار `decryption` نیاز دارد و outbound به `encryption` و Mux فعال. پنل XDRIVE
|
||||||
|
را فقط برای VLESS ارائه میکند، ذخیرهٔ inbound بدون `decryption` را رد میکند و inboundی را
|
||||||
|
که پیش از بهروزرسانی ذخیره شده بیرون از پیکربندی در حال اجرا نگه میدارد.
|
||||||
|
|
||||||
| فیلد | پیشفرض | معنا |
|
| فیلد | پیشفرض | معنا |
|
||||||
| -------------- | -------------- | ---- |
|
| -------------- | -------------- | ---- |
|
||||||
| `service` | `Google Drive` | `Google Drive`، `local` (پوشهای روی دیسک) یا `template` (هر API ذخیرهسازی HTTP). |
|
| `service` | `google-drive` | `google-drive`، `local` (پوشهای روی دیسک) یا `template` (هر API ذخیرهسازی HTTP). نگارش قدیمی `Google Drive` همچنان خوانده میشود. |
|
||||||
| `remoteFolder` | — | پوشهای که فایلهای نشست در آن قرار میگیرند. |
|
| `remoteFolder` | — | پوشهای که فایلهای نشست در آن قرار میگیرند. |
|
||||||
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده میشوند. |
|
| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده میشوند. |
|
||||||
| `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیرهساز. |
|
| `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیرهساز. |
|
||||||
@@ -148,9 +153,10 @@ ALPN آن را میبیند.
|
|||||||
|
|
||||||
- **ماسکهای TCP** — `fragment`، `sudoku`، `header-custom`، `xmc` (ترافیک را به شکل
|
- **ماسکهای TCP** — `fragment`، `sudoku`، `header-custom`، `xmc` (ترافیک را به شکل
|
||||||
پروتکل Minecraft استتار میکند؛ گذرواژه الزامی است و نام میزبان و نامهای بازیکن
|
پروتکل Minecraft استتار میکند؛ گذرواژه الزامی است و نام میزبان و نامهای بازیکن
|
||||||
اختیاریاند).
|
اختیاریاند) و `filter` که فقط برای inbound است.
|
||||||
- **ماسکهای UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`،
|
- **ماسکهای UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`،
|
||||||
`noise`، `sudoku`، `realm` و `udphop` که فقط سمت کلاینت است. (`mkcp-legacy`
|
`noise`، `sudoku`، `realm`، `udphop` که فقط سمت کلاینت است و `filter` که فقط برای
|
||||||
|
inbound است. (`mkcp-legacy`
|
||||||
همان مبهمسازی قدیمی هدر mKCP را بازتولید میکند؛ `udphop` پورت مقصد — و در صورت
|
همان مبهمسازی قدیمی هدر mKCP را بازتولید میکند؛ `udphop` پورت مقصد — و در صورت
|
||||||
نیاز IP مقصد — را در فواصل زمانی یا بهازای هر اتصال عوض میکند تا مسدودسازی پورت
|
نیاز IP مقصد — را در فواصل زمانی یا بهازای هر اتصال عوض میکند تا مسدودسازی پورت
|
||||||
دور زده شود.)
|
دور زده شود.)
|
||||||
@@ -161,6 +167,11 @@ ALPN آن را میبیند.
|
|||||||
26.9.9 کلاینت فقط با ماسک `udphop` پورت عوض میکند، برای همین پنل `quicParams.udpHop`
|
26.9.9 کلاینت فقط با ماسک `udphop` پورت عوض میکند، برای همین پنل `quicParams.udpHop`
|
||||||
قدیمی کلاینت را به آن تبدیل میکند.
|
قدیمی کلاینت را به آن تبدیل میکند.
|
||||||
|
|
||||||
|
`filter` (Xray 26.10.21) اتصالهای یک inbound را بر اساس IP مبدأ رد میکند:
|
||||||
|
`allowedSourceIPs` فقط مبدأهای فهرستشده را میپذیرد، `blockedSourceIPs` مبدأهایش را رد
|
||||||
|
میکند و تطابق با فهرست مسدود اولویت دارد. هر دو CIDR یا مدخل `geoip:` میپذیرند. لینکهای
|
||||||
|
اشتراک و اشتراک JSON آن را حذف میکنند، چون فقط روی شنونده اثر دارد.
|
||||||
|
|
||||||
FinalMask جایگزین مبهمسازی `header`/`seed` بهازای هر انتقال میشود که بیلدهای
|
FinalMask جایگزین مبهمسازی `header`/`seed` بهازای هر انتقال میشود که بیلدهای
|
||||||
قدیمیتر Xray نمایش میدادند.
|
قدیمیتر Xray نمایش میدادند.
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ SQLite گزینه پیشفرض است و برای بیشتر استقراره
|
|||||||
|
|
||||||
3x-ui یک فورک ارتقایافته از پروژه اصلی X-UI است. این پروژه پشتیبانی گستردهتری از
|
3x-ui یک فورک ارتقایافته از پروژه اصلی X-UI است. این پروژه پشتیبانی گستردهتری از
|
||||||
پروتکلها، پایداری بهبودیافته، حسابداری ترافیک بهازای هر کلاینت، مدیریت چندنودی،
|
پروتکلها، پایداری بهبودیافته، حسابداری ترافیک بهازای هر کلاینت، مدیریت چندنودی،
|
||||||
یک رابط کاربری ۱۳ زبانه و بسیاری از امکانات رفاهی را اضافه میکند.
|
یک رابط کاربری ۱۴ زبانه و بسیاری از امکانات رفاهی را اضافه میکند.
|
||||||
|
|
||||||
## چگونه بهروزرسانی کنم؟
|
## چگونه بهروزرسانی کنم؟
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ Caddy گواهیها را برای شما دریافت و تمدید می
|
|||||||
|
|
||||||
```text title="Caddyfile"
|
```text title="Caddyfile"
|
||||||
panel.example.com {
|
panel.example.com {
|
||||||
reverse_proxy 127.0.0.1:2053
|
reverse_proxy 127.0.0.1:2053 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ icon: Send
|
|||||||
|
|
||||||
در تنظیمات پنل، ربات Telegram را فعال کنید و **توکن** و **شناسه(های) چت ادمین** را
|
در تنظیمات پنل، ربات Telegram را فعال کنید و **توکن** و **شناسه(های) چت ادمین** را
|
||||||
(با کاما جداشده) تنظیم نمایید. ذخیره کنید، سپس به ربات خود پیام بدهید.
|
(با کاما جداشده) تنظیم نمایید. ذخیره کنید، سپس به ربات خود پیام بدهید.
|
||||||
|
|
||||||
|
برای ارسال اعلانها به یک تاپیک مشخص از یک سوپرگروه فروم، شناسهی تاپیک را پس از
|
||||||
|
دونقطه اضافه کنید، مثلاً `-1001234567890:42`. شناسهی تاپیک عدد بعد از چت در لینک یک
|
||||||
|
پیام تاپیک است (`https://t.me/c/1234567890/42/…`). برای تاپیک General آن را ننویسید
|
||||||
|
(یا `1` بگذارید).
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
@@ -101,7 +106,7 @@ icon: Send
|
|||||||
| -------------- | -------------------------- | ---------------------------------------------- |
|
| -------------- | -------------------------- | ---------------------------------------------- |
|
||||||
| `tgBotEnable` | `false` | روشن/خاموش اصلی. |
|
| `tgBotEnable` | `false` | روشن/خاموش اصلی. |
|
||||||
| `tgBotToken` | _(محرمانه)_ | توکن API ربات. |
|
| `tgBotToken` | _(محرمانه)_ | توکن API ربات. |
|
||||||
| `tgBotChatId` | _(هیچکدام)_ | شناسههای Telegram **ادمین**، جداشده با کاما. |
|
| `tgBotChatId` | _(هیچکدام)_ | شناسههای Telegram **ادمین**، جداشده با کاما؛ `chatId:topicId` به یک تاپیک فروم میفرستد. |
|
||||||
| `tgBotProxy` | _(هیچکدام)_ | پروکسی `socks5://`، `http://` یا `https://`. |
|
| `tgBotProxy` | _(هیچکدام)_ | پروکسی `socks5://`، `http://` یا `https://`. |
|
||||||
| `tgBotAPIServer` | _(پیشفرض)_ | سرور سفارشی Telegram Bot API. |
|
| `tgBotAPIServer` | _(پیشفرض)_ | سرور سفارشی Telegram Bot API. |
|
||||||
| `tgRunTime` | `@daily` | زمانبندی گزارش (cron / `@daily` / `@every …`). |
|
| `tgRunTime` | `@daily` | زمانبندی گزارش (cron / `@daily` / `@every …`). |
|
||||||
|
|||||||
@@ -135,10 +135,12 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||||||
нужен отпечаток с ML-KEM (`chrome` при uTLS v1.8.7 в Mihomo). Сам флаг не
|
нужен отпечаток с ML-KEM (`chrome` при uTLS v1.8.7 в Mihomo). Сам флаг не
|
||||||
обновляет старые отпечатки. Исходные ссылки `vless://` передают эквивалентную
|
обновляет старые отпечатки. Исходные ссылки `vless://` передают эквивалентную
|
||||||
подсказку `support-x25519mlkem768=true`; клиенты, поддерживающие это расширение
|
подсказку `support-x25519mlkem768=true`; клиенты, поддерживающие это расширение
|
||||||
URI, включая актуальные сборки Mihomo, применяют её при импорте. Для очень старых
|
URI, включая актуальные сборки Mihomo, применяют её при импорте. Серверы на
|
||||||
серверов REALITY, отвергающих ML-KEM, задайте `false` для соответствующего узла
|
Xray-core ниже 26.9.8 принимают обычный `X25519`, а очень старые отвергают
|
||||||
в клиенте или обновите сервер. Снятие ограничения версии не исправляет это
|
ML-KEM. Для них, а также если сеть отбрасывает крупный ClientHello с ML-KEM,
|
||||||
рукопожатие.
|
включите **Mihomo X25519** в хосте: его прокси в Clash получит
|
||||||
|
`support-x25519mlkem768: false`, а ссылка `vless://` — без подсказки. Снятие
|
||||||
|
ограничения версии не исправляет это рукопожатие.
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -128,9 +128,14 @@ XDRIVE передаёт поток файлами в папке, доступн
|
|||||||
**одинаковые** сервис, папку и секреты, поэтому JSON-подписка передаёт их каждому
|
**одинаковые** сервис, папку и секреты, поэтому JSON-подписка передаёт их каждому
|
||||||
клиенту; для XDRIVE-инбаундов не создаются ссылки и записи Clash.
|
клиенту; для XDRIVE-инбаундов не создаются ссылки и записи Clash.
|
||||||
|
|
||||||
|
Начиная с Xray 26.10.17 XDRIVE передаёт только **VLESS** внутри **шифрования VLESS** и
|
||||||
|
без flow: инбаунду нужно значение `decryption`, исходящему — `encryption` и включённый
|
||||||
|
Mux. Панель предлагает XDRIVE только для VLESS, не сохраняет инбаунд без `decryption` и
|
||||||
|
не включает в работающую конфигурацию инбаунд, сохранённый до обновления.
|
||||||
|
|
||||||
| Поле | По умолчанию | Значение |
|
| Поле | По умолчанию | Значение |
|
||||||
| -------------- | -------------- | -------- |
|
| -------------- | -------------- | -------- |
|
||||||
| `service` | `Google Drive` | `Google Drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). |
|
| `service` | `google-drive` | `google-drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). Старое написание `Google Drive` по-прежнему читается. |
|
||||||
| `remoteFolder` | — | Папка с файлами сессий. |
|
| `remoteFolder` | — | Папка с файлами сессий. |
|
||||||
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. |
|
| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. |
|
||||||
| `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. |
|
| `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. |
|
||||||
@@ -150,9 +155,10 @@ SNI, отпечаток uTLS и ALPN.
|
|||||||
|
|
||||||
- **TCP-маски** — `fragment`, `sudoku`, `header-custom`, `xmc` (маскирует поток
|
- **TCP-маски** — `fragment`, `sudoku`, `header-custom`, `xmc` (маскирует поток
|
||||||
под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков
|
под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков
|
||||||
опциональны).
|
опциональны), а также только инбаундная `filter`.
|
||||||
- **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
|
- **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
|
||||||
`noise`, `sudoku`, `realm`, а также только клиентская `udphop`. (`mkcp-legacy`
|
`noise`, `sudoku`, `realm`, а также только клиентская `udphop` и только инбаундная
|
||||||
|
`filter`. (`mkcp-legacy`
|
||||||
воспроизводит старую обфускацию заголовка mKCP; `udphop` меняет удалённый порт —
|
воспроизводит старую обфускацию заголовка mKCP; `udphop` меняет удалённый порт —
|
||||||
и при желании удалённый IP — по интервалу или для каждого соединения, чтобы
|
и при желании удалённый IP — по интервалу или для каждого соединения, чтобы
|
||||||
обходить блокировку портов.)
|
обходить блокировку портов.)
|
||||||
@@ -163,6 +169,12 @@ SNI, отпечаток uTLS и ALPN.
|
|||||||
порт. Начиная с Xray 26.9.9 клиент меняет порт только через маску `udphop`,
|
порт. Начиная с Xray 26.9.9 клиент меняет порт только через маску `udphop`,
|
||||||
поэтому панель преобразует старый клиентский `quicParams.udpHop` в неё.
|
поэтому панель преобразует старый клиентский `quicParams.udpHop` в неё.
|
||||||
|
|
||||||
|
`filter` (Xray 26.10.21) отбрасывает соединения инбаунда по IP источника:
|
||||||
|
`allowedSourceIPs` пропускает только перечисленные источники, `blockedSourceIPs`
|
||||||
|
отбрасывает свои, а совпадение с блок-листом имеет приоритет. Оба принимают CIDR или
|
||||||
|
записи `geoip:`. Ссылки и JSON-подписки её не содержат, ведь она действует только на
|
||||||
|
слушающей стороне.
|
||||||
|
|
||||||
FinalMask заменяет обфускацию `header`/`seed` на уровне отдельного транспорта,
|
FinalMask заменяет обфускацию `header`/`seed` на уровне отдельного транспорта,
|
||||||
которую предоставляли старые сборки Xray.
|
которую предоставляли старые сборки Xray.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ icon: Zap
|
|||||||
и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов.
|
и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
TUIC работает как **встроенный нативный Go-сервер** прямо внутри процесса 3x-ui. Расшифрованный трафик направляется в ядро Xray-core через локальный SOCKS5-мост, что обеспечивает полную поддержку правил маршрутизации Xray, каскадирования (например, TUIC → VLESS / WARP), персональных квот клиентов (`totalGB`) и горячего обновления пользователей без обрыва соединений.
|
TUIC работает как **встроенный нативный Go-сервер** прямо внутри процесса 3x-ui. Расшифрованный трафик направляется в ядро Xray-core через локальный SOCKS5-мост, что обеспечивает полную поддержку правил маршрутизации Xray, каскадирования (например, TUIC → VLESS / WARP), персональных квот клиентов (`totalGB`) и управления клиентами в памяти.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Ключевые параметры
|
## Ключевые параметры
|
||||||
@@ -54,7 +54,7 @@ icon: Zap
|
|||||||
<Step>
|
<Step>
|
||||||
### Добавьте клиентов
|
### Добавьте клиентов
|
||||||
|
|
||||||
Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Панель автоматически генерирует надёжные случайные данные при создании клиента.
|
Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Идентификатор Email для TUIC должен занимать 1–255 байт UTF-8. Старые клиенты с некорректными идентификаторами исключаются из обслуживания TUIC с предупреждением; для восстановления доступа сократите идентификатор. Панель автоматически генерирует надёжные случайные данные при создании клиента.
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step>
|
<Step>
|
||||||
@@ -102,10 +102,10 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
|
|||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
|
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
|
||||||
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
|
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Идентификатор **Email** клиента передаётся в правила `user` для TCP и обоих режимов UDP. Поддерживаются теги инбаундов (`in-<port>-udp`), правила по доменам/IP и каскадирование через любой outbound. Ставьте специфичные правила `inboundTag` + `user` выше общих правил `user`: Xray выбирает первое совпадение.
|
||||||
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
|
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
|
||||||
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay; событие скорости TUIC повторно его не начисляет.
|
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay. Отдельная policy отключает в relay пользовательские счётчики трафика и учёт loopback-IP: источником клиентского трафика, скорости и активности остаётся нативный TUIC.
|
||||||
- **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
|
- **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
|
||||||
- **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
|
- **Обновление клиентов**: Включённость, квоты и срок проверяет нативный TUIC; сброс квоты восстанавливает доступ без перезапуска Xray. Добавление, удаление или переименование клиента заменяет только внутренний SOCKS relay его inbound. Аккаунты проверяются до замены и повторно сверяются с работающим relay после неё. При ошибке восстанавливается прежний relay и планируется повторная попытка. Ошибки изолированы в группе связанных relay: исправные TUIC inbound и независимые изменения Xray продолжают применяться. Изменения маршрутизации, зависящие от неудачной смены тега, ожидают восстановления этого relay. Связанные изменения клиентов и inbound тоже откладываются; новые TUIC-сессии допускаются после применения их правил маршрутизации. Новые relay-соединения ожидают до 10 секунд во время замены или отката; открытые TCP-потоки и UDP-ассоциации продолжают работать. Пакеты новых UDP-ассоциаций помещаются в ограниченную очередь, не блокируя действующие потоки того же QUIC-соединения. При отключении клиента ожидание отменяется. Переполнение очереди или недоступность relay всё ещё могут привести к отказу нового запроса; остальные протоколы продолжают работать. Изменение только TUIC-пароля или параметров клиентского профиля не меняет relay-аккаунты.
|
||||||
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
|
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ SQLite используется по умолчанию и подходит дл
|
|||||||
|
|
||||||
3x-ui — это улучшенный форк оригинального проекта X-UI. Он добавляет более широкую поддержку
|
3x-ui — это улучшенный форк оригинального проекта X-UI. Он добавляет более широкую поддержку
|
||||||
протоколов, повышенную стабильность, поклиентский учёт трафика, управление несколькими
|
протоколов, повышенную стабильность, поклиентский учёт трафика, управление несколькими
|
||||||
узлами, интерфейс на 13 языках и множество улучшений для удобства работы.
|
узлами, интерфейс на 14 языках и множество улучшений для удобства работы.
|
||||||
|
|
||||||
## Как мне обновиться?
|
## Как мне обновиться?
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ Caddy получает и продлевает сертификаты за ва
|
|||||||
|
|
||||||
```text title="Caddyfile"
|
```text title="Caddyfile"
|
||||||
panel.example.com {
|
panel.example.com {
|
||||||
reverse_proxy 127.0.0.1:2053
|
reverse_proxy 127.0.0.1:2053 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ icon: Send
|
|||||||
|
|
||||||
В настройках панели включите Telegram-бота и задайте **токен** и **админские
|
В настройках панели включите Telegram-бота и задайте **токен** и **админские
|
||||||
chat ID** (через запятую). Сохраните, затем напишите своему боту.
|
chat ID** (через запятую). Сохраните, затем напишите своему боту.
|
||||||
|
|
||||||
|
Чтобы отправлять уведомления в определённую тему форумной супергруппы, добавьте
|
||||||
|
ID темы после двоеточия, например `-1001234567890:42`. ID темы — число после чата
|
||||||
|
в ссылке на сообщение темы (`https://t.me/c/1234567890/42/…`). Для темы General
|
||||||
|
не указывайте его (или укажите `1`).
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
@@ -108,7 +113,7 @@ chat ID** (через запятую). Сохраните, затем напиш
|
|||||||
| -------------- | -------------------------- | ---------------------------------------------- |
|
| -------------- | -------------------------- | ---------------------------------------------- |
|
||||||
| `tgBotEnable` | `false` | Главный переключатель вкл./выкл. |
|
| `tgBotEnable` | `false` | Главный переключатель вкл./выкл. |
|
||||||
| `tgBotToken` | _(секрет)_ | Токен Bot API. |
|
| `tgBotToken` | _(секрет)_ | Токен Bot API. |
|
||||||
| `tgBotChatId` | _(нет)_ | **Админские** Telegram ID через запятую. |
|
| `tgBotChatId` | _(нет)_ | **Админские** Telegram ID через запятую; `chatId:topicId` — в тему форума. |
|
||||||
| `tgBotProxy` | _(нет)_ | Прокси `socks5://`, `http://` или `https://`. |
|
| `tgBotProxy` | _(нет)_ | Прокси `socks5://`, `http://` или `https://`. |
|
||||||
| `tgBotAPIServer` | _(по умолчанию)_ | Пользовательский сервер Telegram Bot API. |
|
| `tgBotAPIServer` | _(по умолчанию)_ | Пользовательский сервер Telegram Bot API. |
|
||||||
| `tgRunTime` | `@daily` | Расписание отчётов (cron / `@daily` / `@every …`).|
|
| `tgRunTime` | `@daily` | Расписание отчётов (cron / `@daily` / `@every …`).|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
|
|||||||
- **私钥泄露。** 永远只把**公钥**分发给客户端。
|
- **私钥泄露。** 永远只把**公钥**分发给客户端。
|
||||||
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
|
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
|
||||||
- **客户端版本限制。** Xray-core v26.9.8+ 在**最小客户端版本**留空时不再设置默认下限,但已明确保存的限制仍生效。较早的内核可能使用内置下限(如 `26.3.27`),导致第三方客户端即使密钥正确也被拒绝。修改前先核对运行中的内核版本;降低限制也会放行较旧的指纹。
|
- **客户端版本限制。** Xray-core v26.9.8+ 在**最小客户端版本**留空时不再设置默认下限,但已明确保存的限制仍生效。较早的内核可能使用内置下限(如 `26.3.27`),导致第三方客户端即使密钥正确也被拒绝。修改前先核对运行中的内核版本;降低限制也会放行较旧的指纹。
|
||||||
- **Mihomo 与 ML-KEM。** Xray-core v26.9.8+ 还独立要求 `X25519MLKEM768` key share 位于可选的 `X25519` 之前。Clash/Mihomo YAML 订阅会为 REALITY 节点(含外部链接)启用 `reality-opts.support-x25519mlkem768`,未设置指纹时使用 `chrome`。明确选择的指纹会保留,必须选择支持 ML-KEM 的指纹(Mihomo 使用 uTLS v1.8.7 时可选 `chrome`);开关无法让旧指纹获得新能力。原始 `vless://` 链接会携带等效的 `support-x25519mlkem768=true` 提示;支持此 URI 扩展的客户端(包括当前 Mihomo 版本)会在导入时应用它。对拒绝 ML-KEM 的很旧的 REALITY 服务端,需在客户端按节点将此项覆写为 `false`,或升级服务端。仅清空版本限制无法解决握手问题。
|
- **Mihomo 与 ML-KEM。** Xray-core v26.9.8+ 还独立要求 `X25519MLKEM768` key share 位于可选的 `X25519` 之前。Clash/Mihomo YAML 订阅会为 REALITY 节点(含外部链接)启用 `reality-opts.support-x25519mlkem768`,未设置指纹时使用 `chrome`。明确选择的指纹会保留,必须选择支持 ML-KEM 的指纹(Mihomo 使用 uTLS v1.8.7 时可选 `chrome`);开关无法让旧指纹获得新能力。原始 `vless://` 链接会携带等效的 `support-x25519mlkem768=true` 提示;支持此 URI 扩展的客户端(包括当前 Mihomo 版本)会在导入时应用它。Xray-core 26.9.8 以下的服务端接受普通 `X25519` 握手,很旧的服务端则会拒绝 ML-KEM。对这些服务端,或网络会丢弃较大的 ML-KEM ClientHello 时,可在主机(Host)上启用 **Mihomo X25519**:该主机的 Clash 代理会得到 `support-x25519mlkem768: false`,`vless://` 链接也不再携带该提示。仅清空版本限制无法解决握手问题。
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,12 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、
|
|||||||
XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服务端轮询该文件夹,而不是在端口上接受连接。
|
XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服务端轮询该文件夹,而不是在端口上接受连接。
|
||||||
双方必须使用**相同**的服务、文件夹和密钥,因此 JSON 订阅会把它们下发给每个客户端;XDRIVE 入站不生成分享链接或 Clash 条目。
|
双方必须使用**相同**的服务、文件夹和密钥,因此 JSON 订阅会把它们下发给每个客户端;XDRIVE 入站不生成分享链接或 Clash 条目。
|
||||||
|
|
||||||
|
自 Xray 26.10.17 起,XDRIVE 只在 **VLESS 加密**内承载 **VLESS**,且不支持 flow:入站需要设置 `decryption`,
|
||||||
|
出站需要 `encryption` 并启用 Mux。面板只为 VLESS 提供 XDRIVE,拒绝保存没有 `decryption` 的入站,并把升级前保存的此类入站排除在运行配置之外。
|
||||||
|
|
||||||
| 字段 | 默认值 | 含义 |
|
| 字段 | 默认值 | 含义 |
|
||||||
| -------------- | -------------- | ---- |
|
| -------------- | -------------- | ---- |
|
||||||
| `service` | `Google Drive` | `Google Drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。 |
|
| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。旧写法 `Google Drive` 仍可读取。 |
|
||||||
| `remoteFolder` | — | 存放会话文件的文件夹。 |
|
| `remoteFolder` | — | 存放会话文件的文件夹。 |
|
||||||
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 |
|
| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 |
|
||||||
| `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 |
|
| `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 |
|
||||||
@@ -137,9 +140,9 @@ XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的
|
|||||||
传输(如 mKCP),也能在 TLS 之上再加一层外壳。掩码按方向分别配置:
|
传输(如 mKCP),也能在 TLS 之上再加一层外壳。掩码按方向分别配置:
|
||||||
|
|
||||||
- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成
|
- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成
|
||||||
Minecraft 协议;密码必填,主机名和玩家用户名可选)。
|
Minecraft 协议;密码必填,主机名和玩家用户名可选),以及仅限入站的 `filter`。
|
||||||
- **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、
|
- **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、
|
||||||
`noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop`。(`mkcp-legacy` 重现旧版
|
`noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop` 和仅限入站的 `filter`。(`mkcp-legacy` 重现旧版
|
||||||
mKCP 的头部混淆;`udphop` 按时间间隔或按连接轮换远端端口——也可轮换远端 IP——以规避
|
mKCP 的头部混淆;`udphop` 按时间间隔或按连接轮换远端端口——也可轮换远端 IP——以规避
|
||||||
端口封锁。)
|
端口封锁。)
|
||||||
- **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行
|
- **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行
|
||||||
@@ -148,6 +151,10 @@ XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的
|
|||||||
26.9.9 起客户端只通过 `udphop` 掩码跳端口,因此面板会把客户端旧的
|
26.9.9 起客户端只通过 `udphop` 掩码跳端口,因此面板会把客户端旧的
|
||||||
`quicParams.udpHop` 转换为该掩码。
|
`quicParams.udpHop` 转换为该掩码。
|
||||||
|
|
||||||
|
`filter`(Xray 26.10.21)按源 IP 丢弃入站连接:`allowedSourceIPs` 只放行所列来源,
|
||||||
|
`blockedSourceIPs` 丢弃所列来源,命中封锁列表优先。两者都接受 CIDR 或 `geoip:` 条目。
|
||||||
|
分享链接和 JSON 订阅会去掉它,因为它只作用于监听端。
|
||||||
|
|
||||||
FinalMask 取代了旧版 Xray 构建中暴露的、按每种传输各自实现的 `header`/`seed` 混淆。
|
FinalMask 取代了旧版 Xray 构建中暴露的、按每种传输各自实现的 `header`/`seed` 混淆。
|
||||||
|
|
||||||
## sockopt — 底层套接字选项
|
## sockopt — 底层套接字选项
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ SQLite 是默认选项,适用于大多数部署场景。对于规模更大的
|
|||||||
## 3x-ui 与 x-ui 有什么区别?
|
## 3x-ui 与 x-ui 有什么区别?
|
||||||
|
|
||||||
3x-ui 是原版 X-UI 项目的增强分支。它增加了更广泛的协议支持、更佳的稳定性、
|
3x-ui 是原版 X-UI 项目的增强分支。它增加了更广泛的协议支持、更佳的稳定性、
|
||||||
按客户端的流量统计、多节点管理、13 种语言的界面,以及众多提升使用体验的功能。
|
按客户端的流量统计、多节点管理、14 种语言的界面,以及众多提升使用体验的功能。
|
||||||
|
|
||||||
## 我该如何更新?
|
## 我该如何更新?
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ Caddy 会为你获取并续期证书——只需让 Caddyfile 指向面板,它
|
|||||||
|
|
||||||
```text title="Caddyfile"
|
```text title="Caddyfile"
|
||||||
panel.example.com {
|
panel.example.com {
|
||||||
reverse_proxy 127.0.0.1:2053
|
reverse_proxy 127.0.0.1:2053 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ icon: Send
|
|||||||
|
|
||||||
在面板设置中启用 Telegram 机器人,并填写**令牌**和**管理员聊天 ID**
|
在面板设置中启用 Telegram 机器人,并填写**令牌**和**管理员聊天 ID**
|
||||||
(多个时以逗号分隔)。保存后,再向你的机器人发送消息。
|
(多个时以逗号分隔)。保存后,再向你的机器人发送消息。
|
||||||
|
|
||||||
|
要把通知发送到论坛超级群组的某个话题,在聊天 ID 后加冒号和话题 ID,例如
|
||||||
|
`-1001234567890:42`。话题 ID 是话题消息链接中聊天之后的数字
|
||||||
|
(`https://t.me/c/1234567890/42/…`)。General 话题不填(或填 `1`)。
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
@@ -97,7 +101,7 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长
|
|||||||
| -------------- | -------------------------- | ---------------------------------------------- |
|
| -------------- | -------------------------- | ---------------------------------------------- |
|
||||||
| `tgBotEnable` | `false` | 总开关。 |
|
| `tgBotEnable` | `false` | 总开关。 |
|
||||||
| `tgBotToken` | _(机密)_ | 机器人 API 令牌。 |
|
| `tgBotToken` | _(机密)_ | 机器人 API 令牌。 |
|
||||||
| `tgBotChatId` | _(无)_ | 以逗号分隔的**管理员** Telegram ID。 |
|
| `tgBotChatId` | _(无)_ | 以逗号分隔的**管理员** Telegram ID;`chatId:topicId` 发送到论坛话题。 |
|
||||||
| `tgBotProxy` | _(无)_ | `socks5://`、`http://` 或 `https://` 代理。 |
|
| `tgBotProxy` | _(无)_ | `socks5://`、`http://` 或 `https://` 代理。 |
|
||||||
| `tgBotAPIServer` | _(默认)_ | 自定义 Telegram Bot API 服务器。 |
|
| `tgBotAPIServer` | _(默认)_ | 自定义 Telegram Bot API 服务器。 |
|
||||||
| `tgRunTime` | `@daily` | 报告计划(cron / `@daily` / `@every …`)。 |
|
| `tgRunTime` | `@daily` | 报告计划(cron / `@daily` / `@every …`)。 |
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ describe('buildProxyConfig — caddy', () => {
|
|||||||
const cfg = buildProxyConfig({ ...base, server: 'caddy', panelPath: '/' });
|
const cfg = buildProxyConfig({ ...base, server: 'caddy', panelPath: '/' });
|
||||||
expect(cfg).toContain('reverse_proxy 127.0.0.1:2053');
|
expect(cfg).toContain('reverse_proxy 127.0.0.1:2053');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Caddy forwards a client-sent X-Real-IP unchanged, and the panel's login
|
||||||
|
// limiter would key on it; only an overwrite gives the address Caddy saw.
|
||||||
|
it('overwrites X-Real-IP with the address Caddy saw', () => {
|
||||||
|
for (const panelPath of ['/', '/panel']) {
|
||||||
|
const cfg = buildProxyConfig({ ...base, server: 'caddy', panelPath });
|
||||||
|
expect(cfg).toContain('header_up X-Real-IP {remote_host}');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildCertCommand', () => {
|
describe('buildCertCommand', () => {
|
||||||
|
|||||||
@@ -49,14 +49,12 @@ function buildNginx(o: ReverseProxyOptions): string {
|
|||||||
|
|
||||||
function buildCaddy(o: ReverseProxyOptions): string {
|
function buildCaddy(o: ReverseProxyOptions): string {
|
||||||
const path = normalizePath(o.panelPath);
|
const path = normalizePath(o.panelPath);
|
||||||
if (path === '/') {
|
const matcher = path === '/' ? '' : `${path.replace(/\/$/, '')}/* `;
|
||||||
return `${o.domain} {
|
// Caddy forwards a client-sent X-Real-IP as is; the panel's login limiter reads it.
|
||||||
reverse_proxy 127.0.0.1:${o.panelPort}
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
const matcher = `${path.replace(/\/$/, '')}/*`;
|
|
||||||
return `${o.domain} {
|
return `${o.domain} {
|
||||||
reverse_proxy ${matcher} 127.0.0.1:${o.panelPort}
|
reverse_proxy ${matcher}127.0.0.1:${o.panelPort} {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,18 +32,47 @@ describe('validateBotToken', () => {
|
|||||||
|
|
||||||
describe('parseAdminIds', () => {
|
describe('parseAdminIds', () => {
|
||||||
it('splits a comma list into integer ids', () => {
|
it('splits a comma list into integer ids', () => {
|
||||||
expect(parseAdminIds('111, 222')).toEqual({ ids: [111, 222], invalid: [] });
|
expect(parseAdminIds('111, 222')).toEqual({
|
||||||
|
ids: [111, 222],
|
||||||
|
entries: ['111', '222'],
|
||||||
|
invalid: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts negative group ids and captures invalid entries', () => {
|
it('accepts negative group ids and captures invalid entries', () => {
|
||||||
expect(parseAdminIds('-1001234567, abc, 42')).toEqual({
|
expect(parseAdminIds('-1001234567, abc, 42')).toEqual({
|
||||||
ids: [-1001234567, 42],
|
ids: [-1001234567, 42],
|
||||||
|
entries: ['-1001234567', '42'],
|
||||||
invalid: ['abc'],
|
invalid: ['abc'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops the General topic suffix, since the Bot API rejects thread id 1', () => {
|
||||||
|
expect(parseAdminIds('-1001234567:1')).toEqual({
|
||||||
|
ids: [-1001234567],
|
||||||
|
entries: ['-1001234567'],
|
||||||
|
invalid: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a second topic for the same chat, as the panel does', () => {
|
||||||
|
expect(parseAdminIds('-1001:7, -1001:1, -1001:8')).toEqual({
|
||||||
|
ids: [-1001],
|
||||||
|
entries: ['-1001:7'],
|
||||||
|
invalid: ['-1001:8'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a forum topic suffix and rejects a malformed one', () => {
|
||||||
|
expect(parseAdminIds('-1001234567:42, 111:0, 222:x')).toEqual({
|
||||||
|
ids: [-1001234567],
|
||||||
|
entries: ['-1001234567:42'],
|
||||||
|
invalid: ['111:0', '222:x'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns empty for blank input', () => {
|
it('returns empty for blank input', () => {
|
||||||
expect(parseAdminIds(' ')).toEqual({ ids: [], invalid: [] });
|
expect(parseAdminIds(' ')).toEqual({ ids: [], entries: [], invalid: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,4 +132,13 @@ describe('buildBotConfigSummary', () => {
|
|||||||
expect(s.tgBotChatId).toBe('111,222');
|
expect(s.tgBotChatId).toBe('111,222');
|
||||||
expect(s.tgRunTime).toBe('@daily');
|
expect(s.tgRunTime).toBe('@daily');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps forum topic suffixes in the chat id setting', () => {
|
||||||
|
const s = buildBotConfigSummary({
|
||||||
|
token: VALID_TOKEN,
|
||||||
|
adminIds: '111, -1001234567 : 42',
|
||||||
|
runTime: '@daily',
|
||||||
|
});
|
||||||
|
expect(s.tgBotChatId).toBe('111,-1001234567:42');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Pure validation + templating helpers for 3x-ui's Telegram bot settings.
|
// Pure validation + templating helpers for 3x-ui's Telegram bot settings.
|
||||||
// Grounded in internal/web/service/tgbot/tgbot.go (admin ids parsed with
|
// Grounded in internal/web/service/tgbot/tgbot.go (admin ids parsed by
|
||||||
// strconv.ParseInt(_,10,64); token handed to telego.NewBot → api.telegram.org)
|
// parseAdminChats as "chatId[:topicId]"; token handed to telego.NewBot → api.telegram.org)
|
||||||
// and the panel's tg* settings (tgRunTime uses robfig/cron). No React/DOM.
|
// and the panel's tg* settings (tgRunTime uses robfig/cron). No React/DOM.
|
||||||
|
|
||||||
export interface BotConfig {
|
export interface BotConfig {
|
||||||
@@ -17,6 +17,7 @@ export interface TokenValidation {
|
|||||||
|
|
||||||
export interface AdminIdsResult {
|
export interface AdminIdsResult {
|
||||||
ids: number[];
|
ids: number[];
|
||||||
|
entries: string[]; // normalized "chatId" or "chatId:topicId", as the panel stores them
|
||||||
invalid: string[];
|
invalid: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,17 +42,29 @@ export function validateBotToken(token: string): TokenValidation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseAdminIds(raw: string): AdminIdsResult {
|
export function parseAdminIds(raw: string): AdminIdsResult {
|
||||||
const ids: number[] = [];
|
// Mirrors parseAdminChats in tgbot.go: one topic per chat, repeats collapse.
|
||||||
|
const topics = new Map<string, number>();
|
||||||
const invalid: string[] = [];
|
const invalid: string[] = [];
|
||||||
for (const part of raw
|
for (const part of raw
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean)) {
|
.filter(Boolean)) {
|
||||||
// Telegram chat ids are integers; group/channel ids are negative.
|
// Telegram chat ids are integers; group/channel ids are negative. An
|
||||||
if (/^-?\d+$/.test(part)) ids.push(Number(part));
|
// optional ":<topicId>" targets a forum topic inside a supergroup.
|
||||||
else invalid.push(part);
|
const m = /^(-?\d+)\s*(?::\s*(\d+))?$/.exec(part);
|
||||||
|
if (!m || (m[2] !== undefined && Number(m[2]) <= 0)) {
|
||||||
|
invalid.push(part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// General (thread 1) is reached by omitting the topic; the Bot API rejects 1.
|
||||||
|
const topic = m[2] === undefined || Number(m[2]) === 1 ? 0 : Number(m[2]);
|
||||||
|
const prev = topics.get(m[1]) ?? 0;
|
||||||
|
if (topic && prev && prev !== topic) invalid.push(part);
|
||||||
|
else topics.set(m[1], topic || prev);
|
||||||
}
|
}
|
||||||
return { ids, invalid };
|
const ids = [...topics.keys()].map(Number);
|
||||||
|
const entries = [...topics].map(([chat, topic]) => (topic ? `${chat}:${topic}` : chat));
|
||||||
|
return { ids, entries, invalid };
|
||||||
}
|
}
|
||||||
|
|
||||||
// robfig/cron predefined macros (note: @reboot is NOT supported).
|
// robfig/cron predefined macros (note: @reboot is NOT supported).
|
||||||
@@ -108,11 +121,11 @@ export function buildBotConfigSummary(c: BotConfig): {
|
|||||||
tgBotChatId: string;
|
tgBotChatId: string;
|
||||||
tgRunTime: string;
|
tgRunTime: string;
|
||||||
} {
|
} {
|
||||||
const { ids } = parseAdminIds(c.adminIds);
|
const { entries } = parseAdminIds(c.adminIds);
|
||||||
return {
|
return {
|
||||||
tgBotEnable: true,
|
tgBotEnable: true,
|
||||||
tgBotToken: c.token.trim(),
|
tgBotToken: c.token.trim(),
|
||||||
tgBotChatId: ids.join(','),
|
tgBotChatId: entries.join(','),
|
||||||
tgRunTime: c.runTime.trim(),
|
tgRunTime: c.runTime.trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -18,34 +18,34 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fumadocs-core": "^16.16.2",
|
"fumadocs-core": "^16.17.8",
|
||||||
"fumadocs-docgen": "^3.1.1",
|
"fumadocs-docgen": "^3.2.0",
|
||||||
"fumadocs-mdx": "^15.4.6",
|
"fumadocs-mdx": "^15.4.10",
|
||||||
"fumadocs-openapi": "^12.3.0",
|
"fumadocs-openapi": "^12.5.5",
|
||||||
"fumadocs-ui": "^16.16.2",
|
"fumadocs-ui": "^16.17.8",
|
||||||
"lucide-react": "^1.52.0",
|
"lucide-react": "^1.61.0",
|
||||||
"mermaid": "^12.1.0",
|
"mermaid": "^12.2.0",
|
||||||
"next": "16.3.8",
|
"next": "16.4.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.1",
|
||||||
"react-qr-code": "^2.2.0",
|
"react-qr-code": "^2.2.0",
|
||||||
"tailwind-merge": "^3.7.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
"zbsearch": "4.0.1",
|
"zbsearch": "4.0.1",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/mdx": "^2.0.14",
|
"@types/mdx": "^2.0.14",
|
||||||
"@types/node": "^26.6.4",
|
"@types/node": "^26.6.5",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"oxfmt": "0.72.0",
|
"oxfmt": "0.74.0",
|
||||||
"oxlint": "1.87.0",
|
"oxlint": "1.89.0",
|
||||||
"postcss": "^8.5.29",
|
"postcss": "^8.5.31",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "^5.0.3"
|
"vitest": "^5.0.5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@12.9.1"
|
"packageManager": "pnpm@12.15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1160
-1476
File diff suppressed because it is too large
Load Diff
+18
-42
@@ -7,46 +7,22 @@ allowBuilds:
|
|||||||
overrides:
|
overrides:
|
||||||
'postcss@<8.5.10': '^8.5.15'
|
'postcss@<8.5.10': '^8.5.15'
|
||||||
'sharp@<0.35.0': '^0.35.3'
|
'sharp@<0.35.0': '^0.35.3'
|
||||||
|
# @tailwindcss/node 4.3.3 pins 1.32.0, which rejects fumadocs-ui's ::highlight(fd-search).
|
||||||
|
'lightningcss@<1.33.0': '^1.33.0'
|
||||||
|
minimumReleaseAgeExcludePrune: true
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- fumadocs-core@16.16.2
|
- fumadocs-core@16.17.8
|
||||||
- fumadocs-ui@16.16.2
|
- fumadocs-ui@16.17.8
|
||||||
- '@oxfmt/binding-android-arm-eabi@0.72.0'
|
- '@fumadocs/json-schema@0.3.5'
|
||||||
- '@oxfmt/binding-android-arm64@0.72.0'
|
- '@next/env@16.4.3'
|
||||||
- '@oxfmt/binding-darwin-arm64@0.72.0'
|
- '@next/swc-darwin-arm64@16.4.3'
|
||||||
- '@oxfmt/binding-darwin-x64@0.72.0'
|
- '@next/swc-darwin-x64@16.4.3'
|
||||||
- '@oxfmt/binding-freebsd-x64@0.72.0'
|
- '@next/swc-linux-arm64-gnu@16.4.3'
|
||||||
- '@oxfmt/binding-linux-arm-gnueabihf@0.72.0'
|
- '@next/swc-linux-arm64-musl@16.4.3'
|
||||||
- '@oxfmt/binding-linux-arm-musleabihf@0.72.0'
|
- '@next/swc-linux-x64-gnu@16.4.3'
|
||||||
- '@oxfmt/binding-linux-arm64-gnu@0.72.0'
|
- '@next/swc-linux-x64-musl@16.4.3'
|
||||||
- '@oxfmt/binding-linux-arm64-musl@0.72.0'
|
- '@next/swc-win32-arm64-msvc@16.4.3'
|
||||||
- '@oxfmt/binding-linux-ppc64-gnu@0.72.0'
|
- '@next/swc-win32-x64-msvc@16.4.3'
|
||||||
- '@oxfmt/binding-linux-riscv64-gnu@0.72.0'
|
- fumadocs-mdx@15.4.10
|
||||||
- '@oxfmt/binding-linux-riscv64-musl@0.72.0'
|
- fumadocs-openapi@12.5.5
|
||||||
- '@oxfmt/binding-linux-s390x-gnu@0.72.0'
|
- next@16.4.3
|
||||||
- '@oxfmt/binding-linux-x64-gnu@0.72.0'
|
|
||||||
- '@oxfmt/binding-linux-x64-musl@0.72.0'
|
|
||||||
- '@oxfmt/binding-openharmony-arm64@0.72.0'
|
|
||||||
- '@oxfmt/binding-win32-arm64-msvc@0.72.0'
|
|
||||||
- '@oxfmt/binding-win32-ia32-msvc@0.72.0'
|
|
||||||
- '@oxfmt/binding-win32-x64-msvc@0.72.0'
|
|
||||||
- '@oxlint/binding-android-arm-eabi@1.87.0'
|
|
||||||
- '@oxlint/binding-android-arm64@1.87.0'
|
|
||||||
- '@oxlint/binding-darwin-arm64@1.87.0'
|
|
||||||
- '@oxlint/binding-darwin-x64@1.87.0'
|
|
||||||
- '@oxlint/binding-freebsd-x64@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-arm-gnueabihf@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-arm-musleabihf@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-arm64-gnu@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-arm64-musl@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-ppc64-gnu@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-riscv64-gnu@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-riscv64-musl@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-s390x-gnu@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-x64-gnu@1.87.0'
|
|
||||||
- '@oxlint/binding-linux-x64-musl@1.87.0'
|
|
||||||
- '@oxlint/binding-openharmony-arm64@1.87.0'
|
|
||||||
- '@oxlint/binding-win32-arm64-msvc@1.87.0'
|
|
||||||
- '@oxlint/binding-win32-ia32-msvc@1.87.0'
|
|
||||||
- '@oxlint/binding-win32-x64-msvc@1.87.0'
|
|
||||||
- oxfmt@0.72.0
|
|
||||||
- oxlint@1.87.0
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
+516
-13
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "3X-UI Panel API",
|
"title": "3X-UI Panel API",
|
||||||
"version": "3.x",
|
"version": "3.x",
|
||||||
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password."
|
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"bearerAuth": {
|
"bearerAuth": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"scheme": "bearer",
|
"scheme": "bearer",
|
||||||
"description": "API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
|
"description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
|
||||||
},
|
},
|
||||||
"cookieAuth": {
|
"cookieAuth": {
|
||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
@@ -152,6 +152,11 @@
|
|||||||
"ldapVlessField": {
|
"ldapVlessField": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"nodeDownThreshold": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"outboundDownThreshold": {
|
"outboundDownThreshold": {
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -242,6 +247,9 @@
|
|||||||
"subClashPath": {
|
"subClashPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subClashRemoteDNS": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subClashRules": {
|
"subClashRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -342,6 +350,10 @@
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subHostSortMode": {
|
||||||
|
"example": "inbound-first",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyAnnounceUrl": {
|
"subIncyAnnounceUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -636,6 +648,7 @@
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"nodeDownThreshold",
|
||||||
"outboundDownThreshold",
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
@@ -662,6 +675,7 @@
|
|||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
"subClashEnableRouting",
|
"subClashEnableRouting",
|
||||||
"subClashPath",
|
"subClashPath",
|
||||||
|
"subClashRemoteDNS",
|
||||||
"subClashRules",
|
"subClashRules",
|
||||||
"subClashURI",
|
"subClashURI",
|
||||||
"subClashUserAgentRegex",
|
"subClashUserAgentRegex",
|
||||||
@@ -695,6 +709,7 @@
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subHostSortMode",
|
||||||
"subIncyAnnounceUrl",
|
"subIncyAnnounceUrl",
|
||||||
"subIncyAppAutoDetect",
|
"subIncyAppAutoDetect",
|
||||||
"subIncyBannerBgColor",
|
"subIncyBannerBgColor",
|
||||||
@@ -928,6 +943,11 @@
|
|||||||
"ldapVlessField": {
|
"ldapVlessField": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"nodeDownThreshold": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"outboundDownThreshold": {
|
"outboundDownThreshold": {
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -1018,6 +1038,9 @@
|
|||||||
"subClashPath": {
|
"subClashPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subClashRemoteDNS": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subClashRules": {
|
"subClashRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1118,6 +1141,10 @@
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subHostSortMode": {
|
||||||
|
"example": "inbound-first",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyAnnounceUrl": {
|
"subIncyAnnounceUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1420,6 +1447,7 @@
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"nodeDownThreshold",
|
||||||
"outboundDownThreshold",
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
@@ -1446,6 +1474,7 @@
|
|||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
"subClashEnableRouting",
|
"subClashEnableRouting",
|
||||||
"subClashPath",
|
"subClashPath",
|
||||||
|
"subClashRemoteDNS",
|
||||||
"subClashRules",
|
"subClashRules",
|
||||||
"subClashURI",
|
"subClashURI",
|
||||||
"subClashUserAgentRegex",
|
"subClashUserAgentRegex",
|
||||||
@@ -1479,6 +1508,7 @@
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subHostSortMode",
|
||||||
"subIncyAnnounceUrl",
|
"subIncyAnnounceUrl",
|
||||||
"subIncyAppAutoDetect",
|
"subIncyAppAutoDetect",
|
||||||
"subIncyBannerBgColor",
|
"subIncyBannerBgColor",
|
||||||
@@ -1673,6 +1703,23 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Blocker": {
|
||||||
|
"description": "Blocker is one unmet requirement.",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"example": "tools",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"example": "awg",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"code"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Client": {
|
"Client": {
|
||||||
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1700,6 +1747,17 @@
|
|||||||
"description": "Auth password (Hysteria)",
|
"description": "Auth password (Hysteria)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"clientHostRuleIds": {
|
||||||
|
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"description": "Client comment",
|
"description": "Client comment",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1739,9 +1797,15 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
|
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
|
||||||
"nullable": true,
|
"oneOf": [
|
||||||
"type": "integer"
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"description": "IP limit for this client",
|
"description": "IP limit for this client",
|
||||||
@@ -1931,6 +1995,16 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"clientHostRuleIds": {
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1961,7 +2035,15 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"type": "integer"
|
"description": "Numeric values or AmneziaWG ranges.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitHwid": {
|
"limitHwid": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2738,6 +2820,10 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"randomCount": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"remark": {
|
"remark": {
|
||||||
"example": "cdn-front",
|
"example": "cdn-front",
|
||||||
"maxLength": 256,
|
"maxLength": 256,
|
||||||
@@ -2763,6 +2849,15 @@
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {},
|
"sockoptParams": {},
|
||||||
"sortOrder": {
|
"sortOrder": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2810,11 +2905,13 @@
|
|||||||
"path",
|
"path",
|
||||||
"pinnedPeerCertSha256",
|
"pinnedPeerCertSha256",
|
||||||
"port",
|
"port",
|
||||||
|
"randomCount",
|
||||||
"remark",
|
"remark",
|
||||||
"security",
|
"security",
|
||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -2919,6 +3016,11 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"randomCount": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"remark": {
|
"remark": {
|
||||||
"maxLength": 256,
|
"maxLength": 256,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2942,6 +3044,15 @@
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {
|
"sockoptParams": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2984,11 +3095,14 @@
|
|||||||
"path",
|
"path",
|
||||||
"pinnedPeerCertSha256",
|
"pinnedPeerCertSha256",
|
||||||
"port",
|
"port",
|
||||||
|
"randomCount",
|
||||||
"remark",
|
"remark",
|
||||||
"security",
|
"security",
|
||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
|
"snis",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -3664,6 +3778,13 @@
|
|||||||
"transitive": {
|
"transitive": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"unavailableMetrics": {
|
||||||
|
"description": "Nil on legacy rows and responses: zero remains a valid measured value.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"example": 1700000000,
|
"example": 1700000000,
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -3972,6 +4093,12 @@
|
|||||||
"example": false,
|
"example": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"unavailableMetrics": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"example": 1700003600,
|
"example": 1700003600,
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -4184,6 +4311,12 @@
|
|||||||
"example": "online",
|
"example": "online",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"unavailableMetrics": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"uptimeSecs": {
|
"uptimeSecs": {
|
||||||
"example": 86400,
|
"example": 86400,
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -4215,6 +4348,37 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Readiness": {
|
||||||
|
"description": "Readiness is the host's ability to run any kernel-mode inbound at all.",
|
||||||
|
"properties": {
|
||||||
|
"blockers": {
|
||||||
|
"example": [
|
||||||
|
{
|
||||||
|
"code": "tools",
|
||||||
|
"detail": "awg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Blocker"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"installCommand": {
|
||||||
|
"example": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ready": {
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"blockers",
|
||||||
|
"installCommand",
|
||||||
|
"ready"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"RealityScanResult": {
|
"RealityScanResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"alpn": {
|
"alpn": {
|
||||||
@@ -4397,6 +4561,10 @@
|
|||||||
"keepaliveTimeout": {
|
"keepaliveTimeout": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"kernelMode": {
|
||||||
|
"description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"maxHandshakeAttempts": {
|
"maxHandshakeAttempts": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4651,6 +4819,35 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TgBotCapabilities": {
|
||||||
|
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
|
||||||
|
"properties": {
|
||||||
|
"groupPrivacy": {
|
||||||
|
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"inlineEnabled": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"running": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"example": "xui_bot",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"groupPrivacy",
|
||||||
|
"inlineEnabled",
|
||||||
|
"running",
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Traffic": {
|
"Traffic": {
|
||||||
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
|
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4855,7 +5052,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "API Tokens",
|
"name": "API Tokens",
|
||||||
"description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
|
"description": "Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Xray Settings",
|
"name": "Xray Settings",
|
||||||
@@ -5319,6 +5516,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/inbounds/amneziawgKernelReadiness": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Inbounds"
|
||||||
|
],
|
||||||
|
"summary": "Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.",
|
||||||
|
"operationId": "get_panel_api_inbounds_amneziawgKernelReadiness",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/Readiness"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"code": "tools",
|
||||||
|
"detail": "awg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
|
||||||
|
"ready": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/inbounds/allLinks": {
|
"/panel/api/inbounds/allLinks": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7184,7 +7426,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Server"
|
"Server"
|
||||||
],
|
],
|
||||||
"summary": "Self-update the panel to the latest version. The server restarts on success.",
|
"summary": "Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.",
|
||||||
"operationId": "post_panel_api_server_updatePanel",
|
"operationId": "post_panel_api_server_updatePanel",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": false,
|
"required": false,
|
||||||
@@ -8481,6 +8723,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/clients/get/subId/{subId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Clients"
|
||||||
|
],
|
||||||
|
"summary": "Fetch clients by subscription ID.",
|
||||||
|
"operationId": "get_panel_api_clients_get_subId_subId",
|
||||||
|
"description": "Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "subId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Exact subscription ID (URL-encoded).",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"type": "array",
|
||||||
|
"nullable": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"client": {
|
||||||
|
"$ref": "#/components/schemas/ClientRecord"
|
||||||
|
},
|
||||||
|
"inboundIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"externalLinks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usedTraffic": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"tunnelAllowedIPs": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/get/tgId/{tgId}": {
|
"/panel/api/clients/get/tgId/{tgId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -10983,6 +11300,9 @@
|
|||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
@@ -11184,6 +11504,9 @@
|
|||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
@@ -11334,6 +11657,9 @@
|
|||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
@@ -11557,6 +11883,9 @@
|
|||||||
"memPct": 45.2,
|
"memPct": 45.2,
|
||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"status": "online",
|
"status": "online",
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
"xrayState": "",
|
"xrayState": "",
|
||||||
@@ -11909,11 +12238,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 0,
|
"port": 0,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -11930,6 +12264,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/hosts/raw": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Hosts"
|
||||||
|
],
|
||||||
|
"summary": "List every host row with its numeric id, for id-based pickers such as the per-client host rule override.",
|
||||||
|
"operationId": "get_panel_api_hosts_raw",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Host"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": [
|
||||||
|
{
|
||||||
|
"address": "cdn.example.com",
|
||||||
|
"allowInsecure": false,
|
||||||
|
"alpn": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
|
"createdAt": 0,
|
||||||
|
"echConfigList": "",
|
||||||
|
"excludeFromSubTypes": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"finalMask": "",
|
||||||
|
"fingerprint": "",
|
||||||
|
"groupId": "",
|
||||||
|
"hostHeader": "",
|
||||||
|
"id": 1,
|
||||||
|
"inboundId": 1,
|
||||||
|
"isDisabled": false,
|
||||||
|
"isHidden": false,
|
||||||
|
"keepSniBlank": false,
|
||||||
|
"mihomoIpVersion": "dual",
|
||||||
|
"mihomoX25519": false,
|
||||||
|
"muxParams": null,
|
||||||
|
"nodeGuids": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"overrideSniFromAddress": false,
|
||||||
|
"path": "",
|
||||||
|
"pinnedPeerCertSha256": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"port": 8443,
|
||||||
|
"randomCount": 1,
|
||||||
|
"remark": "cdn-front",
|
||||||
|
"security": "same",
|
||||||
|
"serverDescription": "",
|
||||||
|
"shuffleHost": false,
|
||||||
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"sockoptParams": null,
|
||||||
|
"sortOrder": 0,
|
||||||
|
"tags": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"updatedAt": 0,
|
||||||
|
"verifyPeerCertByName": "",
|
||||||
|
"vlessRoute": "443"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/hosts/get/{groupId}": {
|
"/panel/api/hosts/get/{groupId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12004,11 +12430,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 0,
|
"port": 0,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12102,11 +12533,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 0,
|
"port": 0,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12250,11 +12686,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 8443,
|
"port": 8443,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "cdn-front",
|
"remark": "cdn-front",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12372,11 +12813,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 8443,
|
"port": 8443,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "cdn-front",
|
"remark": "cdn-front",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12494,7 +12940,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Hosts"
|
"Hosts"
|
||||||
],
|
],
|
||||||
"summary": "Set host group sort order by the position of each groupId in the array.",
|
"summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
|
||||||
"operationId": "post_panel_api_hosts_reorder",
|
"operationId": "post_panel_api_hosts_reorder",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -12624,11 +13070,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 8443,
|
"port": 8443,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "cdn-front",
|
"remark": "cdn-front",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -13115,6 +13566,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/setting/tgBotCapabilities": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
],
|
||||||
|
"summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.",
|
||||||
|
"operationId": "post_panel_api_setting_tgBotCapabilities",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/TgBotCapabilities"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"groupPrivacy": false,
|
||||||
|
"inlineEnabled": true,
|
||||||
|
"running": true,
|
||||||
|
"username": "xui_bot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/setting/testDiscord": {
|
"/panel/api/setting/testDiscord": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -13208,9 +13700,11 @@
|
|||||||
"obj": [
|
"obj": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "default",
|
"name": "central-panel-a",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"createdAt": 1736000000
|
"createdAt": 1736000000,
|
||||||
|
"scope": "node-admin",
|
||||||
|
"expiresAt": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13240,7 +13734,13 @@
|
|||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "admin (default), monitor, or node-sync."
|
"enum": [
|
||||||
|
"admin",
|
||||||
|
"monitor",
|
||||||
|
"node-sync",
|
||||||
|
"node-admin"
|
||||||
|
],
|
||||||
|
"description": "admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update."
|
||||||
},
|
},
|
||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -13253,7 +13753,7 @@
|
|||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
"name": "central-panel-a",
|
"name": "central-panel-a",
|
||||||
"scope": "node-sync",
|
"scope": "node-admin",
|
||||||
"expiresAt": 1798761600000
|
"expiresAt": 1798761600000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16387,6 +16887,9 @@
|
|||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ fields) is pushed to child nodes verbatim, so the node's Xray records the real I
|
|||||||
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
|
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
|
||||||
shows up on the parent automatically.
|
shows up on the parent automatically.
|
||||||
|
|
||||||
|
Reports retain each address's newest timestamp within the 30-minute retention window.
|
||||||
|
Flat and per-node IP blobs use a stable timestamp-ascending order, with IP text breaking
|
||||||
|
ties, so equivalent reports preserve the same bytes and skip redundant writes. Flat-row
|
||||||
|
merges use compare-and-set retries to preserve concurrent observations; advancing a
|
||||||
|
timestamp remains a real change. GUID attribution stays separate from the flat union.
|
||||||
|
|
||||||
## Security note
|
## Security note
|
||||||
|
|
||||||
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
|
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
|
||||||
|
|||||||
+25
-2
@@ -9,7 +9,9 @@
|
|||||||
"jsx-a11y"
|
"jsx-a11y"
|
||||||
],
|
],
|
||||||
"jsPlugins": [
|
"jsPlugins": [
|
||||||
"./tools/oxlint/input-number-guard.mjs"
|
"./tools/oxlint/input-number-guard.mjs",
|
||||||
|
"./tools/oxlint/control-label-guard.mjs",
|
||||||
|
"./tools/oxlint/icon-a11y-guard.mjs"
|
||||||
],
|
],
|
||||||
"categories": {
|
"categories": {
|
||||||
"correctness": "error"
|
"correctness": "error"
|
||||||
@@ -48,7 +50,9 @@
|
|||||||
"react-hooks/exhaustive-deps": "error",
|
"react-hooks/exhaustive-deps": "error",
|
||||||
"jsx-a11y/no-autofocus": "off",
|
"jsx-a11y/no-autofocus": "off",
|
||||||
"input-number/no-synthetic-clear": "off",
|
"input-number/no-synthetic-clear": "off",
|
||||||
"jsx-a11y/prefer-tag-over-role": "off"
|
"jsx-a11y/prefer-tag-over-role": "off",
|
||||||
|
"control-label/has-name": "off",
|
||||||
|
"antd-icon/explicit-a11y": "off"
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
@@ -67,6 +71,25 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"input-number/no-synthetic-clear": "off"
|
"input-number/no-synthetic-clear": "off"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"src/**/*.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"control-label/has-name": "error",
|
||||||
|
"antd-icon/explicit-a11y": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"src/**/*.stories.tsx",
|
||||||
|
"src/test/**"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"control-label/has-name": "off",
|
||||||
|
"antd-icon/explicit-a11y": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const config: StorybookConfig = {
|
|||||||
if (viteConfig.build) {
|
if (viteConfig.build) {
|
||||||
viteConfig.build.outDir = undefined;
|
viteConfig.build.outDir = undefined;
|
||||||
viteConfig.build.emptyOutDir = false;
|
viteConfig.build.emptyOutDir = false;
|
||||||
|
// Storybook bundles the whole UI plus the axe engine; hold it to the app's chunk ceiling.
|
||||||
|
viteConfig.build.chunkSizeWarningLimit = 1500;
|
||||||
if (viteConfig.build.rollupOptions) {
|
if (viteConfig.build.rollupOptions) {
|
||||||
viteConfig.build.rollupOptions.input = undefined;
|
viteConfig.build.rollupOptions.input = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-6
@@ -33,11 +33,12 @@ The `@` import alias maps to `src/`.
|
|||||||
- Function components + hooks only; no class components.
|
- Function components + hooks only; no class components.
|
||||||
- Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the
|
- Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the
|
||||||
*why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine.
|
*why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine.
|
||||||
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with `useZodForm` +
|
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with React Hook
|
||||||
`FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
|
Form's `useForm` + `FormField` from `@/components/form/rhf` (wrap the tree in
|
||||||
validate through the `zodResolver` or per-field
|
`FormProvider`) and validate each field with
|
||||||
`rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
|
`rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
|
||||||
issue keys resolved via `t()`, never inline `z.string()`. AntD `<Form>` stays
|
issue keys resolved via `t()`, never inline `z.string()`. A whole-form
|
||||||
|
`zodResolver` is only for a form that submits one schema. AntD `<Form>` stays
|
||||||
only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
|
only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
|
||||||
Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
|
Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
|
||||||
`src/lib/xray/forms/fields/`, bound via a `Controller`.
|
`src/lib/xray/forms/fields/`, bound via a `Controller`.
|
||||||
@@ -54,8 +55,10 @@ The `@` import alias maps to `src/`.
|
|||||||
|
|
||||||
## Adding a panel route
|
## Adding a panel route
|
||||||
1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
|
1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
|
||||||
2. Register in `src/routes.tsx` under `/panel` (lazy import).
|
2. Add it to `src/routes/panelRoutes.tsx`. The router, the sidebar, the page
|
||||||
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it needs nav.
|
title and the command palette all read that one list;
|
||||||
|
`src/test/navigation-contract.test.tsx` holds it to the router and to the
|
||||||
|
translation bundle.
|
||||||
Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
|
Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
|
||||||
`rollupOptions.input` (in `vite.config.js`) + a Go controller route.
|
`rollupOptions.input` (in `vite.config.js`) + a Go controller route.
|
||||||
|
|
||||||
|
|||||||
+23
-20
@@ -30,7 +30,7 @@ production-style links work without round-tripping through Go.
|
|||||||
| Command | What |
|
| Command | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `npm run dev` | Vite dev server with API + WS proxy to Go |
|
| `npm run dev` | Vite dev server with API + WS proxy to Go |
|
||||||
| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../internal/web/dist/` |
|
| `npm run build` | Regenerates the OpenAPI spec, then builds into `../internal/web/dist/` |
|
||||||
| `npm run preview` | Serve the built bundle locally |
|
| `npm run preview` | Serve the built bundle locally |
|
||||||
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
|
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
|
||||||
| `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) |
|
| `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) |
|
||||||
@@ -42,7 +42,7 @@ production-style links work without round-tripping through Go.
|
|||||||
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
|
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
|
||||||
| `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
|
| `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
|
||||||
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
|
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
|
||||||
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
|
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types,schemas,examples}.ts` |
|
||||||
|
|
||||||
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
|
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
|
||||||
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
|
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
|
||||||
@@ -156,9 +156,10 @@ Patterns:
|
|||||||
security.
|
security.
|
||||||
- **Three validation layers**, non-overlapping:
|
- **Three validation layers**, non-overlapping:
|
||||||
- API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
|
- API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
|
||||||
Query `queryFn` — warn-only in prod, throws in dev
|
Query `queryFn` — warns on a mismatch and returns the payload; pass
|
||||||
- Form input: `antdRule(schema.shape.field)` on every `<Form.Item>` —
|
`{ strict: true }` to throw instead
|
||||||
blocks submit + per-field inline error
|
- Form input: `rules={{ validate: rhfZodValidate(schema.shape.field) }}` on
|
||||||
|
every `FormField` — blocks submit + per-field inline error
|
||||||
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
|
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
|
||||||
because a malformed payload here is always a developer bug
|
because a malformed payload here is always a developer bug
|
||||||
- **No `.loose()` or `[key: string]: any`** in production schemas.
|
- **No `.loose()` or `[key: string]: any`** in production schemas.
|
||||||
@@ -166,27 +167,29 @@ Patterns:
|
|||||||
|
|
||||||
## Form pattern (Pattern A)
|
## Form pattern (Pattern A)
|
||||||
|
|
||||||
All non-trivial modals use this single pattern:
|
All non-trivial modals use this single pattern — React Hook Form for the
|
||||||
|
state, AntD's `<Form>` only as the layout:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [form] = Form.useForm<InboundFormValues>();
|
const form = useForm<InboundFormValues>({ defaultValues: initialValues });
|
||||||
|
|
||||||
const onFinish = async () => {
|
const onSubmit = (values: InboundFormValues) => createInbound.mutateAsync(values);
|
||||||
const values = await form.validateFields();
|
|
||||||
await createInbound.mutateAsync(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
<Form form={form} onFinish={onFinish}>
|
<FormProvider {...form}>
|
||||||
<Form.Item
|
<Form layout="vertical" onFinish={form.handleSubmit(onSubmit)}>
|
||||||
name="port"
|
<FormField
|
||||||
label="Port"
|
name="port"
|
||||||
rules={[antdRule(InboundFormSchema.shape.port, t)]}
|
label={t('pages.inbounds.port')}
|
||||||
>
|
rules={{ validate: rhfZodValidate(InboundFormSchema.shape.port) }}
|
||||||
<InputNumber min={1} max={65535} />
|
>
|
||||||
</Form.Item>
|
<InputNumber min={1} max={65535} />
|
||||||
</Form>
|
</FormField>
|
||||||
|
</Form>
|
||||||
|
</FormProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`FormField` (from `@/components/form/rhf`) bridges a React Hook Form
|
||||||
|
`Controller` to an AntD input and renders the field's error as help text.
|
||||||
No `safeParse`-on-submit handlers, no `useRef<any>` for form
|
No `safeParse`-on-submit handlers, no `useRef<any>` for form
|
||||||
references, no inline `z.string().min(1)` in rules. Conditional
|
references, no inline `z.string().min(1)` in rules. Conditional
|
||||||
fields use `<Form.Item dependencies={...} shouldUpdate>` with the
|
fields use `<Form.Item dependencies={...} shouldUpdate>` with the
|
||||||
|
|||||||
Generated
+885
-911
File diff suppressed because it is too large
Load Diff
+30
-34
@@ -24,12 +24,13 @@
|
|||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"gen": "npm run gen:zod && npm run gen:api",
|
"gen": "npm run gen:zod && npm run gen:api",
|
||||||
"gen:api": "node scripts/build-openapi.mjs",
|
"gen:api": "node scripts/build-openapi.mjs",
|
||||||
|
"audit": "npm audit --omit=dev --audit-level=high",
|
||||||
"gen:zod": "cd .. && go run ./tools/openapigen",
|
"gen:zod": "cd .. && go run ./tools/openapigen",
|
||||||
"prepare": "cd .. && husky frontend/.husky || true"
|
"prepare": "cd .. && husky frontend/.husky || true"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"src/**/*.{ts,tsx}": [
|
"src/**/*.{ts,tsx}": [
|
||||||
"oxfmt",
|
"oxfmt --no-error-on-unmatched-pattern",
|
||||||
"oxlint --fix"
|
"oxlint --fix"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -37,53 +38,53 @@
|
|||||||
"@ant-design/icons": "^6.3.4",
|
"@ant-design/icons": "^6.3.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@hookform/resolvers": "^5.9.1",
|
"@hookform/resolvers": "^6.0.0",
|
||||||
"@noble/hashes": "^2.4.0",
|
"@noble/hashes": "^2.4.0",
|
||||||
"@tanstack/react-query": "^5.104.2",
|
"@tanstack/react-query": "^5.104.5",
|
||||||
"@tanstack/react-query-devtools": "^5.104.2",
|
"@tanstack/react-query-devtools": "^5.104.5",
|
||||||
"antd": "^6.6.5",
|
"antd": "^6.6.7",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.23",
|
"dayjs": "^1.11.23",
|
||||||
"i18next": "^26.4.3",
|
"i18next": "^26.5.6",
|
||||||
"otpauth": "^9.5.2",
|
"otpauth": "^9.5.2",
|
||||||
"persian-calendar-suite": "^1.5.6",
|
"persian-calendar-suite": "^1.5.6",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.1",
|
||||||
"react-hook-form": "^7.90.0",
|
"react-hook-form": "^7.90.0",
|
||||||
"react-i18next": "^17.0.16",
|
"react-i18next": "^17.1.3",
|
||||||
"react-router": "^8.4.0",
|
"react-router": "^8.5.0",
|
||||||
"swagger-ui-react": "^5.33.1",
|
"swagger-ui-react": "^5.34.0",
|
||||||
"uplot": "^1.6.32",
|
"uplot": "^1.6.32",
|
||||||
"zod": "^4.6.6"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-a11y": "^10.6.1",
|
"@storybook/addon-a11y": "^10.6.3",
|
||||||
"@storybook/addon-docs": "^10.6.1",
|
"@storybook/addon-docs": "^10.6.3",
|
||||||
"@storybook/addon-vitest": "^10.6.1",
|
"@storybook/addon-vitest": "^10.6.3",
|
||||||
"@storybook/react-vite": "^10.6.1",
|
"@storybook/react-vite": "^10.6.3",
|
||||||
"@testing-library/dom": "^10.4.2",
|
"@testing-library/dom": "^10.4.2",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
"@vitest/browser-playwright": "5.0.3",
|
"@vitest/browser-playwright": "5.0.5",
|
||||||
"@vitest/coverage-v8": "^5.0.3",
|
"@vitest/coverage-v8": "^5.0.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^30.1.2",
|
"jsdom": "^30.1.3",
|
||||||
"lint-staged": "^17.6.0",
|
"lint-staged": "^17.6.0",
|
||||||
"msw": "^3.0.3",
|
"msw": "^3.0.3",
|
||||||
"oxfmt": "0.72.0",
|
"oxfmt": "0.74.0",
|
||||||
"oxlint": "1.87.0",
|
"oxlint": "1.89.0",
|
||||||
"oxlint-tsgolint": "^7.0.2004",
|
"oxlint-tsgolint": "^7.0.2004",
|
||||||
"playwright": "^1.64.0",
|
"playwright": "^1.64.1",
|
||||||
"storybook": "^10.6.1",
|
"storybook": "^10.6.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.3.4",
|
"vite": "8.3.5",
|
||||||
"vitest": "^5.0.3"
|
"vitest": "^5.0.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"dompurify": "^3.4.15",
|
"dompurify": "^3.4.17",
|
||||||
"react-copy-to-clipboard": "^5.1.1",
|
"react-copy-to-clipboard": "^5.1.1",
|
||||||
"react-inspector": "^9.0.0",
|
"react-inspector": "^9.0.0",
|
||||||
"seroval": "^1.6.8",
|
"seroval": "^1.6.8",
|
||||||
@@ -99,9 +100,9 @@
|
|||||||
"valibot": "^1.1.0"
|
"valibot": "^1.1.0"
|
||||||
},
|
},
|
||||||
"@storybook/addon-vitest": {
|
"@storybook/addon-vitest": {
|
||||||
"vitest": "^5.0.0",
|
"vitest": "5.0.5",
|
||||||
"@vitest/browser-playwright": "^5.0.0",
|
"@vitest/browser-playwright": "5.0.5",
|
||||||
"@vitest/browser": "^5.0.0"
|
"@vitest/browser": "5.0.5"
|
||||||
},
|
},
|
||||||
"remarkable": {
|
"remarkable": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -115,10 +116,5 @@
|
|||||||
"tree-sitter-json": false,
|
"tree-sitter-json": false,
|
||||||
"msw": false,
|
"msw": false,
|
||||||
"esbuild": false
|
"esbuild": false
|
||||||
},
|
|
||||||
"msw": {
|
|
||||||
"workerDirectory": [
|
|
||||||
"public"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/* tslint:disable */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock Service Worker.
|
|
||||||
* @see https://github.com/mswjs/msw
|
|
||||||
* - Please do NOT modify this file.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PACKAGE_VERSION = '2.15.0'
|
|
||||||
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
|
|
||||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
|
||||||
const activeClientIds = new Set()
|
|
||||||
|
|
||||||
addEventListener('install', function () {
|
|
||||||
self.skipWaiting()
|
|
||||||
})
|
|
||||||
|
|
||||||
addEventListener('activate', function (event) {
|
|
||||||
event.waitUntil(self.clients.claim())
|
|
||||||
})
|
|
||||||
|
|
||||||
addEventListener('message', async function (event) {
|
|
||||||
const clientId = Reflect.get(event.source || {}, 'id')
|
|
||||||
|
|
||||||
if (!clientId || !self.clients) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await self.clients.get(clientId)
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const allClients = await self.clients.matchAll({
|
|
||||||
type: 'window',
|
|
||||||
})
|
|
||||||
|
|
||||||
switch (event.data) {
|
|
||||||
case 'KEEPALIVE_REQUEST': {
|
|
||||||
sendToClient(client, {
|
|
||||||
type: 'KEEPALIVE_RESPONSE',
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'INTEGRITY_CHECK_REQUEST': {
|
|
||||||
sendToClient(client, {
|
|
||||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
|
||||||
payload: {
|
|
||||||
packageVersion: PACKAGE_VERSION,
|
|
||||||
checksum: INTEGRITY_CHECKSUM,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'MOCK_ACTIVATE': {
|
|
||||||
activeClientIds.add(clientId)
|
|
||||||
|
|
||||||
sendToClient(client, {
|
|
||||||
type: 'MOCKING_ENABLED',
|
|
||||||
payload: {
|
|
||||||
client: {
|
|
||||||
id: client.id,
|
|
||||||
frameType: client.frameType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'CLIENT_CLOSED': {
|
|
||||||
activeClientIds.delete(clientId)
|
|
||||||
|
|
||||||
const remainingClients = allClients.filter((client) => {
|
|
||||||
return client.id !== clientId
|
|
||||||
})
|
|
||||||
|
|
||||||
// Unregister itself when there are no more clients
|
|
||||||
if (remainingClients.length === 0) {
|
|
||||||
self.registration.unregister()
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
addEventListener('fetch', function (event) {
|
|
||||||
const requestInterceptedAt = Date.now()
|
|
||||||
|
|
||||||
// Bypass navigation requests.
|
|
||||||
if (event.request.mode === 'navigate') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opening the DevTools triggers the "only-if-cached" request
|
|
||||||
// that cannot be handled by the worker. Bypass such requests.
|
|
||||||
if (
|
|
||||||
event.request.cache === 'only-if-cached' &&
|
|
||||||
event.request.mode !== 'same-origin'
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bypass all requests when there are no active clients.
|
|
||||||
// Prevents the self-unregistered worked from handling requests
|
|
||||||
// after it's been terminated (still remains active until the next reload).
|
|
||||||
if (activeClientIds.size === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = crypto.randomUUID()
|
|
||||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {FetchEvent} event
|
|
||||||
* @param {string} requestId
|
|
||||||
* @param {number} requestInterceptedAt
|
|
||||||
*/
|
|
||||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
|
||||||
const client = await resolveMainClient(event)
|
|
||||||
const requestCloneForEvents = event.request.clone()
|
|
||||||
const response = await getResponse(
|
|
||||||
event,
|
|
||||||
client,
|
|
||||||
requestId,
|
|
||||||
requestInterceptedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send back the response clone for the "response:*" life-cycle events.
|
|
||||||
// Ensure MSW is active and ready to handle the message, otherwise
|
|
||||||
// this message will pend indefinitely.
|
|
||||||
if (client && activeClientIds.has(client.id)) {
|
|
||||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
|
||||||
|
|
||||||
// Omit the body of server-sent event stream responses.
|
|
||||||
// Cloning such responses would prevent client-side stream cancelations
|
|
||||||
// from reaching the original stream (a teed stream only cancels its
|
|
||||||
// source once both of its branches cancel) and would buffer the
|
|
||||||
// entire stream into the unconsumed clone indefinitely.
|
|
||||||
const isEventStreamResponse = response.headers
|
|
||||||
.get('content-type')
|
|
||||||
?.toLowerCase()
|
|
||||||
.startsWith('text/event-stream')
|
|
||||||
|
|
||||||
// Clone the response so both the client and the library could consume it.
|
|
||||||
const responseClone = isEventStreamResponse ? null : response.clone()
|
|
||||||
|
|
||||||
sendToClient(
|
|
||||||
client,
|
|
||||||
{
|
|
||||||
type: 'RESPONSE',
|
|
||||||
payload: {
|
|
||||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
|
||||||
request: {
|
|
||||||
id: requestId,
|
|
||||||
...serializedRequest,
|
|
||||||
},
|
|
||||||
response: {
|
|
||||||
type: response.type,
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: Object.fromEntries(response.headers.entries()),
|
|
||||||
body: responseClone ? responseClone.body : null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responseClone && responseClone.body
|
|
||||||
? [serializedRequest.body, responseClone.body]
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the main client for the given event.
|
|
||||||
* Client that issues a request doesn't necessarily equal the client
|
|
||||||
* that registered the worker. It's with the latter the worker should
|
|
||||||
* communicate with during the response resolving phase.
|
|
||||||
* @param {FetchEvent} event
|
|
||||||
* @returns {Promise<Client | undefined>}
|
|
||||||
*/
|
|
||||||
async function resolveMainClient(event) {
|
|
||||||
const client = await self.clients.get(event.clientId)
|
|
||||||
|
|
||||||
if (activeClientIds.has(event.clientId)) {
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client?.frameType === 'top-level') {
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
const allClients = await self.clients.matchAll({
|
|
||||||
type: 'window',
|
|
||||||
})
|
|
||||||
|
|
||||||
return allClients
|
|
||||||
.filter((client) => {
|
|
||||||
// Get only those clients that are currently visible.
|
|
||||||
return client.visibilityState === 'visible'
|
|
||||||
})
|
|
||||||
.find((client) => {
|
|
||||||
// Find the client ID that's recorded in the
|
|
||||||
// set of clients that have registered the worker.
|
|
||||||
return activeClientIds.has(client.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {FetchEvent} event
|
|
||||||
* @param {Client | undefined} client
|
|
||||||
* @param {string} requestId
|
|
||||||
* @param {number} requestInterceptedAt
|
|
||||||
* @returns {Promise<Response>}
|
|
||||||
*/
|
|
||||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
|
||||||
// Clone the request because it might've been already used
|
|
||||||
// (i.e. its body has been read and sent to the client).
|
|
||||||
const requestClone = event.request.clone()
|
|
||||||
|
|
||||||
function passthrough() {
|
|
||||||
// Cast the request headers to a new Headers instance
|
|
||||||
// so the headers can be manipulated with.
|
|
||||||
const headers = new Headers(requestClone.headers)
|
|
||||||
|
|
||||||
// Remove the "accept" header value that marked this request as passthrough.
|
|
||||||
// This prevents request alteration and also keeps it compliant with the
|
|
||||||
// user-defined CORS policies.
|
|
||||||
const acceptHeader = headers.get('accept')
|
|
||||||
if (acceptHeader) {
|
|
||||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
|
||||||
const filteredValues = values.filter(
|
|
||||||
(value) => value !== 'msw/passthrough',
|
|
||||||
)
|
|
||||||
|
|
||||||
if (filteredValues.length > 0) {
|
|
||||||
headers.set('accept', filteredValues.join(', '))
|
|
||||||
} else {
|
|
||||||
headers.delete('accept')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(requestClone, { headers })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bypass mocking when the client is not active.
|
|
||||||
if (!client) {
|
|
||||||
return passthrough()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bypass initial page load requests (i.e. static assets).
|
|
||||||
// The absence of the immediate/parent client in the map of the active clients
|
|
||||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
|
||||||
// and is not ready to handle requests.
|
|
||||||
if (!activeClientIds.has(client.id)) {
|
|
||||||
return passthrough()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify the client that a request has been intercepted.
|
|
||||||
const serializedRequest = await serializeRequest(event.request)
|
|
||||||
const clientMessage = await sendToClient(
|
|
||||||
client,
|
|
||||||
{
|
|
||||||
type: 'REQUEST',
|
|
||||||
payload: {
|
|
||||||
id: requestId,
|
|
||||||
interceptedAt: requestInterceptedAt,
|
|
||||||
...serializedRequest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[serializedRequest.body],
|
|
||||||
)
|
|
||||||
|
|
||||||
switch (clientMessage.type) {
|
|
||||||
case 'MOCK_RESPONSE': {
|
|
||||||
return respondWithMock(clientMessage.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'PASSTHROUGH': {
|
|
||||||
return passthrough()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return passthrough()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Client} client
|
|
||||||
* @param {any} message
|
|
||||||
* @param {Array<Transferable>} transferrables
|
|
||||||
* @returns {Promise<any>}
|
|
||||||
*/
|
|
||||||
function sendToClient(client, message, transferrables = []) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const channel = new MessageChannel()
|
|
||||||
|
|
||||||
channel.port1.onmessage = (event) => {
|
|
||||||
if (event.data && event.data.error) {
|
|
||||||
return reject(event.data.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(event.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
client.postMessage(message, [
|
|
||||||
channel.port2,
|
|
||||||
...transferrables.filter(Boolean),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Response} response
|
|
||||||
* @returns {Response}
|
|
||||||
*/
|
|
||||||
function respondWithMock(response) {
|
|
||||||
// Setting response status code to 0 is a no-op.
|
|
||||||
// However, when responding with a "Response.error()", the produced Response
|
|
||||||
// instance will have status code set to 0. Since it's not possible to create
|
|
||||||
// a Response instance with status code 0, handle that use-case separately.
|
|
||||||
if (response.status === 0) {
|
|
||||||
return Response.error()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockedResponse = new Response(response.body, response)
|
|
||||||
|
|
||||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
|
||||||
value: true,
|
|
||||||
enumerable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return mockedResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Request} request
|
|
||||||
*/
|
|
||||||
async function serializeRequest(request) {
|
|
||||||
return {
|
|
||||||
url: request.url,
|
|
||||||
mode: request.mode,
|
|
||||||
method: request.method,
|
|
||||||
headers: Object.fromEntries(request.headers.entries()),
|
|
||||||
cache: request.cache,
|
|
||||||
credentials: request.credentials,
|
|
||||||
destination: request.destination,
|
|
||||||
integrity: request.integrity,
|
|
||||||
redirect: request.redirect,
|
|
||||||
referrer: request.referrer,
|
|
||||||
referrerPolicy: request.referrerPolicy,
|
|
||||||
body: await request.arrayBuffer(),
|
|
||||||
keepalive: request.keepalive,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+516
-13
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "3X-UI Panel API",
|
"title": "3X-UI Panel API",
|
||||||
"version": "3.x",
|
"version": "3.x",
|
||||||
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password."
|
"description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"bearerAuth": {
|
"bearerAuth": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"scheme": "bearer",
|
"scheme": "bearer",
|
||||||
"description": "API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
|
"description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
|
||||||
},
|
},
|
||||||
"cookieAuth": {
|
"cookieAuth": {
|
||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
@@ -152,6 +152,11 @@
|
|||||||
"ldapVlessField": {
|
"ldapVlessField": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"nodeDownThreshold": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"outboundDownThreshold": {
|
"outboundDownThreshold": {
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -242,6 +247,9 @@
|
|||||||
"subClashPath": {
|
"subClashPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subClashRemoteDNS": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subClashRules": {
|
"subClashRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -342,6 +350,10 @@
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subHostSortMode": {
|
||||||
|
"example": "inbound-first",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyAnnounceUrl": {
|
"subIncyAnnounceUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -636,6 +648,7 @@
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"nodeDownThreshold",
|
||||||
"outboundDownThreshold",
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
@@ -662,6 +675,7 @@
|
|||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
"subClashEnableRouting",
|
"subClashEnableRouting",
|
||||||
"subClashPath",
|
"subClashPath",
|
||||||
|
"subClashRemoteDNS",
|
||||||
"subClashRules",
|
"subClashRules",
|
||||||
"subClashURI",
|
"subClashURI",
|
||||||
"subClashUserAgentRegex",
|
"subClashUserAgentRegex",
|
||||||
@@ -695,6 +709,7 @@
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subHostSortMode",
|
||||||
"subIncyAnnounceUrl",
|
"subIncyAnnounceUrl",
|
||||||
"subIncyAppAutoDetect",
|
"subIncyAppAutoDetect",
|
||||||
"subIncyBannerBgColor",
|
"subIncyBannerBgColor",
|
||||||
@@ -928,6 +943,11 @@
|
|||||||
"ldapVlessField": {
|
"ldapVlessField": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"nodeDownThreshold": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"outboundDownThreshold": {
|
"outboundDownThreshold": {
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -1018,6 +1038,9 @@
|
|||||||
"subClashPath": {
|
"subClashPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subClashRemoteDNS": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subClashRules": {
|
"subClashRules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1118,6 +1141,10 @@
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subHostSortMode": {
|
||||||
|
"example": "inbound-first",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subIncyAnnounceUrl": {
|
"subIncyAnnounceUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1420,6 +1447,7 @@
|
|||||||
"ldapUserAttr",
|
"ldapUserAttr",
|
||||||
"ldapUserFilter",
|
"ldapUserFilter",
|
||||||
"ldapVlessField",
|
"ldapVlessField",
|
||||||
|
"nodeDownThreshold",
|
||||||
"outboundDownThreshold",
|
"outboundDownThreshold",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
"panelOutbound",
|
||||||
@@ -1446,6 +1474,7 @@
|
|||||||
"subClashEnable",
|
"subClashEnable",
|
||||||
"subClashEnableRouting",
|
"subClashEnableRouting",
|
||||||
"subClashPath",
|
"subClashPath",
|
||||||
|
"subClashRemoteDNS",
|
||||||
"subClashRules",
|
"subClashRules",
|
||||||
"subClashURI",
|
"subClashURI",
|
||||||
"subClashUserAgentRegex",
|
"subClashUserAgentRegex",
|
||||||
@@ -1479,6 +1508,7 @@
|
|||||||
"subHappTunMode",
|
"subHappTunMode",
|
||||||
"subHappTunType",
|
"subHappTunType",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
|
"subHostSortMode",
|
||||||
"subIncyAnnounceUrl",
|
"subIncyAnnounceUrl",
|
||||||
"subIncyAppAutoDetect",
|
"subIncyAppAutoDetect",
|
||||||
"subIncyBannerBgColor",
|
"subIncyBannerBgColor",
|
||||||
@@ -1673,6 +1703,23 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Blocker": {
|
||||||
|
"description": "Blocker is one unmet requirement.",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"example": "tools",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"example": "awg",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"code"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Client": {
|
"Client": {
|
||||||
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1700,6 +1747,17 @@
|
|||||||
"description": "Auth password (Hysteria)",
|
"description": "Auth password (Hysteria)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"clientHostRuleIds": {
|
||||||
|
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"description": "Client comment",
|
"description": "Client comment",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1739,9 +1797,15 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
|
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
|
||||||
"nullable": true,
|
"oneOf": [
|
||||||
"type": "integer"
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"description": "IP limit for this client",
|
"description": "IP limit for this client",
|
||||||
@@ -1931,6 +1995,16 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"clientHostRuleIds": {
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1961,7 +2035,15 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"type": "integer"
|
"description": "Numeric values or AmneziaWG ranges.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitHwid": {
|
"limitHwid": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2738,6 +2820,10 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"randomCount": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"remark": {
|
"remark": {
|
||||||
"example": "cdn-front",
|
"example": "cdn-front",
|
||||||
"maxLength": 256,
|
"maxLength": 256,
|
||||||
@@ -2763,6 +2849,15 @@
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {},
|
"sockoptParams": {},
|
||||||
"sortOrder": {
|
"sortOrder": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2810,11 +2905,13 @@
|
|||||||
"path",
|
"path",
|
||||||
"pinnedPeerCertSha256",
|
"pinnedPeerCertSha256",
|
||||||
"port",
|
"port",
|
||||||
|
"randomCount",
|
||||||
"remark",
|
"remark",
|
||||||
"security",
|
"security",
|
||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -2919,6 +3016,11 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"randomCount": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"remark": {
|
"remark": {
|
||||||
"maxLength": 256,
|
"maxLength": 256,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2942,6 +3044,15 @@
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {
|
"sockoptParams": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2984,11 +3095,14 @@
|
|||||||
"path",
|
"path",
|
||||||
"pinnedPeerCertSha256",
|
"pinnedPeerCertSha256",
|
||||||
"port",
|
"port",
|
||||||
|
"randomCount",
|
||||||
"remark",
|
"remark",
|
||||||
"security",
|
"security",
|
||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
|
"snis",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -3664,6 +3778,13 @@
|
|||||||
"transitive": {
|
"transitive": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"unavailableMetrics": {
|
||||||
|
"description": "Nil on legacy rows and responses: zero remains a valid measured value.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"example": 1700000000,
|
"example": 1700000000,
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -3972,6 +4093,12 @@
|
|||||||
"example": false,
|
"example": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"unavailableMetrics": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"example": 1700003600,
|
"example": 1700003600,
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -4184,6 +4311,12 @@
|
|||||||
"example": "online",
|
"example": "online",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"unavailableMetrics": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"uptimeSecs": {
|
"uptimeSecs": {
|
||||||
"example": 86400,
|
"example": 86400,
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -4215,6 +4348,37 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Readiness": {
|
||||||
|
"description": "Readiness is the host's ability to run any kernel-mode inbound at all.",
|
||||||
|
"properties": {
|
||||||
|
"blockers": {
|
||||||
|
"example": [
|
||||||
|
{
|
||||||
|
"code": "tools",
|
||||||
|
"detail": "awg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Blocker"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"installCommand": {
|
||||||
|
"example": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ready": {
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"blockers",
|
||||||
|
"installCommand",
|
||||||
|
"ready"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"RealityScanResult": {
|
"RealityScanResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"alpn": {
|
"alpn": {
|
||||||
@@ -4397,6 +4561,10 @@
|
|||||||
"keepaliveTimeout": {
|
"keepaliveTimeout": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"kernelMode": {
|
||||||
|
"description": "KernelMode runs this inbound on the AmneziaWG kernel module (internal/amneziawgkern)\ninstead of the embedded amneziawg-go + gVisor stack.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"maxHandshakeAttempts": {
|
"maxHandshakeAttempts": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4651,6 +4819,35 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TgBotCapabilities": {
|
||||||
|
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
|
||||||
|
"properties": {
|
||||||
|
"groupPrivacy": {
|
||||||
|
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
|
||||||
|
"example": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"inlineEnabled": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"running": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"example": "xui_bot",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"groupPrivacy",
|
||||||
|
"inlineEnabled",
|
||||||
|
"running",
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Traffic": {
|
"Traffic": {
|
||||||
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
|
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4855,7 +5052,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "API Tokens",
|
"name": "API Tokens",
|
||||||
"description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
|
"description": "Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Xray Settings",
|
"name": "Xray Settings",
|
||||||
@@ -5319,6 +5516,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/inbounds/amneziawgKernelReadiness": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Inbounds"
|
||||||
|
],
|
||||||
|
"summary": "Whether this host can run an AmneziaWG inbound on the kernel module. Returns ready, host blocker codes (root/tools/module/nft), and the command that fixes them. Saving an inbound also checks its features and rejects IPv6, port forwarding, routeThroughXray, or a pinned listen address that the kernel backend cannot honor.",
|
||||||
|
"operationId": "get_panel_api_inbounds_amneziawgKernelReadiness",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/Readiness"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"code": "tools",
|
||||||
|
"detail": "awg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"installCommand": "sudo /usr/local/x-ui/x-ui install-awg-kernel",
|
||||||
|
"ready": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/inbounds/allLinks": {
|
"/panel/api/inbounds/allLinks": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7184,7 +7426,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Server"
|
"Server"
|
||||||
],
|
],
|
||||||
"summary": "Self-update the panel to the latest version. The server restarts on success.",
|
"summary": "Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.",
|
||||||
"operationId": "post_panel_api_server_updatePanel",
|
"operationId": "post_panel_api_server_updatePanel",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": false,
|
"required": false,
|
||||||
@@ -8481,6 +8723,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/clients/get/subId/{subId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Clients"
|
||||||
|
],
|
||||||
|
"summary": "Fetch clients by subscription ID.",
|
||||||
|
"operationId": "get_panel_api_clients_get_subId_subId",
|
||||||
|
"description": "Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "subId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Exact subscription ID (URL-encoded).",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"type": "array",
|
||||||
|
"nullable": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"client": {
|
||||||
|
"$ref": "#/components/schemas/ClientRecord"
|
||||||
|
},
|
||||||
|
"inboundIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"externalLinks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usedTraffic": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"tunnelAllowedIPs": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/get/tgId/{tgId}": {
|
"/panel/api/clients/get/tgId/{tgId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -10983,6 +11300,9 @@
|
|||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
@@ -11184,6 +11504,9 @@
|
|||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
@@ -11334,6 +11657,9 @@
|
|||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
@@ -11557,6 +11883,9 @@
|
|||||||
"memPct": 45.2,
|
"memPct": 45.2,
|
||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"status": "online",
|
"status": "online",
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
"xrayState": "",
|
"xrayState": "",
|
||||||
@@ -11909,11 +12238,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 0,
|
"port": 0,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -11930,6 +12264,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/hosts/raw": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Hosts"
|
||||||
|
],
|
||||||
|
"summary": "List every host row with its numeric id, for id-based pickers such as the per-client host rule override.",
|
||||||
|
"operationId": "get_panel_api_hosts_raw",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Host"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": [
|
||||||
|
{
|
||||||
|
"address": "cdn.example.com",
|
||||||
|
"allowInsecure": false,
|
||||||
|
"alpn": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"cipherSuites": "",
|
||||||
|
"createdAt": 0,
|
||||||
|
"echConfigList": "",
|
||||||
|
"excludeFromSubTypes": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"finalMask": "",
|
||||||
|
"fingerprint": "",
|
||||||
|
"groupId": "",
|
||||||
|
"hostHeader": "",
|
||||||
|
"id": 1,
|
||||||
|
"inboundId": 1,
|
||||||
|
"isDisabled": false,
|
||||||
|
"isHidden": false,
|
||||||
|
"keepSniBlank": false,
|
||||||
|
"mihomoIpVersion": "dual",
|
||||||
|
"mihomoX25519": false,
|
||||||
|
"muxParams": null,
|
||||||
|
"nodeGuids": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"overrideSniFromAddress": false,
|
||||||
|
"path": "",
|
||||||
|
"pinnedPeerCertSha256": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"port": 8443,
|
||||||
|
"randomCount": 1,
|
||||||
|
"remark": "cdn-front",
|
||||||
|
"security": "same",
|
||||||
|
"serverDescription": "",
|
||||||
|
"shuffleHost": false,
|
||||||
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"sockoptParams": null,
|
||||||
|
"sortOrder": 0,
|
||||||
|
"tags": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"updatedAt": 0,
|
||||||
|
"verifyPeerCertByName": "",
|
||||||
|
"vlessRoute": "443"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/hosts/get/{groupId}": {
|
"/panel/api/hosts/get/{groupId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12004,11 +12430,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 0,
|
"port": 0,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12102,11 +12533,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 0,
|
"port": 0,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12250,11 +12686,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 8443,
|
"port": 8443,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "cdn-front",
|
"remark": "cdn-front",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12372,11 +12813,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 8443,
|
"port": 8443,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "cdn-front",
|
"remark": "cdn-front",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12494,7 +12940,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Hosts"
|
"Hosts"
|
||||||
],
|
],
|
||||||
"summary": "Set host group sort order by the position of each groupId in the array.",
|
"summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
|
||||||
"operationId": "post_panel_api_hosts_reorder",
|
"operationId": "post_panel_api_hosts_reorder",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -12624,11 +13070,16 @@
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"port": 8443,
|
"port": 8443,
|
||||||
|
"randomCount": 1,
|
||||||
"remark": "cdn-front",
|
"remark": "cdn-front",
|
||||||
"security": "same",
|
"security": "same",
|
||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -13115,6 +13566,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/setting/tgBotCapabilities": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
],
|
||||||
|
"summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.",
|
||||||
|
"operationId": "post_panel_api_setting_tgBotCapabilities",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/TgBotCapabilities"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"groupPrivacy": false,
|
||||||
|
"inlineEnabled": true,
|
||||||
|
"running": true,
|
||||||
|
"username": "xui_bot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/setting/testDiscord": {
|
"/panel/api/setting/testDiscord": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -13208,9 +13700,11 @@
|
|||||||
"obj": [
|
"obj": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "default",
|
"name": "central-panel-a",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"createdAt": 1736000000
|
"createdAt": 1736000000,
|
||||||
|
"scope": "node-admin",
|
||||||
|
"expiresAt": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13240,7 +13734,13 @@
|
|||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "admin (default), monitor, or node-sync."
|
"enum": [
|
||||||
|
"admin",
|
||||||
|
"monitor",
|
||||||
|
"node-sync",
|
||||||
|
"node-admin"
|
||||||
|
],
|
||||||
|
"description": "admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update."
|
||||||
},
|
},
|
||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -13253,7 +13753,7 @@
|
|||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
"name": "central-panel-a",
|
"name": "central-panel-a",
|
||||||
"scope": "node-sync",
|
"scope": "node-admin",
|
||||||
"expiresAt": 1798761600000
|
"expiresAt": 1798761600000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16387,6 +16887,9 @@
|
|||||||
"status": "online",
|
"status": "online",
|
||||||
"tlsVerifyMode": "verify",
|
"tlsVerifyMode": "verify",
|
||||||
"transitive": false,
|
"transitive": false,
|
||||||
|
"unavailableMetrics": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"updatedAt": 1700003600,
|
"updatedAt": 1700003600,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
"xrayError": "",
|
"xrayError": "",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const SECURITY_SCHEMES = {
|
|||||||
type: 'http',
|
type: 'http',
|
||||||
scheme: 'bearer',
|
scheme: 'bearer',
|
||||||
description:
|
description:
|
||||||
'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
|
'Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
|
||||||
},
|
},
|
||||||
cookieAuth: {
|
cookieAuth: {
|
||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
@@ -342,7 +342,7 @@ export function buildSpec() {
|
|||||||
title: '3X-UI Panel API',
|
title: '3X-UI Panel API',
|
||||||
version: PANEL_VERSION,
|
version: PANEL_VERSION,
|
||||||
description:
|
description:
|
||||||
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
|
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password.',
|
||||||
},
|
},
|
||||||
servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
|
servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ReadinessSchema, type Readiness } from '@/generated/zod';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
|
export type KernelReadiness = Readiness;
|
||||||
|
|
||||||
|
async function fetchKernelReadiness(): Promise<KernelReadiness> {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/inbounds/amneziawgKernelReadiness', undefined, {
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch kernel readiness');
|
||||||
|
return ReadinessSchema.parse(msg.obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probing the module is cheap but not free, and the answer only changes when an
|
||||||
|
// admin installs something, so a short stale window is plenty.
|
||||||
|
export function useAmneziawgKernelReadiness(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.inbounds.amneziawgKernelReadiness(),
|
||||||
|
queryFn: fetchKernelReadiness,
|
||||||
|
enabled,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export function useHostMutations() {
|
|||||||
mutationFn: (groupIds: string[]) =>
|
mutationFn: (groupIds: string[]) =>
|
||||||
HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
|
HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
if (msg?.success) invalidate();
|
if (msg?.success) return invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
|
// One row of the raw hosts table (GET /panel/api/hosts/raw), carrying the
|
||||||
|
// numeric id that per-client host rule bindings reference.
|
||||||
|
export const RawHostSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.number(),
|
||||||
|
inboundId: z.number().optional(),
|
||||||
|
remark: z.string().optional(),
|
||||||
|
address: z.string().optional(),
|
||||||
|
port: z.number().optional(),
|
||||||
|
isDisabled: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.loose();
|
||||||
|
|
||||||
|
export type RawHost = z.infer<typeof RawHostSchema>;
|
||||||
|
|
||||||
|
const RawHostListSchema = z
|
||||||
|
.array(RawHostSchema)
|
||||||
|
.nullable()
|
||||||
|
.transform((v) => v ?? []);
|
||||||
|
|
||||||
|
async function fetchRawHosts(): Promise<RawHost[]> {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/hosts/raw', undefined, { silent: true });
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
|
||||||
|
const validated = parseMsg(msg, RawHostListSchema, 'hosts/raw');
|
||||||
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRawHostsQuery() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: keys.hosts.raw(),
|
||||||
|
queryFn: fetchRawHosts,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export const keys = {
|
|||||||
hosts: {
|
hosts: {
|
||||||
root: () => ['hosts'] as const,
|
root: () => ['hosts'] as const,
|
||||||
list: () => ['hosts', 'list'] as const,
|
list: () => ['hosts', 'list'] as const,
|
||||||
|
raw: () => ['hosts', 'raw'] as const,
|
||||||
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
||||||
tags: () => ['hosts', 'tags'] as const,
|
tags: () => ['hosts', 'tags'] as const,
|
||||||
},
|
},
|
||||||
@@ -28,6 +29,7 @@ export const keys = {
|
|||||||
root: () => ['inbounds'] as const,
|
root: () => ['inbounds'] as const,
|
||||||
slim: () => ['inbounds', 'slim'] as const,
|
slim: () => ['inbounds', 'slim'] as const,
|
||||||
options: () => ['inbounds', 'options'] as const,
|
options: () => ['inbounds', 'options'] as const,
|
||||||
|
amneziawgKernelReadiness: () => ['inbounds', 'amneziawgKernelReadiness'] as const,
|
||||||
},
|
},
|
||||||
clients: {
|
clients: {
|
||||||
root: () => ['clients'] as const,
|
root: () => ['clients'] as const,
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ export default function ClientHwidListModal({
|
|||||||
zIndex={zIndex}
|
zIndex={zIndex}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="refresh" icon={<ReloadOutlined />} loading={loading} onClick={onRefresh}>
|
<Button
|
||||||
|
key="refresh"
|
||||||
|
icon={<ReloadOutlined aria-hidden />}
|
||||||
|
loading={loading}
|
||||||
|
onClick={onRefresh}
|
||||||
|
>
|
||||||
{t('refresh')}
|
{t('refresh')}
|
||||||
</Button>,
|
</Button>,
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
@@ -122,7 +127,7 @@ export default function ClientHwidListModal({
|
|||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={t('pages.clients.deleteHwid')}
|
aria-label={t('pages.clients.deleteHwid')}
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined aria-hidden />}
|
||||||
loading={deletingId === entry.id}
|
loading={deletingId === entry.id}
|
||||||
/>
|
/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
|||||||
@@ -38,12 +38,17 @@ export default function ConfigBlock({
|
|||||||
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||||
<div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
|
<div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
|
||||||
<Tooltip title={t('copy')}>
|
<Tooltip title={t('copy')}>
|
||||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined aria-hidden />}
|
||||||
|
aria-label={t('copy')}
|
||||||
|
onClick={copy}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('download')}>
|
<Tooltip title={t('download')}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined aria-hidden />}
|
||||||
aria-label={t('download')}
|
aria-label={t('download')}
|
||||||
onClick={() => FileManager.downloadTextFile(text, fileName)}
|
onClick={() => FileManager.downloadTextFile(text, fileName)}
|
||||||
/>
|
/>
|
||||||
@@ -56,7 +61,11 @@ export default function ConfigBlock({
|
|||||||
content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
|
content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
|
||||||
>
|
>
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
<Tooltip title={t('pages.clients.qrCode')}>
|
||||||
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<QrcodeOutlined aria-hidden />}
|
||||||
|
aria-label={t('pages.clients.qrCode')}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
|
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
ApiOutlined,
|
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
@@ -13,17 +12,14 @@ import {
|
|||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
CrownOutlined,
|
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
DiscordOutlined,
|
DiscordOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
GlobalOutlined,
|
|
||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
MessageOutlined,
|
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
@@ -32,8 +28,7 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
SunOutlined,
|
SunOutlined,
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
TagsOutlined,
|
TelegramFilled,
|
||||||
TeamOutlined,
|
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
@@ -42,6 +37,7 @@ import { activateOnKey } from '@/utils/a11y';
|
|||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
import { PANEL_ROUTES } from '@/routes/panelRoutes';
|
||||||
import type { ClientRecord, InboundOption } from '@/schemas/client';
|
import type { ClientRecord, InboundOption } from '@/schemas/client';
|
||||||
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
|
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
|
||||||
import './CommandPalette.css';
|
import './CommandPalette.css';
|
||||||
@@ -62,6 +58,34 @@ interface PaletteItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The search terms of every panel page, so the palette finds it by what it
|
||||||
|
// does rather than by its title.
|
||||||
|
export const PALETTE_KEYWORDS: Record<string, string[]> = {
|
||||||
|
'/': ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
|
||||||
|
'/inbounds': [
|
||||||
|
'inbounds',
|
||||||
|
'ports',
|
||||||
|
'vless',
|
||||||
|
'vmess',
|
||||||
|
'reality',
|
||||||
|
'trojan',
|
||||||
|
'shadowsocks',
|
||||||
|
'wireguard',
|
||||||
|
'hysteria',
|
||||||
|
'masque',
|
||||||
|
],
|
||||||
|
'/clients': ['clients', 'users', 'sub', 'traffic', 'quota'],
|
||||||
|
'/groups': ['groups', 'tags', 'batch'],
|
||||||
|
'/nodes': ['nodes', 'servers', 'cluster', 'remote nodes'],
|
||||||
|
'/hosts': ['hosts', 'sni', 'domains'],
|
||||||
|
'/outbound': ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
|
||||||
|
'/routing': ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
|
||||||
|
'/settings': ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
|
||||||
|
'/xray': ['xray', 'templates', 'balancer', 'dns'],
|
||||||
|
'/api-docs': ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||||
|
'/sponsors': ['sponsors', 'sponsor', 'partners'],
|
||||||
|
};
|
||||||
|
|
||||||
export default function CommandPalette() {
|
export default function CommandPalette() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
@@ -262,9 +286,9 @@ export default function CommandPalette() {
|
|||||||
title: c.email,
|
title: c.email,
|
||||||
subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
|
subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
|
||||||
icon: isOnline ? (
|
icon: isOnline ? (
|
||||||
<CheckCircleFilled style={{ color: '#52c41a' }} />
|
<CheckCircleFilled aria-hidden style={{ color: '#52c41a' }} />
|
||||||
) : (
|
) : (
|
||||||
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
|
<CloseCircleFilled aria-hidden style={{ color: '#ff4d4f' }} />
|
||||||
),
|
),
|
||||||
action: () => {
|
action: () => {
|
||||||
close();
|
close();
|
||||||
@@ -274,7 +298,7 @@ export default function CommandPalette() {
|
|||||||
c.subId && allSetting.subURI
|
c.subId && allSetting.subURI
|
||||||
? {
|
? {
|
||||||
label: t('commandPalette.copySubscription'),
|
label: t('commandPalette.copySubscription'),
|
||||||
icon: <CopyOutlined />,
|
icon: <CopyOutlined aria-hidden />,
|
||||||
execute: (e) => {
|
execute: (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
copySubscription(c);
|
copySubscription(c);
|
||||||
@@ -331,7 +355,7 @@ export default function CommandPalette() {
|
|||||||
category: 'inbounds',
|
category: 'inbounds',
|
||||||
title: ib.remark || ib.tag || `Inbound #${ib.id}`,
|
title: ib.remark || ib.tag || `Inbound #${ib.id}`,
|
||||||
subtitle: `Port ${ib.port || ''}`,
|
subtitle: `Port ${ib.port || ''}`,
|
||||||
icon: <ImportOutlined style={{ color: '#1677ff' }} />,
|
icon: <ImportOutlined aria-hidden style={{ color: '#1677ff' }} />,
|
||||||
tag:
|
tag:
|
||||||
tags.length > 0 ? (
|
tags.length > 0 ? (
|
||||||
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
|
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
|
||||||
@@ -343,91 +367,12 @@ export default function CommandPalette() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const pages = [
|
const pages = PANEL_ROUTES.map((route) => ({
|
||||||
{
|
path: route.path,
|
||||||
path: '/',
|
title: t(route.titleKey),
|
||||||
title: t('menu.dashboard'),
|
keywords: PALETTE_KEYWORDS[route.path] ?? [],
|
||||||
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
|
icon: <route.icon aria-hidden />,
|
||||||
icon: <DashboardOutlined />,
|
}));
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/inbounds',
|
|
||||||
title: t('menu.inbounds'),
|
|
||||||
keywords: [
|
|
||||||
'inbounds',
|
|
||||||
'ports',
|
|
||||||
'vless',
|
|
||||||
'vmess',
|
|
||||||
'reality',
|
|
||||||
'trojan',
|
|
||||||
'shadowsocks',
|
|
||||||
'wireguard',
|
|
||||||
'hysteria',
|
|
||||||
'masque',
|
|
||||||
],
|
|
||||||
icon: <ImportOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/clients',
|
|
||||||
title: t('menu.clients'),
|
|
||||||
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
|
|
||||||
icon: <TeamOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/groups',
|
|
||||||
title: t('menu.groups'),
|
|
||||||
keywords: ['groups', 'tags', 'batch'],
|
|
||||||
icon: <TagsOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/nodes',
|
|
||||||
title: t('menu.nodes'),
|
|
||||||
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
|
|
||||||
icon: <ClusterOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/hosts',
|
|
||||||
title: t('menu.hosts'),
|
|
||||||
keywords: ['hosts', 'sni', 'domains'],
|
|
||||||
icon: <GlobalOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/outbound',
|
|
||||||
title: t('menu.outbounds'),
|
|
||||||
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
|
|
||||||
icon: <ExportOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/routing',
|
|
||||||
title: t('menu.routing'),
|
|
||||||
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
|
|
||||||
icon: <SwapOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/settings',
|
|
||||||
title: t('menu.settings'),
|
|
||||||
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
|
|
||||||
icon: <SettingOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/xray',
|
|
||||||
title: t('menu.xray'),
|
|
||||||
keywords: ['xray', 'templates', 'balancer', 'dns'],
|
|
||||||
icon: <ToolOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/api-docs',
|
|
||||||
title: t('menu.apiDocs'),
|
|
||||||
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
|
||||||
icon: <ApiOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/sponsors',
|
|
||||||
title: t('menu.sponsors'),
|
|
||||||
keywords: ['sponsors', 'sponsor', 'partners'],
|
|
||||||
icon: <CrownOutlined />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
pages
|
pages
|
||||||
.filter((p) => matches(p.title, undefined, p.keywords))
|
.filter((p) => matches(p.title, undefined, p.keywords))
|
||||||
@@ -451,63 +396,63 @@ export default function CommandPalette() {
|
|||||||
title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
|
title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
|
||||||
subtitle: t('pages.settings.panelSettings'),
|
subtitle: t('pages.settings.panelSettings'),
|
||||||
keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
|
keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings#security',
|
path: '/settings#security',
|
||||||
title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
|
title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
|
||||||
subtitle: t('pages.settings.securitySettings'),
|
subtitle: t('pages.settings.securitySettings'),
|
||||||
keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
|
keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
|
||||||
icon: <SafetyOutlined />,
|
icon: <SafetyOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings#telegram',
|
path: '/settings#telegram',
|
||||||
title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
|
title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
|
||||||
subtitle: t('pages.settings.TGBotSettings'),
|
subtitle: t('pages.settings.TGBotSettings'),
|
||||||
keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
|
keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
|
||||||
icon: <MessageOutlined />,
|
icon: <TelegramFilled aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings#email',
|
path: '/settings#email',
|
||||||
title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
|
title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
|
||||||
subtitle: t('pages.settings.emailSettings'),
|
subtitle: t('pages.settings.emailSettings'),
|
||||||
keywords: ['email', 'smtp', 'mail', 'crash alerts'],
|
keywords: ['email', 'smtp', 'mail', 'crash alerts'],
|
||||||
icon: <MailOutlined />,
|
icon: <MailOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings#discord',
|
path: '/settings#discord',
|
||||||
title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`,
|
title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`,
|
||||||
subtitle: t('pages.settings.discordSettings'),
|
subtitle: t('pages.settings.discordSettings'),
|
||||||
keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'],
|
keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'],
|
||||||
icon: <DiscordOutlined />,
|
icon: <DiscordOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings#subscription',
|
path: '/settings#subscription',
|
||||||
title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
|
title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
|
||||||
subtitle: t('pages.settings.subSettings'),
|
subtitle: t('pages.settings.subSettings'),
|
||||||
keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
|
keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
|
||||||
icon: <CloudServerOutlined />,
|
icon: <CloudServerOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings#subscription-formats',
|
path: '/settings#subscription-formats',
|
||||||
title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
|
title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
|
||||||
subtitle: t('menu.subFormats'),
|
subtitle: t('menu.subFormats'),
|
||||||
keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
|
keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
|
||||||
icon: <CodeOutlined />,
|
icon: <CodeOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings#subscription-balancers',
|
path: '/settings#subscription-balancers',
|
||||||
title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
|
title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
|
||||||
subtitle: t('pages.settings.subBalancers.menu'),
|
subtitle: t('pages.settings.subBalancers.menu'),
|
||||||
keywords: ['balancers', 'sub balancers', 'balancer nodes'],
|
keywords: ['balancers', 'sub balancers', 'balancer nodes'],
|
||||||
icon: <ApartmentOutlined />,
|
icon: <ApartmentOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#basic',
|
path: '/xray#basic',
|
||||||
title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
|
title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
|
||||||
subtitle: t('pages.xray.basicTemplate'),
|
subtitle: t('pages.xray.basicTemplate'),
|
||||||
keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
|
keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
|
||||||
icon: <ToolOutlined />,
|
icon: <ToolOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#basic',
|
path: '/xray#basic',
|
||||||
@@ -522,56 +467,56 @@ export default function CommandPalette() {
|
|||||||
'statsOutbound',
|
'statsOutbound',
|
||||||
'metrics_out',
|
'metrics_out',
|
||||||
],
|
],
|
||||||
icon: <DashboardOutlined />,
|
icon: <DashboardOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#basic',
|
path: '/xray#basic',
|
||||||
title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
|
title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
|
||||||
subtitle: t('pages.xray.connectionLimits'),
|
subtitle: t('pages.xray.connectionLimits'),
|
||||||
keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
|
keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
|
||||||
icon: <ClockCircleOutlined />,
|
icon: <ClockCircleOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#basic',
|
path: '/xray#basic',
|
||||||
title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
|
title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
|
||||||
subtitle: t('pages.xray.logConfigs'),
|
subtitle: t('pages.xray.logConfigs'),
|
||||||
keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
|
keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
|
||||||
icon: <FileTextOutlined />,
|
icon: <FileTextOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#balancer',
|
path: '/xray#balancer',
|
||||||
title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
|
title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
|
||||||
subtitle: t('pages.xray.Balancers'),
|
subtitle: t('pages.xray.Balancers'),
|
||||||
keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
|
keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
|
||||||
icon: <ClusterOutlined />,
|
icon: <ClusterOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#dns',
|
path: '/xray#dns',
|
||||||
title: `${t('menu.xray')} · DNS`,
|
title: `${t('menu.xray')} · DNS`,
|
||||||
subtitle: 'DNS',
|
subtitle: 'DNS',
|
||||||
keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
|
keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
|
||||||
icon: <DatabaseOutlined />,
|
icon: <DatabaseOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#outbound',
|
path: '/xray#outbound',
|
||||||
title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
|
title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
|
||||||
subtitle: t('pages.xray.Outbounds'),
|
subtitle: t('pages.xray.Outbounds'),
|
||||||
keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
|
keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
|
||||||
icon: <ExportOutlined />,
|
icon: <ExportOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#routing',
|
path: '/xray#routing',
|
||||||
title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
|
title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
|
||||||
subtitle: t('pages.xray.basicRouting'),
|
subtitle: t('pages.xray.basicRouting'),
|
||||||
keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
|
keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
|
||||||
icon: <SwapOutlined />,
|
icon: <SwapOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/xray#advanced',
|
path: '/xray#advanced',
|
||||||
title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
|
title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
|
||||||
subtitle: t('pages.xray.advancedTemplate'),
|
subtitle: t('pages.xray.advancedTemplate'),
|
||||||
keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
|
keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
|
||||||
icon: <CodeOutlined />,
|
icon: <CodeOutlined aria-hidden />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -599,7 +544,7 @@ export default function CommandPalette() {
|
|||||||
title: t('commandPalette.restartXray'),
|
title: t('commandPalette.restartXray'),
|
||||||
subtitle: t('pages.index.restartXray'),
|
subtitle: t('pages.index.restartXray'),
|
||||||
keywords: ['restart', 'xray restart', 'reboot xray'],
|
keywords: ['restart', 'xray restart', 'reboot xray'],
|
||||||
icon: <ReloadOutlined style={{ color: '#faad14' }} />,
|
icon: <ReloadOutlined aria-hidden style={{ color: '#faad14' }} />,
|
||||||
action: restartXray,
|
action: restartXray,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -608,7 +553,7 @@ export default function CommandPalette() {
|
|||||||
title: t('menu.theme'),
|
title: t('menu.theme'),
|
||||||
subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
|
subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
|
||||||
keywords: ['theme', 'light', 'dark', 'ultra'],
|
keywords: ['theme', 'light', 'dark', 'ultra'],
|
||||||
icon: isDark ? <SunOutlined /> : <MoonOutlined />,
|
icon: isDark ? <SunOutlined aria-hidden /> : <MoonOutlined aria-hidden />,
|
||||||
action: cycleTheme,
|
action: cycleTheme,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -617,7 +562,7 @@ export default function CommandPalette() {
|
|||||||
title: t('pages.inbounds.addInbound'),
|
title: t('pages.inbounds.addInbound'),
|
||||||
subtitle: t('menu.inbounds'),
|
subtitle: t('menu.inbounds'),
|
||||||
keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
|
keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
|
||||||
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
|
icon: <PlusOutlined aria-hidden style={{ color: '#52c41a' }} />,
|
||||||
action: () => {
|
action: () => {
|
||||||
close();
|
close();
|
||||||
navigate('/inbounds');
|
navigate('/inbounds');
|
||||||
@@ -629,7 +574,7 @@ export default function CommandPalette() {
|
|||||||
title: t('pages.clients.addClient'),
|
title: t('pages.clients.addClient'),
|
||||||
subtitle: t('menu.clients'),
|
subtitle: t('menu.clients'),
|
||||||
keywords: ['add client', 'create user', 'new client', 'new user'],
|
keywords: ['add client', 'create user', 'new client', 'new user'],
|
||||||
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
|
icon: <PlusOutlined aria-hidden style={{ color: '#52c41a' }} />,
|
||||||
action: () => {
|
action: () => {
|
||||||
close();
|
close();
|
||||||
navigate('/clients');
|
navigate('/clients');
|
||||||
@@ -705,9 +650,9 @@ export default function CommandPalette() {
|
|||||||
>
|
>
|
||||||
<div className="command-palette-header">
|
<div className="command-palette-header">
|
||||||
{isClientSearching ? (
|
{isClientSearching ? (
|
||||||
<LoadingOutlined className="command-palette-search-icon spinning" />
|
<LoadingOutlined aria-hidden className="command-palette-search-icon spinning" />
|
||||||
) : (
|
) : (
|
||||||
<SearchOutlined className="command-palette-search-icon" />
|
<SearchOutlined aria-hidden className="command-palette-search-icon" />
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ export default function TextModal({
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
{fileName && (
|
{fileName && (
|
||||||
<Button icon={<DownloadOutlined />} onClick={download}>
|
<Button icon={<DownloadOutlined aria-hidden />} onClick={download}>
|
||||||
{fileName}
|
{fileName}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>
|
<Button type="primary" icon={<CopyOutlined aria-hidden />} onClick={copy}>
|
||||||
{t('copy')}
|
{t('copy')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Select } from 'antd';
|
import { Select } from 'antd';
|
||||||
import type { SelectProps } from 'antd';
|
import type { SelectProps } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { TLS_CIPHER_OPTION } from '@/schemas/primitives';
|
import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives';
|
||||||
|
|
||||||
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
|
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
|
||||||
|
|
||||||
|
const PRESET_PREFIX = 'preset:';
|
||||||
|
const PRESET_OPTIONS = Object.keys(TLS_CIPHER_PRESETS).map((name) => ({
|
||||||
|
value: PRESET_PREFIX + name,
|
||||||
|
label: name,
|
||||||
|
}));
|
||||||
|
|
||||||
type CipherSuitesSelectProps = Omit<
|
type CipherSuitesSelectProps = Omit<
|
||||||
SelectProps<string[]>,
|
SelectProps<string[]>,
|
||||||
'value' | 'onChange' | 'mode' | 'options'
|
'value' | 'onChange' | 'mode' | 'options'
|
||||||
@@ -14,6 +21,13 @@ type CipherSuitesSelectProps = Omit<
|
|||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function expandPreset(next: string[]): string[] {
|
||||||
|
const picked = [...next].reverse().find((v) => v.startsWith(PRESET_PREFIX));
|
||||||
|
if (!picked) return next;
|
||||||
|
const name = picked.slice(PRESET_PREFIX.length) as keyof typeof TLS_CIPHER_PRESETS;
|
||||||
|
return TLS_CIPHER_PRESETS[name].split(':');
|
||||||
|
}
|
||||||
|
|
||||||
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
|
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
|
||||||
// the stored value stays the single colon-joined string xray reads.
|
// the stored value stays the single colon-joined string xray reads.
|
||||||
export default function CipherSuitesSelect({
|
export default function CipherSuitesSelect({
|
||||||
@@ -21,6 +35,7 @@ export default function CipherSuitesSelect({
|
|||||||
onChange,
|
onChange,
|
||||||
...rest
|
...rest
|
||||||
}: CipherSuitesSelectProps) {
|
}: CipherSuitesSelectProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const suites = value
|
const suites = value
|
||||||
.split(':')
|
.split(':')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
@@ -31,9 +46,12 @@ export default function CipherSuitesSelect({
|
|||||||
tokenSeparators={[':', ',']}
|
tokenSeparators={[':', ',']}
|
||||||
{...rest}
|
{...rest}
|
||||||
mode="tags"
|
mode="tags"
|
||||||
options={CIPHER_SUITE_OPTIONS}
|
options={[
|
||||||
|
{ label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS },
|
||||||
|
{ label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS },
|
||||||
|
]}
|
||||||
value={suites}
|
value={suites}
|
||||||
onChange={(next) => onChange?.(next.join(':'))}
|
onChange={(next) => onChange?.(expandPreset(next).join(':'))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ interface DateTimePickerProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
maxDate?: Dayjs;
|
maxDate?: Dayjs;
|
||||||
|
id?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
'aria-describedby'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIGHT_THEME = {
|
const LIGHT_THEME = {
|
||||||
@@ -57,6 +60,9 @@ export default function DateTimePicker({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
allowClear = true,
|
allowClear = true,
|
||||||
maxDate,
|
maxDate,
|
||||||
|
id,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
'aria-describedby': ariaDescribedBy,
|
||||||
}: DateTimePickerProps) {
|
}: DateTimePickerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { datepicker } = useDatepicker();
|
const { datepicker } = useDatepicker();
|
||||||
@@ -97,7 +103,11 @@ export default function DateTimePicker({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (datepicker !== 'jalalian') return;
|
if (datepicker !== 'jalalian') return;
|
||||||
const input = jalaliRef.current?.querySelector('input');
|
const input = jalaliRef.current?.querySelector('input');
|
||||||
if (input) input.placeholder = placeholder;
|
if (!input) return;
|
||||||
|
input.placeholder = placeholder;
|
||||||
|
if (id) input.id = id;
|
||||||
|
if (ariaLabel) input.setAttribute('aria-label', ariaLabel);
|
||||||
|
if (ariaDescribedBy) input.setAttribute('aria-describedby', ariaDescribedBy);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (datepicker === 'jalalian') {
|
if (datepicker === 'jalalian') {
|
||||||
@@ -137,7 +147,7 @@ export default function DateTimePicker({
|
|||||||
setClearNonce((n) => n + 1);
|
setClearNonce((n) => n + 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CloseCircleFilled />
|
<CloseCircleFilled aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,6 +166,9 @@ export default function DateTimePicker({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
allowClear={allowClear}
|
allowClear={allowClear}
|
||||||
maxDate={maxDate}
|
maxDate={maxDate}
|
||||||
|
id={id}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { useId, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Button, Divider, Form, Input, Select, Space, Typography } from 'antd';
|
||||||
|
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_DOH_ENDPOINT,
|
||||||
|
DEFAULT_ECH_DOMAIN,
|
||||||
|
DEFAULT_ECH_PAIR,
|
||||||
|
dohEndpointOptions,
|
||||||
|
echDomainOptions,
|
||||||
|
formatDynamicEch,
|
||||||
|
isSupportedDohUrl,
|
||||||
|
pairPresetOptions,
|
||||||
|
parseDynamicEch,
|
||||||
|
} from '@/lib/xray/ech-presets';
|
||||||
|
|
||||||
|
interface DynamicEchHelperProps {
|
||||||
|
/** Current echConfigList, so an existing dynamic pair can be recovered. */
|
||||||
|
value?: string;
|
||||||
|
/** Node SNI, used by the "resolve from the proxied hostname" target. */
|
||||||
|
sni?: string;
|
||||||
|
/** True when a locally generated ECH keypair is present. */
|
||||||
|
hasLocalKeys?: boolean;
|
||||||
|
onApply?: (value: string) => void;
|
||||||
|
/** Called when the operator accepts dropping CDN-invalid local keys. */
|
||||||
|
onClearLocalKeys?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOM = '__custom__';
|
||||||
|
const AUTO = '__auto__';
|
||||||
|
const ADVANCED = '__advanced__';
|
||||||
|
|
||||||
|
/* Only echConfigList can hold the dynamic `domain+DoH` form; clearing local keys
|
||||||
|
* is a separate, explicitly confirmed step because behind a CDN they are dead. */
|
||||||
|
export default function DynamicEchHelper({
|
||||||
|
value = '',
|
||||||
|
sni = '',
|
||||||
|
hasLocalKeys = false,
|
||||||
|
onApply,
|
||||||
|
onClearLocalKeys,
|
||||||
|
disabled = false,
|
||||||
|
}: DynamicEchHelperProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const uid = useId();
|
||||||
|
const parsed = parseDynamicEch(value);
|
||||||
|
const presetValues = new Set(
|
||||||
|
pairPresetOptions(t).flatMap((group) => group.options.map((o) => o.value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [preset, setPreset] = useState<string>(() => {
|
||||||
|
const current = value.trim();
|
||||||
|
if (presetValues.has(current)) return current;
|
||||||
|
if (!current || !parsed) return DEFAULT_ECH_PAIR;
|
||||||
|
return ADVANCED;
|
||||||
|
});
|
||||||
|
const [domainChoice, setDomainChoice] = useState<string>(
|
||||||
|
() => parsed?.domain ?? DEFAULT_ECH_DOMAIN,
|
||||||
|
);
|
||||||
|
const [domainInput, setDomainInput] = useState<string>(() => parsed?.domain ?? '');
|
||||||
|
const [dohChoice, setDohChoice] = useState<string>(() => parsed?.dohUrl ?? DEFAULT_DOH_ENDPOINT);
|
||||||
|
const [dohInput, setDohInput] = useState<string>(parsed?.dohUrl ?? '');
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const domain =
|
||||||
|
domainChoice === CUSTOM ? domainInput : domainChoice === AUTO ? sni.trim() : domainChoice;
|
||||||
|
const doh = dohChoice === CUSTOM ? dohInput : dohChoice;
|
||||||
|
const advancedBuilt = formatDynamicEch(domain, doh);
|
||||||
|
const built = preset === ADVANCED ? advancedBuilt : preset;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (!built) return;
|
||||||
|
if (hasLocalKeys) setPreview(built);
|
||||||
|
else onApply?.(built);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickPreset = (next: string) => {
|
||||||
|
setPreset(next);
|
||||||
|
setPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDomain = (next: string) => {
|
||||||
|
setDomainChoice(next);
|
||||||
|
setPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDoh = (next: string) => {
|
||||||
|
setDohChoice(next);
|
||||||
|
setPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmOverwrite = () => {
|
||||||
|
if (!built) return;
|
||||||
|
onApply?.(built);
|
||||||
|
onClearLocalKeys?.();
|
||||||
|
setPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="ech-dynamic-helper" style={{ maxWidth: 640 }}>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
<ThunderboltOutlined aria-hidden />
|
||||||
|
{t('pages.inbounds.form.echDynamicTitle')}
|
||||||
|
</div>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||||
|
{t('pages.inbounds.form.echDynamicIntro')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{value.trim() !== '' && !parsed && (
|
||||||
|
<Typography.Paragraph
|
||||||
|
type="warning"
|
||||||
|
data-testid="ech-existing-not-dynamic"
|
||||||
|
style={{ fontSize: 12, marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
{t('pages.inbounds.form.echExistingNotDynamic')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* component={false}: rendered inside the inbound/host <form>, and a nested
|
||||||
|
<form> element is invalid HTML. */}
|
||||||
|
<Form layout="vertical" size="small" colon={false} component={false}>
|
||||||
|
<Form.Item htmlFor={`${uid}-preset`} label={t('pages.inbounds.form.echPresetLabel')}>
|
||||||
|
<Select
|
||||||
|
id={`${uid}-preset`}
|
||||||
|
value={preset}
|
||||||
|
disabled={disabled}
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
data-testid="ech-preset-select"
|
||||||
|
onChange={pickPreset}
|
||||||
|
options={[
|
||||||
|
...pairPresetOptions(t),
|
||||||
|
{
|
||||||
|
label: t('pages.inbounds.form.addCustomOption'),
|
||||||
|
options: [{ value: ADVANCED, label: t('pages.inbounds.form.echPresetAdvanced') }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{preset === ADVANCED && (
|
||||||
|
<>
|
||||||
|
<Form.Item htmlFor={`${uid}-domain`} label={t('pages.inbounds.form.echDynamicDomain')}>
|
||||||
|
<Select
|
||||||
|
id={`${uid}-domain`}
|
||||||
|
value={domainChoice}
|
||||||
|
disabled={disabled}
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
onChange={pickDomain}
|
||||||
|
options={[
|
||||||
|
{ value: AUTO, label: t('pages.inbounds.form.echDomainAuto') },
|
||||||
|
...echDomainOptions(t),
|
||||||
|
{ value: CUSTOM, label: t('pages.inbounds.form.addCustomOption') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{domainChoice === CUSTOM && (
|
||||||
|
<Form.Item>
|
||||||
|
<Input
|
||||||
|
aria-label={t('pages.inbounds.form.echDynamicDomain')}
|
||||||
|
value={domainInput}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={DEFAULT_ECH_DOMAIN}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDomainInput(e.target.value);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item htmlFor={`${uid}-doh`} label={t('pages.inbounds.form.echDynamicDoh')}>
|
||||||
|
<Select
|
||||||
|
id={`${uid}-doh`}
|
||||||
|
value={dohChoice}
|
||||||
|
disabled={disabled}
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
onChange={pickDoh}
|
||||||
|
options={[
|
||||||
|
...dohEndpointOptions(t),
|
||||||
|
{
|
||||||
|
label: t('pages.inbounds.form.addCustomOption'),
|
||||||
|
options: [{ value: CUSTOM, label: t('pages.inbounds.form.echDynamicCustom') }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{dohChoice === CUSTOM && (
|
||||||
|
<Form.Item>
|
||||||
|
<Input
|
||||||
|
aria-label={t('pages.inbounds.form.echDynamicDoh')}
|
||||||
|
value={dohInput}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={DEFAULT_DOH_ENDPOINT}
|
||||||
|
status={dohInput && !isSupportedDohUrl(dohInput) ? 'error' : undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDohInput(e.target.value);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{dohChoice === CUSTOM && dohInput && !isSupportedDohUrl(dohInput) && (
|
||||||
|
<Typography.Paragraph type="danger" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||||
|
{t('pages.inbounds.form.echDynamicUnsupportedScheme')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||||
|
{t('pages.inbounds.form.echMainlandAdvisory')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{built ? (
|
||||||
|
<Typography.Paragraph
|
||||||
|
code
|
||||||
|
copyable={{ text: built }}
|
||||||
|
data-testid="ech-dynamic-preview"
|
||||||
|
style={{ fontSize: 12, marginBottom: 8, wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{built}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : (
|
||||||
|
<Typography.Paragraph type="danger" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||||
|
{t('pages.inbounds.form.echDynamicInvalid')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<ThunderboltOutlined aria-hidden />}
|
||||||
|
disabled={disabled || !built}
|
||||||
|
data-testid="ech-dynamic-apply"
|
||||||
|
onClick={apply}
|
||||||
|
>
|
||||||
|
{t('pages.inbounds.form.echDynamicApply')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
data-testid="ech-dynamic-overwrite-warning"
|
||||||
|
title={t('pages.inbounds.form.echDynamicOverwriteTitle')}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<div>{t('pages.inbounds.form.echDynamicOverwriteBody')}</div>
|
||||||
|
<div data-testid="ech-dynamic-overwrite-value" style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text code>{preview}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Space orientation="vertical">
|
||||||
|
<Button size="small" type="primary" onClick={confirmOverwrite}>
|
||||||
|
{t('pages.inbounds.form.echDynamicOverwriteConfirm')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setPreview(null)}>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -125,21 +125,23 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit
|
|||||||
<Input
|
<Input
|
||||||
value={row.name}
|
value={row.name}
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
|
aria-label={t('pages.inbounds.form.headerName')}
|
||||||
onChange={(e) => setRow(idx, { name: e.target.value })}
|
onChange={(e) => setRow(idx, { name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={row.value}
|
value={row.value}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
|
aria-label={t('pages.inbounds.form.headerValue')}
|
||||||
onChange={(e) => setRow(idx, { value: e.target.value })}
|
onChange={(e) => setRow(idx, { value: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t('remove')}
|
aria-label={t('remove')}
|
||||||
icon={<MinusOutlined />}
|
icon={<MinusOutlined aria-hidden />}
|
||||||
onClick={() => removeRow(idx)}
|
onClick={() => removeRow(idx)}
|
||||||
/>
|
/>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
))}
|
))}
|
||||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
|
<Button size="small" type="primary" icon={<PlusOutlined aria-hidden />} onClick={addRow}>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -157,8 +157,18 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
|
|||||||
if (!view) return;
|
if (!view) return;
|
||||||
const current = view.state.doc.toString();
|
const current = view.state.doc.toString();
|
||||||
if (value === current) return;
|
if (value === current) return;
|
||||||
|
let from = 0;
|
||||||
|
while (from < current.length && from < value.length && current[from] === value[from]) {
|
||||||
|
from += 1;
|
||||||
|
}
|
||||||
|
let oldEnd = current.length;
|
||||||
|
let newEnd = value.length;
|
||||||
|
while (oldEnd > from && newEnd > from && current[oldEnd - 1] === value[newEnd - 1]) {
|
||||||
|
oldEnd -= 1;
|
||||||
|
newEnd -= 1;
|
||||||
|
}
|
||||||
valueRef.current = value;
|
valueRef.current = value;
|
||||||
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
view.dispatch({ changes: { from, to: oldEnd, insert: value.slice(from, newEnd) } });
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ interface RemarkTemplateFieldProps {
|
|||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
metadataOnly?: boolean;
|
metadataOnly?: boolean;
|
||||||
|
id?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
'aria-labelledby'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +40,9 @@ export default function RemarkTemplateField({
|
|||||||
multiline = false,
|
multiline = false,
|
||||||
rows,
|
rows,
|
||||||
metadataOnly = false,
|
metadataOnly = false,
|
||||||
|
id,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
'aria-labelledby': ariaLabelledBy,
|
||||||
}: RemarkTemplateFieldProps) {
|
}: RemarkTemplateFieldProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inputRef = useRef<InputRef>(null);
|
const inputRef = useRef<InputRef>(null);
|
||||||
@@ -76,7 +82,7 @@ export default function RemarkTemplateField({
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<CodeOutlined />}
|
icon={<CodeOutlined aria-hidden />}
|
||||||
aria-label={t('pages.hosts.remarkVars.title')}
|
aria-label={t('pages.hosts.remarkVars.title')}
|
||||||
style={{ marginInlineEnd: -7 }}
|
style={{ marginInlineEnd: -7 }}
|
||||||
/>
|
/>
|
||||||
@@ -90,6 +96,9 @@ export default function RemarkTemplateField({
|
|||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
|
id={id}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledBy}
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -101,6 +110,9 @@ export default function RemarkTemplateField({
|
|||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledBy}
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { useEffect } from 'react';
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { expect, waitFor } from 'storybook/test';
|
import { expect, waitFor } from 'storybook/test';
|
||||||
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
|
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
|
||||||
import { FormProvider } from 'react-hook-form';
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { FormField } from './FormField';
|
import { FormField } from './FormField';
|
||||||
import { useZodForm } from './useZodForm';
|
|
||||||
|
|
||||||
const GB = 1024 * 1024 * 1024;
|
const GB = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -60,7 +60,8 @@ const ClientSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function ClientDemo() {
|
function ClientDemo() {
|
||||||
const methods = useZodForm(ClientSchema, {
|
const methods = useForm<z.infer<typeof ClientSchema>>({
|
||||||
|
resolver: zodResolver(ClientSchema),
|
||||||
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
|
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
@@ -95,8 +96,11 @@ const TrafficSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function TrafficDemo() {
|
function TrafficDemo() {
|
||||||
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
|
const methods = useForm<z.infer<typeof TrafficSchema>>({
|
||||||
const totalBytes = methods.watch('totalBytes');
|
resolver: zodResolver(TrafficSchema),
|
||||||
|
defaultValues: { totalBytes: 50 * GB },
|
||||||
|
});
|
||||||
|
const totalBytes = useWatch({ control: methods.control, name: 'totalBytes' });
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
||||||
@@ -128,7 +132,10 @@ const InboundSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function ValidationDemo() {
|
function ValidationDemo() {
|
||||||
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
|
const methods = useForm<z.infer<typeof InboundSchema>>({
|
||||||
|
resolver: zodResolver(InboundSchema),
|
||||||
|
defaultValues: { remark: '', port: 0 },
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void methods.trigger();
|
void methods.trigger();
|
||||||
}, [methods]);
|
}, [methods]);
|
||||||
@@ -157,7 +164,8 @@ const RealitySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function NestedDemo() {
|
function NestedDemo() {
|
||||||
const methods = useZodForm(RealitySchema, {
|
const methods = useForm<z.infer<typeof RealitySchema>>({
|
||||||
|
resolver: zodResolver(RealitySchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
streamSettings: {
|
streamSettings: {
|
||||||
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
|
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
|
||||||
|
|||||||
@@ -49,8 +49,13 @@ export function FormField<T extends FieldValues = FieldValues>({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dottedName = toDotted(name) as Path<T>;
|
const dottedName = toDotted(name) as Path<T>;
|
||||||
const generatedId = useId();
|
const generatedId = useId();
|
||||||
const explicitId = (children as ReactElement<{ id?: string }>).props.id;
|
const hintId = useId();
|
||||||
const fieldId = explicitId ?? generatedId;
|
const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
|
||||||
|
.props;
|
||||||
|
const fieldId = childOwnProps.id ?? generatedId;
|
||||||
|
const describedBy = tooltip
|
||||||
|
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -74,6 +79,7 @@ export function FormField<T extends FieldValues = FieldValues>({
|
|||||||
onBlur: field.onBlur,
|
onBlur: field.onBlur,
|
||||||
ref: field.ref,
|
ref: field.ref,
|
||||||
};
|
};
|
||||||
|
if (describedBy) childProps['aria-describedby'] = describedBy;
|
||||||
return (
|
return (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={label}
|
label={label}
|
||||||
@@ -88,6 +94,12 @@ export function FormField<T extends FieldValues = FieldValues>({
|
|||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
|
{cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
|
||||||
|
{/* The tooltip only opens on hover, so screen readers get its text as a description. */}
|
||||||
|
{tooltip && (
|
||||||
|
<span id={hintId} hidden>
|
||||||
|
{tooltip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { FormField, type FormFieldProps } from './FormField';
|
export { FormField, type FormFieldProps } from './FormField';
|
||||||
export { useZodForm } from './useZodForm';
|
|
||||||
export { rhfZodValidate } from './rhfZodValidate';
|
export { rhfZodValidate } from './rhfZodValidate';
|
||||||
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
|
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
|
||||||
export { toDotted, type FieldName } from './toDotted';
|
export { toDotted, type FieldName } from './toDotted';
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
export function useZodForm<TFieldValues extends FieldValues>(
|
|
||||||
schema: z.ZodType<TFieldValues>,
|
|
||||||
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
|
|
||||||
): UseFormReturn<TFieldValues> {
|
|
||||||
const resolver = zodResolver(
|
|
||||||
schema as z.ZodType<TFieldValues, TFieldValues>,
|
|
||||||
) as Resolver<TFieldValues>;
|
|
||||||
return useForm<TFieldValues>({
|
|
||||||
mode: 'onSubmit',
|
|
||||||
reValidateMode: 'onChange',
|
|
||||||
shouldUnregister: false,
|
|
||||||
...options,
|
|
||||||
resolver,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -332,6 +332,7 @@ export default function GeoBrowserModal({
|
|||||||
value={categoryQuery}
|
value={categoryQuery}
|
||||||
onChange={(event) => setCategoryQuery(event.target.value)}
|
onChange={(event) => setCategoryQuery(event.target.value)}
|
||||||
placeholder={t('pages.xray.geoBrowser.searchCategory')}
|
placeholder={t('pages.xray.geoBrowser.searchCategory')}
|
||||||
|
aria-label={t('pages.xray.geoBrowser.searchCategory')}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -392,6 +393,7 @@ export default function GeoBrowserModal({
|
|||||||
value={entryQuery}
|
value={entryQuery}
|
||||||
onChange={(event) => setEntryQuery(event.target.value)}
|
onChange={(event) => setEntryQuery(event.target.value)}
|
||||||
placeholder={t('pages.xray.geoBrowser.searchEntries')}
|
placeholder={t('pages.xray.geoBrowser.searchEntries')}
|
||||||
|
aria-label={t('pages.xray.geoBrowser.searchEntries')}
|
||||||
allowClear
|
allowClear
|
||||||
className="geo-entry-filter"
|
className="geo-entry-filter"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface GeoTokenInputProps {
|
|||||||
kind: GeoTokenKind;
|
kind: GeoTokenKind;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
'aria-describedby'?: string;
|
||||||
ref?: Ref<InputRef>;
|
ref?: Ref<InputRef>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export default function GeoTokenInput({
|
|||||||
kind,
|
kind,
|
||||||
placeholder,
|
placeholder,
|
||||||
id,
|
id,
|
||||||
|
'aria-describedby': ariaDescribedBy,
|
||||||
ref,
|
ref,
|
||||||
}: GeoTokenInputProps) {
|
}: GeoTokenInputProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -92,6 +94,7 @@ export default function GeoTokenInput({
|
|||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
onChange={(event) => onChange?.(event.target.value)}
|
||||||
@@ -99,7 +102,7 @@ export default function GeoTokenInput({
|
|||||||
/>
|
/>
|
||||||
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
|
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
|
||||||
<Button
|
<Button
|
||||||
icon={<DatabaseOutlined />}
|
icon={<DatabaseOutlined aria-hidden />}
|
||||||
aria-label={t('pages.xray.geoBrowser.openTooltip')}
|
aria-label={t('pages.xray.geoBrowser.openTooltip')}
|
||||||
onClick={() => setBrowsing(true)}
|
onClick={() => setBrowsing(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user