mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-26 13:07:14 +00:00
Compare commits
21 Commits
a255ab7c65
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f727d04f65 | |||
| fcf60eb2e2 | |||
| 103b0dfe8d | |||
| 2d30ab3ada | |||
| d175050f2e | |||
| 7a595cb46d | |||
| f13baa9af5 | |||
| 9408424959 | |||
| f204997c98 | |||
| effcccceac | |||
| d9b599b9aa | |||
| cc245a908e | |||
| c26ff59b47 | |||
| 6f7a305239 | |||
| da01b7637d | |||
| 81fcacab11 | |||
| 02002dc1c3 | |||
| 326009e9d3 | |||
| 585f4ecdc0 | |||
| bd6a6aba43 | |||
| a3e617215c |
@@ -15,7 +15,7 @@ question it already answers.
|
|||||||
|
|
||||||
3x-ui is an open-source web control panel for managing Xray-core servers.
|
3x-ui is an open-source web control panel for managing Xray-core servers.
|
||||||
|
|
||||||
- Backend: Go 1.26, module `github.com/mhsanaei/3x-ui/v3`, Gin and GORM.
|
- Backend: Go 1.27, module `github.com/mhsanaei/3x-ui/v3`, Gin and GORM.
|
||||||
- It runs Xray-core as a managed child process (`internal/xray/process.go`) and
|
- It runs Xray-core as a managed child process (`internal/xray/process.go`) and
|
||||||
imports `github.com/xtls/xray-core` for config types and the gRPC
|
imports `github.com/xtls/xray-core` for config types and the gRPC
|
||||||
stats/handler/router API. The release the panel BUNDLES is pinned in
|
stats/handler/router API. The release the panel BUNDLES is pinned in
|
||||||
|
|||||||
@@ -494,13 +494,41 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# Read-only: this job holds a write-scoped token, so building or running
|
# An `@claude review` vouches for the head that existed when it was typed;
|
||||||
# anything out of pr-head/ would turn the review into a pwn-request.
|
# a push after it would swap the code out from under that approval.
|
||||||
# checkout v7 refuses a fork PR ref outright unless that risk is accepted
|
- name: Pin the head this run reviews
|
||||||
# here, and nearly every pull request to this repository is from a fork.
|
id: pinned-sha
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||||
|
PAYLOAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
COMMENT_AT: ${{ github.event.comment.created_at }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -n "$PAYLOAD_SHA" ]; then
|
||||||
|
echo "sha=${PAYLOAD_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
head=$(gh api "repos/${REPO}/pulls/${PR}" --jq '"\(.head.sha) \(.head.repo.pushed_at // "")"')
|
||||||
|
HEAD_SHA=${head%% *}
|
||||||
|
HEAD_PUSHED_AT=${head#* }
|
||||||
|
if [ -z "$HEAD_PUSHED_AT" ]; then
|
||||||
|
gh pr comment "$PR" --repo "$REPO" --body "The head repository of this pull request is gone, so the code to review cannot be verified. Nothing was reviewed."
|
||||||
|
echo "::error::The head repository is unavailable; refusing to check it out."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(date -d "$HEAD_PUSHED_AT" +%s)" -gt "$(date -d "$COMMENT_AT" +%s)" ]; then
|
||||||
|
gh pr comment "$PR" --repo "$REPO" --body "The head branch was pushed to at ${HEAD_PUSHED_AT}, after this review was requested at ${COMMENT_AT}, so the code that would be checked out here is not the code the request vouched for. Nothing was reviewed. Ask again to review the current head."
|
||||||
|
echo "::error::The head moved after the request; refusing to check it out."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
# Read-only, and pinned to one immutable commit: this job holds a
|
||||||
|
# write-scoped token, so running anything out of pr-head/ would be a pwn-request.
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/head
|
ref: ${{ steps.pinned-sha.outputs.sha }}
|
||||||
path: pr-head
|
path: pr-head
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
allow-unsafe-pr-checkout: true
|
allow-unsafe-pr-checkout: true
|
||||||
@@ -534,7 +562,7 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: Fail if the review posted nothing
|
- name: Fail if the review posted nothing
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ index, layering rules), read `docs/architecture.md` on demand — do not guess
|
|||||||
file locations when it can answer in one hop.
|
file locations when it can answer in one hop.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
|
- Backend: Go 1.27 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
|
||||||
Runs Xray-core as a managed child process (`internal/xray/process.go`) and
|
Runs Xray-core as a managed child process (`internal/xray/process.go`) and
|
||||||
imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
|
imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
|
||||||
API. MTProto inbounds run a second managed child — the `mtg-multi` binary
|
API. MTProto inbounds run a second managed child — the `mtg-multi` binary
|
||||||
@@ -41,6 +41,14 @@ file locations when it can answer in one hop.
|
|||||||
- `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
|
- `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
|
||||||
category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
|
category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
|
||||||
- `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
|
- `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
|
||||||
|
- `internal/amneziawg/` — AmneziaWG protocol shape: instance/peer derivation
|
||||||
|
from an inbound, 3.1 obfuscation param generation + validation, port-forward
|
||||||
|
spec parsing.
|
||||||
|
- `internal/amneziawgnet/` — embedded AmneziaWG runtime: amneziawg-go device
|
||||||
|
over a gVisor userspace netstack, per-inbound reconcile manager, TCP/UDP
|
||||||
|
relay into a loopback per-peer-auth SOCKS5 Xray inbound, port-forward
|
||||||
|
listeners, per-peer IPv6 egress aliases.
|
||||||
|
- `internal/pia/` — PIA WireGuard protocol client (auth, signed server list, `/addKey`).
|
||||||
- `internal/sub/` — subscription server (raw / JSON / Clash).
|
- `internal/sub/` — subscription server (raw / JSON / Clash).
|
||||||
- `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
|
- `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
|
||||||
cpu.high, memory.high, login.attempt).
|
cpu.high, memory.high, login.attempt).
|
||||||
@@ -50,7 +58,7 @@ file locations when it can answer in one hop.
|
|||||||
- `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
|
- `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
|
||||||
- `service/` — business logic (InboundService, SettingService, XrayService,
|
- `service/` — business logic (InboundService, SettingService, XrayService,
|
||||||
node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
|
node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
|
||||||
- `job/` — 17 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
|
- `job/` — 18 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
|
||||||
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/`.
|
||||||
@@ -124,7 +132,7 @@ file locations when it can answer in one hop.
|
|||||||
|
|
||||||
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
|
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
|
||||||
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
|
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
|
||||||
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Zod schemas in
|
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
|
||||||
`src/schemas/` are the source of truth; infer types with `z.infer`, never
|
`src/schemas/` are the source of truth; infer types with `z.infer`, never
|
||||||
hand-write. Do not edit `src/generated/`.
|
hand-write. Do not edit `src/generated/`.
|
||||||
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
|
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
|
||||||
@@ -146,8 +154,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 + format-check + typecheck + test + build
|
make verify # gen-check + lint + typecheck + test + build + build-storybook
|
||||||
# + build-storybook
|
|
||||||
|
|
||||||
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) and a
|
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Go 1.26+** (the version pinned in `go.mod`)
|
- **Go 1.27+** (the version pinned in `go.mod`)
|
||||||
- **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
|
- **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
|
||||||
- **Git**
|
- **Git**
|
||||||
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
|
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ RUN npm run build
|
|||||||
# ========================================================
|
# ========================================================
|
||||||
# Stage: Builder
|
# Stage: Builder
|
||||||
# ========================================================
|
# ========================================================
|
||||||
FROM golang:1.26-alpine AS builder
|
FROM golang:1.27-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -37,8 +37,8 @@ func section(t *testing.T, doc, from, to string) string {
|
|||||||
t.Fatalf("%s no longer contains the heading %q", botContextPath, from)
|
t.Fatalf("%s no longer contains the heading %q", botContextPath, from)
|
||||||
}
|
}
|
||||||
rest := doc[i+len(from):]
|
rest := doc[i+len(from):]
|
||||||
if j := strings.Index(rest, to); j >= 0 {
|
if before, _, ok := strings.Cut(rest, to); ok {
|
||||||
return rest[:j]
|
return before
|
||||||
}
|
}
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ services:
|
|||||||
# with iptables, which needs NET_ADMIN. Without these caps a ban is logged
|
# with iptables, which needs NET_ADMIN. Without these caps a ban is logged
|
||||||
# and shown in fail2ban status but never actually applied. NET_RAW covers
|
# and shown in fail2ban status but never actually applied. NET_RAW covers
|
||||||
# ip6tables. If you disable Fail2ban, you can drop cap_add.
|
# ip6tables. If you disable Fail2ban, you can drop cap_add.
|
||||||
|
#
|
||||||
|
# AmneziaWG works in this image: it runs embedded in the panel process
|
||||||
|
# (amneziawg-go over a gVisor userspace netstack), so it needs no kernel
|
||||||
|
# module and no host tooling. Publish its UDP listen port to use it.
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Two key ideas that explain most of the complexity:
|
|||||||
|
|
||||||
## 2. Tech stack
|
## 2. Tech stack
|
||||||
|
|
||||||
**Backend (Go 1.26):**
|
**Backend (Go 1.27):**
|
||||||
|
|
||||||
- Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
|
- Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
|
||||||
- ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`).
|
- ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`).
|
||||||
@@ -136,6 +136,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
|
|||||||
│ │ └── model/ # **ALL GORM models** (model.go ~1.1k lines + siblings:
|
│ │ └── model/ # **ALL GORM models** (model.go ~1.1k lines + siblings:
|
||||||
│ │ # node_client_traffic.go, node_client_ip.go,
|
│ │ # node_client_traffic.go, node_client_ip.go,
|
||||||
│ │ # client_global_traffic.go). ⭐ Start here for data shape.
|
│ │ # client_global_traffic.go). ⭐ Start here for data shape.
|
||||||
|
│ ├── pia/ # PIA WireGuard protocol client (auth, signed server list, /addKey)
|
||||||
│ ├── eventbus/ # In-process pub/sub (buffered channel): outbound.down|up,
|
│ ├── eventbus/ # In-process pub/sub (buffered channel): outbound.down|up,
|
||||||
│ │ # xray.crash, node.down|up, cpu.high, memory.high, login.attempt
|
│ │ # xray.crash, node.down|up, cpu.high, memory.high, login.attempt
|
||||||
│ ├── tunnelmonitor/ # Optional tunnel health probe (XUI_TUNNEL_HEALTH_* env vars):
|
│ ├── tunnelmonitor/ # Optional tunnel health probe (XUI_TUNNEL_HEALTH_* env vars):
|
||||||
@@ -163,7 +164,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
|
|||||||
│ │ │ ├── host.go # /panel/api/hosts (per-inbound subscription host overrides)
|
│ │ │ ├── host.go # /panel/api/hosts (per-inbound subscription host overrides)
|
||||||
│ │ │ ├── server.go # /panel/api/server (status, xray version, certs, logs, DB import/export)
|
│ │ │ ├── server.go # /panel/api/server (status, xray version, certs, logs, DB import/export)
|
||||||
│ │ │ ├── setting.go # /panel/api/setting (settings + API tokens)
|
│ │ │ ├── setting.go # /panel/api/setting (settings + API tokens)
|
||||||
│ │ │ ├── xray_setting.go # /panel/api/xray (raw Xray config editor, WARP/Nord, geodata)
|
│ │ │ ├── xray_setting.go # /panel/api/xray (raw Xray config editor, WARP/Nord/PIA, geodata)
|
||||||
│ │ │ ├── api.go # /panel/api gateway (token auth, envelope + CSRF wiring)
|
│ │ │ ├── api.go # /panel/api gateway (token auth, envelope + CSRF wiring)
|
||||||
│ │ │ ├── index.go # login/logout/csrf/2FA
|
│ │ │ ├── index.go # login/logout/csrf/2FA
|
||||||
│ │ │ ├── spa.go # SPA fallback for /panel UI routes
|
│ │ │ ├── spa.go # SPA fallback for /panel UI routes
|
||||||
@@ -202,7 +203,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
|
|||||||
│ │ │ ├── port_conflict.go # Detect inbound port collisions
|
│ │ │ ├── port_conflict.go # Detect inbound port collisions
|
||||||
│ │ │ ├── fallback.go # Xray fallback (SNI/ALPN routing on shared port)
|
│ │ │ ├── fallback.go # Xray fallback (SNI/ALPN routing on shared port)
|
||||||
│ │ │ ├── email/ # Email notification service (SMTP)
|
│ │ │ ├── email/ # Email notification service (SMTP)
|
||||||
│ │ │ ├── integration/ # External providers: warp.go (Cloudflare WARP), nord.go (NordVPN)
|
│ │ │ ├── integration/ # External providers: warp.go, nord.go, pia.go
|
||||||
│ │ │ ├── outbound/ # Outbound config service
|
│ │ │ ├── outbound/ # Outbound config service
|
||||||
│ │ │ ├── panel/ # Cross-cutting panel services:
|
│ │ │ ├── panel/ # Cross-cutting panel services:
|
||||||
│ │ │ │ ├── panel.go # panel-level helpers
|
│ │ │ │ ├── panel.go # panel-level helpers
|
||||||
@@ -372,6 +373,7 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
|
|||||||
| `@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` | 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 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
||||||
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
|
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
|
||||||
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
||||||
@@ -497,7 +499,7 @@ for AutoMigrate in `internal/database/db.go`.
|
|||||||
| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
|
| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
|
||||||
| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |
|
| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |
|
||||||
| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` |
|
| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` |
|
||||||
| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` |
|
| **WARP / Nord / PIA** outbound integration | `service/integration/warp.go` / `nord.go` / `pia.go` | `internal/pia/`, `frontend/src/pages/xray/overrides/` |
|
||||||
| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` |
|
| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` |
|
||||||
| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` |
|
| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` |
|
||||||
| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` |
|
| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` |
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
icon: Lock
|
||||||
|
---
|
||||||
|
|
||||||
|
**AmneziaWG** is a WireGuard fork that adds traffic obfuscation (junk packets,
|
||||||
|
randomized padding, and rewritten protocol magic values) so the tunnel doesn't
|
||||||
|
look like WireGuard to deep-packet inspection. It's a popular choice where
|
||||||
|
plain WireGuard is blocked but a WireGuard-shaped tunnel with a different
|
||||||
|
fingerprint gets through.
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
AmneziaWG runs **embedded in the panel process** — `amneziawg-go` over a
|
||||||
|
userspace (gVisor) network stack, not a kernel module. There is no DKMS
|
||||||
|
build, no Secure Boot conflict, and no host network/kernel access
|
||||||
|
requirement, so it works the same way inside a container as on bare
|
||||||
|
metal. Each peer's decapsulated traffic relays into its own loopback Xray
|
||||||
|
SOCKS5 inbound, so a peer's routing, sniffing, and per-client stats all
|
||||||
|
come from Xray's own machinery — the same as any other protocol's
|
||||||
|
inbound, not a separate code path.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Key settings
|
||||||
|
|
||||||
|
### Server / interface
|
||||||
|
|
||||||
|
| Field | What it is |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------------ |
|
||||||
|
| **Subnet** | The tunnel's IPv4 subnet (e.g. `10.8.1.0/24`); each client gets an address from it. |
|
||||||
|
| **MTU** | Interface MTU. Leave at the default unless you have a reason to change it. |
|
||||||
|
| **DNS (primary/secondary)** | Seeded into downloadable client configs; the server's own interface doesn't need one. |
|
||||||
|
| **External interface** | The host NIC a peer's IPv6 address gets aliased onto when IPv6 is enabled (see below). Leave blank to auto-detect. |
|
||||||
|
|
||||||
|
### Obfuscation (AmneziaWG 3.1)
|
||||||
|
|
||||||
|
The same values must match on both ends of the tunnel, so the server stores
|
||||||
|
them once and every client config inherits them. The panel generates a
|
||||||
|
randomized set for you (with a **regenerate** button) — a static, reused
|
||||||
|
value defeats the point, since DPI can fingerprint it over time.
|
||||||
|
|
||||||
|
| Field | What it is |
|
||||||
|
| ------------ | ---------------------------------------------------------------------------- |
|
||||||
|
| **Jc** | Number of junk packets sent before the handshake. |
|
||||||
|
| **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`. |
|
||||||
|
| **S1/S2** | Padding added to the handshake init/response packets. `S1 + 56` must not equal `S2` — amneziawg-go rejects a value that would make both packets the same size. |
|
||||||
|
| **S3** | Cookie-reply padding, `0`-`64`. |
|
||||||
|
| **S4** | Transport (data) packet padding, `0`-`32`. |
|
||||||
|
| **H1-H4** | Magic header values that replace WireGuard's standard message-type bytes. Each is a single integer or a `low-high` range; `1`-`4` are reserved (real WireGuard message types) and must not be used. |
|
||||||
|
| **I1-I5** | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. |
|
||||||
|
| **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. |
|
||||||
|
| **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. |
|
||||||
|
| **RekeyAfterTime / RekeyTimeout / RejectAfterTime / KeepaliveTimeout / MaxHandshakeAttempts** | Handshake-timing randomization: each is a `low-high` range (seconds; attempts for the last one) the peer samples from, so session timing stops being a WireGuard fingerprint. Every `RekeyAfterTime` value must stay below every `RejectAfterTime` value. Blank keeps the WireGuard default. |
|
||||||
|
| **RandomTrailers** | Appends a random number of bytes to the end of every packet. |
|
||||||
|
| **DisableCookies** | Never send cookie replies — removes a DPI-visible WireGuard message type, at the cost of WireGuard's handshake-flood mitigation. |
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
If you enter obfuscation values by hand instead of using the generated
|
||||||
|
defaults, keep `H1`-`H4` **non-overlapping** and above `4`, and double-check
|
||||||
|
`S1 + 56 != S2` — a bad value here keeps the embedded interface from
|
||||||
|
coming up at all.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
The 3.1 parameters need a **3.1-capable client**. Clients must run a
|
||||||
|
3.1-capable Amnezia app; blanking the 3.1 fields renders a config older
|
||||||
|
clients still understand. There is no host-side version requirement —
|
||||||
|
the panel ships its own pinned `amneziawg-go`, not whatever happens to be
|
||||||
|
installed on the system.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Set it up in the panel
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
### Add an inbound
|
||||||
|
|
||||||
|
Add a new inbound, choose protocol **AmneziaWG**, and set the port and tunnel
|
||||||
|
subnet.
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
### Leave obfuscation on defaults (or regenerate)
|
||||||
|
|
||||||
|
The panel fills in a randomized, kernel-valid obfuscation set automatically.
|
||||||
|
Use **Regenerate** if you want a fresh one; there's no need to hand-edit these
|
||||||
|
unless you have a specific reason to.
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
### Add a client
|
||||||
|
|
||||||
|
Each client gets its own keypair and tunnel address. Download the client's
|
||||||
|
`.conf` or copy its share link (`vpn://…`, importable by the official
|
||||||
|
AmneziaWG/AmneziaVPN apps) from the client list.
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
### Optional: enable IPv6
|
||||||
|
|
||||||
|
Turning on IPv6 allocates an IPv6 address alongside each client's IPv4 one
|
||||||
|
from the configured IPv6 subnet. The panel aliases that address onto the
|
||||||
|
external interface's host NIC so outbound connections carry the peer's own
|
||||||
|
distinct public IPv6 identity — no NAT66 needed.
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
### Optional: forward ports to a client
|
||||||
|
|
||||||
|
Set a client's forwarded ports (e.g. `80, 443, 8000-8100`) to open a real
|
||||||
|
listener on the host that relays that traffic straight to the client's
|
||||||
|
tunnel address — useful for a client that needs to expose a service through
|
||||||
|
the server.
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
Every AmneziaWG inbound's traffic already goes through Xray — each peer
|
||||||
|
relays into its own loopback SOCKS5 inbound, tagged with the AmneziaWG
|
||||||
|
inbound's own tag, so it shows up as a normal source on the
|
||||||
|
[Routing](/docs/operations/outbounds-routing) page like any other protocol's
|
||||||
|
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
|
||||||
|
decapsulated.
|
||||||
|
|
||||||
|
## What the configuration looks like
|
||||||
|
|
||||||
|
A client's downloadable `.conf` (also what the `vpn://` share link encodes,
|
||||||
|
base64url'd) looks like this:
|
||||||
|
|
||||||
|
```ini title="client .conf"
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = <client private key>
|
||||||
|
Address = 10.8.1.2/32
|
||||||
|
DNS = 8.8.8.8, 8.8.4.4
|
||||||
|
Jc = 4
|
||||||
|
Jmin = 65
|
||||||
|
Jmax = 220
|
||||||
|
S1 = 87
|
||||||
|
S2 = 44
|
||||||
|
S3 = 21
|
||||||
|
S4 = 9
|
||||||
|
H1 = 462980921-463150218
|
||||||
|
H2 = 1177681572-1177787900
|
||||||
|
H3 = 1907413509-1907903969
|
||||||
|
H4 = 2029908558-2030313135
|
||||||
|
I1 = <r 148>
|
||||||
|
HeaderProtectionKey = 8Iu83eHDA3fMKKSGaEsVW9Ycd2lYYzc0MYlk1jJTvE4=
|
||||||
|
ContentPaddingAddition = 17-49
|
||||||
|
RekeyAfterTime = 111-139
|
||||||
|
RekeyTimeout = 4-7
|
||||||
|
RejectAfterTime = 187-251
|
||||||
|
KeepaliveTimeout = 9-14
|
||||||
|
MaxHandshakeAttempts = 19-36
|
||||||
|
RandomTrailers = on
|
||||||
|
DisableCookies = on
|
||||||
|
|
||||||
|
# my-client
|
||||||
|
[Peer]
|
||||||
|
PublicKey = <server public key>
|
||||||
|
AllowedIPs = 0.0.0.0/0, ::/0
|
||||||
|
Endpoint = your-server:443
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
```
|
||||||
|
|
||||||
|
## Not yet covered
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
|
||||||
|
- **Multi-node (sub-nodes)** and **Telegram bot** — AmneziaWG inbounds haven't
|
||||||
|
been exercised through those paths yet. They likely work (the reconciler
|
||||||
|
runs the same way regardless of how the panel itself is deployed), but
|
||||||
|
that's not the same as a confirmed, tested claim — treat it as unverified
|
||||||
|
rather than assume it either way until someone reports back.
|
||||||
|
|
||||||
|
</Callout>
|
||||||
@@ -58,6 +58,7 @@ The inbound editor accepts these protocols:
|
|||||||
| **Trojan** | TLS-based; supports XTLS and fallbacks. |
|
| **Trojan** | TLS-based; supports XTLS and fallbacks. |
|
||||||
| **Shadowsocks** | Includes Shadowsocks-2022 (`2022-blake3-*`) ciphers. |
|
| **Shadowsocks** | Includes Shadowsocks-2022 (`2022-blake3-*`) ciphers. |
|
||||||
| **WireGuard** | Modern tunnel. |
|
| **WireGuard** | Modern tunnel. |
|
||||||
|
| **AmneziaWG** | Obfuscated WireGuard fork, embedded in the panel process. See [AmneziaWG](/docs/config/amneziawg). |
|
||||||
| **Hysteria2** | Selected as `hysteria`; the panel emits `hysteria2://` links. |
|
| **Hysteria2** | Selected as `hysteria`; the panel emits `hysteria2://` links. |
|
||||||
| **HTTP** | HTTP proxy. |
|
| **HTTP** | HTTP proxy. |
|
||||||
| **Mixed (SOCKS/HTTP)** | A combined SOCKS + HTTP listener. |
|
| **Mixed (SOCKS/HTTP)** | A combined SOCKS + HTTP listener. |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"ssl-certificates",
|
"ssl-certificates",
|
||||||
"inbounds",
|
"inbounds",
|
||||||
"reality",
|
"reality",
|
||||||
|
"amneziawg",
|
||||||
"transports",
|
"transports",
|
||||||
"clients",
|
"clients",
|
||||||
"subscription",
|
"subscription",
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
title: Outbounds & Routing
|
title: Outbounds & Routing
|
||||||
description: Shape egress in 3x-ui — WARP and NordVPN outbounds, outbound subscriptions (server pools), routing rules, and load balancers.
|
description: Shape egress in 3x-ui — WARP, NordVPN, PIA WireGuard, outbound subscriptions, routing rules, and load balancers.
|
||||||
icon: Route
|
icon: Route
|
||||||
---
|
---
|
||||||
|
|
||||||
Inbounds accept clients; **outbounds** decide where their traffic goes next.
|
Inbounds accept clients; **outbounds** decide where their traffic goes next.
|
||||||
3x-ui can route traffic through Cloudflare WARP, NordVPN, or arbitrary outbound
|
3x-ui can route traffic through Cloudflare WARP, NordVPN, Private Internet Access
|
||||||
pools imported from a subscription, and select between them with routing rules
|
(WireGuard), or arbitrary outbound pools imported from a subscription,
|
||||||
and balancers.
|
and select between them with routing rules and balancers.
|
||||||
|
|
||||||
## Editing outbounds & routing
|
## Editing outbounds & routing
|
||||||
|
|
||||||
@@ -86,6 +86,23 @@ with a routing rule.
|
|||||||
accept a private key directly) and list countries/servers, so you can build a
|
accept a private key directly) and list countries/servers, so you can build a
|
||||||
NordVPN outbound.
|
NordVPN outbound.
|
||||||
|
|
||||||
|
## PIA WireGuard
|
||||||
|
|
||||||
|
3x-ui can sign in with a PIA username and password, list countries/regions/servers
|
||||||
|
from the signed PIA server list, and build a WireGuard outbound. Open
|
||||||
|
**Xray → Outbounds → More → PIA**, sign in, pick a server, and add the outbound.
|
||||||
|
You can add several servers (one outbound per hostname). The tag is
|
||||||
|
`pia-<region>-<server>` (for example `pia-us-east-useast1`). Adding or using
|
||||||
|
**Reset** on a row registers a WireGuard key with PIA `/addKey` for that server.
|
||||||
|
The same hostname cannot be added twice. Logout clears the stored token only;
|
||||||
|
delete unused PIA outbounds from the Outbounds list. Reset and delete do not
|
||||||
|
revoke the WireGuard peer on the PIA account.
|
||||||
|
|
||||||
|
The password is not stored. The PIA API token is stored with the same
|
||||||
|
`NODE_TOKEN_ENCRYPTION` setting as node API tokens. If you retire an old
|
||||||
|
`XUI_NODE_TOKEN_KEY` without signing into PIA again, Add/Reset fail until you
|
||||||
|
re-login. Peer `allowedIPs` is IPv4-only (`0.0.0.0/0`).
|
||||||
|
|
||||||
## Outbound subscriptions (server pools)
|
## Outbound subscriptions (server pools)
|
||||||
|
|
||||||
An **outbound subscription** imports a remote share-link subscription and injects
|
An **outbound subscription** imports a remote share-link subscription and injects
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
---
|
---
|
||||||
title: API Tokens
|
title: API Tokens
|
||||||
description: 'Manage Bearer tokens used for programmatic auth (bots, central
|
description: Manage scoped Bearer tokens for programmatic auth. Tokens grant
|
||||||
panels acting on this node, CI). Each token has a unique name and an enabled
|
admin, monitor, or node-sync access, may expire, and are stored as SHA-256
|
||||||
flag — disable to revoke without deleting, delete to revoke permanently.
|
hashes. The plaintext is returned only once at creation.
|
||||||
Tokens are stored as SHA-256 hashes and the plaintext is returned only once,
|
|
||||||
in the create response — it cannot be retrieved afterwards, so copy it then.
|
|
||||||
Send one as <code>Authorization: Bearer <token></code> on any
|
|
||||||
/panel/api/* request — the token is a full-admin credential.'
|
|
||||||
full: true
|
full: true
|
||||||
_openapi:
|
_openapi:
|
||||||
preload:
|
preload:
|
||||||
|
|||||||
@@ -14,18 +14,24 @@ _openapi:
|
|||||||
JSON-encoded-string form is still accepted on write).
|
JSON-encoded-string form is still accepted on write).
|
||||||
url: '#list-every-client-with-its-attached-inbound-ids-and-traffic-record-the-reverse-field-if-set-is-returned-as-a-nested-json-object-legacy-json-encoded-string-form-is-still-accepted-on-write'
|
url: '#list-every-client-with-its-attached-inbound-ids-and-traffic-record-the-reverse-field-if-set-is-returned-as-a-nested-json-object-legacy-json-encoded-string-form-is-still-accepted-on-write'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Filter, sort, and paginate clients on the server. Each item is a slim row
|
title: 'Filter, sort, and paginate clients on the server. Each item is a slim
|
||||||
(no uuid/password/auth/flow/security/reverse/tgId) so the clients page
|
row (no uuid/password/auth/flow/security/reverse/tgId) so the clients
|
||||||
can ship 25-ish rows in a few KB instead of the full table. The response
|
page can ship 25-ish rows in a few KB instead of the full table. The
|
||||||
also includes a summary computed across the full DB row set so dashboard
|
response also includes a summary computed across the full DB row set so
|
||||||
counters stay stable as the user paginates or filters. Page size capped
|
dashboard counters stay stable as the user paginates or filters: the
|
||||||
at 200; fetch /get/:email to obtain the full per-client payload for an
|
*Count fields are exact, while the email arrays beside them stop at 200
|
||||||
edit/info modal.
|
entries so the payload does not grow with the panel. Page size capped at
|
||||||
url: '#filter-sort-and-paginate-clients-on-the-server-each-item-is-a-slim-row-no-uuidpasswordauthflowsecurityreversetgid-so-the-clients-page-can-ship-25-ish-rows-in-a-few-kb-instead-of-the-full-table-the-response-also-includes-a-summary-computed-across-the-full-db-row-set-so-dashboard-counters-stay-stable-as-the-user-paginates-or-filters-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal'
|
200; fetch /get/:email to obtain the full per-client payload for an
|
||||||
|
edit/info modal.'
|
||||||
|
url: '#filter-sort-and-paginate-clients-on-the-server-each-item-is-a-slim-row-no-uuidpasswordauthflowsecurityreversetgid-so-the-clients-page-can-ship-25-ish-rows-in-a-few-kb-instead-of-the-full-table-the-response-also-includes-a-summary-computed-across-the-full-db-row-set-so-dashboard-counters-stay-stable-as-the-user-paginates-or-filters-the-count-fields-are-exact-while-the-email-arrays-beside-them-stop-at-200-entries-so-the-payload-does-not-grow-with-the-panel-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
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 Telegram user ID. Returns an array since multiple
|
||||||
|
clients can share the same Telegram ID.
|
||||||
|
url: '#fetch-clients-by-telegram-user-id-returns-an-array-since-multiple-clients-can-share-the-same-telegram-id'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Create a new client and attach it to one or more inbounds in a single
|
title: Create a new client and attach it to one or more inbounds in a single
|
||||||
call. Body is JSON. Per-protocol secrets are generated server-side when
|
call. Body is JSON. Per-protocol secrets are generated server-side when
|
||||||
@@ -48,10 +54,10 @@ _openapi:
|
|||||||
title: Detach a client from one or more inbounds without deleting the client.
|
title: Detach a client from one or more inbounds without deleting the client.
|
||||||
url: '#detach-a-client-from-one-or-more-inbounds-without-deleting-the-client'
|
url: '#detach-a-client-from-one-or-more-inbounds-without-deleting-the-client'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Replace a client's external links (per-client share links and remote
|
title: Replace a client's external links and external subscriptions. Sends the
|
||||||
subscription URLs surfaced in their subscription). Sends the full set;
|
full set; the server replaces all rows. Disabled rows stay saved for
|
||||||
the server replaces all rows.
|
editing but are not emitted in generated subscriptions.
|
||||||
url: '#replace-a-clients-external-links-per-client-share-links-and-remote-subscription-urls-surfaced-in-their-subscription-sends-the-full-set-the-server-replaces-all-rows'
|
url: '#replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Reset the up/down counters for every client globally. Quotas and expiry
|
title: Reset the up/down counters for every client globally. Quotas and expiry
|
||||||
are not affected. Triggers an Xray restart if any counter actually
|
are not affected. Triggers an Xray restart if any counter actually
|
||||||
@@ -64,10 +70,10 @@ _openapi:
|
|||||||
url: '#delete-every-client-whose-traffic-quota-is-exhausted-used--total-when-reset-is-disabled-or-whose-expiry-has-passed-returns-the-deleted-count-and-triggers-an-xray-restart-when-any-client-was-on-a-running-inbound'
|
url: '#delete-every-client-whose-traffic-quota-is-exhausted-used--total-when-reset-is-disabled-or-whose-expiry-has-passed-returns-the-deleted-count-and-triggers-an-xray-restart-when-any-client-was-on-a-running-inbound'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Delete every client that is not attached to any inbound, along with its
|
title: Delete every client that is not attached to any inbound, along with its
|
||||||
traffic record, IP log, and external links. Useful for clearing clients
|
traffic record, IP log, HWID devices, and external links. Useful for
|
||||||
left unattached after their inbounds were removed. Returns the deleted
|
clearing clients left unattached after their inbounds were removed.
|
||||||
count. Cannot be undone.
|
Returns the deleted count. Cannot be undone.
|
||||||
url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone'
|
url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Return every client as a {client, inboundIds} array — the same shape
|
title: Return every client as a {client, inboundIds} array — the same shape
|
||||||
/bulkCreate and /import accept — so the payload round-trips straight
|
/bulkCreate and /import accept — so the payload round-trips straight
|
||||||
@@ -88,12 +94,16 @@ _openapi:
|
|||||||
title: 'Shift expiry and/or traffic quota for many clients in one call.
|
title: 'Shift expiry and/or traffic quota for many clients in one call.
|
||||||
addDays/addBytes may be negative. Clients with unlimited expiry
|
addDays/addBytes may be negative. Clients with unlimited expiry
|
||||||
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
|
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
|
||||||
corresponding field — bulk extend never converts unlimited to limited.
|
corresponding field — bulk extend never converts unlimited to limited. A
|
||||||
The optional flow directive sets the XTLS flow on every client: "none"
|
client that was auto-disabled solely because it was depleted (expired or
|
||||||
clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the
|
over quota) is automatically re-enabled — locally and on its node — when
|
||||||
inbound supports it (omit or "" to leave it unchanged). Returns the
|
the adjustment lifts it out of depletion; a manually-disabled or
|
||||||
adjusted count and per-email skip reasons.'
|
still-depleted client is left disabled. The optional flow directive sets
|
||||||
url: '#shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons'
|
the XTLS flow on every client: "none" clears it,
|
||||||
|
"xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound
|
||||||
|
supports it (omit or "" to leave it unchanged). Returns the adjusted
|
||||||
|
count and per-email skip reasons.'
|
||||||
|
url: '#shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Enable many clients in one call. Emails are grouped by inbound and
|
title: Enable many clients in one call. Emails are grouped by inbound and
|
||||||
applied with a single read-modify-write per inbound; the running Xray
|
applied with a single read-modify-write per inbound; the running Xray
|
||||||
@@ -188,6 +198,13 @@ _openapi:
|
|||||||
after filtering by group for that. Returns the count of clients whose
|
after filtering by group for that. Returns the count of clients whose
|
||||||
label was cleared.
|
label was cleared.
|
||||||
url: '#remove-a-group-deletes-the-client_groups-row-and-clears-the-group-label-from-every-matching-client-both-clientsgroup_name-and-the-inbound-settings-json-the-clients-themselves-are-not-deleted--use-bulkdel-after-filtering-by-group-for-that-returns-the-count-of-clients-whose-label-was-cleared'
|
url: '#remove-a-group-deletes-the-client_groups-row-and-clears-the-group-label-from-every-matching-client-both-clientsgroup_name-and-the-inbound-settings-json-the-clients-themselves-are-not-deleted--use-bulkdel-after-filtering-by-group-for-that-returns-the-count-of-clients-whose-label-was-cleared'
|
||||||
|
- depth: 2
|
||||||
|
title: Reset only the group-level traffic counter shown on the groups page.
|
||||||
|
Snapshots the current up/down sum of the group's members as a baseline
|
||||||
|
so the group total reads zero, while leaving each client's own counters
|
||||||
|
(and their quotas) untouched. No Xray restart is triggered. Creates the
|
||||||
|
client_groups row if the group exists only as a derived label.
|
||||||
|
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Zero out a single client’s up/down counters. Re-enables the client across
|
title: Zero out a single client’s up/down counters. Re-enables the client across
|
||||||
every attached inbound and pushes the change to Xray (or the remote
|
every attached inbound and pushes the change to Xray (or the remote
|
||||||
@@ -204,6 +221,17 @@ _openapi:
|
|||||||
- depth: 2
|
- depth: 2
|
||||||
title: Reset the recorded IP list for a client.
|
title: Reset the recorded IP list for a client.
|
||||||
url: '#reset-the-recorded-ip-list-for-a-client'
|
url: '#reset-the-recorded-ip-list-for-a-client'
|
||||||
|
- depth: 2
|
||||||
|
title: List registered HWID devices for a client. Hashes are not exposed.
|
||||||
|
url: '#list-registered-hwid-devices-for-a-client-hashes-are-not-exposed'
|
||||||
|
- depth: 2
|
||||||
|
title: Clear all registered HWID devices for a client so new devices can
|
||||||
|
register again.
|
||||||
|
url: '#clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again'
|
||||||
|
- depth: 2
|
||||||
|
title: Remove a single registered HWID device by its id, freeing one slot under
|
||||||
|
the HWID limit.
|
||||||
|
url: '#remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: List the emails of currently connected clients (last seen within the
|
title: List the emails of currently connected clients (last seen within the
|
||||||
heartbeat window), deduped across every node.
|
heartbeat window), deduped across every node.
|
||||||
@@ -248,34 +276,28 @@ _openapi:
|
|||||||
Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
|
Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
|
||||||
tunnel) contribute nothing.'
|
tunnel) contribute nothing.'
|
||||||
url: '#return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
|
url: '#return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
|
||||||
- depth: 2
|
|
||||||
title: List registered HWID devices for a client. Hashes are not exposed.
|
|
||||||
url: '#list-registered-hwid-devices-for-a-client-hashes-are-not-exposed'
|
|
||||||
- depth: 2
|
|
||||||
title: Clear all registered HWID devices for a client so new devices can
|
|
||||||
register again.
|
|
||||||
url: '#clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again'
|
|
||||||
- depth: 2
|
|
||||||
title: Remove a single registered HWID device by its id, freeing one slot under
|
|
||||||
the HWID limit.
|
|
||||||
url: '#remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit'
|
|
||||||
structuredData:
|
structuredData:
|
||||||
headings:
|
headings:
|
||||||
- content: List every client with its attached inbound IDs and traffic record. The
|
- content: List every client with its attached inbound IDs and traffic record. The
|
||||||
reverse field, if set, is returned as a nested JSON object (legacy
|
reverse field, if set, is returned as a nested JSON object (legacy
|
||||||
JSON-encoded-string form is still accepted on write).
|
JSON-encoded-string form is still accepted on write).
|
||||||
id: list-every-client-with-its-attached-inbound-ids-and-traffic-record-the-reverse-field-if-set-is-returned-as-a-nested-json-object-legacy-json-encoded-string-form-is-still-accepted-on-write
|
id: list-every-client-with-its-attached-inbound-ids-and-traffic-record-the-reverse-field-if-set-is-returned-as-a-nested-json-object-legacy-json-encoded-string-form-is-still-accepted-on-write
|
||||||
- content: Filter, sort, and paginate clients on the server. Each item is a slim
|
- content: 'Filter, sort, and paginate clients on the server. Each item is a slim
|
||||||
row (no uuid/password/auth/flow/security/reverse/tgId) so the clients
|
row (no uuid/password/auth/flow/security/reverse/tgId) so the clients
|
||||||
page can ship 25-ish rows in a few KB instead of the full table. The
|
page can ship 25-ish rows in a few KB instead of the full table. The
|
||||||
response also includes a summary computed across the full DB row set
|
response also includes a summary computed across the full DB row set
|
||||||
so dashboard counters stay stable as the user paginates or filters.
|
so dashboard counters stay stable as the user paginates or filters:
|
||||||
Page size capped at 200; fetch /get/:email to obtain the full
|
the *Count fields are exact, while the email arrays beside them stop
|
||||||
per-client payload for an edit/info modal.
|
at 200 entries so the payload does not grow with the panel. Page size
|
||||||
id: filter-sort-and-paginate-clients-on-the-server-each-item-is-a-slim-row-no-uuidpasswordauthflowsecurityreversetgid-so-the-clients-page-can-ship-25-ish-rows-in-a-few-kb-instead-of-the-full-table-the-response-also-includes-a-summary-computed-across-the-full-db-row-set-so-dashboard-counters-stay-stable-as-the-user-paginates-or-filters-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal
|
capped at 200; fetch /get/:email to obtain the full per-client payload
|
||||||
|
for an edit/info modal.'
|
||||||
|
id: filter-sort-and-paginate-clients-on-the-server-each-item-is-a-slim-row-no-uuidpasswordauthflowsecurityreversetgid-so-the-clients-page-can-ship-25-ish-rows-in-a-few-kb-instead-of-the-full-table-the-response-also-includes-a-summary-computed-across-the-full-db-row-set-so-dashboard-counters-stay-stable-as-the-user-paginates-or-filters-the-count-fields-are-exact-while-the-email-arrays-beside-them-stop-at-200-entries-so-the-payload-does-not-grow-with-the-panel-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal
|
||||||
- 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 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
|
||||||
- content: Create a new client and attach it to one or more inbounds in a single
|
- content: Create a new client and attach it to one or more inbounds in a single
|
||||||
call. Body is JSON. Per-protocol secrets are generated server-side
|
call. Body is JSON. Per-protocol secrets are generated server-side
|
||||||
when omitted, so callers can send only the universal fields.
|
when omitted, so callers can send only the universal fields.
|
||||||
@@ -293,10 +315,10 @@ _openapi:
|
|||||||
id: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
|
id: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
|
||||||
- content: Detach a client from one or more inbounds without deleting the client.
|
- content: Detach a client from one or more inbounds without deleting the client.
|
||||||
id: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
|
id: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
|
||||||
- content: Replace a client's external links (per-client share links and remote
|
- content: Replace a client's external links and external subscriptions. Sends the
|
||||||
subscription URLs surfaced in their subscription). Sends the full set;
|
full set; the server replaces all rows. Disabled rows stay saved for
|
||||||
the server replaces all rows.
|
editing but are not emitted in generated subscriptions.
|
||||||
id: replace-a-clients-external-links-per-client-share-links-and-remote-subscription-urls-surfaced-in-their-subscription-sends-the-full-set-the-server-replaces-all-rows
|
id: replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions
|
||||||
- content: Reset the up/down counters for every client globally. Quotas and expiry
|
- content: Reset the up/down counters for every client globally. Quotas and expiry
|
||||||
are not affected. Triggers an Xray restart if any counter actually
|
are not affected. Triggers an Xray restart if any counter actually
|
||||||
moved.
|
moved.
|
||||||
@@ -307,10 +329,10 @@ _openapi:
|
|||||||
running inbound.
|
running inbound.
|
||||||
id: delete-every-client-whose-traffic-quota-is-exhausted-used--total-when-reset-is-disabled-or-whose-expiry-has-passed-returns-the-deleted-count-and-triggers-an-xray-restart-when-any-client-was-on-a-running-inbound
|
id: delete-every-client-whose-traffic-quota-is-exhausted-used--total-when-reset-is-disabled-or-whose-expiry-has-passed-returns-the-deleted-count-and-triggers-an-xray-restart-when-any-client-was-on-a-running-inbound
|
||||||
- content: Delete every client that is not attached to any inbound, along with its
|
- content: Delete every client that is not attached to any inbound, along with its
|
||||||
traffic record, IP log, and external links. Useful for clearing
|
traffic record, IP log, HWID devices, and external links. Useful for
|
||||||
clients left unattached after their inbounds were removed. Returns the
|
clearing clients left unattached after their inbounds were removed.
|
||||||
deleted count. Cannot be undone.
|
Returns the deleted count. Cannot be undone.
|
||||||
id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone
|
id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone
|
||||||
- content: Return every client as a {client, inboundIds} array — the same shape
|
- content: Return every client as a {client, inboundIds} array — the same shape
|
||||||
/bulkCreate and /import accept — so the payload round-trips straight
|
/bulkCreate and /import accept — so the payload round-trips straight
|
||||||
back through /import. Clients with no inbound attachment are included
|
back through /import. Clients with no inbound attachment are included
|
||||||
@@ -329,11 +351,15 @@ _openapi:
|
|||||||
addDays/addBytes may be negative. Clients with unlimited expiry
|
addDays/addBytes may be negative. Clients with unlimited expiry
|
||||||
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
|
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
|
||||||
corresponding field — bulk extend never converts unlimited to limited.
|
corresponding field — bulk extend never converts unlimited to limited.
|
||||||
The optional flow directive sets the XTLS flow on every client: "none"
|
A client that was auto-disabled solely because it was depleted
|
||||||
|
(expired or over quota) is automatically re-enabled — locally and on
|
||||||
|
its node — when the adjustment lifts it out of depletion; a
|
||||||
|
manually-disabled or still-depleted client is left disabled. The
|
||||||
|
optional flow directive sets the XTLS flow on every client: "none"
|
||||||
clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where
|
clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where
|
||||||
the inbound supports it (omit or "" to leave it unchanged). Returns
|
the inbound supports it (omit or "" to leave it unchanged). Returns
|
||||||
the adjusted count and per-email skip reasons.'
|
the adjusted count and per-email skip reasons.'
|
||||||
id: shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons
|
id: shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons
|
||||||
- content: Enable many clients in one call. Emails are grouped by inbound and
|
- content: Enable many clients in one call. Emails are grouped by inbound and
|
||||||
applied with a single read-modify-write per inbound; the running Xray
|
applied with a single read-modify-write per inbound; the running Xray
|
||||||
(local or remote node) is updated to add each user. Note that enabling
|
(local or remote node) is updated to add each user. Note that enabling
|
||||||
@@ -417,6 +443,13 @@ _openapi:
|
|||||||
/bulkDel after filtering by group for that. Returns the count of
|
/bulkDel after filtering by group for that. Returns the count of
|
||||||
clients whose label was cleared.
|
clients whose label was cleared.
|
||||||
id: remove-a-group-deletes-the-client_groups-row-and-clears-the-group-label-from-every-matching-client-both-clientsgroup_name-and-the-inbound-settings-json-the-clients-themselves-are-not-deleted--use-bulkdel-after-filtering-by-group-for-that-returns-the-count-of-clients-whose-label-was-cleared
|
id: remove-a-group-deletes-the-client_groups-row-and-clears-the-group-label-from-every-matching-client-both-clientsgroup_name-and-the-inbound-settings-json-the-clients-themselves-are-not-deleted--use-bulkdel-after-filtering-by-group-for-that-returns-the-count-of-clients-whose-label-was-cleared
|
||||||
|
- content: Reset only the group-level traffic counter shown on the groups page.
|
||||||
|
Snapshots the current up/down sum of the group's members as a baseline
|
||||||
|
so the group total reads zero, while leaving each client's own
|
||||||
|
counters (and their quotas) untouched. No Xray restart is triggered.
|
||||||
|
Creates the client_groups row if the group exists only as a derived
|
||||||
|
label.
|
||||||
|
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
|
||||||
- content: Zero out a single client’s up/down counters. Re-enables the client
|
- content: Zero out a single client’s up/down counters. Re-enables the client
|
||||||
across every attached inbound and pushes the change to Xray (or the
|
across every attached inbound and pushes the change to Xray (or the
|
||||||
remote node) so depleted users can connect again immediately.
|
remote node) so depleted users can connect again immediately.
|
||||||
@@ -429,6 +462,14 @@ _openapi:
|
|||||||
id: list-source-ips-that-have-connected-with-the-given-clients-credentials-returns-an-array-of-ip-timestamp-strings
|
id: list-source-ips-that-have-connected-with-the-given-clients-credentials-returns-an-array-of-ip-timestamp-strings
|
||||||
- content: Reset the recorded IP list for a client.
|
- content: Reset the recorded IP list for a client.
|
||||||
id: reset-the-recorded-ip-list-for-a-client
|
id: reset-the-recorded-ip-list-for-a-client
|
||||||
|
- content: List registered HWID devices for a client. Hashes are not exposed.
|
||||||
|
id: list-registered-hwid-devices-for-a-client-hashes-are-not-exposed
|
||||||
|
- content: Clear all registered HWID devices for a client so new devices can
|
||||||
|
register again.
|
||||||
|
id: clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again
|
||||||
|
- content: Remove a single registered HWID device by its id, freeing one slot
|
||||||
|
under the HWID limit.
|
||||||
|
id: remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit
|
||||||
- content: List the emails of currently connected clients (last seen within the
|
- content: List the emails of currently connected clients (last seen within the
|
||||||
heartbeat window), deduped across every node.
|
heartbeat window), deduped across every node.
|
||||||
id: list-the-emails-of-currently-connected-clients-last-seen-within-the-heartbeat-window-deduped-across-every-node
|
id: list-the-emails-of-currently-connected-clients-last-seen-within-the-heartbeat-window-deduped-across-every-node
|
||||||
@@ -466,14 +507,6 @@ _openapi:
|
|||||||
proxy. Protocols without a URL form (socks, http, mixed, wireguard,
|
proxy. Protocols without a URL form (socks, http, mixed, wireguard,
|
||||||
dokodemo, tunnel) contribute nothing.'
|
dokodemo, tunnel) contribute nothing.'
|
||||||
id: return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
|
id: return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
|
||||||
- content: List registered HWID devices for a client. Hashes are not exposed.
|
|
||||||
id: list-registered-hwid-devices-for-a-client-hashes-are-not-exposed
|
|
||||||
- content: Clear all registered HWID devices for a client so new devices can
|
|
||||||
register again.
|
|
||||||
id: clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again
|
|
||||||
- content: Remove a single registered HWID device by its id, freeing one slot
|
|
||||||
under the HWID limit.
|
|
||||||
id: remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit
|
|
||||||
contents:
|
contents:
|
||||||
- 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
|
||||||
@@ -536,7 +569,7 @@ export default function Layout(props) {
|
|||||||
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/add","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/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/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/links/{email}","method":"get"},{"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"}]} showTitle />
|
<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/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/links/{email}","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,66 +13,65 @@ _openapi:
|
|||||||
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
|
- depth: 2
|
||||||
title: Fetch a single host by ID.
|
title: Fetch a single host group by Group ID.
|
||||||
url: '#fetch-a-single-host-by-id'
|
url: '#fetch-a-single-host-group-by-group-id'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Fetch one inbound's hosts, ordered by sort order then id.
|
title: Fetch one inbound's hosts, grouped by host group.
|
||||||
url: '#fetch-one-inbounds-hosts-ordered-by-sort-order-then-id'
|
url: '#fetch-one-inbounds-hosts-grouped-by-host-group'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Distinct, sorted set of tags used across all hosts.
|
title: Distinct, sorted set of tags used across all hosts.
|
||||||
url: '#distinct-sorted-set-of-tags-used-across-all-hosts'
|
url: '#distinct-sorted-set-of-tags-used-across-all-hosts'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Create a host on an inbound. inboundId and remark are required; security
|
title: Create a host group on inbounds.
|
||||||
defaults to "same" (inherit the inbound).
|
url: '#create-a-host-group-on-inbounds'
|
||||||
url: '#create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound'
|
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Replace a host’s content. The inbound and sort order are immutable here
|
title: Replace a host group’s content.
|
||||||
(use /reorder for ordering).
|
url: '#replace-a-host-groups-content'
|
||||||
url: '#replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering'
|
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Delete a host.
|
title: Delete a host group.
|
||||||
url: '#delete-a-host'
|
url: '#delete-a-host-group'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Enable or disable a single host (disabled hosts are skipped in
|
title: Enable or disable a host group.
|
||||||
subscriptions).
|
url: '#enable-or-disable-a-host-group'
|
||||||
url: '#enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions'
|
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Set host sort order by the position of each id in the array.
|
title: Set host group sort order by the position of each groupId in the array.
|
||||||
url: '#set-host-sort-order-by-the-position-of-each-id-in-the-array'
|
url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Enable or disable many hosts in one call.
|
title: Add a host group to inbounds (same as /add).
|
||||||
url: '#enable-or-disable-many-hosts-in-one-call'
|
url: '#add-a-host-group-to-inbounds-same-as-add'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Delete many hosts in one call.
|
title: Enable or disable many host groups in one call.
|
||||||
url: '#delete-many-hosts-in-one-call'
|
url: '#enable-or-disable-many-host-groups-in-one-call'
|
||||||
|
- depth: 2
|
||||||
|
title: Delete many host groups in one call.
|
||||||
|
url: '#delete-many-host-groups-in-one-call'
|
||||||
structuredData:
|
structuredData:
|
||||||
headings:
|
headings:
|
||||||
- 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: Fetch a single host by ID.
|
- content: Fetch a single host group by Group ID.
|
||||||
id: fetch-a-single-host-by-id
|
id: fetch-a-single-host-group-by-group-id
|
||||||
- content: Fetch one inbound's hosts, ordered by sort order then id.
|
- content: Fetch one inbound's hosts, grouped by host group.
|
||||||
id: fetch-one-inbounds-hosts-ordered-by-sort-order-then-id
|
id: fetch-one-inbounds-hosts-grouped-by-host-group
|
||||||
- content: Distinct, sorted set of tags used across all hosts.
|
- content: Distinct, sorted set of tags used across all hosts.
|
||||||
id: distinct-sorted-set-of-tags-used-across-all-hosts
|
id: distinct-sorted-set-of-tags-used-across-all-hosts
|
||||||
- content: Create a host on an inbound. inboundId and remark are required;
|
- content: Create a host group on inbounds.
|
||||||
security defaults to "same" (inherit the inbound).
|
id: create-a-host-group-on-inbounds
|
||||||
id: create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound
|
- content: Replace a host group’s content.
|
||||||
- content: Replace a host’s content. The inbound and sort order are immutable here
|
id: replace-a-host-groups-content
|
||||||
(use /reorder for ordering).
|
- content: Delete a host group.
|
||||||
id: replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering
|
id: delete-a-host-group
|
||||||
- content: Delete a host.
|
- content: Enable or disable a host group.
|
||||||
id: delete-a-host
|
id: enable-or-disable-a-host-group
|
||||||
- content: Enable or disable a single host (disabled hosts are skipped in
|
- content: Set host group sort order by the position of each groupId in the array.
|
||||||
subscriptions).
|
id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array
|
||||||
id: enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions
|
- content: Add a host group to inbounds (same as /add).
|
||||||
- content: Set host sort order by the position of each id in the array.
|
id: add-a-host-group-to-inbounds-same-as-add
|
||||||
id: set-host-sort-order-by-the-position-of-each-id-in-the-array
|
- content: Enable or disable many host groups in one call.
|
||||||
- content: Enable or disable many hosts in one call.
|
id: enable-or-disable-many-host-groups-in-one-call
|
||||||
id: enable-or-disable-many-hosts-in-one-call
|
- content: Delete many host groups in one call.
|
||||||
- content: Delete many hosts in one call.
|
id: delete-many-host-groups-in-one-call
|
||||||
id: delete-many-hosts-in-one-call
|
|
||||||
contents: []
|
contents: []
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ export default function Layout(props) {
|
|||||||
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/{id}","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/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
<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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,15 @@ _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: Return every protocol URL (vless://, vmess://, trojan://, ss://,
|
||||||
|
hysteria://, mtproto) across all inbounds and all of their clients.
|
||||||
|
Links are rendered through the subscription engine, so the configured
|
||||||
|
remark template (name-only display part) is applied per client — the
|
||||||
|
same output the client info/QR pages use. Protocols without a URL form
|
||||||
|
(socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.
|
||||||
|
Used by the panel’s "Export all inbound links" action.
|
||||||
|
url: '#return-every-protocol-url-vless-vmess-trojan-ss-hysteria-mtproto-across-all-inbounds-and-all-of-their-clients-links-are-rendered-through-the-subscription-engine-so-the-configured-remark-template-name-only-display-part-is-applied-per-client--the-same-output-the-client-infoqr-pages-use-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing-used-by-the-panels-export-all-inbound-links-action'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Fetch a single inbound by numeric ID.
|
title: Fetch a single inbound by numeric ID.
|
||||||
url: '#fetch-a-single-inbound-by-numeric-id'
|
url: '#fetch-a-single-inbound-by-numeric-id'
|
||||||
@@ -59,6 +68,10 @@ _openapi:
|
|||||||
title: Toggle only the enable flag without serialising the whole settings JSON.
|
title: Toggle only the enable flag without serialising the whole settings JSON.
|
||||||
Recommended for UI switches on large inbounds.
|
Recommended for UI switches on large inbounds.
|
||||||
url: '#toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds'
|
url: '#toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds'
|
||||||
|
- depth: 2
|
||||||
|
title: Set only the subscription sort order. Reads the stored inbound, so a
|
||||||
|
reorder cannot carry a stale client list over a concurrent edit.
|
||||||
|
url: '#set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Zero out upload + download counters for a single inbound. Does not touch
|
title: Zero out upload + download counters for a single inbound. Does not touch
|
||||||
per-client counters.
|
per-client counters.
|
||||||
@@ -94,10 +107,6 @@ _openapi:
|
|||||||
title: Replace the entire fallback list for a master inbound. Body is JSON.
|
title: Replace the entire fallback list for a master inbound. Body is JSON.
|
||||||
Triggers an Xray restart.
|
Triggers an Xray restart.
|
||||||
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
|
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
|
||||||
- depth: 2
|
|
||||||
title: Set only the subscription sort order. Reads the stored inbound, so a
|
|
||||||
reorder cannot carry a stale client list over a concurrent edit.
|
|
||||||
url: '#set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit'
|
|
||||||
structuredData:
|
structuredData:
|
||||||
headings:
|
headings:
|
||||||
- content: List every inbound owned by the authenticated user, including each
|
- content: List every inbound owned by the authenticated user, including each
|
||||||
@@ -121,6 +130,14 @@ _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: Return every protocol URL (vless://, vmess://, trojan://, ss://,
|
||||||
|
hysteria://, mtproto) across all inbounds and all of their clients.
|
||||||
|
Links are rendered through the subscription engine, so the configured
|
||||||
|
remark template (name-only display part) is applied per client — the
|
||||||
|
same output the client info/QR pages use. Protocols without a URL form
|
||||||
|
(socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.
|
||||||
|
Used by the panel’s "Export all inbound links" action.
|
||||||
|
id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-mtproto-across-all-inbounds-and-all-of-their-clients-links-are-rendered-through-the-subscription-engine-so-the-configured-remark-template-name-only-display-part-is-applied-per-client--the-same-output-the-client-infoqr-pages-use-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing-used-by-the-panels-export-all-inbound-links-action
|
||||||
- content: Fetch a single inbound by numeric ID.
|
- content: Fetch a single inbound by numeric ID.
|
||||||
id: fetch-a-single-inbound-by-numeric-id
|
id: fetch-a-single-inbound-by-numeric-id
|
||||||
- content: Create a new inbound. Send the full inbound payload (protocol, port,
|
- content: Create a new inbound. Send the full inbound payload (protocol, port,
|
||||||
@@ -141,6 +158,9 @@ _openapi:
|
|||||||
- content: Toggle only the enable flag without serialising the whole settings
|
- content: Toggle only the enable flag without serialising the whole settings
|
||||||
JSON. Recommended for UI switches on large inbounds.
|
JSON. Recommended for UI switches on large inbounds.
|
||||||
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
|
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
|
||||||
|
- content: Set only the subscription sort order. Reads the stored inbound, so a
|
||||||
|
reorder cannot carry a stale client list over a concurrent edit.
|
||||||
|
id: set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit
|
||||||
- content: Zero out upload + download counters for a single inbound. Does not
|
- content: Zero out upload + download counters for a single inbound. Does not
|
||||||
touch per-client counters.
|
touch per-client counters.
|
||||||
id: zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
|
id: zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
|
||||||
@@ -169,9 +189,6 @@ _openapi:
|
|||||||
- content: Replace the entire fallback list for a master inbound. Body is JSON.
|
- content: Replace the entire fallback list for a master inbound. Body is JSON.
|
||||||
Triggers an Xray restart.
|
Triggers an Xray restart.
|
||||||
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
|
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
|
||||||
- content: Set only the subscription sort order. Reads the stored inbound, so a
|
|
||||||
reorder cannot carry a stale client list over a concurrent edit.
|
|
||||||
id: set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit
|
|
||||||
contents: []
|
contents: []
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -184,7 +201,7 @@ export default function Layout(props) {
|
|||||||
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/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}/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"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"}]} showTitle />
|
<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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"settings",
|
"settings",
|
||||||
"xray-settings",
|
"xray-settings",
|
||||||
"subscription-server",
|
"subscription-server",
|
||||||
|
"subscription-balancers",
|
||||||
"hosts",
|
"hosts",
|
||||||
"nodes",
|
"nodes",
|
||||||
"backup",
|
"backup",
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ _openapi:
|
|||||||
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
|
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
|
||||||
must be a PEM certificate. Applied on the next panel restart.
|
must be a PEM certificate. Applied on the next panel restart.
|
||||||
url: '#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart'
|
url: '#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart'
|
||||||
|
- depth: 2
|
||||||
|
title: Validate the stored master mTLS client credential and invalidate cached
|
||||||
|
transports. Each transport closes its old idle pool and rebuilds with
|
||||||
|
the rotated certificate before its next request.
|
||||||
|
url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Fetch a single node by ID.
|
title: Fetch a single node by ID.
|
||||||
url: '#fetch-a-single-node-by-id'
|
url: '#fetch-a-single-node-by-id'
|
||||||
@@ -32,12 +37,15 @@ _openapi:
|
|||||||
panel.
|
panel.
|
||||||
url: '#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel'
|
url: '#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Register a new remote node. Provide its URL, apiToken, and optional
|
title: Register a new remote node. Provide its URL, write-only apiToken, and
|
||||||
remark / allowPrivateAddress flag.
|
optional remark / allowPrivateAddress flag. Responses expose hasApiToken
|
||||||
url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag'
|
only.
|
||||||
|
url: '#register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Replace a node’s connection details. Same body shape as /add.
|
title: 'Replace a node’s connection details. apiToken is write-only: omit it or
|
||||||
url: '#replace-a-nodes-connection-details-same-body-shape-as-add'
|
send an empty string to keep the stored token; set clearApiToken=true to
|
||||||
|
clear it.'
|
||||||
|
url: '#replace-a-nodes-connection-details-apitoken-is-write-only-omit-it-or-send-an-empty-string-to-keep-the-stored-token-set-clearapitokentrue-to-clear-it'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Delete a node. Inbounds bound to it are not auto-migrated.
|
title: Delete a node. Inbounds bound to it are not auto-migrated.
|
||||||
url: '#delete-a-node-inbounds-bound-to-it-are-not-auto-migrated'
|
url: '#delete-a-node-inbounds-bound-to-it-are-not-auto-migrated'
|
||||||
@@ -72,11 +80,6 @@ _openapi:
|
|||||||
title: Aggregated metric history for a node — same shape as /server/history,
|
title: Aggregated metric history for a node — same shape as /server/history,
|
||||||
scoped to one node.
|
scoped to one node.
|
||||||
url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node'
|
url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node'
|
||||||
- depth: 2
|
|
||||||
title: Validate the stored master mTLS client credential and invalidate cached
|
|
||||||
transports. Each transport closes its old idle pool and rebuilds with
|
|
||||||
the rotated certificate before its next request.
|
|
||||||
url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
|
|
||||||
structuredData:
|
structuredData:
|
||||||
headings:
|
headings:
|
||||||
- content: List every configured node with its connection details, health, and
|
- content: List every configured node with its connection details, health, and
|
||||||
@@ -91,6 +94,10 @@ _openapi:
|
|||||||
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
|
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
|
||||||
value must be a PEM certificate. Applied on the next panel restart.
|
value must be a PEM certificate. Applied on the next panel restart.
|
||||||
id: set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
|
id: set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
|
||||||
|
- content: Validate the stored master mTLS client credential and invalidate cached
|
||||||
|
transports. Each transport closes its old idle pool and rebuilds with
|
||||||
|
the rotated certificate before its next request.
|
||||||
|
id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
|
||||||
- content: Fetch a single node by ID.
|
- content: Fetch a single node by ID.
|
||||||
id: fetch-a-single-node-by-id
|
id: fetch-a-single-node-by-id
|
||||||
- content: Fetch a node's own web TLS certificate/key file paths (proxied to the
|
- content: Fetch a node's own web TLS certificate/key file paths (proxied to the
|
||||||
@@ -98,11 +105,14 @@ _openapi:
|
|||||||
node-assigned inbound gets paths that exist on the node, not the
|
node-assigned inbound gets paths that exist on the node, not the
|
||||||
central panel.
|
central panel.
|
||||||
id: fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
|
id: fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
|
||||||
- content: Register a new remote node. Provide its URL, apiToken, and optional
|
- content: Register a new remote node. Provide its URL, write-only apiToken, and
|
||||||
remark / allowPrivateAddress flag.
|
optional remark / allowPrivateAddress flag. Responses expose
|
||||||
id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
|
hasApiToken only.
|
||||||
- content: Replace a node’s connection details. Same body shape as /add.
|
id: register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only
|
||||||
id: replace-a-nodes-connection-details-same-body-shape-as-add
|
- content: 'Replace a node’s connection details. apiToken is write-only: omit it
|
||||||
|
or send an empty string to keep the stored token; set
|
||||||
|
clearApiToken=true to clear it.'
|
||||||
|
id: replace-a-nodes-connection-details-apitoken-is-write-only-omit-it-or-send-an-empty-string-to-keep-the-stored-token-set-clearapitokentrue-to-clear-it
|
||||||
- content: Delete a node. Inbounds bound to it are not auto-migrated.
|
- content: Delete a node. Inbounds bound to it are not auto-migrated.
|
||||||
id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
|
id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
|
||||||
- content: Pause or resume traffic sync with this node.
|
- content: Pause or resume traffic sync with this node.
|
||||||
@@ -129,10 +139,6 @@ _openapi:
|
|||||||
- content: Aggregated metric history for a node — same shape as /server/history,
|
- content: Aggregated metric history for a node — same shape as /server/history,
|
||||||
scoped to one node.
|
scoped to one node.
|
||||||
id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
|
id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
|
||||||
- content: Validate the stored master mTLS client credential and invalidate cached
|
|
||||||
transports. Each transport closes its old idle pool and rebuilds with
|
|
||||||
the rotated certificate before its next request.
|
|
||||||
id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
|
|
||||||
contents: []
|
contents: []
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,7 +151,7 @@ export default function Layout(props) {
|
|||||||
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/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"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"}]} showTitle />
|
<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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,12 @@ _openapi:
|
|||||||
preload:
|
preload:
|
||||||
- ./public/openapi.json
|
- ./public/openapi.json
|
||||||
toc:
|
toc:
|
||||||
|
- depth: 2
|
||||||
|
title: Serve this API description as an OpenAPI 3 document — the same file that
|
||||||
|
powers the API Docs page. Requires a session or Bearer token like the
|
||||||
|
rest of /panel/api. Useful for generating clients or importing into API
|
||||||
|
tooling.
|
||||||
|
url: '#serve-this-api-description-as-an-openapi-3-document--the-same-file-that-powers-the-api-docs-page-requires-a-session-or-bearer-token-like-the-rest-of-panelapi-useful-for-generating-clients-or-importing-into-api-tooling'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load
|
title: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load
|
||||||
averages, open connections, Xray state. Cached and refreshed every 2
|
averages, open connections, Xray state. Cached and refreshed every 2
|
||||||
@@ -49,12 +55,19 @@ _openapi:
|
|||||||
- depth: 2
|
- depth: 2
|
||||||
title: Check whether a newer 3x-ui release is available on GitHub.
|
title: Check whether a newer 3x-ui release is available on GitHub.
|
||||||
url: '#check-whether-a-newer-3x-ui-release-is-available-on-github'
|
url: '#check-whether-a-newer-3x-ui-release-is-available-on-github'
|
||||||
|
- depth: 2
|
||||||
|
title: Report the outcome of the most recently launched panel self-update (see
|
||||||
|
POST updatePanel). Compare the returned runId against the one
|
||||||
|
updatePanel returned to tell this run apart from a stale result.
|
||||||
|
url: '#report-the-outcome-of-the-most-recently-launched-panel-self-update-see-post-updatepanel-compare-the-returned-runid-against-the-one-updatepanel-returned-to-tell-this-run-apart-from-a-stale-result'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Return the assembled Xray config that’s currently running on this host.
|
title: Return the assembled Xray config that’s currently running on this host.
|
||||||
url: '#return-the-assembled-xray-config-thats-currently-running-on-this-host'
|
url: '#return-the-assembled-xray-config-thats-currently-running-on-this-host'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Stream the SQLite database file as an attachment. Use as a manual backup.
|
title: 'Stream a full database backup as an attachment: the SQLite .db file on
|
||||||
url: '#stream-the-sqlite-database-file-as-an-attachment-use-as-a-manual-backup'
|
SQLite panels, or a pg_dump custom-format archive (.dump) on PostgreSQL
|
||||||
|
panels. Use as a manual backup.'
|
||||||
|
url: '#stream-a-full-database-backup-as-an-attachment-the-sqlite-db-file-on-sqlite-panels-or-a-pg_dump-custom-format-archive-dump-on-postgresql-panels-use-as-a-manual-backup'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: 'Stream a cross-engine migration file as an attachment: a .dump (SQL
|
title: 'Stream a cross-engine migration file as an attachment: a .dump (SQL
|
||||||
text) on SQLite, or a .db SQLite database built from the live data on
|
text) on SQLite, or a .db SQLite database built from the live data on
|
||||||
@@ -123,9 +136,16 @@ _openapi:
|
|||||||
title: Return the last N lines of the Xray process log.
|
title: Return the last N lines of the Xray process log.
|
||||||
url: '#return-the-last-n-lines-of-the-xray-process-log'
|
url: '#return-the-last-n-lines-of-the-xray-process-log'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Restore the panel DB from an uploaded SQLite file (multipart form, field
|
title: Return live AmneziaWG peer activity (handshake, endpoint, transfer) plus
|
||||||
name "db"). The panel restarts after restore. Destructive.
|
the panel’s own AmneziaWG event lines.
|
||||||
url: '#restore-the-panel-db-from-an-uploaded-sqlite-file-multipart-form-field-name-db-the-panel-restarts-after-restore-destructive'
|
url: '#return-live-amneziawg-peer-activity-handshake-endpoint-transfer-plus-the-panels-own-amneziawg-event-lines'
|
||||||
|
- depth: 2
|
||||||
|
title: Restore the panel DB from an uploaded backup (multipart form, field name
|
||||||
|
"db"). SQLite panels accept a SQLite database (.db) or a SQLite
|
||||||
|
migration dump (.dump); PostgreSQL panels accept a pg_dump archive
|
||||||
|
(.dump), a SQLite database (.db), or a SQLite migration dump. The panel
|
||||||
|
restarts after restore. Destructive.
|
||||||
|
url: '#restore-the-panel-db-from-an-uploaded-backup-multipart-form-field-name-db-sqlite-panels-accept-a-sqlite-database-db-or-a-sqlite-migration-dump-dump-postgresql-panels-accept-a-pg_dump-archive-dump-a-sqlite-database-db-or-a-sqlite-migration-dump-the-panel-restarts-after-restore-destructive'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Generate a new ECH (Encrypted Client Hello) keypair and config list for
|
title: Generate a new ECH (Encrypted Client Hello) keypair and config list for
|
||||||
the given SNI.
|
the given SNI.
|
||||||
@@ -139,6 +159,20 @@ _openapi:
|
|||||||
title: Run `xray tls ping` against a remote server and return its live
|
title: Run `xray tls ping` against a remote server and return its live
|
||||||
leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256).
|
leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256).
|
||||||
url: '#run-xray-tls-ping-against-a-remote-server-and-return-its-live-leaf-certificate-sha-256-hashes-for-pinning-pinnedpeercertsha256'
|
url: '#run-xray-tls-ping-against-a-remote-server-and-return-its-live-leaf-certificate-sha-256-hashes-for-pinning-pinnedpeercertsha256'
|
||||||
|
- depth: 2
|
||||||
|
title: Run a live TLS 1.3 probe against a candidate REALITY target and return a
|
||||||
|
feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus
|
||||||
|
the certificate SAN DNS names. A target on a private/loopback address is
|
||||||
|
reported with privateTarget=true and probed only when allowPrivate is
|
||||||
|
set.
|
||||||
|
url: '#run-a-live-tls-13-probe-against-a-candidate-reality-target-and-return-a-feasibility-verdict-tls-13--h2--x25519--trusted-certificate-plus-the-certificate-san-dns-names-a-target-on-a-privateloopback-address-is-reported-with-privatetargettrue-and-probed-only-when-allowprivate-is-set'
|
||||||
|
- depth: 2
|
||||||
|
title: Probe/discover REALITY targets and return each verdict ranked by
|
||||||
|
feasibility then latency. Each comma-separated token may be a domain
|
||||||
|
(validated with SNI), a bare IP, or a CIDR range (discovered without SNI
|
||||||
|
by reading the certificate domain). When empty, a built-in seed list is
|
||||||
|
probed.
|
||||||
|
url: '#probediscover-reality-targets-and-return-each-verdict-ranked-by-feasibility-then-latency-each-comma-separated-token-may-be-a-domain-validated-with-sni-a-bare-ip-or-a-cidr-range-discovered-without-sni-by-reading-the-certificate-domain-when-empty-a-built-in-seed-list-is-probed'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Fetch the fully aggregated inbound_client_ips database table. Used by
|
title: Fetch the fully aggregated inbound_client_ips database table. Used by
|
||||||
nodes to sync recently active IPs across the cluster.
|
nodes to sync recently active IPs across the cluster.
|
||||||
@@ -149,6 +183,11 @@ _openapi:
|
|||||||
url: '#submit-a-list-of-recently-active-ip-timestamps-the-panel-merges-them-with-the-existing-database-to-maintain-a-unified-global-ip-limit-view'
|
url: '#submit-a-list-of-recently-active-ip-timestamps-the-panel-merges-them-with-the-existing-database-to-maintain-a-unified-global-ip-limit-view'
|
||||||
structuredData:
|
structuredData:
|
||||||
headings:
|
headings:
|
||||||
|
- content: Serve this API description as an OpenAPI 3 document — the same file
|
||||||
|
that powers the API Docs page. Requires a session or Bearer token like
|
||||||
|
the rest of /panel/api. Useful for generating clients or importing
|
||||||
|
into API tooling.
|
||||||
|
id: serve-this-api-description-as-an-openapi-3-document--the-same-file-that-powers-the-api-docs-page-requires-a-session-or-bearer-token-like-the-rest-of-panelapi-useful-for-generating-clients-or-importing-into-api-tooling
|
||||||
- content: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load
|
- content: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load
|
||||||
averages, open connections, Xray state. Cached and refreshed every 2
|
averages, open connections, Xray state. Cached and refreshed every 2
|
||||||
seconds in the background.'
|
seconds in the background.'
|
||||||
@@ -181,11 +220,16 @@ _openapi:
|
|||||||
id: list-xray-binary-versions-available-for-install-on-this-host
|
id: list-xray-binary-versions-available-for-install-on-this-host
|
||||||
- content: Check whether a newer 3x-ui release is available on GitHub.
|
- content: Check whether a newer 3x-ui release is available on GitHub.
|
||||||
id: check-whether-a-newer-3x-ui-release-is-available-on-github
|
id: check-whether-a-newer-3x-ui-release-is-available-on-github
|
||||||
|
- content: Report the outcome of the most recently launched panel self-update (see
|
||||||
|
POST updatePanel). Compare the returned runId against the one
|
||||||
|
updatePanel returned to tell this run apart from a stale result.
|
||||||
|
id: report-the-outcome-of-the-most-recently-launched-panel-self-update-see-post-updatepanel-compare-the-returned-runid-against-the-one-updatepanel-returned-to-tell-this-run-apart-from-a-stale-result
|
||||||
- content: Return the assembled Xray config that’s currently running on this host.
|
- content: Return the assembled Xray config that’s currently running on this host.
|
||||||
id: return-the-assembled-xray-config-thats-currently-running-on-this-host
|
id: return-the-assembled-xray-config-thats-currently-running-on-this-host
|
||||||
- content: Stream the SQLite database file as an attachment. Use as a manual
|
- content: 'Stream a full database backup as an attachment: the SQLite .db file on
|
||||||
backup.
|
SQLite panels, or a pg_dump custom-format archive (.dump) on
|
||||||
id: stream-the-sqlite-database-file-as-an-attachment-use-as-a-manual-backup
|
PostgreSQL panels. Use as a manual backup.'
|
||||||
|
id: stream-a-full-database-backup-as-an-attachment-the-sqlite-db-file-on-sqlite-panels-or-a-pg_dump-custom-format-archive-dump-on-postgresql-panels-use-as-a-manual-backup
|
||||||
- content: 'Stream a cross-engine migration file as an attachment: a .dump (SQL
|
- content: 'Stream a cross-engine migration file as an attachment: a .dump (SQL
|
||||||
text) on SQLite, or a .db SQLite database built from the live data on
|
text) on SQLite, or a .db SQLite database built from the live data on
|
||||||
PostgreSQL.'
|
PostgreSQL.'
|
||||||
@@ -236,9 +280,15 @@ _openapi:
|
|||||||
id: return-the-last-n-lines-of-the-panels-own-log
|
id: return-the-last-n-lines-of-the-panels-own-log
|
||||||
- content: Return the last N lines of the Xray process log.
|
- content: Return the last N lines of the Xray process log.
|
||||||
id: return-the-last-n-lines-of-the-xray-process-log
|
id: return-the-last-n-lines-of-the-xray-process-log
|
||||||
- content: Restore the panel DB from an uploaded SQLite file (multipart form,
|
- content: Return live AmneziaWG peer activity (handshake, endpoint, transfer)
|
||||||
field name "db"). The panel restarts after restore. Destructive.
|
plus the panel’s own AmneziaWG event lines.
|
||||||
id: restore-the-panel-db-from-an-uploaded-sqlite-file-multipart-form-field-name-db-the-panel-restarts-after-restore-destructive
|
id: return-live-amneziawg-peer-activity-handshake-endpoint-transfer-plus-the-panels-own-amneziawg-event-lines
|
||||||
|
- content: Restore the panel DB from an uploaded backup (multipart form, field
|
||||||
|
name "db"). SQLite panels accept a SQLite database (.db) or a SQLite
|
||||||
|
migration dump (.dump); PostgreSQL panels accept a pg_dump archive
|
||||||
|
(.dump), a SQLite database (.db), or a SQLite migration dump. The
|
||||||
|
panel restarts after restore. Destructive.
|
||||||
|
id: restore-the-panel-db-from-an-uploaded-backup-multipart-form-field-name-db-sqlite-panels-accept-a-sqlite-database-db-or-a-sqlite-migration-dump-dump-postgresql-panels-accept-a-pg_dump-archive-dump-a-sqlite-database-db-or-a-sqlite-migration-dump-the-panel-restarts-after-restore-destructive
|
||||||
- content: Generate a new ECH (Encrypted Client Hello) keypair and config list for
|
- content: Generate a new ECH (Encrypted Client Hello) keypair and config list for
|
||||||
the given SNI.
|
the given SNI.
|
||||||
id: generate-a-new-ech-encrypted-client-hello-keypair-and-config-list-for-the-given-sni
|
id: generate-a-new-ech-encrypted-client-hello-keypair-and-config-list-for-the-given-sni
|
||||||
@@ -249,6 +299,18 @@ _openapi:
|
|||||||
- content: Run `xray tls ping` against a remote server and return its live
|
- content: Run `xray tls ping` against a remote server and return its live
|
||||||
leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256).
|
leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256).
|
||||||
id: run-xray-tls-ping-against-a-remote-server-and-return-its-live-leaf-certificate-sha-256-hashes-for-pinning-pinnedpeercertsha256
|
id: run-xray-tls-ping-against-a-remote-server-and-return-its-live-leaf-certificate-sha-256-hashes-for-pinning-pinnedpeercertsha256
|
||||||
|
- content: Run a live TLS 1.3 probe against a candidate REALITY target and return
|
||||||
|
a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate)
|
||||||
|
plus the certificate SAN DNS names. A target on a private/loopback
|
||||||
|
address is reported with privateTarget=true and probed only when
|
||||||
|
allowPrivate is set.
|
||||||
|
id: run-a-live-tls-13-probe-against-a-candidate-reality-target-and-return-a-feasibility-verdict-tls-13--h2--x25519--trusted-certificate-plus-the-certificate-san-dns-names-a-target-on-a-privateloopback-address-is-reported-with-privatetargettrue-and-probed-only-when-allowprivate-is-set
|
||||||
|
- content: Probe/discover REALITY targets and return each verdict ranked by
|
||||||
|
feasibility then latency. Each comma-separated token may be a domain
|
||||||
|
(validated with SNI), a bare IP, or a CIDR range (discovered without
|
||||||
|
SNI by reading the certificate domain). When empty, a built-in seed
|
||||||
|
list is probed.
|
||||||
|
id: probediscover-reality-targets-and-return-each-verdict-ranked-by-feasibility-then-latency-each-comma-separated-token-may-be-a-domain-validated-with-sni-a-bare-ip-or-a-cidr-range-discovered-without-sni-by-reading-the-certificate-domain-when-empty-a-built-in-seed-list-is-probed
|
||||||
- content: Fetch the fully aggregated inbound_client_ips database table. Used by
|
- content: Fetch the fully aggregated inbound_client_ips database table. Used by
|
||||||
nodes to sync recently active IPs across the cluster.
|
nodes to sync recently active IPs across the cluster.
|
||||||
id: fetch-the-fully-aggregated-inbound_client_ips-database-table-used-by-nodes-to-sync-recently-active-ips-across-the-cluster
|
id: fetch-the-fully-aggregated-inbound_client_ips-database-table-used-by-nodes-to-sync-recently-active-ips-across-the-cluster
|
||||||
@@ -267,7 +329,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"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/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/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/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle />
|
<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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,11 +15,21 @@ _openapi:
|
|||||||
title: Return the computed default settings based on the request host. Useful to
|
title: Return the computed default settings based on the request host. Useful to
|
||||||
preview what a fresh install would use.
|
preview what a fresh install would use.
|
||||||
url: '#return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use'
|
url: '#return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use'
|
||||||
|
- depth: 2
|
||||||
|
title: Return the shipped (factory) default value per browser-safe setting key,
|
||||||
|
so clients can tell a stored value apart from the default it would fall
|
||||||
|
back to. Per-install material (secret, panelGuid, mTLS keys) and
|
||||||
|
credential fields are never included.
|
||||||
|
url: '#return-the-shipped-factory-default-value-per-browser-safe-setting-key-so-clients-can-tell-a-stored-value-apart-from-the-default-it-would-fall-back-to-per-install-material-secret-panelguid-mtls-keys-and-credential-fields-are-never-included'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Persist every setting at once. The body mirrors the shape returned by
|
title: Persist every setting at once. The body mirrors the shape returned by
|
||||||
/all. Invalid values (bad ports, missing cert pairs, etc.) are rejected
|
/all. Invalid values (bad ports, missing cert pairs, etc.) are rejected
|
||||||
before write.
|
before write.
|
||||||
url: '#persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write'
|
url: '#persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write'
|
||||||
|
- depth: 2
|
||||||
|
title: Validate any regular expression with the backend Go RE2 compiler without
|
||||||
|
saving it.
|
||||||
|
url: '#validate-any-regular-expression-with-the-backend-go-re2-compiler-without-saving-it'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Change the panel admin username and password. Requires the current
|
title: Change the panel admin username and password. Requires the current
|
||||||
credentials for verification. The session is refreshed with the new
|
credentials for verification. The session is refreshed with the new
|
||||||
@@ -50,10 +60,18 @@ _openapi:
|
|||||||
- content: Return the computed default settings based on the request host. Useful
|
- content: Return the computed default settings based on the request host. Useful
|
||||||
to preview what a fresh install would use.
|
to preview what a fresh install would use.
|
||||||
id: return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use
|
id: return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use
|
||||||
|
- content: Return the shipped (factory) default value per browser-safe setting
|
||||||
|
key, so clients can tell a stored value apart from the default it
|
||||||
|
would fall back to. Per-install material (secret, panelGuid, mTLS
|
||||||
|
keys) and credential fields are never included.
|
||||||
|
id: return-the-shipped-factory-default-value-per-browser-safe-setting-key-so-clients-can-tell-a-stored-value-apart-from-the-default-it-would-fall-back-to-per-install-material-secret-panelguid-mtls-keys-and-credential-fields-are-never-included
|
||||||
- content: Persist every setting at once. The body mirrors the shape returned by
|
- content: Persist every setting at once. The body mirrors the shape returned by
|
||||||
/all. Invalid values (bad ports, missing cert pairs, etc.) are
|
/all. Invalid values (bad ports, missing cert pairs, etc.) are
|
||||||
rejected before write.
|
rejected before write.
|
||||||
id: persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write
|
id: persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write
|
||||||
|
- content: Validate any regular expression with the backend Go RE2 compiler
|
||||||
|
without saving it.
|
||||||
|
id: validate-any-regular-expression-with-the-backend-go-re2-compiler-without-saving-it
|
||||||
- content: Change the panel admin username and password. Requires the current
|
- content: Change the panel admin username and password. Requires the current
|
||||||
credentials for verification. The session is refreshed with the new
|
credentials for verification. The session is refreshed with the new
|
||||||
values on success.
|
values on success.
|
||||||
@@ -83,7 +101,7 @@ export default function Layout(props) {
|
|||||||
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/update","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/getDefaultJsonConfig","method":"get"}]} showTitle />
|
<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/getDefaultJsonConfig","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
title: Subscription Balancers
|
||||||
|
description: 'Client-side balancers for the JSON subscription: each enabled
|
||||||
|
balancer is emitted as one extra config document whose members are the proxy
|
||||||
|
outbounds of the selected inbounds (routing.balancers + burstObservatory).
|
||||||
|
Managed in Settings → Sub Balancers.'
|
||||||
|
full: true
|
||||||
|
_openapi:
|
||||||
|
preload:
|
||||||
|
- ./public/openapi.json
|
||||||
|
toc:
|
||||||
|
- depth: 2
|
||||||
|
title: List all subscription balancers in sort order (sort_order asc, id asc).
|
||||||
|
url: '#list-all-subscription-balancers-in-sort-order-sort_order-asc-id-asc'
|
||||||
|
- depth: 2
|
||||||
|
title: Create a subscription balancer. It appears in the JSON subscription of
|
||||||
|
every client that sits on at least one selected inbound.
|
||||||
|
url: '#create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound'
|
||||||
|
- depth: 2
|
||||||
|
title: Update a balancer by id. Accepts the same form fields as create (full-row
|
||||||
|
update, including the enabled toggle).
|
||||||
|
url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle'
|
||||||
|
- depth: 2
|
||||||
|
title: Delete a balancer by id.
|
||||||
|
url: '#delete-a-balancer-by-id'
|
||||||
|
- depth: 2
|
||||||
|
title: Delete a balancer by id (POST alias of DELETE for clients that cannot
|
||||||
|
send DELETE).
|
||||||
|
url: '#delete-a-balancer-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete'
|
||||||
|
structuredData:
|
||||||
|
headings:
|
||||||
|
- content: List all subscription balancers in sort order (sort_order asc, id asc).
|
||||||
|
id: list-all-subscription-balancers-in-sort-order-sort_order-asc-id-asc
|
||||||
|
- content: Create a subscription balancer. It appears in the JSON subscription of
|
||||||
|
every client that sits on at least one selected inbound.
|
||||||
|
id: create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound
|
||||||
|
- content: Update a balancer by id. Accepts the same form fields as create
|
||||||
|
(full-row update, including the enabled toggle).
|
||||||
|
id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle
|
||||||
|
- content: Delete a balancer by id.
|
||||||
|
id: delete-a-balancer-by-id
|
||||||
|
- content: Delete a balancer by id (POST alias of DELETE for clients that cannot
|
||||||
|
send DELETE).
|
||||||
|
id: delete-a-balancer-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete
|
||||||
|
contents: []
|
||||||
|
---
|
||||||
|
|
||||||
|
{/* 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) {
|
||||||
|
const { APIPage, OpenAPIPage } = props.components ?? {};
|
||||||
|
// "APIPage" is the old name from v10, this allows both for backward compatibility
|
||||||
|
const Comp = OpenAPIPage ?? APIPage;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ _openapi:
|
|||||||
- depth: 2
|
- depth: 2
|
||||||
title: 'Return base64-encoded subscription links for all enabled clients
|
title: 'Return base64-encoded subscription links for all enabled clients
|
||||||
matching the subscription ID. When the request has an Accept: text/html
|
matching the subscription ID. When the request has an Accept: text/html
|
||||||
header or ?html=1, renders a styled info page instead. Default path:
|
header or ?html=1, renders a styled info page instead. With
|
||||||
/sub/:subid.'
|
?format=info, returns the page view-model as JSON (traffic, expiry,
|
||||||
url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid'
|
online status; no links) for live polling. Default path: /sub/:subid.'
|
||||||
|
url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-default-path-subsubid'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: 'Return subscription as a JSON array of proxy configs (one per enabled
|
title: 'Return subscription as a JSON array of proxy configs (one per enabled
|
||||||
client). Only when JSON subscription is enabled in settings. Default
|
client). Only when JSON subscription is enabled in settings. Default
|
||||||
@@ -30,9 +31,10 @@ _openapi:
|
|||||||
headings:
|
headings:
|
||||||
- content: 'Return base64-encoded subscription links for all enabled clients
|
- content: 'Return base64-encoded subscription links for all enabled clients
|
||||||
matching the subscription ID. When the request has an Accept:
|
matching the subscription ID. When the request has an Accept:
|
||||||
text/html header or ?html=1, renders a styled info page instead.
|
text/html header or ?html=1, renders a styled info page instead. With
|
||||||
Default path: /sub/:subid.'
|
?format=info, returns the page view-model as JSON (traffic, expiry,
|
||||||
id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid
|
online status; no links) for live polling. Default path: /sub/:subid.'
|
||||||
|
id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-default-path-subsubid
|
||||||
- content: 'Return subscription as a JSON array of proxy configs (one per enabled
|
- content: 'Return subscription as a JSON array of proxy configs (one per enabled
|
||||||
client). Only when JSON subscription is enabled in settings. Default
|
client). Only when JSON subscription is enabled in settings. Default
|
||||||
path: /json/:subid.'
|
path: /json/:subid.'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Xray Settings
|
title: Xray Settings
|
||||||
description: Xray configuration template, outbound management, Warp/Nord
|
description: Xray configuration template, outbound management, Warp/Nord/PIA
|
||||||
integration, and config testing. All endpoints under /panel/api/xray.
|
integration, and config testing. All endpoints under /panel/api/xray.
|
||||||
full: true
|
full: true
|
||||||
_openapi:
|
_openapi:
|
||||||
@@ -35,6 +35,10 @@ _openapi:
|
|||||||
- depth: 2
|
- depth: 2
|
||||||
title: Manage NordVPN integration. The action parameter selects the operation.
|
title: Manage NordVPN integration. The action parameter selects the operation.
|
||||||
url: '#manage-nordvpn-integration-the-action-parameter-selects-the-operation'
|
url: '#manage-nordvpn-integration-the-action-parameter-selects-the-operation'
|
||||||
|
- depth: 2
|
||||||
|
title: Manage PIA WireGuard integration. The action parameter selects the
|
||||||
|
operation.
|
||||||
|
url: '#manage-pia-wireguard-integration-the-action-parameter-selects-the-operation'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Reset traffic counters for a specific outbound by tag.
|
title: Reset traffic counters for a specific outbound by tag.
|
||||||
url: '#reset-traffic-counters-for-a-specific-outbound-by-tag'
|
url: '#reset-traffic-counters-for-a-specific-outbound-by-tag'
|
||||||
@@ -62,6 +66,25 @@ _openapi:
|
|||||||
title: Ask the running core which outbound its router would pick for a synthetic
|
title: Ask the running core which outbound its router would pick for a synthetic
|
||||||
connection (RoutingService.TestRoute). No traffic is sent.
|
connection (RoutingService.TestRoute). No traffic is sent.
|
||||||
url: '#ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent'
|
url: '#ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent'
|
||||||
|
- depth: 2
|
||||||
|
title: List the geo databases (.dat files) in the Xray asset folder, with the
|
||||||
|
layout detected from their contents, size, modification time and
|
||||||
|
category count. A database that fails to parse is still listed, with the
|
||||||
|
reason in "error".
|
||||||
|
url: '#list-the-geo-databases-dat-files-in-the-xray-asset-folder-with-the-layout-detected-from-their-contents-size-modification-time-and-category-count-a-database-that-fails-to-parse-is-still-listed-with-the-reason-in-error'
|
||||||
|
- depth: 2
|
||||||
|
title: One page of a database's categories, each with its entry count and the
|
||||||
|
attributes its domains carry (e.g. "ads", "cn").
|
||||||
|
url: '#one-page-of-a-databases-categories-each-with-its-entry-count-and-the-attributes-its-domains-carry-eg-ads-cn'
|
||||||
|
- depth: 2
|
||||||
|
title: One page of the rules inside a category — domain rules typed as
|
||||||
|
domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.
|
||||||
|
url: '#one-page-of-the-rules-inside-a-category--domain-rules-typed-as-domainfullkeywordregexp-for-geosite-databases-cidrs-for-geoip-ones'
|
||||||
|
- depth: 2
|
||||||
|
title: 'Check routing tokens against the databases on disk and return only the
|
||||||
|
ones that do not resolve. Plain domains and CIDRs are ignored. Each
|
||||||
|
issue carries a reason: syntax, fileMissing or categoryMissing.'
|
||||||
|
url: '#check-routing-tokens-against-the-databases-on-disk-and-return-only-the-ones-that-do-not-resolve-plain-domains-and-cidrs-are-ignored-each-issue-carries-a-reason-syntax-filemissing-or-categorymissing'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: List all outbound subscriptions (remote URLs that supply additional
|
title: List all outbound subscriptions (remote URLs that supply additional
|
||||||
outbounds), newest first.
|
outbounds), newest first.
|
||||||
@@ -79,9 +102,9 @@ _openapi:
|
|||||||
title: Delete an outbound subscription by id.
|
title: Delete an outbound subscription by id.
|
||||||
url: '#delete-an-outbound-subscription-by-id'
|
url: '#delete-an-outbound-subscription-by-id'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Delete an outbound subscription by id (POST alias of DELETE for
|
title: Delete an outbound subscription by id (POST alias of DELETE for clients
|
||||||
axios-friendly clients).
|
that cannot send DELETE).
|
||||||
url: '#delete-an-outbound-subscription-by-id-post-alias-of-delete-for-axios-friendly-clients'
|
url: '#delete-an-outbound-subscription-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Force an immediate re-fetch of the subscription and return the parsed
|
title: Force an immediate re-fetch of the subscription and return the parsed
|
||||||
outbounds. Signals Xray to reload.
|
outbounds. Signals Xray to reload.
|
||||||
@@ -117,6 +140,9 @@ _openapi:
|
|||||||
id: manage-cloudflare-warp-integration-the-action-parameter-selects-the-operation
|
id: manage-cloudflare-warp-integration-the-action-parameter-selects-the-operation
|
||||||
- content: Manage NordVPN integration. The action parameter selects the operation.
|
- content: Manage NordVPN integration. The action parameter selects the operation.
|
||||||
id: manage-nordvpn-integration-the-action-parameter-selects-the-operation
|
id: manage-nordvpn-integration-the-action-parameter-selects-the-operation
|
||||||
|
- content: Manage PIA WireGuard integration. The action parameter selects the
|
||||||
|
operation.
|
||||||
|
id: manage-pia-wireguard-integration-the-action-parameter-selects-the-operation
|
||||||
- content: Reset traffic counters for a specific outbound by tag.
|
- content: Reset traffic counters for a specific outbound by tag.
|
||||||
id: reset-traffic-counters-for-a-specific-outbound-by-tag
|
id: reset-traffic-counters-for-a-specific-outbound-by-tag
|
||||||
- content: Test an outbound configuration. Sends the outbound JSON (required),
|
- content: Test an outbound configuration. Sends the outbound JSON (required),
|
||||||
@@ -139,6 +165,22 @@ _openapi:
|
|||||||
- content: Ask the running core which outbound its router would pick for a
|
- content: Ask the running core which outbound its router would pick for a
|
||||||
synthetic connection (RoutingService.TestRoute). No traffic is sent.
|
synthetic connection (RoutingService.TestRoute). No traffic is sent.
|
||||||
id: ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent
|
id: ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent
|
||||||
|
- content: List the geo databases (.dat files) in the Xray asset folder, with the
|
||||||
|
layout detected from their contents, size, modification time and
|
||||||
|
category count. A database that fails to parse is still listed, with
|
||||||
|
the reason in "error".
|
||||||
|
id: list-the-geo-databases-dat-files-in-the-xray-asset-folder-with-the-layout-detected-from-their-contents-size-modification-time-and-category-count-a-database-that-fails-to-parse-is-still-listed-with-the-reason-in-error
|
||||||
|
- content: One page of a database's categories, each with its entry count and the
|
||||||
|
attributes its domains carry (e.g. "ads", "cn").
|
||||||
|
id: one-page-of-a-databases-categories-each-with-its-entry-count-and-the-attributes-its-domains-carry-eg-ads-cn
|
||||||
|
- content: One page of the rules inside a category — domain rules typed as
|
||||||
|
domain/full/keyword/regexp for geosite databases, CIDRs for geoip
|
||||||
|
ones.
|
||||||
|
id: one-page-of-the-rules-inside-a-category--domain-rules-typed-as-domainfullkeywordregexp-for-geosite-databases-cidrs-for-geoip-ones
|
||||||
|
- content: 'Check routing tokens against the databases on disk and return only the
|
||||||
|
ones that do not resolve. Plain domains and CIDRs are ignored. Each
|
||||||
|
issue carries a reason: syntax, fileMissing or categoryMissing.'
|
||||||
|
id: check-routing-tokens-against-the-databases-on-disk-and-return-only-the-ones-that-do-not-resolve-plain-domains-and-cidrs-are-ignored-each-issue-carries-a-reason-syntax-filemissing-or-categorymissing
|
||||||
- content: List all outbound subscriptions (remote URLs that supply additional
|
- content: List all outbound subscriptions (remote URLs that supply additional
|
||||||
outbounds), newest first.
|
outbounds), newest first.
|
||||||
id: list-all-outbound-subscriptions-remote-urls-that-supply-additional-outbounds-newest-first
|
id: list-all-outbound-subscriptions-remote-urls-that-supply-additional-outbounds-newest-first
|
||||||
@@ -151,9 +193,9 @@ _openapi:
|
|||||||
id: update-an-existing-outbound-subscription-by-id-accepts-the-same-form-fields-as-create
|
id: update-an-existing-outbound-subscription-by-id-accepts-the-same-form-fields-as-create
|
||||||
- content: Delete an outbound subscription by id.
|
- content: Delete an outbound subscription by id.
|
||||||
id: delete-an-outbound-subscription-by-id
|
id: delete-an-outbound-subscription-by-id
|
||||||
- content: Delete an outbound subscription by id (POST alias of DELETE for
|
- content: Delete an outbound subscription by id (POST alias of DELETE for clients
|
||||||
axios-friendly clients).
|
that cannot send DELETE).
|
||||||
id: delete-an-outbound-subscription-by-id-post-alias-of-delete-for-axios-friendly-clients
|
id: delete-an-outbound-subscription-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete
|
||||||
- content: Force an immediate re-fetch of the subscription and return the parsed
|
- content: Force an immediate re-fetch of the subscription and return the parsed
|
||||||
outbounds. Signals Xray to reload.
|
outbounds. Signals Xray to reload.
|
||||||
id: force-an-immediate-re-fetch-of-the-subscription-and-return-the-parsed-outbounds-signals-xray-to-reload
|
id: force-an-immediate-re-fetch-of-the-subscription-and-return-the-parsed-outbounds-signals-xray-to-reload
|
||||||
@@ -175,7 +217,7 @@ export default function Layout(props) {
|
|||||||
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/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/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 />
|
<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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
title: خروجیها و مسیریابی
|
title: خروجیها و مسیریابی
|
||||||
description: مدیریت ترافیک خروجی در 3x-ui — خروجیهای WARP و NordVPN، اشتراکهای خروجی (مجموعه سرورها)، قواعد مسیریابی و متعادلکنندههای بار.
|
description: مدیریت ترافیک خروجی در 3x-ui — خروجیهای WARP، NordVPN، WireGuard PIA، اشتراکهای خروجی (مجموعه سرورها)، قواعد مسیریابی و متعادلکنندههای بار.
|
||||||
icon: Route
|
icon: Route
|
||||||
---
|
---
|
||||||
|
|
||||||
ورودیها کلاینتها را میپذیرند؛ **خروجیها** تعیین میکنند ترافیک آنها در ادامه به کجا برود.
|
ورودیها کلاینتها را میپذیرند؛ **خروجیها** تعیین میکنند ترافیک آنها در ادامه به کجا برود.
|
||||||
3x-ui میتواند ترافیک را از طریق Cloudflare WARP، NordVPN یا مجموعههای خروجی دلخواه
|
3x-ui میتواند ترافیک را از طریق Cloudflare WARP، NordVPN، Private Internet Access
|
||||||
|
(خروجی WireGuard) یا مجموعههای خروجی دلخواه
|
||||||
واردشده از یک اشتراک مسیریابی کند و با قواعد مسیریابی و متعادلکنندهها میان آنها
|
واردشده از یک اشتراک مسیریابی کند و با قواعد مسیریابی و متعادلکنندهها میان آنها
|
||||||
انتخاب نماید.
|
انتخاب نماید.
|
||||||
|
|
||||||
@@ -86,6 +87,23 @@ WARP به سرور شما امکان میدهد ترافیک خود را از
|
|||||||
یک کلید خصوصی را مستقیماً بپذیرد) و کشورها/سرورها را فهرست کند تا بتوانید یک خروجی NordVPN
|
یک کلید خصوصی را مستقیماً بپذیرد) و کشورها/سرورها را فهرست کند تا بتوانید یک خروجی NordVPN
|
||||||
بسازید.
|
بسازید.
|
||||||
|
|
||||||
|
## خروجی WireGuard PIA
|
||||||
|
|
||||||
|
3x-ui میتواند با نام کاربری و رمز عبور PIA وارد شود، کشورها/منطقهها/سرورها را
|
||||||
|
از فهرست امضاشده نشان دهد و یک خروجی WireGuard بسازد. از
|
||||||
|
**Xray → خروجیها → بیشتر → PIA** وارد شوید، سرور را انتخاب کنید و خروجی را
|
||||||
|
اضافه کنید. میتوان چند سرور افزود (هر hostname یک خروجی). برچسب
|
||||||
|
`pia-<region>-<server>` است (مثلاً `pia-us-east-useast1`). افزودن یا **Reset**
|
||||||
|
در هر ردیف کلید را با `/addKey` ثبت میکند. یک hostname را نمیتوان دو بار
|
||||||
|
افزود. خروج فقط توکن ذخیرهشده را پاک میکند؛ حذف خروجی از فهرست خروجیها.
|
||||||
|
Reset یا حذف، peer مربوط به WireGuard را در حساب PIA باطل نمیکند.
|
||||||
|
|
||||||
|
گذرواژه ذخیره نمیشود. توکن API مربوط به PIA با همان تنظیم
|
||||||
|
`NODE_TOKEN_ENCRYPTION` گرهها ذخیره میشود. اگر کلید قدیمی
|
||||||
|
`XUI_NODE_TOKEN_KEY` را بدون ورود دوباره به PIA کنار بگذارید، Add/Reset
|
||||||
|
تا ورود مجدد شکست میخورد. `allowedIPs` فقط
|
||||||
|
`0.0.0.0/0` است.
|
||||||
|
|
||||||
## اشتراکهای خروجی (مجموعه سرورها)
|
## اشتراکهای خروجی (مجموعه سرورها)
|
||||||
|
|
||||||
یک **اشتراک خروجی** یک اشتراک share-link از راه دور را وارد میکند و سرورهای آن را بهعنوان
|
یک **اشتراک خروجی** یک اشتراک share-link از راه دور را وارد میکند و سرورهای آن را بهعنوان
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function Layout(props) {
|
|||||||
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/{id}","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/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
<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/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
---
|
---
|
||||||
title: Исходящие соединения и маршрутизация
|
title: Исходящие соединения и маршрутизация
|
||||||
description: Управляйте исходящим трафиком в 3x-ui — outbound-соединения WARP и NordVPN, подписки на исходящие соединения (пулы серверов), правила маршрутизации и балансировщики нагрузки.
|
description: Управляйте исходящим трафиком в 3x-ui — WARP, NordVPN, PIA WireGuard, подписки на исходящие соединения (пулы серверов), правила маршрутизации и балансировщики нагрузки.
|
||||||
icon: Route
|
icon: Route
|
||||||
---
|
---
|
||||||
|
|
||||||
Inbound-соединения принимают клиентов; **outbound-соединения** определяют, куда
|
Inbound-соединения принимают клиентов; **outbound-соединения** определяют, куда
|
||||||
дальше пойдёт их трафик. 3x-ui может направлять трафик через Cloudflare WARP,
|
дальше пойдёт их трафик. 3x-ui может направлять трафик через Cloudflare WARP,
|
||||||
NordVPN или произвольные пулы исходящих соединений, импортированные из подписки,
|
NordVPN, Private Internet Access (WireGuard) или произвольные пулы
|
||||||
|
исходящих соединений, импортированные из подписки,
|
||||||
а также выбирать между ними с помощью правил маршрутизации и балансировщиков.
|
а также выбирать между ними с помощью правил маршрутизации и балансировщиков.
|
||||||
|
|
||||||
## Редактирование исходящих соединений и маршрутизации
|
## Редактирование исходящих соединений и маршрутизации
|
||||||
@@ -93,6 +94,24 @@ WARP. Также можно применить бесплатную лиценз
|
|||||||
(или принимать приватный ключ напрямую) и выводить список стран/серверов, чтобы вы
|
(или принимать приватный ключ напрямую) и выводить список стран/серверов, чтобы вы
|
||||||
могли построить outbound-соединение NordVPN.
|
могли построить outbound-соединение NordVPN.
|
||||||
|
|
||||||
|
## PIA WireGuard
|
||||||
|
|
||||||
|
3x-ui может войти с именем пользователя и паролем PIA, показать
|
||||||
|
страны/регионы/серверы из подписанного списка и собрать WireGuard-исходящее.
|
||||||
|
Откройте **Xray → Исходящие → Ещё → PIA**, войдите, выберите сервер и добавьте
|
||||||
|
исходящее. Можно добавить несколько серверов (по одному исходящему на hostname).
|
||||||
|
Тег: `pia-<region>-<server>` (например `pia-us-east-useast1`). Добавление или
|
||||||
|
**Reset** в строке регистрирует ключ через PIA `/addKey`. Один и тот же hostname
|
||||||
|
нельзя добавить дважды. Выход очищает только сохранённый токен; удаляйте
|
||||||
|
исходящие в списке исходящих. Reset и удаление не отзывают WireGuard-peer
|
||||||
|
в аккаунте PIA.
|
||||||
|
|
||||||
|
Пароль не сохраняется. Токен PIA API хранится с той же настройкой
|
||||||
|
`NODE_TOKEN_ENCRYPTION`, что и токены API узлов. Если убрать старый
|
||||||
|
`XUI_NODE_TOKEN_KEY` без повторного входа в PIA, Add/Reset не будут
|
||||||
|
работать, пока вы не войдёте снова. `allowedIPs` только
|
||||||
|
`0.0.0.0/0`.
|
||||||
|
|
||||||
## Подписки на исходящие соединения (пулы серверов)
|
## Подписки на исходящие соединения (пулы серверов)
|
||||||
|
|
||||||
**Подписка на исходящие соединения** импортирует удалённую подписку со
|
**Подписка на исходящие соединения** импортирует удалённую подписку со
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function Layout(props) {
|
|||||||
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/{id}","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/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
<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/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
title: 出站与路由
|
title: 出站与路由
|
||||||
description: 在 3x-ui 中调整出口流量——WARP 与 NordVPN 出站、出站订阅(服务器池)、路由规则以及负载均衡器。
|
description: 在 3x-ui 中调整出口流量——WARP、NordVPN、PIA WireGuard、出站订阅(服务器池)、路由规则以及负载均衡器。
|
||||||
icon: Route
|
icon: Route
|
||||||
---
|
---
|
||||||
|
|
||||||
入站负责接受客户端;**出站**则决定客户端的流量接下来发往何处。
|
入站负责接受客户端;**出站**则决定客户端的流量接下来发往何处。
|
||||||
3x-ui 可以让流量经由 Cloudflare WARP、NordVPN,或从订阅导入的任意出站池转发,
|
3x-ui 可以让流量经由 Cloudflare WARP、NordVPN、Private Internet Access
|
||||||
|
(WireGuard),或从订阅导入的任意出站池转发,
|
||||||
并通过路由规则和均衡器在它们之间进行选择。
|
并通过路由规则和均衡器在它们之间进行选择。
|
||||||
|
|
||||||
## 编辑出站与路由
|
## 编辑出站与路由
|
||||||
@@ -81,6 +82,19 @@ WARP 账户,并将其接入一个标签为 **`warp`** 的 WireGuard 出站:
|
|||||||
直接接受一个私钥),并列出国家/服务器,从而让你构建一个
|
直接接受一个私钥),并列出国家/服务器,从而让你构建一个
|
||||||
NordVPN 出站。
|
NordVPN 出站。
|
||||||
|
|
||||||
|
## PIA WireGuard
|
||||||
|
|
||||||
|
3x-ui 可以用 PIA 用户名和密码登录,从已验签的服务器列表里选择国家/区域/服务器,
|
||||||
|
并生成 WireGuard 出站。打开 **Xray → 出站 → 更多 → PIA**,登录后选服务器并添加出站。
|
||||||
|
可以添加多台服务器(每个 hostname 一条出站)。标签为 `pia-<region>-<server>`(例如
|
||||||
|
`pia-us-east-useast1`)。添加或对该行 **Reset** 会向该服务器的 PIA `/addKey` 注册密钥。
|
||||||
|
同一 hostname 不能添加两次。登出只清除保存的 token;删除出站请在出站列表里操作。
|
||||||
|
Reset 或删除出站不会撤销 PIA 账户侧的 WireGuard peer。
|
||||||
|
|
||||||
|
密码不落库。PIA API token 与节点 API token 共用 `NODE_TOKEN_ENCRYPTION`。
|
||||||
|
若在未重新登录 PIA 的情况下淘汰旧的 `XUI_NODE_TOKEN_KEY`,Add/Reset 会失败,直到再次登录。
|
||||||
|
对端 `allowedIPs` 仅为 `0.0.0.0/0`(IPv4)。
|
||||||
|
|
||||||
## 出站订阅(服务器池)
|
## 出站订阅(服务器池)
|
||||||
|
|
||||||
**出站订阅**会导入一个远程分享链接订阅,并将其中的服务器作为**出站**注入到正在运行的
|
**出站订阅**会导入一个远程分享链接订阅,并将其中的服务器作为**出站**注入到正在运行的
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function Layout(props) {
|
|||||||
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/{id}","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/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
<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/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -241,6 +241,8 @@ function proxyOutbound(c: SubClient): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors the one-document-per-client model only; the panel also emits
|
||||||
|
// balancer documents (sub_balancers) that are intentionally out of scope here.
|
||||||
function jsonConfig(c: SubClient): Record<string, unknown> {
|
function jsonConfig(c: SubClient): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
remarks: c.remark,
|
remarks: c.remark,
|
||||||
|
|||||||
+3162
-605
File diff suppressed because it is too large
Load Diff
Generated
+192
-188
@@ -1,46 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"version": "0.6.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"version": "0.6.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.3.2",
|
"@ant-design/icons": "^6.3.2",
|
||||||
"@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": "^5.9.1",
|
||||||
"@noble/hashes": "^2.3.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.102.2",
|
||||||
"@tanstack/react-query-devtools": "^5.101.4",
|
"@tanstack/react-query-devtools": "^5.102.2",
|
||||||
"antd": "^6.6.1",
|
"antd": "^6.6.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.23",
|
"dayjs": "^1.11.23",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.4.0",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"persian-calendar-suite": "^1.5.6",
|
"persian-calendar-suite": "^1.5.6",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.85.0",
|
"react-hook-form": "^7.86.0",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.12",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.3.0",
|
||||||
"swagger-ui-react": "^5.32.14",
|
"swagger-ui-react": "^5.32.14",
|
||||||
"uplot": "^1.6.32",
|
"uplot": "^1.6.32",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-a11y": "^10.5.9",
|
"@storybook/addon-a11y": "^10.5.10",
|
||||||
"@storybook/addon-docs": "^10.5.9",
|
"@storybook/addon-docs": "^10.5.10",
|
||||||
"@storybook/addon-vitest": "^10.5.9",
|
"@storybook/addon-vitest": "^10.5.10",
|
||||||
"@storybook/react-vite": "^10.5.9",
|
"@storybook/react-vite": "^10.5.10",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.5",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"@vitest/browser-playwright": "4.1.11",
|
"@vitest/browser-playwright": "4.1.11",
|
||||||
"@vitest/coverage-v8": "^4.1.11",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
@@ -51,9 +51,9 @@
|
|||||||
"oxlint": "1.79.0",
|
"oxlint": "1.79.0",
|
||||||
"oxlint-tsgolint": "^7.0.2001",
|
"oxlint-tsgolint": "^7.0.2001",
|
||||||
"playwright": "^1.62.1",
|
"playwright": "^1.62.1",
|
||||||
"storybook": "^10.5.9",
|
"storybook": "^10.5.10",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.2.1",
|
"vite": "8.2.2",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.11"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2051,9 +2051,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
|
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.2.tgz",
|
||||||
"integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==",
|
"integrity": "sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2065,9 +2065,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-android-arm64": {
|
"node_modules/@oxc-resolver/binding-android-arm64": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.2.tgz",
|
||||||
"integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==",
|
"integrity": "sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2079,9 +2079,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-darwin-arm64": {
|
"node_modules/@oxc-resolver/binding-darwin-arm64": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.2.tgz",
|
||||||
"integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==",
|
"integrity": "sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2093,9 +2093,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-darwin-x64": {
|
"node_modules/@oxc-resolver/binding-darwin-x64": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.2.tgz",
|
||||||
"integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==",
|
"integrity": "sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2107,9 +2107,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-freebsd-x64": {
|
"node_modules/@oxc-resolver/binding-freebsd-x64": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.2.tgz",
|
||||||
"integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==",
|
"integrity": "sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2121,9 +2121,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
|
"node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.2.tgz",
|
||||||
"integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==",
|
"integrity": "sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2135,9 +2135,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
|
"node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.2.tgz",
|
||||||
"integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==",
|
"integrity": "sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2149,9 +2149,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
|
"node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.2.tgz",
|
||||||
"integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==",
|
"integrity": "sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2166,9 +2166,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-arm64-musl": {
|
"node_modules/@oxc-resolver/binding-linux-arm64-musl": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.2.tgz",
|
||||||
"integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==",
|
"integrity": "sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2183,9 +2183,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
|
"node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.2.tgz",
|
||||||
"integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==",
|
"integrity": "sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -2200,9 +2200,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
|
"node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.2.tgz",
|
||||||
"integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==",
|
"integrity": "sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2217,9 +2217,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
|
"node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.2.tgz",
|
||||||
"integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==",
|
"integrity": "sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2234,9 +2234,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
|
"node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.2.tgz",
|
||||||
"integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==",
|
"integrity": "sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -2251,9 +2251,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-x64-gnu": {
|
"node_modules/@oxc-resolver/binding-linux-x64-gnu": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.2.tgz",
|
||||||
"integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==",
|
"integrity": "sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2268,9 +2268,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-linux-x64-musl": {
|
"node_modules/@oxc-resolver/binding-linux-x64-musl": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.2.tgz",
|
||||||
"integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==",
|
"integrity": "sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2285,9 +2285,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-openharmony-arm64": {
|
"node_modules/@oxc-resolver/binding-openharmony-arm64": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.2.tgz",
|
||||||
"integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==",
|
"integrity": "sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2299,9 +2299,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-wasm32-wasi": {
|
"node_modules/@oxc-resolver/binding-wasm32-wasi": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.2.tgz",
|
||||||
"integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==",
|
"integrity": "sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -2309,18 +2309,18 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "1.11.2",
|
"@emnapi/core": "1.11.0",
|
||||||
"@emnapi/runtime": "1.11.2",
|
"@emnapi/runtime": "1.11.0",
|
||||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
"@napi-rs/wasm-runtime": "^1.1.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
"node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
"version": "1.11.2",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
|
||||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2330,9 +2330,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
"node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
"version": "1.11.2",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
|
||||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2352,9 +2352,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
|
"node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.2.tgz",
|
||||||
"integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==",
|
"integrity": "sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2366,9 +2366,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-resolver/binding-win32-x64-msvc": {
|
"node_modules/@oxc-resolver/binding-win32-x64-msvc": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.2.tgz",
|
||||||
"integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==",
|
"integrity": "sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4209,9 +4209,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/addon-a11y": {
|
"node_modules/@storybook/addon-a11y": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.10.tgz",
|
||||||
"integrity": "sha512-MvXkoJIVRcZrqhWi+wynYAmFkvTWm5aHdJfYohhgPeEJVDRF91zejtOsN7zPRE/lQvBDY+IiomKxeRN3UhWNVw==",
|
"integrity": "sha512-RpRQV5xUbrl6hCiNrd5FSMIo6pnRZ0VZxWvEW/ASLcreGkKUW5jl2AeLCe5YROE2i80s/dU+6VPzOYKrwWNFbQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4223,20 +4223,20 @@
|
|||||||
"url": "https://opencollective.com/storybook"
|
"url": "https://opencollective.com/storybook"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"storybook": "^10.5.9"
|
"storybook": "^10.5.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/addon-docs": {
|
"node_modules/@storybook/addon-docs": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.10.tgz",
|
||||||
"integrity": "sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==",
|
"integrity": "sha512-06JoK3/a7FWI/6GzuidJP9iHp1/Vejboe6lzS1jW+d8ItpecriBt+oXh1VNmUM7i7PjI6pZnet+j51QnLyeOoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdx-js/react": "^3.0.0",
|
"@mdx-js/react": "^3.0.0",
|
||||||
"@storybook/csf-plugin": "10.5.9",
|
"@storybook/csf-plugin": "10.5.10",
|
||||||
"@storybook/icons": "^2.0.2",
|
"@storybook/icons": "^2.0.2",
|
||||||
"@storybook/react-dom-shim": "10.5.9",
|
"@storybook/react-dom-shim": "10.5.10",
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"ts-dedent": "^2.0.0"
|
"ts-dedent": "^2.0.0"
|
||||||
@@ -4247,7 +4247,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"storybook": "^10.5.9"
|
"storybook": "^10.5.10"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/react": {
|
"@types/react": {
|
||||||
@@ -4256,9 +4256,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/addon-vitest": {
|
"node_modules/@storybook/addon-vitest": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.10.tgz",
|
||||||
"integrity": "sha512-TgHczbDANprH9bEj+Z/DZ4b2LaCHpNrBdMOWK7gd+ZRGZz0ZZnGm8H5WoIh2uW4AHYibZmYCyWDSf69Plblz2w==",
|
"integrity": "sha512-JNQ9DSkLfxC8qqytBCej91zBExIZ7z97B410U2zgfQPki4HkI9Ffz97a15f5yhVZ79ppIrZ/ssI+WcyWn0ykXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4273,7 +4273,7 @@
|
|||||||
"@vitest/browser": "^3.0.0 || ^4.0.0",
|
"@vitest/browser": "^3.0.0 || ^4.0.0",
|
||||||
"@vitest/browser-playwright": "^4.0.0",
|
"@vitest/browser-playwright": "^4.0.0",
|
||||||
"@vitest/runner": "^3.0.0 || ^4.0.0",
|
"@vitest/runner": "^3.0.0 || ^4.0.0",
|
||||||
"storybook": "^10.5.9",
|
"storybook": "^10.5.10",
|
||||||
"vitest": "^3.0.0 || ^4.0.0"
|
"vitest": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -4292,13 +4292,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/builder-vite": {
|
"node_modules/@storybook/builder-vite": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.10.tgz",
|
||||||
"integrity": "sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==",
|
"integrity": "sha512-O4GgIP0tKLRueom3EmU3OaBUHKjNYj+jkOvmTIkn3PYTiWVkCuHqSKEs4ADvRyaQuLH+peHhFe4JtkNC9KbtrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@storybook/csf-plugin": "10.5.9",
|
"@storybook/csf-plugin": "10.5.10",
|
||||||
"ts-dedent": "^2.0.0"
|
"ts-dedent": "^2.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -4306,14 +4306,14 @@
|
|||||||
"url": "https://opencollective.com/storybook"
|
"url": "https://opencollective.com/storybook"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"storybook": "^10.5.9",
|
"storybook": "^10.5.10",
|
||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/csf-plugin": {
|
"node_modules/@storybook/csf-plugin": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.10.tgz",
|
||||||
"integrity": "sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==",
|
"integrity": "sha512-TaCLBrqVEr767+w58QDotDUiCTuE5cyRJuRcDlsKQyUIyGBv+lYD3lu8wBiVCYxgIjB/gu9HmqiC+0yx1rHzaw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4326,7 +4326,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"esbuild": "*",
|
"esbuild": "*",
|
||||||
"rollup": "*",
|
"rollup": "*",
|
||||||
"storybook": "^10.5.9",
|
"storybook": "^10.5.10",
|
||||||
"vite": "*",
|
"vite": "*",
|
||||||
"webpack": "*"
|
"webpack": "*"
|
||||||
},
|
},
|
||||||
@@ -4363,14 +4363,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/react": {
|
"node_modules/@storybook/react": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.10.tgz",
|
||||||
"integrity": "sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==",
|
"integrity": "sha512-4MBV5e1SXIMfPynLHzr+Mp0dwGv/FW1bklWAsS4ynBOAbC98W9p/I9vqBnUctsvE3BJkhzHQQyPwMHL5tTcHVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@storybook/global": "^5.0.0",
|
"@storybook/global": "^5.0.0",
|
||||||
"@storybook/react-dom-shim": "10.5.9",
|
"@storybook/react-dom-shim": "10.5.10",
|
||||||
"react-docgen": "^8.0.2",
|
"react-docgen": "^8.0.2",
|
||||||
"react-docgen-typescript": "^2.2.2"
|
"react-docgen-typescript": "^2.2.2"
|
||||||
},
|
},
|
||||||
@@ -4383,7 +4383,7 @@
|
|||||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"storybook": "^10.5.9",
|
"storybook": "^10.5.10",
|
||||||
"typescript": ">= 4.9.x"
|
"typescript": ">= 4.9.x"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -4399,9 +4399,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/react-dom-shim": {
|
"node_modules/@storybook/react-dom-shim": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.10.tgz",
|
||||||
"integrity": "sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==",
|
"integrity": "sha512-rbu62ILo/VE3iXKmu+kWXFpD1H1Lwi0f19q/x7JnDsD2dxKS9w5znLEqPIq2qxpzi/wjjIb2iUP1cRG1d/9W5A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -4413,7 +4413,7 @@
|
|||||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"storybook": "^10.5.9"
|
"storybook": "^10.5.10"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/react": {
|
"@types/react": {
|
||||||
@@ -4425,16 +4425,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/react-vite": {
|
"node_modules/@storybook/react-vite": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.10.tgz",
|
||||||
"integrity": "sha512-mDI+UssrOEP8IwgBsefLG+gqW1gZbZEPbpH5PoHK/P1WPi8BfrLEqrFi9H9wmMkal/Vh8SmL1awgOi0ir3XwcQ==",
|
"integrity": "sha512-xOztxefUnqKeuyvcnjspqmlDnER4cExL+liltrpdXLPJVqfFNr9lgM49FyEPajzsUVG9W/vHJWjbaQGGu1UsYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
|
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
|
||||||
"@rollup/pluginutils": "^5.0.2",
|
"@rollup/pluginutils": "^5.0.2",
|
||||||
"@storybook/builder-vite": "10.5.9",
|
"@storybook/builder-vite": "10.5.10",
|
||||||
"@storybook/react": "10.5.9",
|
"@storybook/react": "10.5.10",
|
||||||
"empathic": "^2.0.0",
|
"empathic": "^2.0.0",
|
||||||
"magic-string": "^0.30.0",
|
"magic-string": "^0.30.0",
|
||||||
"react-docgen": "^8.0.2",
|
"react-docgen": "^8.0.2",
|
||||||
@@ -4448,7 +4448,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"storybook": "^10.5.9",
|
"storybook": "^10.5.10",
|
||||||
"typescript": ">= 4.9.x",
|
"typescript": ">= 4.9.x",
|
||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
},
|
},
|
||||||
@@ -5168,9 +5168,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.101.4",
|
"version": "5.102.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.2.tgz",
|
||||||
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
|
"integrity": "sha512-zQ5794PXBlV5Wl7N23SR1/Ss+wPE/h2Ye4XlKpm3omN8i8b/Fd4DAdqpLS2AXj5M/RMObt3k5qy3E7kzt/AvBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5178,9 +5178,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-devtools": {
|
"node_modules/@tanstack/query-devtools": {
|
||||||
"version": "5.101.4",
|
"version": "5.102.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.102.2.tgz",
|
||||||
"integrity": "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA==",
|
"integrity": "sha512-sbRVlyRWfhKm2z4cnTdHydHiaMYMcH1mCYVNwreE9oR08VmyCsSz97fhy1253gKB4ycMcaRjbfUxQLO0SSLFYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5188,12 +5188,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.101.4",
|
"version": "5.102.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.2.tgz",
|
||||||
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
|
"integrity": "sha512-KxU8ZyOEuJ81eTSgXa8GQbk/jO/rz0elYtNKt3VMtM2pRjeO8ADIu7sqqmEjFKJKqco9h2N1ojIDuHs6VfDItQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.101.4"
|
"@tanstack/query-core": "5.102.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5204,19 +5204,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query-devtools": {
|
"node_modules/@tanstack/react-query-devtools": {
|
||||||
"version": "5.101.4",
|
"version": "5.102.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.102.2.tgz",
|
||||||
"integrity": "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA==",
|
"integrity": "sha512-5KzH1NAdess98Jgbo8R0sraP0yto49+eGgSGu6umvrLbXPNdsNJ7iOgAiXegBOoWJrHygp6hW01L2U6MvSIT5Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-devtools": "5.101.4"
|
"@tanstack/query-devtools": "5.102.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.102.2",
|
||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5456,9 +5456,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
|
||||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -5839,9 +5839,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.5",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
|
||||||
"integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
|
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5853,6 +5853,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"oxc-transform-react": "^0.145.0",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -5861,6 +5862,9 @@
|
|||||||
},
|
},
|
||||||
"babel-plugin-react-compiler": {
|
"babel-plugin-react-compiler": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"oxc-transform-react": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6390,9 +6394,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.11.15",
|
"version": "2.11.18",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz",
|
||||||
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
|
"integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -7050,9 +7054,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.411",
|
"version": "1.5.413",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz",
|
||||||
"integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==",
|
"integrity": "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -7651,9 +7655,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.6",
|
"version": "26.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
|
||||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -8826,34 +8830,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/oxc-resolver": {
|
"node_modules/oxc-resolver": {
|
||||||
"version": "11.24.2",
|
"version": "11.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.2.tgz",
|
||||||
"integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==",
|
"integrity": "sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@oxc-resolver/binding-android-arm-eabi": "11.24.2",
|
"@oxc-resolver/binding-android-arm-eabi": "11.21.2",
|
||||||
"@oxc-resolver/binding-android-arm64": "11.24.2",
|
"@oxc-resolver/binding-android-arm64": "11.21.2",
|
||||||
"@oxc-resolver/binding-darwin-arm64": "11.24.2",
|
"@oxc-resolver/binding-darwin-arm64": "11.21.2",
|
||||||
"@oxc-resolver/binding-darwin-x64": "11.24.2",
|
"@oxc-resolver/binding-darwin-x64": "11.21.2",
|
||||||
"@oxc-resolver/binding-freebsd-x64": "11.24.2",
|
"@oxc-resolver/binding-freebsd-x64": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2",
|
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2",
|
"@oxc-resolver/binding-linux-arm-musleabihf": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-arm64-gnu": "11.24.2",
|
"@oxc-resolver/binding-linux-arm64-gnu": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-arm64-musl": "11.24.2",
|
"@oxc-resolver/binding-linux-arm64-musl": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2",
|
"@oxc-resolver/binding-linux-ppc64-gnu": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2",
|
"@oxc-resolver/binding-linux-riscv64-gnu": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-riscv64-musl": "11.24.2",
|
"@oxc-resolver/binding-linux-riscv64-musl": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-s390x-gnu": "11.24.2",
|
"@oxc-resolver/binding-linux-s390x-gnu": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-x64-gnu": "11.24.2",
|
"@oxc-resolver/binding-linux-x64-gnu": "11.21.2",
|
||||||
"@oxc-resolver/binding-linux-x64-musl": "11.24.2",
|
"@oxc-resolver/binding-linux-x64-musl": "11.21.2",
|
||||||
"@oxc-resolver/binding-openharmony-arm64": "11.24.2",
|
"@oxc-resolver/binding-openharmony-arm64": "11.21.2",
|
||||||
"@oxc-resolver/binding-wasm32-wasi": "11.24.2",
|
"@oxc-resolver/binding-wasm32-wasi": "11.21.2",
|
||||||
"@oxc-resolver/binding-win32-arm64-msvc": "11.24.2",
|
"@oxc-resolver/binding-win32-arm64-msvc": "11.21.2",
|
||||||
"@oxc-resolver/binding-win32-x64-msvc": "11.24.2"
|
"@oxc-resolver/binding-win32-x64-msvc": "11.21.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/oxfmt": {
|
"node_modules/oxfmt": {
|
||||||
@@ -9381,9 +9385,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.85.0",
|
"version": "7.86.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.85.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz",
|
||||||
"integrity": "sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==",
|
"integrity": "sha512-4kbWJrh5jPZt1+YqVcXcGKffGcXV/XVbozknLh0Yjh0KhpoAkus21TAQhzRYqNwFkkObmnSvRlZZ3GT+ehoIrA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -9397,12 +9401,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.11",
|
"version": "17.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
|
||||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.7",
|
||||||
"html-parse-stringify": "^4.0.1",
|
"html-parse-stringify": "^4.0.1",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
@@ -9999,9 +10003,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/storybook": {
|
"node_modules/storybook": {
|
||||||
"version": "10.5.9",
|
"version": "10.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.10.tgz",
|
||||||
"integrity": "sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==",
|
"integrity": "sha512-Rz8k9ejFHsi7lbtJTaxZlhCUz4GkbJIKEoKDjXeLfr/ZhXip73E6keKxW0KH8iGeKiCqHAbJCV4YIQrxTOLiig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -10017,7 +10021,7 @@
|
|||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"open": "^10.2.0",
|
"open": "^10.2.0",
|
||||||
"oxc-parser": "^0.127.0",
|
"oxc-parser": "^0.127.0",
|
||||||
"oxc-resolver": "^11.19.1",
|
"oxc-resolver": "11.21.2",
|
||||||
"recast": "^0.23.5",
|
"recast": "^0.23.5",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"use-sync-external-store": "^1.5.0",
|
"use-sync-external-store": "^1.5.0",
|
||||||
@@ -10701,16 +10705,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.2.1",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
||||||
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
|
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.33.0",
|
"lightningcss": "^1.33.0",
|
||||||
"picomatch": "^4.0.5",
|
"picomatch": "^4.0.5",
|
||||||
"postcss": "^8.5.25",
|
"postcss": "^8.5.26",
|
||||||
"rolldown": "~1.2.1",
|
"rolldown": "~1.2.4",
|
||||||
"tinyglobby": "^0.2.17"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -10727,7 +10731,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.4.0",
|
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
|||||||
+14
-14
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -39,34 +39,34 @@
|
|||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@hookform/resolvers": "^5.9.1",
|
"@hookform/resolvers": "^5.9.1",
|
||||||
"@noble/hashes": "^2.3.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.102.2",
|
||||||
"@tanstack/react-query-devtools": "^5.101.4",
|
"@tanstack/react-query-devtools": "^5.102.2",
|
||||||
"antd": "^6.6.1",
|
"antd": "^6.6.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.23",
|
"dayjs": "^1.11.23",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.4.0",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"persian-calendar-suite": "^1.5.6",
|
"persian-calendar-suite": "^1.5.6",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.85.0",
|
"react-hook-form": "^7.86.0",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.12",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.3.0",
|
||||||
"swagger-ui-react": "^5.32.14",
|
"swagger-ui-react": "^5.32.14",
|
||||||
"uplot": "^1.6.32",
|
"uplot": "^1.6.32",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-a11y": "^10.5.9",
|
"@storybook/addon-a11y": "^10.5.10",
|
||||||
"@storybook/addon-docs": "^10.5.9",
|
"@storybook/addon-docs": "^10.5.10",
|
||||||
"@storybook/addon-vitest": "^10.5.9",
|
"@storybook/addon-vitest": "^10.5.10",
|
||||||
"@storybook/react-vite": "^10.5.9",
|
"@storybook/react-vite": "^10.5.10",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.5",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
"@vitest/browser-playwright": "4.1.11",
|
"@vitest/browser-playwright": "4.1.11",
|
||||||
"@vitest/coverage-v8": "^4.1.11",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
@@ -77,9 +77,9 @@
|
|||||||
"oxlint": "1.79.0",
|
"oxlint": "1.79.0",
|
||||||
"oxlint-tsgolint": "^7.0.2001",
|
"oxlint-tsgolint": "^7.0.2001",
|
||||||
"playwright": "^1.62.1",
|
"playwright": "^1.62.1",
|
||||||
"storybook": "^10.5.9",
|
"storybook": "^10.5.10",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.2.1",
|
"vite": "8.2.2",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.11"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -241,6 +241,9 @@
|
|||||||
"subJsonMux": {
|
"subJsonMux": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subJsonObservatory": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subJsonPath": {
|
"subJsonPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -438,6 +441,7 @@
|
|||||||
"subJsonEnable",
|
"subJsonEnable",
|
||||||
"subJsonFinalMask",
|
"subJsonFinalMask",
|
||||||
"subJsonMux",
|
"subJsonMux",
|
||||||
|
"subJsonObservatory",
|
||||||
"subJsonPath",
|
"subJsonPath",
|
||||||
"subJsonRules",
|
"subJsonRules",
|
||||||
"subJsonURI",
|
"subJsonURI",
|
||||||
@@ -716,6 +720,9 @@
|
|||||||
"subJsonMux": {
|
"subJsonMux": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subJsonObservatory": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subJsonPath": {
|
"subJsonPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -920,6 +927,7 @@
|
|||||||
"subJsonEnable",
|
"subJsonEnable",
|
||||||
"subJsonFinalMask",
|
"subJsonFinalMask",
|
||||||
"subJsonMux",
|
"subJsonMux",
|
||||||
|
"subJsonObservatory",
|
||||||
"subJsonPath",
|
"subJsonPath",
|
||||||
"subJsonRules",
|
"subJsonRules",
|
||||||
"subJsonURI",
|
"subJsonURI",
|
||||||
@@ -962,6 +970,36 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"AmneziaWGLogs": {
|
||||||
|
"description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
|
||||||
|
"properties": {
|
||||||
|
"events": {
|
||||||
|
"example": [
|
||||||
|
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"peers": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PeerActivity"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"running": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"events",
|
||||||
|
"peers",
|
||||||
|
"running"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ApiToken": {
|
"ApiToken": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
@@ -1056,6 +1094,16 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"allowedIPsByInbound": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"description": "AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound\nbasis, keyed by inbound id. Lets one identity attached to both\nWireGuard and AmneziaWG carry two genuinely different addresses in a\nsingle Create/Update call instead of the shared AllowedIPs field\nbeing broadcast to every attached tunnel inbound. Absent/unset for a\ngiven inbound id falls back to the shared AllowedIPs exactly as\nbefore -- fully backward compatible for callers that never set this.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"description": "Auth password (Hysteria)",
|
"description": "Auth password (Hysteria)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1086,6 +1134,10 @@
|
|||||||
"description": "Flow control (XTLS)",
|
"description": "Flow control (XTLS)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"forwardedPorts": {
|
||||||
|
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"description": "Logical grouping label",
|
"description": "Logical grouping label",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1250,6 +1302,9 @@
|
|||||||
"flow": {
|
"flow": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"forwardedPorts": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1328,6 +1383,7 @@
|
|||||||
"enable",
|
"enable",
|
||||||
"expiryTime",
|
"expiryTime",
|
||||||
"flow",
|
"flow",
|
||||||
|
"forwardedPorts",
|
||||||
"group",
|
"group",
|
||||||
"id",
|
"id",
|
||||||
"keepAlive",
|
"keepAlive",
|
||||||
@@ -2071,7 +2127,8 @@
|
|||||||
"mixed",
|
"mixed",
|
||||||
"tunnel",
|
"tunnel",
|
||||||
"tun",
|
"tun",
|
||||||
"mtproto"
|
"mtproto",
|
||||||
|
"amneziawg"
|
||||||
],
|
],
|
||||||
"example": "vless",
|
"example": "vless",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2223,6 +2280,15 @@
|
|||||||
},
|
},
|
||||||
"InboundOption": {
|
"InboundOption": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"awgServer": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ServerSettings"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AwgServer carries the full AmneziaWG server block (keys, subnet,\nobfuscation params) so the clients page can render a downloadable\nper-client .conf without a second round trip.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"enable": {
|
"enable": {
|
||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -2905,6 +2971,68 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PeerActivity": {
|
||||||
|
"description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
|
||||||
|
"properties": {
|
||||||
|
"allowedIPs": {
|
||||||
|
"example": "10.8.1.2/32",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"down": {
|
||||||
|
"example": 4194304,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"example": "peer@example.com",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"example": "203.0.113.9:51820",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"handshake": {
|
||||||
|
"description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
|
||||||
|
"example": 1735732800000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"inboundId": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"interface": {
|
||||||
|
"example": "awg1",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"online": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"example": "inbound-51820",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"up": {
|
||||||
|
"example": 1048576,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"allowedIPs",
|
||||||
|
"down",
|
||||||
|
"email",
|
||||||
|
"endpoint",
|
||||||
|
"handshake",
|
||||||
|
"inboundId",
|
||||||
|
"interface",
|
||||||
|
"online",
|
||||||
|
"tag",
|
||||||
|
"up"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ProbeResultUI": {
|
"ProbeResultUI": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"cpuPct": {
|
"cpuPct": {
|
||||||
@@ -3071,6 +3199,150 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ServerSettings": {
|
||||||
|
"description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.",
|
||||||
|
"properties": {
|
||||||
|
"contentPaddingAddition": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"disableCookies": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"externalInterface": {
|
||||||
|
"description": "ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live\nagain as of Phase 3.5 -- see the matching fields on Instance for what\nthey gate (internal/amneziawgnet's IPv6-address-alias mechanism).\nIPv6Subnet was never actually vestigial either: InstanceFromInbound\nalready consumes it (via serverAddressV6) to build the server's own\ntunnel address, same as always. Only RouteThroughXray, below, remains\ngenuinely vestigial as of the hard cutover to the embedded path\n(internal/amneziawgnet) -- read from existing stored settings for\nbackward compatibility, but not acted on by anything.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"h1": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"h2": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"h3": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"h4": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"headerProtectionKey": {
|
||||||
|
"description": "HeaderProtectionKey and ContentPaddingAddition are AmneziaWG 3.0\nfields, flat and top-level for the same tools/openapigen reason as\nthe block above; Obfuscation() below folds them back into\nObfuscation31's own identically named fields.\nHeaderProtectionKey is a base64 32-byte key; empty (the default)\ndisables AWG 3.0 header protection. A non-empty value requires\nevery one of S1-S4 above to be >= 12 -- ValidateObfuscation\nenforces this at save time, not just at IpcSet time.\nContentPaddingAddition is a \"low-high\" range or bare integer, the\nsame grammar and uint32 cap as H1-H4.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i1": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i2": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i3": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i4": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i5": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ipv6Enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"ipv6ExternalInterface": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ipv6Subnet": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"jc": {
|
||||||
|
"description": "Obfuscation31's fields, repeated flat (not embedded) rather than\nnested under their own key: encoding/json would happily inline an\nembedded Obfuscation31 the same way, but the frontend's Go->Zod/TS\ngenerator (tools/openapigen) does not — it emits a genuinely nested\n`obfuscation31` object, which would silently diverge from the real\nwire JSON. See Obfuscation() below for the manager-facing conversion.",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"jmax": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"jmin": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"keepaliveTimeout": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxHandshakeAttempts": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mtu": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"primaryDns": {
|
||||||
|
"description": "PrimaryDNS/SecondaryDNS seed client configs' DNS line. Blank is\nmeaningful, so no omitempty: a dropped key resurrects frontend defaults.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"randomTrailers": {
|
||||||
|
"description": "RandomTrailers/DisableCookies mirror Instance's identically named\nAmneziaWG 3.1 fields -- see that type's own doc comment for the real\nprotocol/interop details. Both real bool fields (not omitempty):\nbuildUAPIConfig always emits both lines explicitly so the\nreconfigure-in-place diff correctly notices a true->false edit, not\njust false->true.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"rejectAfterTime": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rekeyAfterTime": {
|
||||||
|
"description": "RekeyAfterTime/RekeyTimeout/RejectAfterTime/KeepaliveTimeout/\nMaxHandshakeAttempts mirror Instance's identically named fields --\nsee that type's own doc comment for the grammar/width/real-default\ndetails. Flat and top-level for the same tools/openapigen reason as\nthe rest of this struct.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rekeyTimeout": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"routeThroughXray": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"s1": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"s2": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"s4": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"secondaryDns": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subnetCidr": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"subnetIp": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"disableCookies",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"jc",
|
||||||
|
"jmax",
|
||||||
|
"jmin",
|
||||||
|
"primaryDns",
|
||||||
|
"privateKey",
|
||||||
|
"publicKey",
|
||||||
|
"randomTrailers",
|
||||||
|
"s1",
|
||||||
|
"s2",
|
||||||
|
"s3",
|
||||||
|
"s4",
|
||||||
|
"secondaryDns",
|
||||||
|
"subnetCidr",
|
||||||
|
"subnetIp"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Setting": {
|
"Setting": {
|
||||||
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3091,6 +3363,71 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SubBalancer": {
|
||||||
|
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||||
|
"properties": {
|
||||||
|
"createdAt": {
|
||||||
|
"example": 1710000000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"description": "No gorm default:true — a bool default makes an explicit false at insert\ncollapse back to the column default (zero value is skipped).",
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"inboundIds": {
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"remark": {
|
||||||
|
"example": "auto-fastest",
|
||||||
|
"maxLength": 256,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sortOrder": {
|
||||||
|
"example": 1,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"strategy": {
|
||||||
|
"enum": [
|
||||||
|
"leastLoad",
|
||||||
|
"leastPing",
|
||||||
|
"random",
|
||||||
|
"roundRobin"
|
||||||
|
],
|
||||||
|
"example": "random",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"example": 1710000000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"createdAt",
|
||||||
|
"enabled",
|
||||||
|
"id",
|
||||||
|
"inboundIds",
|
||||||
|
"remark",
|
||||||
|
"sortOrder",
|
||||||
|
"strategy",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"User": {
|
"User": {
|
||||||
"description": "User represents a user account in the 3x-ui panel.",
|
"description": "User represents a user account in the 3x-ui panel.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3160,7 +3497,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Xray Settings",
|
"name": "Xray Settings",
|
||||||
"description": "Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray."
|
"description": "Xray configuration template, outbound management, Warp/Nord/PIA integration, and config testing. All endpoints under /panel/api/xray."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Subscription Balancers",
|
||||||
|
"description": "Client-side balancers for the JSON subscription: each enabled balancer is emitted as one extra config document whose members are the proxy outbounds of the selected inbounds (routing.balancers + burstObservatory). Managed in Settings → Sub Balancers."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Subscription Server",
|
"name": "Subscription Server",
|
||||||
@@ -3531,6 +3872,7 @@
|
|||||||
"success": true,
|
"success": true,
|
||||||
"obj": [
|
"obj": [
|
||||||
{
|
{
|
||||||
|
"awgServer": null,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"listen": "",
|
"listen": "",
|
||||||
@@ -5583,6 +5925,82 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/server/amneziawglogs/{count}": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Server"
|
||||||
|
],
|
||||||
|
"summary": "Return live AmneziaWG peer activity (handshake, endpoint, transfer) plus the panel’s own AmneziaWG event lines.",
|
||||||
|
"operationId": "post_panel_api_server_amneziawglogs_count",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "count",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Maximum peer rows and event lines to return.",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/AmneziaWGLogs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"events": [
|
||||||
|
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
|
||||||
|
],
|
||||||
|
"peers": [
|
||||||
|
{
|
||||||
|
"allowedIPs": "10.8.1.2/32",
|
||||||
|
"down": 4194304,
|
||||||
|
"email": "peer@example.com",
|
||||||
|
"endpoint": "203.0.113.9:51820",
|
||||||
|
"handshake": 1735732800000,
|
||||||
|
"inboundId": 1,
|
||||||
|
"interface": "awg1",
|
||||||
|
"online": true,
|
||||||
|
"tag": "inbound-51820",
|
||||||
|
"up": 1048576
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"running": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/server/importDB": {
|
"/panel/api/server/importDB": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -11143,6 +11561,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/xray/pia/{action}": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Xray Settings"
|
||||||
|
],
|
||||||
|
"summary": "Manage PIA WireGuard integration. The action parameter selects the operation.",
|
||||||
|
"operationId": "post_panel_api_xray_pia_action",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "action",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "countries — list available countries from the signed PIA server list. servers — list regions and WireGuard servers in a country (sends countryCode). reg — sign in with a PIA username and password (sends username, password). data — return the signed-in account hint. del — delete stored PIA credentials. addKey — register a WireGuard key with the selected server (sends hostname) and return fields to build the outbound.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/xray/resetOutboundsTraffic": {
|
"/panel/api/xray/resetOutboundsTraffic": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -11889,6 +12348,280 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/sub-balancers": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Subscription Balancers"
|
||||||
|
],
|
||||||
|
"summary": "List all subscription balancers in sort order (sort_order asc, id asc).",
|
||||||
|
"operationId": "get_panel_api_sub_balancers",
|
||||||
|
"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/SubBalancer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": [
|
||||||
|
{
|
||||||
|
"createdAt": 1710000000000,
|
||||||
|
"enabled": true,
|
||||||
|
"id": 1,
|
||||||
|
"inboundIds": [
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"remark": "auto-fastest",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"strategy": "random",
|
||||||
|
"updatedAt": 1710000000000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Subscription Balancers"
|
||||||
|
],
|
||||||
|
"summary": "Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.",
|
||||||
|
"operationId": "post_panel_api_sub_balancers",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/SubBalancer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"createdAt": 1710000000000,
|
||||||
|
"enabled": true,
|
||||||
|
"id": 1,
|
||||||
|
"inboundIds": [
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"remark": "auto-fastest",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"strategy": "random",
|
||||||
|
"updatedAt": 1710000000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/panel/api/sub-balancers/{id}": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Subscription Balancers"
|
||||||
|
],
|
||||||
|
"summary": "Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle).",
|
||||||
|
"operationId": "post_panel_api_sub_balancers_id",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Balancer id.",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/SubBalancer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"createdAt": 1710000000000,
|
||||||
|
"enabled": true,
|
||||||
|
"id": 1,
|
||||||
|
"inboundIds": [
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"remark": "auto-fastest",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"strategy": "random",
|
||||||
|
"updatedAt": 1710000000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Subscription Balancers"
|
||||||
|
],
|
||||||
|
"summary": "Delete a balancer by id.",
|
||||||
|
"operationId": "delete_panel_api_sub_balancers_id",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Balancer id.",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/SubBalancer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"createdAt": 1710000000000,
|
||||||
|
"enabled": true,
|
||||||
|
"id": 1,
|
||||||
|
"inboundIds": [
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"remark": "auto-fastest",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"strategy": "random",
|
||||||
|
"updatedAt": 1710000000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/panel/api/sub-balancers/{id}/del": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Subscription Balancers"
|
||||||
|
],
|
||||||
|
"summary": "Delete a balancer by id (POST alias of DELETE for clients that cannot send DELETE).",
|
||||||
|
"operationId": "post_panel_api_sub_balancers_id_del",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Balancer id.",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"$ref": "#/components/schemas/SubBalancer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"createdAt": 1710000000000,
|
||||||
|
"enabled": true,
|
||||||
|
"id": 1,
|
||||||
|
"inboundIds": [
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"remark": "auto-fastest",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"strategy": "random",
|
||||||
|
"updatedAt": 1710000000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/{subPath}{subid}": {
|
"/{subPath}{subid}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { ClientRecordSchema, type ClientRecord } from '@/schemas/client';
|
||||||
|
|
||||||
|
const ClientRecordListSchema = z
|
||||||
|
.array(ClientRecordSchema)
|
||||||
|
.nullable()
|
||||||
|
.transform((value) => value ?? []);
|
||||||
|
|
||||||
|
async function fetchClients(): Promise<ClientRecord[]> {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/clients/list', undefined, { silent: true });
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load clients');
|
||||||
|
const validated = parseMsg(msg, ClientRecordListSchema, 'clients/list');
|
||||||
|
return validated.obj ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientOptions(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.clients.all(),
|
||||||
|
queryFn: fetchClients,
|
||||||
|
enabled,
|
||||||
|
staleTime: 30_000,
|
||||||
|
select: (clients) =>
|
||||||
|
clients
|
||||||
|
.map((client) => client.email.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.localeCompare(b)),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import type { SubBalancerFormValues } from '@/schemas/subBalancer';
|
||||||
|
|
||||||
|
// Deliberately urlencoded (no JSON headers): the Go side binds inboundIds from
|
||||||
|
// repeated form keys, which is exactly how HttpUtil encodes arrays.
|
||||||
|
export function useSubBalancerMutations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.subBalancers.root() });
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (payload: SubBalancerFormValues) =>
|
||||||
|
HttpUtil.post('/panel/api/sub-balancers', payload),
|
||||||
|
onSuccess: (msg) => {
|
||||||
|
if (msg?.success) invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: SubBalancerFormValues }) =>
|
||||||
|
HttpUtil.post(`/panel/api/sub-balancers/${id}`, payload),
|
||||||
|
onSuccess: (msg) => {
|
||||||
|
if (msg?.success) invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMut = useMutation({
|
||||||
|
mutationFn: (id: number) => HttpUtil.post(`/panel/api/sub-balancers/${id}/del`),
|
||||||
|
onSuccess: (msg) => {
|
||||||
|
if (msg?.success) invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: (payload: SubBalancerFormValues) => createMut.mutateAsync(payload),
|
||||||
|
update: (id: number, payload: SubBalancerFormValues) => updateMut.mutateAsync({ id, payload }),
|
||||||
|
remove: (id: number) => removeMut.mutateAsync(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { SubBalancerListSchema, type SubBalancer } from '@/schemas/subBalancer';
|
||||||
|
|
||||||
|
async function fetchSubBalancers(): Promise<SubBalancer[]> {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/sub-balancers', undefined, { silent: true });
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch subscription balancers');
|
||||||
|
const validated = parseMsg(msg, SubBalancerListSchema, 'sub-balancers');
|
||||||
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubBalancersQuery() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: keys.subBalancers.list(),
|
||||||
|
queryFn: fetchSubBalancers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const balancers = useMemo(() => query.data ?? [], [query.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
balancers,
|
||||||
|
loading: query.isFetching,
|
||||||
|
fetched: query.data !== undefined || query.isError,
|
||||||
|
fetchError: query.error ? (query.error as Error).message : '',
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ export const keys = {
|
|||||||
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,
|
||||||
},
|
},
|
||||||
|
subBalancers: {
|
||||||
|
root: () => ['sub-balancers'] as const,
|
||||||
|
list: () => ['sub-balancers', 'list'] as const,
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
root: () => ['settings'] as const,
|
root: () => ['settings'] as const,
|
||||||
all: () => ['settings', 'all'] as const,
|
all: () => ['settings', 'all'] as const,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subJsonEnable": false,
|
"subJsonEnable": false,
|
||||||
"subJsonFinalMask": "",
|
"subJsonFinalMask": "",
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
|
"subJsonObservatory": "",
|
||||||
"subJsonPath": "",
|
"subJsonPath": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
"subJsonURI": "",
|
"subJsonURI": "",
|
||||||
@@ -179,6 +180,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subJsonEnable": false,
|
"subJsonEnable": false,
|
||||||
"subJsonFinalMask": "",
|
"subJsonFinalMask": "",
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
|
"subJsonObservatory": "",
|
||||||
"subJsonPath": "",
|
"subJsonPath": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
"subJsonURI": "",
|
"subJsonURI": "",
|
||||||
@@ -219,6 +221,26 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"webListen": "",
|
"webListen": "",
|
||||||
"webPort": 1
|
"webPort": 1
|
||||||
},
|
},
|
||||||
|
"AmneziaWGLogs": {
|
||||||
|
"events": [
|
||||||
|
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
|
||||||
|
],
|
||||||
|
"peers": [
|
||||||
|
{
|
||||||
|
"allowedIPs": "10.8.1.2/32",
|
||||||
|
"down": 4194304,
|
||||||
|
"email": "peer@example.com",
|
||||||
|
"endpoint": "203.0.113.9:51820",
|
||||||
|
"handshake": 1735732800000,
|
||||||
|
"inboundId": 1,
|
||||||
|
"interface": "awg1",
|
||||||
|
"online": true,
|
||||||
|
"tag": "inbound-51820",
|
||||||
|
"up": 1048576
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"running": true
|
||||||
|
},
|
||||||
"ApiToken": {
|
"ApiToken": {
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -242,6 +264,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"allowedIPs": [
|
"allowedIPs": [
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
"allowedIPsByInbound": {},
|
||||||
"auth": "",
|
"auth": "",
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"created_at": 0,
|
"created_at": 0,
|
||||||
@@ -249,6 +272,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"enable": false,
|
"enable": false,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"flow": "",
|
"flow": "",
|
||||||
|
"forwardedPorts": "",
|
||||||
"group": "",
|
"group": "",
|
||||||
"id": "",
|
"id": "",
|
||||||
"keepAlive": 0,
|
"keepAlive": 0,
|
||||||
@@ -286,6 +310,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"enable": false,
|
"enable": false,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"flow": "",
|
"flow": "",
|
||||||
|
"forwardedPorts": "",
|
||||||
"group": "",
|
"group": "",
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"keepAlive": 0,
|
"keepAlive": 0,
|
||||||
@@ -542,6 +567,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"xver": 0
|
"xver": 0
|
||||||
},
|
},
|
||||||
"InboundOption": {
|
"InboundOption": {
|
||||||
|
"awgServer": null,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"listen": "",
|
"listen": "",
|
||||||
@@ -687,6 +713,18 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"runId": "1735689600123456789",
|
"runId": "1735689600123456789",
|
||||||
"state": "success"
|
"state": "success"
|
||||||
},
|
},
|
||||||
|
"PeerActivity": {
|
||||||
|
"allowedIPs": "10.8.1.2/32",
|
||||||
|
"down": 4194304,
|
||||||
|
"email": "peer@example.com",
|
||||||
|
"endpoint": "203.0.113.9:51820",
|
||||||
|
"handshake": 1735732800000,
|
||||||
|
"inboundId": 1,
|
||||||
|
"interface": "awg1",
|
||||||
|
"online": true,
|
||||||
|
"tag": "inbound-51820",
|
||||||
|
"up": 1048576
|
||||||
|
},
|
||||||
"ProbeResultUI": {
|
"ProbeResultUI": {
|
||||||
"cpuPct": 12.5,
|
"cpuPct": 12.5,
|
||||||
"error": "",
|
"error": "",
|
||||||
@@ -723,11 +761,63 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"tlsVersion": "1.3",
|
"tlsVersion": "1.3",
|
||||||
"x25519": true
|
"x25519": true
|
||||||
},
|
},
|
||||||
|
"ServerSettings": {
|
||||||
|
"contentPaddingAddition": "",
|
||||||
|
"disableCookies": false,
|
||||||
|
"externalInterface": "",
|
||||||
|
"h1": "",
|
||||||
|
"h2": "",
|
||||||
|
"h3": "",
|
||||||
|
"h4": "",
|
||||||
|
"headerProtectionKey": "",
|
||||||
|
"i1": "",
|
||||||
|
"i2": "",
|
||||||
|
"i3": "",
|
||||||
|
"i4": "",
|
||||||
|
"i5": "",
|
||||||
|
"ipv6Enabled": false,
|
||||||
|
"ipv6ExternalInterface": "",
|
||||||
|
"ipv6Subnet": "",
|
||||||
|
"jc": 0,
|
||||||
|
"jmax": 0,
|
||||||
|
"jmin": 0,
|
||||||
|
"keepaliveTimeout": "",
|
||||||
|
"maxHandshakeAttempts": "",
|
||||||
|
"mtu": 0,
|
||||||
|
"primaryDns": "",
|
||||||
|
"privateKey": "",
|
||||||
|
"publicKey": "",
|
||||||
|
"randomTrailers": false,
|
||||||
|
"rejectAfterTime": "",
|
||||||
|
"rekeyAfterTime": "",
|
||||||
|
"rekeyTimeout": "",
|
||||||
|
"routeThroughXray": false,
|
||||||
|
"s1": 0,
|
||||||
|
"s2": 0,
|
||||||
|
"s3": 0,
|
||||||
|
"s4": 0,
|
||||||
|
"secondaryDns": "",
|
||||||
|
"subnetCidr": 0,
|
||||||
|
"subnetIp": ""
|
||||||
|
},
|
||||||
"Setting": {
|
"Setting": {
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"key": "",
|
"key": "",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
"SubBalancer": {
|
||||||
|
"createdAt": 1710000000000,
|
||||||
|
"enabled": true,
|
||||||
|
"id": 1,
|
||||||
|
"inboundIds": [
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"remark": "auto-fastest",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"strategy": "random",
|
||||||
|
"updatedAt": 1710000000000
|
||||||
|
},
|
||||||
"User": {
|
"User": {
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"password": "",
|
"password": "",
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subJsonMux": {
|
"subJsonMux": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subJsonObservatory": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subJsonPath": {
|
"subJsonPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -412,6 +415,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subJsonEnable",
|
"subJsonEnable",
|
||||||
"subJsonFinalMask",
|
"subJsonFinalMask",
|
||||||
"subJsonMux",
|
"subJsonMux",
|
||||||
|
"subJsonObservatory",
|
||||||
"subJsonPath",
|
"subJsonPath",
|
||||||
"subJsonRules",
|
"subJsonRules",
|
||||||
"subJsonURI",
|
"subJsonURI",
|
||||||
@@ -690,6 +694,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subJsonMux": {
|
"subJsonMux": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subJsonObservatory": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subJsonPath": {
|
"subJsonPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -894,6 +901,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subJsonEnable",
|
"subJsonEnable",
|
||||||
"subJsonFinalMask",
|
"subJsonFinalMask",
|
||||||
"subJsonMux",
|
"subJsonMux",
|
||||||
|
"subJsonObservatory",
|
||||||
"subJsonPath",
|
"subJsonPath",
|
||||||
"subJsonRules",
|
"subJsonRules",
|
||||||
"subJsonURI",
|
"subJsonURI",
|
||||||
@@ -936,6 +944,36 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"AmneziaWGLogs": {
|
||||||
|
"description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
|
||||||
|
"properties": {
|
||||||
|
"events": {
|
||||||
|
"example": [
|
||||||
|
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"peers": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PeerActivity"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"running": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"events",
|
||||||
|
"peers",
|
||||||
|
"running"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ApiToken": {
|
"ApiToken": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
@@ -1030,6 +1068,16 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"allowedIPsByInbound": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"description": "AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound\nbasis, keyed by inbound id. Lets one identity attached to both\nWireGuard and AmneziaWG carry two genuinely different addresses in a\nsingle Create/Update call instead of the shared AllowedIPs field\nbeing broadcast to every attached tunnel inbound. Absent/unset for a\ngiven inbound id falls back to the shared AllowedIPs exactly as\nbefore -- fully backward compatible for callers that never set this.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"description": "Auth password (Hysteria)",
|
"description": "Auth password (Hysteria)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1060,6 +1108,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Flow control (XTLS)",
|
"description": "Flow control (XTLS)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"forwardedPorts": {
|
||||||
|
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"description": "Logical grouping label",
|
"description": "Logical grouping label",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1224,6 +1276,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"flow": {
|
"flow": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"forwardedPorts": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1302,6 +1357,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"enable",
|
"enable",
|
||||||
"expiryTime",
|
"expiryTime",
|
||||||
"flow",
|
"flow",
|
||||||
|
"forwardedPorts",
|
||||||
"group",
|
"group",
|
||||||
"id",
|
"id",
|
||||||
"keepAlive",
|
"keepAlive",
|
||||||
@@ -2045,7 +2101,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"mixed",
|
"mixed",
|
||||||
"tunnel",
|
"tunnel",
|
||||||
"tun",
|
"tun",
|
||||||
"mtproto"
|
"mtproto",
|
||||||
|
"amneziawg"
|
||||||
],
|
],
|
||||||
"example": "vless",
|
"example": "vless",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2197,6 +2254,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"InboundOption": {
|
"InboundOption": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"awgServer": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ServerSettings"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AwgServer carries the full AmneziaWG server block (keys, subnet,\nobfuscation params) so the clients page can render a downloadable\nper-client .conf without a second round trip.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"enable": {
|
"enable": {
|
||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -2879,6 +2945,68 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PeerActivity": {
|
||||||
|
"description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
|
||||||
|
"properties": {
|
||||||
|
"allowedIPs": {
|
||||||
|
"example": "10.8.1.2/32",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"down": {
|
||||||
|
"example": 4194304,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"example": "peer@example.com",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"example": "203.0.113.9:51820",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"handshake": {
|
||||||
|
"description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
|
||||||
|
"example": 1735732800000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"inboundId": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"interface": {
|
||||||
|
"example": "awg1",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"online": {
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"example": "inbound-51820",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"up": {
|
||||||
|
"example": 1048576,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"allowedIPs",
|
||||||
|
"down",
|
||||||
|
"email",
|
||||||
|
"endpoint",
|
||||||
|
"handshake",
|
||||||
|
"inboundId",
|
||||||
|
"interface",
|
||||||
|
"online",
|
||||||
|
"tag",
|
||||||
|
"up"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ProbeResultUI": {
|
"ProbeResultUI": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"cpuPct": {
|
"cpuPct": {
|
||||||
@@ -3045,6 +3173,150 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ServerSettings": {
|
||||||
|
"description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.",
|
||||||
|
"properties": {
|
||||||
|
"contentPaddingAddition": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"disableCookies": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"externalInterface": {
|
||||||
|
"description": "ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live\nagain as of Phase 3.5 -- see the matching fields on Instance for what\nthey gate (internal/amneziawgnet's IPv6-address-alias mechanism).\nIPv6Subnet was never actually vestigial either: InstanceFromInbound\nalready consumes it (via serverAddressV6) to build the server's own\ntunnel address, same as always. Only RouteThroughXray, below, remains\ngenuinely vestigial as of the hard cutover to the embedded path\n(internal/amneziawgnet) -- read from existing stored settings for\nbackward compatibility, but not acted on by anything.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"h1": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"h2": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"h3": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"h4": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"headerProtectionKey": {
|
||||||
|
"description": "HeaderProtectionKey and ContentPaddingAddition are AmneziaWG 3.0\nfields, flat and top-level for the same tools/openapigen reason as\nthe block above; Obfuscation() below folds them back into\nObfuscation31's own identically named fields.\nHeaderProtectionKey is a base64 32-byte key; empty (the default)\ndisables AWG 3.0 header protection. A non-empty value requires\nevery one of S1-S4 above to be \u003e= 12 -- ValidateObfuscation\nenforces this at save time, not just at IpcSet time.\nContentPaddingAddition is a \"low-high\" range or bare integer, the\nsame grammar and uint32 cap as H1-H4.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i1": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i2": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i3": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i4": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"i5": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ipv6Enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"ipv6ExternalInterface": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ipv6Subnet": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"jc": {
|
||||||
|
"description": "Obfuscation31's fields, repeated flat (not embedded) rather than\nnested under their own key: encoding/json would happily inline an\nembedded Obfuscation31 the same way, but the frontend's Go-\u003eZod/TS\ngenerator (tools/openapigen) does not — it emits a genuinely nested\n`obfuscation31` object, which would silently diverge from the real\nwire JSON. See Obfuscation() below for the manager-facing conversion.",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"jmax": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"jmin": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"keepaliveTimeout": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxHandshakeAttempts": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mtu": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"primaryDns": {
|
||||||
|
"description": "PrimaryDNS/SecondaryDNS seed client configs' DNS line. Blank is\nmeaningful, so no omitempty: a dropped key resurrects frontend defaults.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"randomTrailers": {
|
||||||
|
"description": "RandomTrailers/DisableCookies mirror Instance's identically named\nAmneziaWG 3.1 fields -- see that type's own doc comment for the real\nprotocol/interop details. Both real bool fields (not omitempty):\nbuildUAPIConfig always emits both lines explicitly so the\nreconfigure-in-place diff correctly notices a true-\u003efalse edit, not\njust false-\u003etrue.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"rejectAfterTime": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rekeyAfterTime": {
|
||||||
|
"description": "RekeyAfterTime/RekeyTimeout/RejectAfterTime/KeepaliveTimeout/\nMaxHandshakeAttempts mirror Instance's identically named fields --\nsee that type's own doc comment for the grammar/width/real-default\ndetails. Flat and top-level for the same tools/openapigen reason as\nthe rest of this struct.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rekeyTimeout": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"routeThroughXray": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"s1": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"s2": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"s4": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"secondaryDns": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subnetCidr": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"subnetIp": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"disableCookies",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"jc",
|
||||||
|
"jmax",
|
||||||
|
"jmin",
|
||||||
|
"primaryDns",
|
||||||
|
"privateKey",
|
||||||
|
"publicKey",
|
||||||
|
"randomTrailers",
|
||||||
|
"s1",
|
||||||
|
"s2",
|
||||||
|
"s3",
|
||||||
|
"s4",
|
||||||
|
"secondaryDns",
|
||||||
|
"subnetCidr",
|
||||||
|
"subnetIp"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Setting": {
|
"Setting": {
|
||||||
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3065,6 +3337,71 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SubBalancer": {
|
||||||
|
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||||
|
"properties": {
|
||||||
|
"createdAt": {
|
||||||
|
"example": 1710000000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"description": "No gorm default:true — a bool default makes an explicit false at insert\ncollapse back to the column default (zero value is skipped).",
|
||||||
|
"example": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"example": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"inboundIds": {
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"remark": {
|
||||||
|
"example": "auto-fastest",
|
||||||
|
"maxLength": 256,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sortOrder": {
|
||||||
|
"example": 1,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"strategy": {
|
||||||
|
"enum": [
|
||||||
|
"leastLoad",
|
||||||
|
"leastPing",
|
||||||
|
"random",
|
||||||
|
"roundRobin"
|
||||||
|
],
|
||||||
|
"example": "random",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"example": 1710000000000,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"createdAt",
|
||||||
|
"enabled",
|
||||||
|
"id",
|
||||||
|
"inboundIds",
|
||||||
|
"remark",
|
||||||
|
"sortOrder",
|
||||||
|
"strategy",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"User": {
|
"User": {
|
||||||
"description": "User represents a user account in the 3x-ui panel.",
|
"description": "User represents a user account in the 3x-ui panel.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface AllSetting {
|
|||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subJsonFinalMask: string;
|
subJsonFinalMask: string;
|
||||||
subJsonMux: string;
|
subJsonMux: string;
|
||||||
|
subJsonObservatory: string;
|
||||||
subJsonPath: string;
|
subJsonPath: string;
|
||||||
subJsonRules: string;
|
subJsonRules: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
@@ -188,6 +189,7 @@ export interface AllSettingView {
|
|||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subJsonFinalMask: string;
|
subJsonFinalMask: string;
|
||||||
subJsonMux: string;
|
subJsonMux: string;
|
||||||
|
subJsonObservatory: string;
|
||||||
subJsonPath: string;
|
subJsonPath: string;
|
||||||
subJsonRules: string;
|
subJsonRules: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
@@ -229,6 +231,12 @@ export interface AllSettingView {
|
|||||||
webPort: number;
|
webPort: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AmneziaWGLogs {
|
||||||
|
events: string[];
|
||||||
|
peers: PeerActivity[];
|
||||||
|
running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiToken {
|
export interface ApiToken {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -252,6 +260,7 @@ export interface ApiTokenView {
|
|||||||
export interface Client {
|
export interface Client {
|
||||||
adTag?: string;
|
adTag?: string;
|
||||||
allowedIPs?: string[];
|
allowedIPs?: string[];
|
||||||
|
allowedIPsByInbound?: Record<number, string[]>;
|
||||||
auth?: string;
|
auth?: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
created_at?: number;
|
created_at?: number;
|
||||||
@@ -259,6 +268,7 @@ export interface Client {
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
flow?: string;
|
flow?: string;
|
||||||
|
forwardedPorts?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
keepAlive?: number;
|
keepAlive?: number;
|
||||||
@@ -298,6 +308,7 @@ export interface ClientRecord {
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
flow: string;
|
flow: string;
|
||||||
|
forwardedPorts: string;
|
||||||
group: string;
|
group: string;
|
||||||
id: number;
|
id: number;
|
||||||
keepAlive: number;
|
keepAlive: number;
|
||||||
@@ -510,6 +521,7 @@ export interface InboundFallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InboundOption {
|
export interface InboundOption {
|
||||||
|
awgServer?: ServerSettings | null;
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
listen?: string;
|
listen?: string;
|
||||||
@@ -656,6 +668,19 @@ export interface PanelUpdateStatus {
|
|||||||
state: string;
|
state: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PeerActivity {
|
||||||
|
allowedIPs: string;
|
||||||
|
down: number;
|
||||||
|
email: string;
|
||||||
|
endpoint: string;
|
||||||
|
handshake: number;
|
||||||
|
inboundId: number;
|
||||||
|
interface: string;
|
||||||
|
online: boolean;
|
||||||
|
tag: string;
|
||||||
|
up: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProbeResultUI {
|
export interface ProbeResultUI {
|
||||||
cpuPct: number;
|
cpuPct: number;
|
||||||
error: string;
|
error: string;
|
||||||
@@ -692,12 +717,63 @@ export interface RealityScanResult {
|
|||||||
x25519: boolean;
|
x25519: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerSettings {
|
||||||
|
contentPaddingAddition?: string;
|
||||||
|
disableCookies: boolean;
|
||||||
|
externalInterface?: string;
|
||||||
|
h1: string;
|
||||||
|
h2: string;
|
||||||
|
h3: string;
|
||||||
|
h4: string;
|
||||||
|
headerProtectionKey?: string;
|
||||||
|
i1?: string;
|
||||||
|
i2?: string;
|
||||||
|
i3?: string;
|
||||||
|
i4?: string;
|
||||||
|
i5?: string;
|
||||||
|
ipv6Enabled?: boolean;
|
||||||
|
ipv6ExternalInterface?: string;
|
||||||
|
ipv6Subnet?: string;
|
||||||
|
jc: number;
|
||||||
|
jmax: number;
|
||||||
|
jmin: number;
|
||||||
|
keepaliveTimeout?: string;
|
||||||
|
maxHandshakeAttempts?: string;
|
||||||
|
mtu?: number;
|
||||||
|
primaryDns: string;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
randomTrailers: boolean;
|
||||||
|
rejectAfterTime?: string;
|
||||||
|
rekeyAfterTime?: string;
|
||||||
|
rekeyTimeout?: string;
|
||||||
|
routeThroughXray?: boolean;
|
||||||
|
s1: number;
|
||||||
|
s2: number;
|
||||||
|
s3: number;
|
||||||
|
s4: number;
|
||||||
|
secondaryDns: string;
|
||||||
|
subnetCidr: number;
|
||||||
|
subnetIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Setting {
|
export interface Setting {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubBalancer {
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
inboundIds: number[];
|
||||||
|
remark: string;
|
||||||
|
sortOrder: number;
|
||||||
|
strategy: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const AllSettingSchema = z.object({
|
|||||||
subJsonEnable: z.boolean(),
|
subJsonEnable: z.boolean(),
|
||||||
subJsonFinalMask: z.string(),
|
subJsonFinalMask: z.string(),
|
||||||
subJsonMux: z.string(),
|
subJsonMux: z.string(),
|
||||||
|
subJsonObservatory: z.string(),
|
||||||
subJsonPath: z.string(),
|
subJsonPath: z.string(),
|
||||||
subJsonRules: z.string(),
|
subJsonRules: z.string(),
|
||||||
subJsonURI: z.string(),
|
subJsonURI: z.string(),
|
||||||
@@ -205,6 +206,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subJsonEnable: z.boolean(),
|
subJsonEnable: z.boolean(),
|
||||||
subJsonFinalMask: z.string(),
|
subJsonFinalMask: z.string(),
|
||||||
subJsonMux: z.string(),
|
subJsonMux: z.string(),
|
||||||
|
subJsonObservatory: z.string(),
|
||||||
subJsonPath: z.string(),
|
subJsonPath: z.string(),
|
||||||
subJsonRules: z.string(),
|
subJsonRules: z.string(),
|
||||||
subJsonURI: z.string(),
|
subJsonURI: z.string(),
|
||||||
@@ -247,6 +249,13 @@ export const AllSettingViewSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
|
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
|
||||||
|
|
||||||
|
export const AmneziaWGLogsSchema = z.object({
|
||||||
|
events: z.array(z.string()),
|
||||||
|
peers: z.array(z.lazy(() => PeerActivitySchema)),
|
||||||
|
running: z.boolean(),
|
||||||
|
});
|
||||||
|
export type AmneziaWGLogs = z.infer<typeof AmneziaWGLogsSchema>;
|
||||||
|
|
||||||
export const ApiTokenSchema = z.object({
|
export const ApiTokenSchema = z.object({
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
@@ -272,6 +281,7 @@ export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
|
|||||||
export const ClientSchema = z.object({
|
export const ClientSchema = z.object({
|
||||||
adTag: z.string().optional(),
|
adTag: z.string().optional(),
|
||||||
allowedIPs: z.array(z.string()).optional(),
|
allowedIPs: z.array(z.string()).optional(),
|
||||||
|
allowedIPsByInbound: z.record(z.number().int(), z.array(z.string())).optional(),
|
||||||
auth: z.string().optional(),
|
auth: z.string().optional(),
|
||||||
comment: z.string(),
|
comment: z.string(),
|
||||||
created_at: z.number().int().optional(),
|
created_at: z.number().int().optional(),
|
||||||
@@ -279,6 +289,7 @@ export const ClientSchema = z.object({
|
|||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
expiryTime: z.number().int(),
|
expiryTime: z.number().int(),
|
||||||
flow: z.string().optional(),
|
flow: z.string().optional(),
|
||||||
|
forwardedPorts: z.string().optional(),
|
||||||
group: z.string().optional(),
|
group: z.string().optional(),
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
keepAlive: z.number().int().optional(),
|
keepAlive: z.number().int().optional(),
|
||||||
@@ -320,6 +331,7 @@ export const ClientRecordSchema = z.object({
|
|||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
expiryTime: z.number().int(),
|
expiryTime: z.number().int(),
|
||||||
flow: z.string(),
|
flow: z.string(),
|
||||||
|
forwardedPorts: z.string(),
|
||||||
group: z.string(),
|
group: z.string(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
keepAlive: z.number().int(),
|
keepAlive: z.number().int(),
|
||||||
@@ -511,7 +523,7 @@ export const InboundSchema = z.object({
|
|||||||
nodeId: z.number().int().nullable().optional(),
|
nodeId: z.number().int().nullable().optional(),
|
||||||
originNodeGuid: z.string().optional(),
|
originNodeGuid: z.string().optional(),
|
||||||
port: z.number().int().min(0).max(65535),
|
port: z.number().int().min(0).max(65535),
|
||||||
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
|
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg']),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
settings: z.unknown(),
|
settings: z.unknown(),
|
||||||
shareAddr: z.string(),
|
shareAddr: z.string(),
|
||||||
@@ -548,6 +560,7 @@ export const InboundFallbackSchema = z.object({
|
|||||||
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
||||||
|
|
||||||
export const InboundOptionSchema = z.object({
|
export const InboundOptionSchema = z.object({
|
||||||
|
awgServer: z.lazy(() => ServerSettingsSchema).nullable().optional(),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
listen: z.string().optional(),
|
listen: z.string().optional(),
|
||||||
@@ -701,6 +714,20 @@ export const PanelUpdateStatusSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
|
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
|
||||||
|
|
||||||
|
export const PeerActivitySchema = z.object({
|
||||||
|
allowedIPs: z.string(),
|
||||||
|
down: z.number().int(),
|
||||||
|
email: z.string(),
|
||||||
|
endpoint: z.string(),
|
||||||
|
handshake: z.number().int(),
|
||||||
|
inboundId: z.number().int(),
|
||||||
|
interface: z.string(),
|
||||||
|
online: z.boolean(),
|
||||||
|
tag: z.string(),
|
||||||
|
up: z.number().int(),
|
||||||
|
});
|
||||||
|
export type PeerActivity = z.infer<typeof PeerActivitySchema>;
|
||||||
|
|
||||||
export const ProbeResultUISchema = z.object({
|
export const ProbeResultUISchema = z.object({
|
||||||
cpuPct: z.number(),
|
cpuPct: z.number(),
|
||||||
error: z.string(),
|
error: z.string(),
|
||||||
@@ -739,6 +766,47 @@ export const RealityScanResultSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RealityScanResult = z.infer<typeof RealityScanResultSchema>;
|
export type RealityScanResult = z.infer<typeof RealityScanResultSchema>;
|
||||||
|
|
||||||
|
export const ServerSettingsSchema = z.object({
|
||||||
|
contentPaddingAddition: z.string().optional(),
|
||||||
|
disableCookies: z.boolean(),
|
||||||
|
externalInterface: z.string().optional(),
|
||||||
|
h1: z.string(),
|
||||||
|
h2: z.string(),
|
||||||
|
h3: z.string(),
|
||||||
|
h4: z.string(),
|
||||||
|
headerProtectionKey: z.string().optional(),
|
||||||
|
i1: z.string().optional(),
|
||||||
|
i2: z.string().optional(),
|
||||||
|
i3: z.string().optional(),
|
||||||
|
i4: z.string().optional(),
|
||||||
|
i5: z.string().optional(),
|
||||||
|
ipv6Enabled: z.boolean().optional(),
|
||||||
|
ipv6ExternalInterface: z.string().optional(),
|
||||||
|
ipv6Subnet: z.string().optional(),
|
||||||
|
jc: z.number().int(),
|
||||||
|
jmax: z.number().int(),
|
||||||
|
jmin: z.number().int(),
|
||||||
|
keepaliveTimeout: z.string().optional(),
|
||||||
|
maxHandshakeAttempts: z.string().optional(),
|
||||||
|
mtu: z.number().int().optional(),
|
||||||
|
primaryDns: z.string(),
|
||||||
|
privateKey: z.string(),
|
||||||
|
publicKey: z.string(),
|
||||||
|
randomTrailers: z.boolean(),
|
||||||
|
rejectAfterTime: z.string().optional(),
|
||||||
|
rekeyAfterTime: z.string().optional(),
|
||||||
|
rekeyTimeout: z.string().optional(),
|
||||||
|
routeThroughXray: z.boolean().optional(),
|
||||||
|
s1: z.number().int(),
|
||||||
|
s2: z.number().int(),
|
||||||
|
s3: z.number().int(),
|
||||||
|
s4: z.number().int(),
|
||||||
|
secondaryDns: z.string(),
|
||||||
|
subnetCidr: z.number().int(),
|
||||||
|
subnetIp: z.string(),
|
||||||
|
});
|
||||||
|
export type ServerSettings = z.infer<typeof ServerSettingsSchema>;
|
||||||
|
|
||||||
export const SettingSchema = z.object({
|
export const SettingSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
@@ -746,6 +814,18 @@ export const SettingSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Setting = z.infer<typeof SettingSchema>;
|
export type Setting = z.infer<typeof SettingSchema>;
|
||||||
|
|
||||||
|
export const SubBalancerSchema = z.object({
|
||||||
|
createdAt: z.number().int(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
id: z.number().int(),
|
||||||
|
inboundIds: z.array(z.number().int()),
|
||||||
|
remark: z.string().max(256),
|
||||||
|
sortOrder: z.number().int().min(1),
|
||||||
|
strategy: z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']),
|
||||||
|
updatedAt: z.number().int(),
|
||||||
|
});
|
||||||
|
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
|
||||||
|
|
||||||
export const UserSchema = z.object({
|
export const UserSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Drawer, Layout, Menu } from 'antd';
|
|||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
|
ApartmentOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
@@ -177,6 +178,7 @@ export default function AppSidebar() {
|
|||||||
const { pathname, hash } = useLocation();
|
const { pathname, hash } = useLocation();
|
||||||
const { allSetting } = useAllSettings();
|
const { allSetting } = useAllSettings();
|
||||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
||||||
|
const showSubBalancers = !!allSetting.subJsonEnable;
|
||||||
|
|
||||||
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||||
const [pinned, setPinned] = useState(readSidebarPinned);
|
const [pinned, setPinned] = useState(readSidebarPinned);
|
||||||
@@ -262,8 +264,15 @@ export default function AppSidebar() {
|
|||||||
label: t('menu.subFormats'),
|
label: t('menu.subFormats'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (showSubBalancers) {
|
||||||
|
children.push({
|
||||||
|
key: '/settings#subscription-balancers',
|
||||||
|
icon: <ApartmentOutlined />,
|
||||||
|
label: t('pages.settings.subBalancers.menu'),
|
||||||
|
});
|
||||||
|
}
|
||||||
return children;
|
return children;
|
||||||
}, [t, showSubFormats]);
|
}, [t, showSubFormats, showSubBalancers]);
|
||||||
|
|
||||||
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
|
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { AmneziawgServer } from '@/schemas/protocols/inbound/amneziawg';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Client-side AmneziaWG 3.1 obfuscation generator, mirroring the ranges and
|
||||||
|
* constraints of the Go backend's amneziawg.GenerateObfuscation31
|
||||||
|
* (internal/amneziawg/params.go). Exact parity isn't required — the user can
|
||||||
|
* edit any field afterward and the backend validates on save — but the two
|
||||||
|
* generators must stay range-compatible so a value produced here always
|
||||||
|
* passes the Go-side ValidateObfuscation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AwgObfuscation = Pick<
|
||||||
|
AmneziawgServer,
|
||||||
|
| 'jc'
|
||||||
|
| 'jmin'
|
||||||
|
| 'jmax'
|
||||||
|
| 's1'
|
||||||
|
| 's2'
|
||||||
|
| 's3'
|
||||||
|
| 's4'
|
||||||
|
| 'h1'
|
||||||
|
| 'h2'
|
||||||
|
| 'h3'
|
||||||
|
| 'h4'
|
||||||
|
| 'i1'
|
||||||
|
| 'i2'
|
||||||
|
| 'i3'
|
||||||
|
| 'i4'
|
||||||
|
| 'i5'
|
||||||
|
| 'headerProtectionKey'
|
||||||
|
| 'contentPaddingAddition'
|
||||||
|
| 'rekeyAfterTime'
|
||||||
|
| 'rekeyTimeout'
|
||||||
|
| 'rejectAfterTime'
|
||||||
|
| 'keepaliveTimeout'
|
||||||
|
| 'maxHandshakeAttempts'
|
||||||
|
| 'randomTrailers'
|
||||||
|
| 'disableCookies'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* base64 of 32 crypto-grade random bytes — the exact HeaderProtectionKey
|
||||||
|
* shape amneziawg-tools parses and the Go backend validates.
|
||||||
|
*/
|
||||||
|
const generateHeaderProtectionKey = (): string => {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return btoa(String.fromCharCode(...bytes));
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Four non-overlapping "low-high" ranges for H1-H4: split the space into
|
||||||
|
* four bands and take a random sub-range from each (>= 1000 wide, low
|
||||||
|
* bound >= 5 since 1-4 are reserved for vanilla WireGuard message types).
|
||||||
|
*/
|
||||||
|
const generateHRanges = (): [string, string, string, string] => {
|
||||||
|
const hMax = 2147483647;
|
||||||
|
const hMinWidth = 1000;
|
||||||
|
const lo = 5;
|
||||||
|
const bandSize = Math.floor((hMax - lo + 1) / 4);
|
||||||
|
return Array.from({ length: 4 }, (_, i) => {
|
||||||
|
const bandLo = lo + i * bandSize;
|
||||||
|
const bandHi = bandLo + bandSize - 1;
|
||||||
|
const start = randInt(bandLo, bandHi - hMinWidth - 1);
|
||||||
|
const end = randInt(start + hMinWidth, bandHi - 1);
|
||||||
|
return `${start}-${end}`;
|
||||||
|
}) as [string, string, string, string];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateAwgObfuscation(): AwgObfuscation {
|
||||||
|
const jmin = randInt(40, 89);
|
||||||
|
const s1 = randInt(15, 150);
|
||||||
|
let s2 = randInt(15, 150);
|
||||||
|
while (s1 + 56 === s2) {
|
||||||
|
s2 = randInt(15, 150);
|
||||||
|
}
|
||||||
|
const [h1, h2, h3, h4] = generateHRanges();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Timing windows bracket WireGuard's stock constants (rekey 120s, reject
|
||||||
|
* 180s, retry 5s, keepalive 10s); every reject value exceeds every rekey
|
||||||
|
* value by >= 30s by construction, matching the Go generator and its
|
||||||
|
* ValidateObfuscation cross-check. Content padding stays <= 64 total for
|
||||||
|
* the same MTU-headroom reason that caps s4 at 32.
|
||||||
|
*/
|
||||||
|
const cpLo = randInt(8, 24);
|
||||||
|
const rekeyLo = randInt(100, 120);
|
||||||
|
const rekeyHi = rekeyLo + randInt(10, 40);
|
||||||
|
const rejectLo = rekeyHi + randInt(30, 60);
|
||||||
|
const rekeyTimeoutLo = randInt(3, 6);
|
||||||
|
const keepaliveLo = randInt(8, 12);
|
||||||
|
const attemptsLo = randInt(15, 25);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jc: randInt(3, 6),
|
||||||
|
jmin,
|
||||||
|
jmax: jmin + randInt(50, 250),
|
||||||
|
s1,
|
||||||
|
s2,
|
||||||
|
// Floored at 12, not the protocol's 0/8/4 minima: headerProtectionKey is
|
||||||
|
// always generated below, and IpcSet rejects it unless every s1-s4 >= 12.
|
||||||
|
s3: randInt(12, 55),
|
||||||
|
s4: randInt(12, 27),
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
i1: `<r ${randInt(32, 256)}>`,
|
||||||
|
i2: '',
|
||||||
|
i3: '',
|
||||||
|
i4: '',
|
||||||
|
i5: '',
|
||||||
|
headerProtectionKey: generateHeaderProtectionKey(),
|
||||||
|
contentPaddingAddition: `${cpLo}-${cpLo + randInt(8, 40)}`,
|
||||||
|
rekeyAfterTime: `${rekeyLo}-${rekeyHi}`,
|
||||||
|
rekeyTimeout: `${rekeyTimeoutLo}-${rekeyTimeoutLo + randInt(1, 4)}`,
|
||||||
|
rejectAfterTime: `${rejectLo}-${rejectLo + randInt(30, 90)}`,
|
||||||
|
keepaliveTimeout: `${keepaliveLo}-${keepaliveLo + randInt(2, 8)}`,
|
||||||
|
maxHandshakeAttempts: `${attemptsLo}-${attemptsLo + randInt(5, 25)}`,
|
||||||
|
randomTrailers: true,
|
||||||
|
disableCookies: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { RandomUtil, Wireguard } from '@/utils';
|
import { RandomUtil, Wireguard } from '@/utils';
|
||||||
|
import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
|
||||||
|
|
||||||
|
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
||||||
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
|
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
|
||||||
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
|
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
|
||||||
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
|
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
|
||||||
@@ -263,12 +265,20 @@ export interface WireguardInboundSeed {
|
|||||||
mtu?: number;
|
mtu?: number;
|
||||||
secretKey?: string;
|
secretKey?: string;
|
||||||
noKernelTun?: boolean;
|
noKernelTun?: boolean;
|
||||||
|
subnetIp?: string;
|
||||||
|
subnetCidr?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WireGuard is multi-client now: a new inbound holds only the server identity
|
// WireGuard is multi-client now: a new inbound holds only the server identity
|
||||||
// (secretKey/mtu) and starts with no clients. Clients (peers) are added later
|
// (secretKey/mtu) and starts with no clients. Clients (peers) are added later
|
||||||
// through the client modal, which generates each one's keypair and a unique
|
// through the client modal, which generates each one's keypair and a unique
|
||||||
// tunnel address. peers stays empty for backward-compatible parsing.
|
// tunnel address. peers stays empty for backward-compatible parsing.
|
||||||
|
//
|
||||||
|
// subnetIp/subnetCidr default to 10.0.0.0/24 here — the same value the Go
|
||||||
|
// backend has always fallen back to for an inbound with no clients yet — so
|
||||||
|
// a freshly created inbound shows an explicit, editable value from the
|
||||||
|
// start (matching AmneziaWG's own subnet field), rather than an empty one
|
||||||
|
// that silently relies on server-side inference until an admin fills it in.
|
||||||
export function createDefaultWireguardInboundSettings(
|
export function createDefaultWireguardInboundSettings(
|
||||||
seed: WireguardInboundSeed = {},
|
seed: WireguardInboundSeed = {},
|
||||||
): WireguardInboundSettings {
|
): WireguardInboundSettings {
|
||||||
@@ -278,6 +288,36 @@ export function createDefaultWireguardInboundSettings(
|
|||||||
peers: [],
|
peers: [],
|
||||||
clients: [],
|
clients: [],
|
||||||
noKernelTun: seed.noKernelTun ?? false,
|
noKernelTun: seed.noKernelTun ?? false,
|
||||||
|
subnetIp: seed.subnetIp ?? '10.0.0.0',
|
||||||
|
subnetCidr: seed.subnetCidr ?? 24,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmneziaWG is multi-client, like WireGuard, and uses the same Curve25519
|
||||||
|
// keypair format — Wireguard.generateKeypair() works unchanged. Unlike
|
||||||
|
// WireGuard's Xray-native inbound, the server's publicKey is a real
|
||||||
|
// persisted field here (the Go backend reads it directly rather than
|
||||||
|
// re-deriving it), so it's seeded alongside privateKey. The obfuscation
|
||||||
|
// parameters are randomized per inbound (a static default would give every
|
||||||
|
// install the same DPI fingerprint), mirroring the Go backend's
|
||||||
|
// internal/amneziawg.GenerateObfuscation31.
|
||||||
|
export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSettings {
|
||||||
|
const kp = Wireguard.generateKeypair();
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
privateKey: kp.privateKey,
|
||||||
|
publicKey: kp.publicKey,
|
||||||
|
subnetIp: '10.8.1.0',
|
||||||
|
subnetCidr: 24,
|
||||||
|
primaryDns: '8.8.8.8',
|
||||||
|
secondaryDns: '8.8.4.4',
|
||||||
|
externalInterface: '',
|
||||||
|
ipv6Enabled: false,
|
||||||
|
ipv6Subnet: '',
|
||||||
|
ipv6ExternalInterface: '',
|
||||||
|
...generateAwgObfuscation(),
|
||||||
|
},
|
||||||
|
clients: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +337,8 @@ export type AnyInboundSettings =
|
|||||||
| TunInboundSettings
|
| TunInboundSettings
|
||||||
| TunnelInboundSettings
|
| TunnelInboundSettings
|
||||||
| WireguardInboundSettings
|
| WireguardInboundSettings
|
||||||
| MtprotoInboundSettings;
|
| MtprotoInboundSettings
|
||||||
|
| AmneziawgInboundSettings;
|
||||||
|
|
||||||
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
|
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
@@ -323,6 +364,8 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
|
|||||||
return createDefaultWireguardInboundSettings();
|
return createDefaultWireguardInboundSettings();
|
||||||
case 'mtproto':
|
case 'mtproto':
|
||||||
return createDefaultMtprotoInboundSettings();
|
return createDefaultMtprotoInboundSettings();
|
||||||
|
case 'amneziawg':
|
||||||
|
return createDefaultAmneziawgInboundSettings();
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from '@/schemas/forms/inbound-form';
|
} from '@/schemas/forms/inbound-form';
|
||||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||||
import {
|
import {
|
||||||
|
AmneziawgClientSchema,
|
||||||
HysteriaClientSchema,
|
HysteriaClientSchema,
|
||||||
MtprotoClientSchema,
|
MtprotoClientSchema,
|
||||||
ShadowsocksClientSchema,
|
ShadowsocksClientSchema,
|
||||||
@@ -268,6 +269,8 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
|||||||
return WireguardClientSchema;
|
return WireguardClientSchema;
|
||||||
case 'mtproto':
|
case 'mtproto':
|
||||||
return MtprotoClientSchema;
|
return MtprotoClientSchema;
|
||||||
|
case 'amneziawg':
|
||||||
|
return AmneziawgClientSchema;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Base64, Wireguard } from '@/utils';
|
import { Base64, Wireguard } from '@/utils';
|
||||||
|
|
||||||
import type { Inbound } from '@/schemas/api/inbound';
|
import type { Inbound } from '@/schemas/api/inbound';
|
||||||
|
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
||||||
import type { VlessClient } from '@/schemas/protocols/inbound/vless';
|
import type { VlessClient } from '@/schemas/protocols/inbound/vless';
|
||||||
import type { VmessSecurity } from '@/schemas/protocols/shared/vmess';
|
import type { VmessSecurity } from '@/schemas/protocols/shared/vmess';
|
||||||
import type {
|
import type {
|
||||||
@@ -911,6 +912,168 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
|||||||
return txt;
|
return txt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared input shape for both the per-client vpn:// link and .conf
|
||||||
|
// builders below — settings.clients (not a peers array; unlike WireGuard,
|
||||||
|
// AmneziaWG was multi-client from day one, so there's no legacy format).
|
||||||
|
export interface GenAmneziaWGLinkInput {
|
||||||
|
settings: AmneziawgInboundSettings;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
remark?: string;
|
||||||
|
peerIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function amneziaWGHLine(key: string, value: string | undefined, fallback: string): string {
|
||||||
|
return `${key} = ${value && value.trim() !== '' ? value : fallback}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64url (RFC 4648 §5), no padding — matches the real AmneziaVPN app's
|
||||||
|
// own Qt::Base64UrlEncoding | Qt::OmitTrailingEquals framing for vpn:// links.
|
||||||
|
function toBase64Url(text: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
let binary = '';
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmneziaWG share link: vpn://<base64url .conf text>, matching the real
|
||||||
|
// AmneziaVPN app's own share-link scheme. The app's import path base64url-
|
||||||
|
// decodes, best-effort qUncompresses (falls back to the raw bytes when the
|
||||||
|
// input isn't qCompress-framed, which plain text never is), then parses the
|
||||||
|
// result as a flat bag of "Key = Value" lines regardless of which
|
||||||
|
// [Interface]/[Peer] section they came from — so wrapping the same .conf
|
||||||
|
// text genAmneziaWGConfig already produces is sufficient; no JSON schema or
|
||||||
|
// compression needs replicating. Confirmed against the app's own source
|
||||||
|
// (importController.cpp's checkConfigFormat/extractWireGuardConfig).
|
||||||
|
export function genAmneziaWGLink(input: GenAmneziaWGLinkInput): string {
|
||||||
|
const cfgText = genAmneziaWGConfig(input);
|
||||||
|
if (!cfgText) return '';
|
||||||
|
return `vpn://${toBase64Url(cfgText)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text AmneziaWG client config (.conf format). Mirrors
|
||||||
|
// genWireguardConfig, plus the obfuscation lines every AmneziaWG client must
|
||||||
|
// share with the server (see internal/amneziawg.writeObfuscation on the Go
|
||||||
|
// side).
|
||||||
|
export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
|
||||||
|
const { settings, address, port, remark = '', peerIndex } = input;
|
||||||
|
const client = settings.clients[peerIndex];
|
||||||
|
if (!client) return '';
|
||||||
|
const server = settings.server;
|
||||||
|
|
||||||
|
// These land unescaped in the .conf; a newline would inject a config line
|
||||||
|
// (e.g. a rogue PostUp) — same guard as the panel's other two emitters.
|
||||||
|
for (const v of [
|
||||||
|
client.privateKey ?? '',
|
||||||
|
server.primaryDns ?? '',
|
||||||
|
server.secondaryDns ?? '',
|
||||||
|
remark,
|
||||||
|
]) {
|
||||||
|
if (/[\r\n]/.test(v)) return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let txt = `[Interface]\n`;
|
||||||
|
txt += `PrivateKey = ${client.privateKey ?? ''}\n`;
|
||||||
|
txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`;
|
||||||
|
const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== '');
|
||||||
|
if (dns.length > 0) txt += `DNS = ${dns.join(', ')}\n`;
|
||||||
|
if (typeof server.mtu === 'number' && server.mtu > 0) {
|
||||||
|
txt += `MTU = ${server.mtu}\n`;
|
||||||
|
}
|
||||||
|
txt += `Jc = ${server.jc}\n`;
|
||||||
|
txt += `Jmin = ${server.jmin}\n`;
|
||||||
|
txt += `Jmax = ${server.jmax}\n`;
|
||||||
|
txt += `S1 = ${server.s1}\n`;
|
||||||
|
txt += `S2 = ${server.s2}\n`;
|
||||||
|
if (server.s3) txt += `S3 = ${server.s3}\n`;
|
||||||
|
if (server.s4) txt += `S4 = ${server.s4}\n`;
|
||||||
|
txt += `${amneziaWGHLine('H1', server.h1, '1')}\n`;
|
||||||
|
txt += `${amneziaWGHLine('H2', server.h2, '2')}\n`;
|
||||||
|
txt += `${amneziaWGHLine('H3', server.h3, '3')}\n`;
|
||||||
|
txt += `${amneziaWGHLine('H4', server.h4, '4')}\n`;
|
||||||
|
if (server.i1) txt += `I1 = ${server.i1}\n`;
|
||||||
|
if (server.i2) txt += `I2 = ${server.i2}\n`;
|
||||||
|
if (server.i3) txt += `I3 = ${server.i3}\n`;
|
||||||
|
if (server.i4) txt += `I4 = ${server.i4}\n`;
|
||||||
|
if (server.i5) txt += `I5 = ${server.i5}\n`;
|
||||||
|
const optional31: Array<[string, string | undefined]> = [
|
||||||
|
['HeaderProtectionKey', server.headerProtectionKey],
|
||||||
|
['ContentPaddingAddition', server.contentPaddingAddition],
|
||||||
|
['RekeyAfterTime', server.rekeyAfterTime],
|
||||||
|
['RekeyTimeout', server.rekeyTimeout],
|
||||||
|
['RejectAfterTime', server.rejectAfterTime],
|
||||||
|
['KeepaliveTimeout', server.keepaliveTimeout],
|
||||||
|
['MaxHandshakeAttempts', server.maxHandshakeAttempts],
|
||||||
|
];
|
||||||
|
for (const [key, value] of optional31) {
|
||||||
|
if (value && value.trim() !== '') txt += `${key} = ${value}\n`;
|
||||||
|
}
|
||||||
|
if (server.randomTrailers) txt += `RandomTrailers = on\n`;
|
||||||
|
if (server.disableCookies) txt += `DisableCookies = on\n`;
|
||||||
|
// Peer field order follows wg-quick(8) and the panel's other two AmneziaWG
|
||||||
|
// emitters (amneziaWGConfigText in Go, buildAmneziaWGClientConfig); all three
|
||||||
|
// are independent implementations and must not drift apart.
|
||||||
|
txt += `\n# ${remark}\n`;
|
||||||
|
txt += `[Peer]\n`;
|
||||||
|
txt += `PublicKey = ${server.publicKey ?? ''}\n`;
|
||||||
|
if (client.preSharedKey && client.preSharedKey.length > 0) {
|
||||||
|
txt += `PresharedKey = ${client.preSharedKey}\n`;
|
||||||
|
}
|
||||||
|
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
|
||||||
|
txt += `Endpoint = ${address}:${port}`;
|
||||||
|
if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
|
||||||
|
txt += `\nPersistentKeepalive = ${client.keepAlive}`;
|
||||||
|
}
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenAmneziaWGFanoutInput {
|
||||||
|
inbound: Inbound;
|
||||||
|
remark?: string;
|
||||||
|
hostOverride?: string;
|
||||||
|
fallbackHostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genAmneziaWGLinks(input: GenAmneziaWGFanoutInput): string {
|
||||||
|
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||||
|
if (inbound.protocol !== 'amneziawg') return '';
|
||||||
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
|
const sep = '-';
|
||||||
|
const settings = inbound.settings as AmneziawgInboundSettings;
|
||||||
|
const clients = settings.clients ?? [];
|
||||||
|
return clients
|
||||||
|
.map((c, i) =>
|
||||||
|
genAmneziaWGLink({
|
||||||
|
settings,
|
||||||
|
address: addr,
|
||||||
|
port: inbound.port,
|
||||||
|
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
|
||||||
|
peerIndex: i,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genAmneziaWGConfigs(input: GenAmneziaWGFanoutInput): string {
|
||||||
|
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||||
|
if (inbound.protocol !== 'amneziawg') return '';
|
||||||
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
|
const sep = '-';
|
||||||
|
const settings = inbound.settings as AmneziawgInboundSettings;
|
||||||
|
const clients = settings.clients ?? [];
|
||||||
|
return clients
|
||||||
|
.map((c, i) =>
|
||||||
|
genAmneziaWGConfig({
|
||||||
|
settings,
|
||||||
|
address: addr,
|
||||||
|
port: inbound.port,
|
||||||
|
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
|
||||||
|
peerIndex: i,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
|
export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
@@ -971,6 +1134,34 @@ export function wireguardConfigFromLink(link: string, fallbackRemark = ''): stri
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse of toBase64Url above -- recovers a vpn:// link's plain .conf
|
||||||
|
// payload for display/copy/download/QR, the AmneziaWG counterpart of
|
||||||
|
// wireguardConfigFromLink. Simpler than that function: a vpn:// link's
|
||||||
|
// payload already *is* the .conf text (see genAmneziaWGLink's own doc
|
||||||
|
// comment), so there's nothing to reconstruct from query params -- just
|
||||||
|
// decode. Mirrors link-label.tsx's own private fromBase64Url (used there
|
||||||
|
// only to pull the remark/port back out for the tag label); duplicated
|
||||||
|
// rather than imported since both are tiny, self-contained, and each
|
||||||
|
// file already owns the matching encode or decode half of this pair.
|
||||||
|
function fromBase64Url(value: string): string {
|
||||||
|
const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function amneziawgConfigFromLink(link: string): string {
|
||||||
|
const trimmed = link.trim();
|
||||||
|
if (!trimmed.startsWith('vpn://')) return '';
|
||||||
|
try {
|
||||||
|
return fromBase64Url(trimmed.slice('vpn://'.length));
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type { WireguardInboundPeer };
|
export type { WireguardInboundPeer };
|
||||||
|
|
||||||
function isUnixSocketListen(listen: string): boolean {
|
function isUnixSocketListen(listen: string): boolean {
|
||||||
@@ -1282,7 +1473,7 @@ export interface GenInboundLinksInput {
|
|||||||
// Top-level entrypoint that produces the full \r\n-joined block a user
|
// Top-level entrypoint that produces the full \r\n-joined block a user
|
||||||
// pastes into a client. Iterates per-client for protocols with clients,
|
// pastes into a client. Iterates per-client for protocols with clients,
|
||||||
// falls back to a single SS link for single-user 2022-blake3-chacha20,
|
// falls back to a single SS link for single-user 2022-blake3-chacha20,
|
||||||
// and emits per-peer .conf blocks for wireguard. Returns '' for the
|
// and emits per-peer .conf blocks for wireguard and amneziawg. Returns '' for the
|
||||||
// other clientless protocols (http, mixed, tunnel).
|
// other clientless protocols (http, mixed, tunnel).
|
||||||
export function genInboundLinks(input: GenInboundLinksInput): string {
|
export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||||
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||||
@@ -1308,6 +1499,9 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
|||||||
if (inbound.protocol === 'wireguard') {
|
if (inbound.protocol === 'wireguard') {
|
||||||
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
||||||
}
|
}
|
||||||
|
if (inbound.protocol === 'amneziawg') {
|
||||||
|
return genAmneziaWGConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function inboundTransports(
|
|||||||
streamSettings: Record<string, unknown> | undefined,
|
streamSettings: Record<string, unknown> | undefined,
|
||||||
settings: Record<string, unknown> | undefined,
|
settings: Record<string, unknown> | undefined,
|
||||||
): TransportBits {
|
): TransportBits {
|
||||||
if (protocol === 'hysteria' || protocol === 'wireguard') return UDP;
|
if (protocol === 'hysteria' || protocol === 'wireguard' || protocol === 'amneziawg') return UDP;
|
||||||
|
|
||||||
let bits: TransportBits = 0;
|
let bits: TransportBits = 0;
|
||||||
const network = asString(streamSettings?.network);
|
const network = asString(streamSettings?.network);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const PROTOCOL_LABELS: Record<string, string> = {
|
|||||||
wireguard: 'WireGuard',
|
wireguard: 'WireGuard',
|
||||||
wg: 'WireGuard',
|
wg: 'WireGuard',
|
||||||
tg: 'MTProto',
|
tg: 'MTProto',
|
||||||
|
vpn: 'AmneziaWG',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROTOCOL_COLORS: Record<string, string> = {
|
const PROTOCOL_COLORS: Record<string, string> = {
|
||||||
@@ -37,6 +38,7 @@ const PROTOCOL_COLORS: Record<string, string> = {
|
|||||||
Hysteria2: 'magenta',
|
Hysteria2: 'magenta',
|
||||||
WireGuard: 'cyan',
|
WireGuard: 'cyan',
|
||||||
MTProto: 'blue',
|
MTProto: 'blue',
|
||||||
|
AmneziaWG: 'yellow',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECURITY_COLORS: Record<string, string> = {
|
const SECURITY_COLORS: Record<string, string> = {
|
||||||
@@ -50,6 +52,18 @@ const TRANSPORT_COLOR = 'gold';
|
|||||||
|
|
||||||
const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
|
const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
|
||||||
|
|
||||||
|
// Reverse of inbound-link.ts's own toBase64Url — base64url (RFC 4648 §5, no
|
||||||
|
// padding) back to the original unicode text, needed to read the remark/
|
||||||
|
// endpoint back out of a vpn:// link's opaque payload below.
|
||||||
|
function fromBase64Url(value: string): string {
|
||||||
|
const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/* Pull protocol, transport, security plus the remark and port out of a share
|
/* Pull protocol, transport, security plus the remark and port out of a share
|
||||||
link. vless/trojan carry network+security as `type`/`security` query params
|
link. vless/trojan carry network+security as `type`/`security` query params
|
||||||
and the remark in the URL hash; vmess packs them into the base64 JSON as
|
and the remark in the URL hash; vmess packs them into the base64 JSON as
|
||||||
@@ -83,6 +97,20 @@ export function parseLinkParts(link: string): LinkParts | null {
|
|||||||
} catch {
|
} catch {
|
||||||
/* unparseable payload, fall back to protocol only */
|
/* unparseable payload, fall back to protocol only */
|
||||||
}
|
}
|
||||||
|
} else if (scheme === 'vpn') {
|
||||||
|
/* AmneziaWG's vpn:// links are base64url of a plain .conf text (matching
|
||||||
|
the real AmneziaVPN app's own share-link scheme), not a structured URL
|
||||||
|
— there's no query string or #hash to read a remark/port from without
|
||||||
|
corrupting the payload the app itself needs to decode. The remark and
|
||||||
|
endpoint are still in there as plain .conf lines, though, so pull them
|
||||||
|
back out directly. */
|
||||||
|
try {
|
||||||
|
const cfgText = fromBase64Url(trimmed.slice('vpn://'.length));
|
||||||
|
remark = /^#\s?(.*)$/m.exec(cfgText)?.[1]?.trim() ?? '';
|
||||||
|
port = /^Endpoint\s*=\s*.+:(\d+)\s*$/m.exec(cfgText)?.[1] ?? '';
|
||||||
|
} catch {
|
||||||
|
/* unparseable payload, fall back to protocol only */
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const url = new URL(trimmed);
|
const url = new URL(trimmed);
|
||||||
|
|||||||
@@ -75,10 +75,11 @@ export function canEnableStream(values: { protocol: string }): boolean {
|
|||||||
return STREAM_PROTOCOLS.includes(values.protocol);
|
return STREAM_PROTOCOLS.includes(values.protocol);
|
||||||
}
|
}
|
||||||
|
|
||||||
// mtproto is served by an external mtg process, not Xray, so the Xray sniffing
|
// mtproto and amneziawg are served by an external process/interface, not
|
||||||
// block does not apply to it. Every other inbound supports sniffing.
|
// Xray, so the Xray sniffing block does not apply to either. Every other
|
||||||
|
// inbound supports sniffing.
|
||||||
export function canEnableSniffing(values: { protocol: string }): boolean {
|
export function canEnableSniffing(values: { protocol: string }): boolean {
|
||||||
return values.protocol !== 'mtproto';
|
return values.protocol !== 'mtproto' && values.protocol !== 'amneziawg';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
|
// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ export class DBInbound {
|
|||||||
return this.protocol === Protocols.WIREGUARD;
|
return this.protocol === Protocols.WIREGUARD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isAmneziawg() {
|
||||||
|
return this.protocol === Protocols.AMNEZIAWG;
|
||||||
|
}
|
||||||
|
|
||||||
get isHysteria() {
|
get isHysteria() {
|
||||||
return this.protocol === Protocols.HYSTERIA;
|
return this.protocol === Protocols.HYSTERIA;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export class AllSetting {
|
|||||||
subJsonMux = '';
|
subJsonMux = '';
|
||||||
subJsonRules = '';
|
subJsonRules = '';
|
||||||
subJsonFinalMask = '';
|
subJsonFinalMask = '';
|
||||||
|
subJsonObservatory = '';
|
||||||
subThemeDir = '';
|
subThemeDir = '';
|
||||||
subHideSettings = false;
|
subHideSettings = false;
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export interface XrayInfo {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AmneziaWGInfo {
|
||||||
|
configured: boolean;
|
||||||
|
running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface StatusInput {
|
interface StatusInput {
|
||||||
cpu?: number;
|
cpu?: number;
|
||||||
cpuCores?: number;
|
cpuCores?: number;
|
||||||
@@ -79,6 +84,7 @@ interface StatusInput {
|
|||||||
appUptime?: number;
|
appUptime?: number;
|
||||||
appStats?: AppStats;
|
appStats?: AppStats;
|
||||||
xray?: Partial<XrayInfo>;
|
xray?: Partial<XrayInfo>;
|
||||||
|
amneziawg?: Partial<AmneziaWGInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Status {
|
export class Status {
|
||||||
@@ -99,6 +105,7 @@ export class Status {
|
|||||||
appUptime = 0;
|
appUptime = 0;
|
||||||
appStats: AppStats = { threads: 0, mem: 0, uptime: 0 };
|
appStats: AppStats = { threads: 0, mem: 0, uptime: 0 };
|
||||||
xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' };
|
xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' };
|
||||||
|
amneziawg: AmneziaWGInfo = { configured: false, running: false };
|
||||||
|
|
||||||
constructor(data?: StatusInput | null) {
|
constructor(data?: StatusInput | null) {
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
@@ -121,5 +128,6 @@ export class Status {
|
|||||||
this.appStats = data.appStats ?? this.appStats;
|
this.appStats = data.appStats ?? this.appStats;
|
||||||
this.xray = { ...this.xray, ...(data.xray || {}) };
|
this.xray = { ...this.xray, ...(data.xray || {}) };
|
||||||
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
|
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
|
||||||
|
this.amneziawg = { ...this.amneziawg, ...(data.amneziawg || {}) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -608,6 +608,28 @@ export const sections: readonly Section[] = [
|
|||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
|
'{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/amneziawglogs/:count',
|
||||||
|
summary:
|
||||||
|
'Return live AmneziaWG peer activity (handshake, endpoint, transfer) plus the panel’s own AmneziaWG event lines.',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'count',
|
||||||
|
in: 'path',
|
||||||
|
type: 'number',
|
||||||
|
desc: 'Maximum peer rows and event lines to return.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'filter',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'string',
|
||||||
|
desc: 'Keyword filter — only rows/lines containing this string.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
body: 'filter=awg1',
|
||||||
|
responseSchema: 'AmneziaWGLogs',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/server/importDB',
|
path: '/panel/api/server/importDB',
|
||||||
@@ -1699,7 +1721,7 @@ export const sections: readonly Section[] = [
|
|||||||
id: 'xray-settings',
|
id: 'xray-settings',
|
||||||
title: 'Xray Settings',
|
title: 'Xray Settings',
|
||||||
description:
|
description:
|
||||||
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray.',
|
'Xray configuration template, outbound management, Warp/Nord/PIA integration, and config testing. All endpoints under /panel/api/xray.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1799,6 +1821,43 @@ export const sections: readonly Section[] = [
|
|||||||
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
|
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/pia/:action',
|
||||||
|
summary: 'Manage PIA WireGuard integration. The action parameter selects the operation.',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
in: 'path',
|
||||||
|
type: 'string',
|
||||||
|
desc: 'countries — list available countries from the signed PIA server list. servers — list regions and WireGuard servers in a country (sends countryCode). reg — sign in with a PIA username and password (sends username, password). data — return the signed-in account hint. del — delete stored PIA credentials. addKey — register a WireGuard key with the selected server (sends hostname) and return fields to build the outbound.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'username',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'string',
|
||||||
|
desc: 'Required when action=reg.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'string',
|
||||||
|
desc: 'Required when action=reg.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'countryCode',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'string',
|
||||||
|
desc: 'Required when action=servers.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hostname',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'string',
|
||||||
|
desc: 'Required when action=addKey.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/xray/resetOutboundsTraffic',
|
path: '/panel/api/xray/resetOutboundsTraffic',
|
||||||
@@ -2152,6 +2211,84 @@ export const sections: readonly Section[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'sub-balancers',
|
||||||
|
title: 'Subscription Balancers',
|
||||||
|
description:
|
||||||
|
'Client-side balancers for the JSON subscription: each enabled balancer is emitted as one extra config document whose members are the proxy outbounds of the selected inbounds (routing.balancers + burstObservatory). Managed in Settings → Sub Balancers.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/sub-balancers',
|
||||||
|
summary: 'List all subscription balancers in sort order (sort_order asc, id asc).',
|
||||||
|
responseSchema: 'SubBalancer',
|
||||||
|
responseSchemaArray: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/sub-balancers',
|
||||||
|
summary:
|
||||||
|
'Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'remark',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'string',
|
||||||
|
desc: 'Display label, used as the config remarks (required).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'strategy',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'string',
|
||||||
|
desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random" (xray routing balancer strategies). Default "random".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'inboundIds',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'integer[]',
|
||||||
|
desc: 'Repeated form keys selecting the member inbounds, e.g. inboundIds=1&inboundIds=3 (required, at least one).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sortOrder',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'integer',
|
||||||
|
desc: '1-based position in the subscription list, interleaved with the inbounds subSortIndex. Default 1.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enabled',
|
||||||
|
in: 'body (form)',
|
||||||
|
type: 'boolean',
|
||||||
|
desc: 'Whether the balancer is emitted. Default true.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responseSchema: 'SubBalancer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/sub-balancers/:id',
|
||||||
|
summary:
|
||||||
|
'Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle).',
|
||||||
|
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
|
||||||
|
responseSchema: 'SubBalancer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/panel/api/sub-balancers/:id',
|
||||||
|
summary: 'Delete a balancer by id.',
|
||||||
|
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
|
||||||
|
responseSchema: 'SubBalancer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/sub-balancers/:id/del',
|
||||||
|
summary:
|
||||||
|
'Delete a balancer by id (POST alias of DELETE for clients that cannot send DELETE).',
|
||||||
|
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
|
||||||
|
responseSchema: 'SubBalancer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'subscription',
|
id: 'subscription',
|
||||||
title: 'Subscription Server',
|
title: 'Subscription Server',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const MULTI_USER_PROTOCOLS = new Set([
|
|||||||
'shadowsocks',
|
'shadowsocks',
|
||||||
'wireguard',
|
'wireguard',
|
||||||
'mtproto',
|
'mtproto',
|
||||||
|
'amneziawg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface BulkAttachInboundsModalProps {
|
interface BulkAttachInboundsModalProps {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const MULTI_USER_PROTOCOLS = new Set([
|
|||||||
'shadowsocks',
|
'shadowsocks',
|
||||||
'wireguard',
|
'wireguard',
|
||||||
'mtproto',
|
'mtproto',
|
||||||
|
'amneziawg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface BulkDetachInboundsModalProps {
|
interface BulkDetachInboundsModalProps {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
|||||||
'trojan',
|
'trojan',
|
||||||
'hysteria',
|
'hysteria',
|
||||||
'wireguard',
|
'wireguard',
|
||||||
|
'amneziawg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const EMPTY: ClientBulkAddFormValues = {
|
const EMPTY: ClientBulkAddFormValues = {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
|||||||
'hysteria',
|
'hysteria',
|
||||||
'wireguard',
|
'wireguard',
|
||||||
'mtproto',
|
'mtproto',
|
||||||
|
'amneziawg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||||
@@ -110,6 +111,7 @@ interface ClientFormModalProps {
|
|||||||
inbounds: InboundOption[];
|
inbounds: InboundOption[];
|
||||||
attachedExternalLinks?: ExternalLink[];
|
attachedExternalLinks?: ExternalLink[];
|
||||||
attachedIds?: number[];
|
attachedIds?: number[];
|
||||||
|
tunnelAllowedIPs?: Record<number, string>;
|
||||||
tgBotEnable?: boolean;
|
tgBotEnable?: boolean;
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
save: (
|
save: (
|
||||||
@@ -128,6 +130,8 @@ type Values = ClientFormValues & {
|
|||||||
wgPublicKey: string;
|
wgPublicKey: string;
|
||||||
wgPreSharedKey: string;
|
wgPreSharedKey: string;
|
||||||
wgAllowedIPs: string;
|
wgAllowedIPs: string;
|
||||||
|
awgAllowedIPs: string;
|
||||||
|
awgForwardedPorts: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
adTag: string;
|
adTag: string;
|
||||||
};
|
};
|
||||||
@@ -162,6 +166,8 @@ const EMPTY: Values = {
|
|||||||
wgPublicKey: '',
|
wgPublicKey: '',
|
||||||
wgPreSharedKey: '',
|
wgPreSharedKey: '',
|
||||||
wgAllowedIPs: '',
|
wgAllowedIPs: '',
|
||||||
|
awgAllowedIPs: '',
|
||||||
|
awgForwardedPorts: '',
|
||||||
secret: '',
|
secret: '',
|
||||||
adTag: '',
|
adTag: '',
|
||||||
};
|
};
|
||||||
@@ -189,6 +195,34 @@ export function gbToBytes(gb: number): number {
|
|||||||
return Math.round(gb * 1024 * 1024 * 1024);
|
return Math.round(gb * 1024 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAllowedIPsList(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps each of the two AllowedIPs fields to the specific wg/awg inbound the
|
||||||
|
// client is currently attached to, so a save with both protocols attached at
|
||||||
|
// once can send each its own value instead of one shared field ambiguously
|
||||||
|
// covering both (see model.Client.AllowedIPsByInbound on the Go side).
|
||||||
|
// Absent from the result when the client isn't actually attached to that
|
||||||
|
// protocol's inbound (e.g. mid-edit, before the attach takes effect).
|
||||||
|
export function resolveTunnelAllowedIPsByInbound(
|
||||||
|
attachedInboundIds: number[],
|
||||||
|
wireguardInboundIds: Set<number>,
|
||||||
|
amneziawgInboundIds: Set<number>,
|
||||||
|
wgAllowedIPs: string[],
|
||||||
|
awgAllowedIPs: string[],
|
||||||
|
): Record<number, string[]> {
|
||||||
|
const wgId = attachedInboundIds.find((id) => wireguardInboundIds.has(id));
|
||||||
|
const awgId = attachedInboundIds.find((id) => amneziawgInboundIds.has(id));
|
||||||
|
const result: Record<number, string[]> = {};
|
||||||
|
if (wgId != null) result[wgId] = wgAllowedIPs;
|
||||||
|
if (awgId != null) result[awgId] = awgAllowedIPs;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveTotalBytes(
|
export function resolveTotalBytes(
|
||||||
originalBytes: number | null | undefined,
|
originalBytes: number | null | undefined,
|
||||||
displayedGB: number,
|
displayedGB: number,
|
||||||
@@ -206,6 +240,7 @@ export default function ClientFormModal({
|
|||||||
inbounds,
|
inbounds,
|
||||||
attachedExternalLinks = [],
|
attachedExternalLinks = [],
|
||||||
attachedIds = [],
|
attachedIds = [],
|
||||||
|
tunnelAllowedIPs = {},
|
||||||
tgBotEnable = false,
|
tgBotEnable = false,
|
||||||
groups = [],
|
groups = [],
|
||||||
save,
|
save,
|
||||||
@@ -262,6 +297,27 @@ export default function ClientFormModal({
|
|||||||
const limitIpDisabled = !fail2ban.usable;
|
const limitIpDisabled = !fail2ban.usable;
|
||||||
const limitIpNotice = getLimitIpNotice(fail2ban, t);
|
const limitIpNotice = getLimitIpNotice(fail2ban, t);
|
||||||
|
|
||||||
|
// Declared ahead of the seeding effect below (which needs them to resolve
|
||||||
|
// which specific wg/awg inbound this client is attached to, for seeding
|
||||||
|
// wgAllowedIPs/awgAllowedIPs from tunnelAllowedIPs) -- both are pure
|
||||||
|
// derivations of the stable `inbounds` prop, so moving them earlier is
|
||||||
|
// just a declaration-order change, not a behavior change.
|
||||||
|
const wireguardIds = useMemo(() => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
for (const row of inbounds || []) {
|
||||||
|
if (row && row.protocol === 'wireguard') ids.add(row.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [inbounds]);
|
||||||
|
|
||||||
|
const amneziawgIds = useMemo(() => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
for (const row of inbounds || []) {
|
||||||
|
if (row && row.protocol === 'amneziawg') ids.add(row.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [inbounds]);
|
||||||
|
|
||||||
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
||||||
appendExternalLink({
|
appendExternalLink({
|
||||||
kind,
|
kind,
|
||||||
@@ -282,6 +338,13 @@ export default function ClientFormModal({
|
|||||||
|
|
||||||
if (isEdit && client) {
|
if (isEdit && client) {
|
||||||
const et = Number(client.expiryTime) || 0;
|
const et = Number(client.expiryTime) || 0;
|
||||||
|
const seedIds = Array.isArray(attachedIds) ? attachedIds : [];
|
||||||
|
const attachedWireguardId = seedIds.find((id) => wireguardIds.has(id));
|
||||||
|
const attachedAmneziawgId = seedIds.find((id) => amneziawgIds.has(id));
|
||||||
|
const wgTunnelIPs =
|
||||||
|
attachedWireguardId != null ? tunnelAllowedIPs[attachedWireguardId] : undefined;
|
||||||
|
const awgTunnelIPs =
|
||||||
|
attachedAmneziawgId != null ? tunnelAllowedIPs[attachedAmneziawgId] : undefined;
|
||||||
const seed: Values = {
|
const seed: Values = {
|
||||||
...EMPTY,
|
...EMPTY,
|
||||||
email: client.email || '',
|
email: client.email || '',
|
||||||
@@ -312,7 +375,9 @@ export default function ClientFormModal({
|
|||||||
wgPrivateKey: client.privateKey || '',
|
wgPrivateKey: client.privateKey || '',
|
||||||
wgPublicKey: client.publicKey || '',
|
wgPublicKey: client.publicKey || '',
|
||||||
wgPreSharedKey: client.preSharedKey || '',
|
wgPreSharedKey: client.preSharedKey || '',
|
||||||
wgAllowedIPs: client.allowedIPs || '',
|
wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
|
||||||
|
awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
|
||||||
|
awgForwardedPorts: client.forwardedPorts || '',
|
||||||
secret: client.secret || '',
|
secret: client.secret || '',
|
||||||
adTag: client.adTag || '',
|
adTag: client.adTag || '',
|
||||||
};
|
};
|
||||||
@@ -369,14 +434,6 @@ export default function ClientFormModal({
|
|||||||
return ids;
|
return ids;
|
||||||
}, [inbounds]);
|
}, [inbounds]);
|
||||||
|
|
||||||
const wireguardIds = useMemo(() => {
|
|
||||||
const ids = new Set<number>();
|
|
||||||
for (const row of inbounds || []) {
|
|
||||||
if (row && row.protocol === 'wireguard') ids.add(row.id);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}, [inbounds]);
|
|
||||||
|
|
||||||
const mtprotoIds = useMemo(() => {
|
const mtprotoIds = useMemo(() => {
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
for (const row of inbounds || []) {
|
for (const row of inbounds || []) {
|
||||||
@@ -431,6 +488,11 @@ export default function ClientFormModal({
|
|||||||
[inboundIds, wireguardIds],
|
[inboundIds, wireguardIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showAmneziawg = useMemo(
|
||||||
|
() => (inboundIds || []).some((id) => amneziawgIds.has(id)),
|
||||||
|
[inboundIds, amneziawgIds],
|
||||||
|
);
|
||||||
|
|
||||||
const showMtproto = useMemo(
|
const showMtproto = useMemo(
|
||||||
() => (inboundIds || []).some((id) => mtprotoIds.has(id)),
|
() => (inboundIds || []).some((id) => mtprotoIds.has(id)),
|
||||||
[inboundIds, mtprotoIds],
|
[inboundIds, mtprotoIds],
|
||||||
@@ -625,18 +687,40 @@ export default function ClientFormModal({
|
|||||||
clientPayload.reverse = { tag: reverseTagValue };
|
clientPayload.reverse = { tag: reverseTagValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showWireguard) {
|
if (showWireguard || showAmneziawg) {
|
||||||
|
// AmneziaWG peers are wire-identical to WireGuard peers (same
|
||||||
|
// privateKey/publicKey/preSharedKey/allowedIPs fields on model.Client),
|
||||||
|
// so both protocols share this one field set — see wgPrivateKey etc.
|
||||||
|
// below and the AmneziaWG-labeled variants of the same inputs.
|
||||||
clientPayload.privateKey = values.wgPrivateKey;
|
clientPayload.privateKey = values.wgPrivateKey;
|
||||||
clientPayload.publicKey = values.wgPublicKey;
|
clientPayload.publicKey = values.wgPublicKey;
|
||||||
if (values.wgPreSharedKey) {
|
if (values.wgPreSharedKey) {
|
||||||
clientPayload.preSharedKey = values.wgPreSharedKey;
|
clientPayload.preSharedKey = values.wgPreSharedKey;
|
||||||
}
|
}
|
||||||
const allowedIPs = values.wgAllowedIPs
|
const wgAllowedIPs = parseAllowedIPsList(values.wgAllowedIPs);
|
||||||
.split(',')
|
if (showWireguard && showAmneziawg) {
|
||||||
.map((s) => s.trim())
|
// Both protocols are attached at once: the two fields hold genuinely
|
||||||
.filter((s) => s !== '');
|
// different addresses, so each must land on its own inbound instead
|
||||||
if (allowedIPs.length > 0) {
|
// of one broadcast value overwriting the other's (allowedIPsByInbound
|
||||||
clientPayload.allowedIPs = allowedIPs;
|
// is what Update/Create key their per-inbound override off of).
|
||||||
|
const awgAllowedIPs = parseAllowedIPsList(values.awgAllowedIPs);
|
||||||
|
clientPayload.allowedIPsByInbound = resolveTunnelAllowedIPsByInbound(
|
||||||
|
values.inboundIds || [],
|
||||||
|
wireguardIds,
|
||||||
|
amneziawgIds,
|
||||||
|
wgAllowedIPs,
|
||||||
|
awgAllowedIPs,
|
||||||
|
);
|
||||||
|
if (wgAllowedIPs.length > 0) {
|
||||||
|
clientPayload.allowedIPs = wgAllowedIPs;
|
||||||
|
}
|
||||||
|
} else if (wgAllowedIPs.length > 0) {
|
||||||
|
clientPayload.allowedIPs = wgAllowedIPs;
|
||||||
|
}
|
||||||
|
// Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
|
||||||
|
// has no host-level iptables layer to hang per-client DNAT off of.
|
||||||
|
if (showAmneziawg) {
|
||||||
|
clientPayload.forwardedPorts = values.awgForwardedPorts.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,9 +1188,15 @@ export default function ClientFormModal({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
{showWireguard && (
|
{(showWireguard || showAmneziawg) && (
|
||||||
<>
|
<>
|
||||||
<Form.Item label={t('pages.clients.wireguardPrivateKey')}>
|
<Form.Item
|
||||||
|
label={t(
|
||||||
|
showAmneziawg
|
||||||
|
? 'pages.clients.amneziaWgPrivateKey'
|
||||||
|
: 'pages.clients.wireguardPrivateKey',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
<Input
|
<Input
|
||||||
value={wgPrivateKey}
|
value={wgPrivateKey}
|
||||||
@@ -1129,23 +1219,67 @@ export default function ClientFormModal({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<FormField
|
<FormField
|
||||||
name="wgPublicKey"
|
name="wgPublicKey"
|
||||||
label={t('pages.clients.wireguardPublicKey')}
|
label={t(
|
||||||
|
showAmneziawg
|
||||||
|
? 'pages.clients.amneziaWgPublicKey'
|
||||||
|
: 'pages.clients.wireguardPublicKey',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Input disabled />
|
<Input disabled />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
name="wgPreSharedKey"
|
name="wgPreSharedKey"
|
||||||
label={t('pages.clients.wireguardPreSharedKey')}
|
label={t(
|
||||||
|
showAmneziawg
|
||||||
|
? 'pages.clients.amneziaWgPreSharedKey'
|
||||||
|
: 'pages.clients.wireguardPreSharedKey',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
{showWireguard && showAmneziawg ? (
|
||||||
name="wgAllowedIPs"
|
<>
|
||||||
label={t('pages.clients.wireguardAllowedIPs')}
|
<FormField
|
||||||
extra={t('pages.clients.wireguardAllowedIPsHint')}
|
name="wgAllowedIPs"
|
||||||
>
|
label={t('pages.clients.wireguardAllowedIPs')}
|
||||||
<Input placeholder="10.0.0.2/32" />
|
extra={t('pages.clients.wireguardAllowedIPsHint')}
|
||||||
</FormField>
|
>
|
||||||
|
<Input placeholder="10.0.0.2/32" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name="awgAllowedIPs"
|
||||||
|
label={t('pages.clients.amneziaWgAllowedIPs')}
|
||||||
|
extra={t('pages.clients.amneziaWgAllowedIPsHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="10.8.1.2/32" />
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
name="wgAllowedIPs"
|
||||||
|
label={t(
|
||||||
|
showAmneziawg
|
||||||
|
? 'pages.clients.amneziaWgAllowedIPs'
|
||||||
|
: 'pages.clients.wireguardAllowedIPs',
|
||||||
|
)}
|
||||||
|
extra={t(
|
||||||
|
showAmneziawg
|
||||||
|
? 'pages.clients.amneziaWgAllowedIPsHint'
|
||||||
|
: 'pages.clients.wireguardAllowedIPsHint',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input placeholder="10.8.1.2/32" />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{showAmneziawg && (
|
||||||
|
<FormField
|
||||||
|
name="awgForwardedPorts"
|
||||||
|
label={t('pages.clients.amneziaWgForwardedPorts')}
|
||||||
|
extra={t('pages.clients.amneziaWgForwardedPortsHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="80, 443, 8000-8100" />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showMtproto && (
|
{showMtproto && (
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import {
|
|||||||
findWireguardInbound,
|
findWireguardInbound,
|
||||||
isWireguardClient,
|
isWireguardClient,
|
||||||
} from './wireguardConfig';
|
} from './wireguardConfig';
|
||||||
|
import {
|
||||||
|
buildAmneziaWGClientConfig,
|
||||||
|
findAmneziaWGInbound,
|
||||||
|
isAmneziaWGClient,
|
||||||
|
} from './amneziawgConfig';
|
||||||
import './ClientInfoModal.css';
|
import './ClientInfoModal.css';
|
||||||
|
|
||||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||||
@@ -35,6 +40,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|||||||
hysteria: 'cyan',
|
hysteria: 'cyan',
|
||||||
hysteria2: 'green',
|
hysteria2: 'green',
|
||||||
wireguard: 'gold',
|
wireguard: 'gold',
|
||||||
|
amneziawg: 'yellow',
|
||||||
http: 'purple',
|
http: 'purple',
|
||||||
mixed: 'lime',
|
mixed: 'lime',
|
||||||
tunnel: 'orange',
|
tunnel: 'orange',
|
||||||
@@ -56,6 +62,7 @@ interface ClientInfoModalProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
client: ClientRecord | null;
|
client: ClientRecord | null;
|
||||||
inboundsById: Record<number, InboundOption>;
|
inboundsById: Record<number, InboundOption>;
|
||||||
|
tunnelAllowedIPs?: Record<number, string>;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
subSettings?: SubSettings;
|
subSettings?: SubSettings;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -86,6 +93,7 @@ export default function ClientInfoModal({
|
|||||||
open,
|
open,
|
||||||
client,
|
client,
|
||||||
inboundsById,
|
inboundsById,
|
||||||
|
tunnelAllowedIPs,
|
||||||
isOnline,
|
isOnline,
|
||||||
subSettings = DEFAULT_SUB,
|
subSettings = DEFAULT_SUB,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -186,6 +194,22 @@ export default function ClientInfoModal({
|
|||||||
);
|
);
|
||||||
}, [client, wgInbound, subSettings?.publicHost]);
|
}, [client, wgInbound, subSettings?.publicHost]);
|
||||||
|
|
||||||
|
const awgInbound = useMemo(
|
||||||
|
() => findAmneziaWGInbound(client, inboundsById),
|
||||||
|
[client, inboundsById],
|
||||||
|
);
|
||||||
|
const awgConfigText = useMemo(() => {
|
||||||
|
if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
|
||||||
|
const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
|
||||||
|
return buildAmneziaWGClientConfig(
|
||||||
|
client,
|
||||||
|
awgInbound,
|
||||||
|
window.location.hostname,
|
||||||
|
subSettings?.publicHost ?? '',
|
||||||
|
address,
|
||||||
|
);
|
||||||
|
}, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
|
||||||
|
|
||||||
async function copyValue(text: string) {
|
async function copyValue(text: string) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const ok = await ClipboardManager.copyText(String(text));
|
const ok = await ClipboardManager.copyText(String(text));
|
||||||
@@ -766,6 +790,18 @@ export default function ClientInfoModal({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{awgConfigText && client && (
|
||||||
|
<>
|
||||||
|
<Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
|
||||||
|
<ConfigBlock
|
||||||
|
label={t('pages.clients.config')}
|
||||||
|
text={awgConfigText}
|
||||||
|
fileName={`${client.email}.conf`}
|
||||||
|
qrRemark={client.email || 'peer'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
findWireguardInbound,
|
findWireguardInbound,
|
||||||
isWireguardClient,
|
isWireguardClient,
|
||||||
} from './wireguardConfig';
|
} from './wireguardConfig';
|
||||||
|
import {
|
||||||
|
buildAmneziaWGClientConfig,
|
||||||
|
findAmneziaWGInbound,
|
||||||
|
isAmneziaWGClient,
|
||||||
|
} from './amneziawgConfig';
|
||||||
|
|
||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
@@ -24,6 +29,7 @@ interface ClientQrModalProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
client: ClientRecord | null;
|
client: ClientRecord | null;
|
||||||
inboundsById: Record<number, InboundOption>;
|
inboundsById: Record<number, InboundOption>;
|
||||||
|
tunnelAllowedIPs?: Record<number, string>;
|
||||||
subSettings?: SubSettings;
|
subSettings?: SubSettings;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -45,6 +51,7 @@ export default function ClientQrModal({
|
|||||||
open,
|
open,
|
||||||
client,
|
client,
|
||||||
inboundsById,
|
inboundsById,
|
||||||
|
tunnelAllowedIPs,
|
||||||
subSettings = DEFAULT_SUB,
|
subSettings = DEFAULT_SUB,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ClientQrModalProps) {
|
}: ClientQrModalProps) {
|
||||||
@@ -74,7 +81,24 @@ export default function ClientQrModal({
|
|||||||
);
|
);
|
||||||
}, [client, wgInbound, subSettings?.publicHost]);
|
}, [client, wgInbound, subSettings?.publicHost]);
|
||||||
|
|
||||||
const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
|
const awgInbound = useMemo(
|
||||||
|
() => findAmneziaWGInbound(client, inboundsById),
|
||||||
|
[client, inboundsById],
|
||||||
|
);
|
||||||
|
const awgConfigText = useMemo(() => {
|
||||||
|
if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
|
||||||
|
const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
|
||||||
|
return buildAmneziaWGClientConfig(
|
||||||
|
client,
|
||||||
|
awgInbound,
|
||||||
|
window.location.hostname,
|
||||||
|
subSettings?.publicHost ?? '',
|
||||||
|
address,
|
||||||
|
);
|
||||||
|
}, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
|
||||||
|
|
||||||
|
const hasAnything =
|
||||||
|
!!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
|
||||||
|
|
||||||
// The reset runs during render so the effect only carries the request.
|
// The reset runs during render so the effect only carries the request.
|
||||||
const openSubId = open ? (client?.subId ?? '') : '';
|
const openSubId = open ? (client?.subId ?? '') : '';
|
||||||
@@ -165,8 +189,25 @@ export default function ClientQrModal({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (awgConfigText) {
|
||||||
|
out.push({
|
||||||
|
key: 'awg-config',
|
||||||
|
label: (
|
||||||
|
<Tag color="purple" style={{ margin: 0 }}>
|
||||||
|
{t('pages.clients.amneziaWgConfig')}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<QrPanel
|
||||||
|
value={awgConfigText}
|
||||||
|
remark={client?.email || 'peer'}
|
||||||
|
downloadName={`${client?.email || 'peer'}.conf`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
|
}, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
|
||||||
|
|
||||||
// Expanding the first panel is a render-time adjustment, not a side effect.
|
// Expanding the first panel is a render-time adjustment, not a side effect.
|
||||||
const firstKey = open && items.length > 0 ? items[0].key : null;
|
const firstKey = open && items.length > 0 ? items[0].key : null;
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|||||||
hysteria: 'cyan',
|
hysteria: 'cyan',
|
||||||
hysteria2: 'green',
|
hysteria2: 'green',
|
||||||
wireguard: 'gold',
|
wireguard: 'gold',
|
||||||
|
amneziawg: 'yellow',
|
||||||
http: 'purple',
|
http: 'purple',
|
||||||
mixed: 'lime',
|
mixed: 'lime',
|
||||||
tunnel: 'orange',
|
tunnel: 'orange',
|
||||||
@@ -349,10 +350,16 @@ export default function ClientsPage() {
|
|||||||
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
||||||
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
||||||
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
|
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
|
||||||
|
const [editingTunnelAllowedIPs, setEditingTunnelAllowedIPs] = useState<Record<number, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
||||||
const [qrOpen, setQrOpen] = useState(false);
|
const [qrOpen, setQrOpen] = useState(false);
|
||||||
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
|
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
|
||||||
|
const [viewingTunnelAllowedIPs, setViewingTunnelAllowedIPs] = useState<Record<number, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
||||||
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
||||||
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
||||||
@@ -619,6 +626,7 @@ export default function ClientsPage() {
|
|||||||
setEditingClient(null);
|
setEditingClient(null);
|
||||||
setEditingAttachedIds([]);
|
setEditingAttachedIds([]);
|
||||||
setEditingExternalLinks([]);
|
setEditingExternalLinks([]);
|
||||||
|
setEditingTunnelAllowedIPs({});
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +643,7 @@ export default function ClientsPage() {
|
|||||||
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
|
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
|
||||||
setEditingAttachedIds([...ids]);
|
setEditingAttachedIds([...ids]);
|
||||||
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
||||||
|
setEditingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
},
|
},
|
||||||
[hydrate],
|
[hydrate],
|
||||||
@@ -686,6 +695,7 @@ export default function ClientsPage() {
|
|||||||
if (!row) return;
|
if (!row) return;
|
||||||
const full = await hydrate(row.email);
|
const full = await hydrate(row.email);
|
||||||
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||||
|
setViewingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
|
||||||
setInfoOpen(true);
|
setInfoOpen(true);
|
||||||
},
|
},
|
||||||
[hydrate],
|
[hydrate],
|
||||||
@@ -697,6 +707,7 @@ export default function ClientsPage() {
|
|||||||
if (!row) return;
|
if (!row) return;
|
||||||
const full = await hydrate(row.email);
|
const full = await hydrate(row.email);
|
||||||
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||||
|
setViewingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
|
||||||
setQrOpen(true);
|
setQrOpen(true);
|
||||||
},
|
},
|
||||||
[hydrate],
|
[hydrate],
|
||||||
@@ -1838,6 +1849,7 @@ export default function ClientsPage() {
|
|||||||
client={editingClient}
|
client={editingClient}
|
||||||
attachedIds={editingAttachedIds}
|
attachedIds={editingAttachedIds}
|
||||||
attachedExternalLinks={editingExternalLinks}
|
attachedExternalLinks={editingExternalLinks}
|
||||||
|
tunnelAllowedIPs={editingTunnelAllowedIPs}
|
||||||
inbounds={inbounds}
|
inbounds={inbounds}
|
||||||
tgBotEnable={tgBotEnable}
|
tgBotEnable={tgBotEnable}
|
||||||
groups={allGroups}
|
groups={allGroups}
|
||||||
@@ -1851,6 +1863,7 @@ export default function ClientsPage() {
|
|||||||
open={infoOpen}
|
open={infoOpen}
|
||||||
client={infoClient}
|
client={infoClient}
|
||||||
inboundsById={inboundsById}
|
inboundsById={inboundsById}
|
||||||
|
tunnelAllowedIPs={viewingTunnelAllowedIPs}
|
||||||
isOnline={infoClient ? isOnline(infoClient.email) : false}
|
isOnline={infoClient ? isOnline(infoClient.email) : false}
|
||||||
subSettings={subSettings}
|
subSettings={subSettings}
|
||||||
onOpenChange={setInfoOpen}
|
onOpenChange={setInfoOpen}
|
||||||
@@ -1861,6 +1874,7 @@ export default function ClientsPage() {
|
|||||||
open={qrOpen}
|
open={qrOpen}
|
||||||
client={qrClient}
|
client={qrClient}
|
||||||
inboundsById={inboundsById}
|
inboundsById={inboundsById}
|
||||||
|
tunnelAllowedIPs={viewingTunnelAllowedIPs}
|
||||||
subSettings={subSettings}
|
subSettings={subSettings}
|
||||||
onOpenChange={setQrOpen}
|
onOpenChange={setQrOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
|
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
|
||||||
|
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||||
|
|
||||||
|
// AmneziaWG clients are wire-identical to WireGuard clients (same
|
||||||
|
// privateKey/publicKey/allowedIPs/preSharedKey/keepAlive fields on
|
||||||
|
// model.Client — see wireguardConfig.ts's isWireguardClient), so this duck
|
||||||
|
// type can't tell the two protocols apart on its own; findAmneziaWGInbound's
|
||||||
|
// protocol==='amneziawg' filter below is what actually disambiguates.
|
||||||
|
export function isAmneziaWGClient(client: ClientRecord | null | undefined): boolean {
|
||||||
|
if (!client) return false;
|
||||||
|
return !!(
|
||||||
|
client.privateKey ||
|
||||||
|
client.publicKey ||
|
||||||
|
client.allowedIPs ||
|
||||||
|
client.preSharedKey ||
|
||||||
|
client.keepAlive
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAmneziaWGInbound(
|
||||||
|
client: ClientRecord | null | undefined,
|
||||||
|
inboundsById: Record<number, InboundOption>,
|
||||||
|
): InboundOption | undefined {
|
||||||
|
return (client?.inboundIds || [])
|
||||||
|
.map((id) => inboundsById[id])
|
||||||
|
.find((ib) => ib?.protocol === 'amneziawg');
|
||||||
|
}
|
||||||
|
|
||||||
|
// h4Line renders one H magic-header line, matching the Go backend's
|
||||||
|
// hOrDefault fallback (blank -> the classic 1/2/3/4 WireGuard message type).
|
||||||
|
function hLine(key: string, value: string | undefined, fallback: string): string {
|
||||||
|
return `${key} = ${value && value.trim() !== '' ? value : fallback}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressOverride carries this inbound's own AllowedIPs (ClientHydrateSchema's
|
||||||
|
// tunnelAllowedIPs). ClientRecord.allowedIPs is a single shared column, so for
|
||||||
|
// an identity attached to both WireGuard and AmneziaWG it holds the WireGuard
|
||||||
|
// address — writing that into the AmneziaWG .conf yields an unroutable peer.
|
||||||
|
export function buildAmneziaWGClientConfig(
|
||||||
|
client: ClientRecord,
|
||||||
|
inbound: InboundOption | undefined,
|
||||||
|
host = window.location.hostname,
|
||||||
|
publicHost = '',
|
||||||
|
addressOverride = '',
|
||||||
|
): string {
|
||||||
|
const server = inbound?.awgServer;
|
||||||
|
const endpointHost = resolveShareHost(
|
||||||
|
inbound ?? {},
|
||||||
|
inbound?.nodeAddress ?? '',
|
||||||
|
preferPublicHost(host, publicHost),
|
||||||
|
);
|
||||||
|
const address = addressOverride || client.allowedIPs || '10.8.1.2/32';
|
||||||
|
const endpoint = `${endpointHost}:${inbound?.port || ''}`;
|
||||||
|
const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
|
||||||
|
const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
|
||||||
|
|
||||||
|
// These land unescaped in [Interface]; a newline here would inject a
|
||||||
|
// config line (e.g. a rogue PostUp) into the downloaded .conf.
|
||||||
|
const privateKey = client.privateKey || client.password || '';
|
||||||
|
for (const v of [privateKey, server?.primaryDns ?? '', server?.secondaryDns ?? '', remark]) {
|
||||||
|
if (/[\r\n]/.test(v)) return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dnsParts = [server?.primaryDns, server?.secondaryDns].filter((v) => !!v && v.trim() !== '');
|
||||||
|
const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `Address = ${address}`];
|
||||||
|
if (dnsParts.length > 0) lines.push(`DNS = ${dnsParts.join(', ')}`);
|
||||||
|
if (server?.mtu && server.mtu > 0) lines.push(`MTU = ${server.mtu}`);
|
||||||
|
|
||||||
|
// AmneziaWG obfuscation parameters — must match the server's values.
|
||||||
|
lines.push(`Jc = ${server?.jc ?? 5}`);
|
||||||
|
lines.push(`Jmin = ${server?.jmin ?? 10}`);
|
||||||
|
lines.push(`Jmax = ${server?.jmax ?? 50}`);
|
||||||
|
lines.push(`S1 = ${server?.s1 ?? 30}`);
|
||||||
|
lines.push(`S2 = ${server?.s2 ?? 45}`);
|
||||||
|
if (server?.s3) lines.push(`S3 = ${server.s3}`);
|
||||||
|
if (server?.s4) lines.push(`S4 = ${server.s4}`);
|
||||||
|
lines.push(hLine('H1', server?.h1, '1'));
|
||||||
|
lines.push(hLine('H2', server?.h2, '2'));
|
||||||
|
lines.push(hLine('H3', server?.h3, '3'));
|
||||||
|
lines.push(hLine('H4', server?.h4, '4'));
|
||||||
|
if (server?.i1) lines.push(`I1 = ${server.i1}`);
|
||||||
|
if (server?.i2) lines.push(`I2 = ${server.i2}`);
|
||||||
|
if (server?.i3) lines.push(`I3 = ${server.i3}`);
|
||||||
|
if (server?.i4) lines.push(`I4 = ${server.i4}`);
|
||||||
|
if (server?.i5) lines.push(`I5 = ${server.i5}`);
|
||||||
|
const optional31: Array<[string, string | undefined]> = [
|
||||||
|
['HeaderProtectionKey', server?.headerProtectionKey],
|
||||||
|
['ContentPaddingAddition', server?.contentPaddingAddition],
|
||||||
|
['RekeyAfterTime', server?.rekeyAfterTime],
|
||||||
|
['RekeyTimeout', server?.rekeyTimeout],
|
||||||
|
['RejectAfterTime', server?.rejectAfterTime],
|
||||||
|
['KeepaliveTimeout', server?.keepaliveTimeout],
|
||||||
|
['MaxHandshakeAttempts', server?.maxHandshakeAttempts],
|
||||||
|
];
|
||||||
|
for (const [key, value] of optional31) {
|
||||||
|
if (value && value.trim() !== '') lines.push(`${key} = ${value}`);
|
||||||
|
}
|
||||||
|
if (server?.randomTrailers) lines.push('RandomTrailers = on');
|
||||||
|
if (server?.disableCookies) lines.push('DisableCookies = on');
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
if (remark) lines.push(`# ${remark}`);
|
||||||
|
lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`);
|
||||||
|
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||||
|
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
||||||
|
if (client.keepAlive && client.keepAlive > 0)
|
||||||
|
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|||||||
hysteria: 'cyan',
|
hysteria: 'cyan',
|
||||||
hysteria2: 'green',
|
hysteria2: 'green',
|
||||||
wireguard: 'gold',
|
wireguard: 'gold',
|
||||||
|
amneziawg: 'yellow',
|
||||||
http: 'purple',
|
http: 'purple',
|
||||||
mixed: 'lime',
|
mixed: 'lime',
|
||||||
tunnel: 'orange',
|
tunnel: 'orange',
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ import {
|
|||||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||||
import { buildClonePayload } from '@/lib/xray/inbound-clone';
|
import { buildClonePayload } from '@/lib/xray/inbound-clone';
|
||||||
import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
|
import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
|
||||||
import { genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
|
import {
|
||||||
|
genAmneziaWGLinks,
|
||||||
|
genInboundLinks,
|
||||||
|
genWireguardLinks,
|
||||||
|
preferPublicHost,
|
||||||
|
} from '@/lib/xray/inbound-link';
|
||||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||||
|
import { Protocols } from '@/schemas/primitives';
|
||||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
@@ -335,7 +341,16 @@ export default function InboundsPage() {
|
|||||||
content: genWireguardLinks(genInput),
|
content: genWireguardLinks(genInput),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: undefined;
|
: projected.protocol === Protocols.AMNEZIAWG
|
||||||
|
? [
|
||||||
|
{ key: 'config', label: t('pages.clients.config'), content },
|
||||||
|
{
|
||||||
|
key: 'links',
|
||||||
|
label: t('pages.clients.tabLinks'),
|
||||||
|
content: genAmneziaWGLinks(genInput),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
openText({
|
openText({
|
||||||
title: t('pages.inbounds.exportLinksTitle'),
|
title: t('pages.inbounds.exportLinksTitle'),
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from
|
|||||||
import type { RealityScanResult } from '@/generated/types';
|
import type { RealityScanResult } from '@/generated/types';
|
||||||
import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
|
import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
|
||||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
||||||
|
import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
|
||||||
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
|
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
|
||||||
import {
|
import {
|
||||||
canEnableReality,
|
canEnableReality,
|
||||||
@@ -56,6 +57,7 @@ import './InboundFormModal.css';
|
|||||||
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
|
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
|
||||||
import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
|
import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
|
||||||
import {
|
import {
|
||||||
|
AmneziawgFields,
|
||||||
HttpFields,
|
HttpFields,
|
||||||
HysteriaFields,
|
HysteriaFields,
|
||||||
MixedFields,
|
MixedFields,
|
||||||
@@ -347,6 +349,41 @@ export default function InboundFormModal({
|
|||||||
setV('settings.secretKey', kp.privateKey);
|
setV('settings.secretKey', kp.privateKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AmneziaWG uses the same Curve25519 keys as WireGuard, just nested under
|
||||||
|
// settings.server instead of flat on settings — see amneziawg.ts. Unlike
|
||||||
|
// WireGuard's Xray-native inbound (which re-derives its public key at
|
||||||
|
// runtime and never stores one), AmneziaWG's server.publicKey is a real,
|
||||||
|
// persisted field the Go backend reads directly, so it must be kept in
|
||||||
|
// sync even when the user free-types a new private key instead of using
|
||||||
|
// the regenerate button.
|
||||||
|
const awgPrivateKey = useWatch({ control, name: 'settings.server.privateKey' });
|
||||||
|
const awgPubKey =
|
||||||
|
typeof awgPrivateKey === 'string' && awgPrivateKey.length > 0
|
||||||
|
? Wireguard.generateKeypair(awgPrivateKey).publicKey
|
||||||
|
: '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (protocol === Protocols.AMNEZIAWG) {
|
||||||
|
setV('settings.server.publicKey', awgPubKey);
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||||
|
}, [awgPubKey, protocol]);
|
||||||
|
|
||||||
|
const regenInboundAwg = () => {
|
||||||
|
const kp = Wireguard.generateKeypair();
|
||||||
|
setV('settings.server.privateKey', kp.privateKey);
|
||||||
|
setV('settings.server.publicKey', kp.publicKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Randomizes the AmneziaWG 3.1 obfuscation set client-side; the shared
|
||||||
|
// generator mirrors the Go backend's amneziawg.GenerateObfuscation31.
|
||||||
|
const regenInboundAwgObfuscation = () => {
|
||||||
|
const obf = generateAwgObfuscation();
|
||||||
|
for (const [field, value] of Object.entries(obf)) {
|
||||||
|
setV(`settings.server.${field}`, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const matchesVlessAuth = (
|
const matchesVlessAuth = (
|
||||||
block: { id?: string; label?: string } | undefined | null,
|
block: { id?: string; label?: string } | undefined | null,
|
||||||
authId: string,
|
authId: string,
|
||||||
@@ -740,6 +777,14 @@ export default function InboundFormModal({
|
|||||||
<WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />
|
<WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{protocol === Protocols.AMNEZIAWG && (
|
||||||
|
<AmneziawgFields
|
||||||
|
awgPubKey={awgPubKey}
|
||||||
|
regenInboundAwg={regenInboundAwg}
|
||||||
|
regenInboundAwgObfuscation={regenInboundAwgObfuscation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{protocol === Protocols.TUN && <TunFields />}
|
{protocol === Protocols.TUN && <TunFields />}
|
||||||
|
|
||||||
{protocol === Protocols.TUNNEL && <TunnelFields />}
|
{protocol === Protocols.TUNNEL && <TunnelFields />}
|
||||||
@@ -1077,6 +1122,7 @@ export default function InboundFormModal({
|
|||||||
Protocols.TUN,
|
Protocols.TUN,
|
||||||
Protocols.WIREGUARD,
|
Protocols.WIREGUARD,
|
||||||
Protocols.MTPROTO,
|
Protocols.MTPROTO,
|
||||||
|
Protocols.AMNEZIAWG,
|
||||||
] as string[]
|
] as string[]
|
||||||
).includes(protocol) || isFallbackHost
|
).includes(protocol) || isFallbackHost
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form/rhf';
|
||||||
|
|
||||||
|
interface AmneziawgFieldsProps {
|
||||||
|
awgPubKey: string;
|
||||||
|
regenInboundAwg: () => void;
|
||||||
|
regenInboundAwgObfuscation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AmneziawgFields({
|
||||||
|
awgPubKey,
|
||||||
|
regenInboundAwg,
|
||||||
|
regenInboundAwgObfuscation,
|
||||||
|
}: AmneziawgFieldsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
|
||||||
|
<Space.Compact block>
|
||||||
|
<FormField name={['settings', 'server', 'privateKey']} noStyle>
|
||||||
|
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||||
|
</FormField>
|
||||||
|
<Button
|
||||||
|
aria-label={t('regenerate')}
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={regenInboundAwg}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.xray.amneziawg.publicKey')}>
|
||||||
|
<Input value={awgPubKey} disabled />
|
||||||
|
</Form.Item>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'subnetIp']}
|
||||||
|
label={t('pages.xray.amneziawg.subnetIp')}
|
||||||
|
>
|
||||||
|
<Input placeholder="10.8.1.0" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'subnetCidr']}
|
||||||
|
label={t('pages.xray.amneziawg.subnetCidr')}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={32} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'mtu']} label={t('pages.xray.amneziawg.mtu')}>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'primaryDns']}
|
||||||
|
label={t('pages.xray.amneziawg.primaryDns')}
|
||||||
|
>
|
||||||
|
<Input placeholder="8.8.8.8" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'secondaryDns']}
|
||||||
|
label={t('pages.xray.amneziawg.secondaryDns')}
|
||||||
|
>
|
||||||
|
<Input placeholder="8.8.4.4" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'externalInterface']}
|
||||||
|
label={t('pages.xray.amneziawg.externalInterface')}
|
||||||
|
extra={t('pages.xray.amneziawg.externalInterfaceHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="eth0" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'ipv6Enabled']}
|
||||||
|
label={t('pages.xray.amneziawg.ipv6Enabled')}
|
||||||
|
valueProp="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'ipv6Subnet']}
|
||||||
|
label={t('pages.xray.amneziawg.ipv6Subnet')}
|
||||||
|
extra={t('pages.xray.amneziawg.ipv6SubnetHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="fd86:ea04:1115::/64" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'ipv6ExternalInterface']}
|
||||||
|
label={t('pages.xray.amneziawg.ipv6ExternalInterface')}
|
||||||
|
extra={t('pages.xray.amneziawg.ipv6ExternalInterfaceHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="eth0" />
|
||||||
|
</FormField>
|
||||||
|
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
|
||||||
|
{t('pages.xray.amneziawg.regenerateObfuscation')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<FormField name={['settings', 'server', 'jc']} label={t('pages.xray.amneziawg.jc')}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'jmin']} label={t('pages.xray.amneziawg.jmin')}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'jmax']} label={t('pages.xray.amneziawg.jmax')}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
|
||||||
|
<InputNumber min={0} max={64} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
|
||||||
|
<InputNumber min={0} max={32} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'h1']}
|
||||||
|
label={t('pages.xray.amneziawg.h1')}
|
||||||
|
extra={t('pages.xray.amneziawg.hHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="1 or 100-800" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'h2']} label={t('pages.xray.amneziawg.h2')}>
|
||||||
|
<Input placeholder="2 or 100-800" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'h3']} label={t('pages.xray.amneziawg.h3')}>
|
||||||
|
<Input placeholder="3 or 100-800" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'h4']} label={t('pages.xray.amneziawg.h4')}>
|
||||||
|
<Input placeholder="4 or 100-800" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'i1']}
|
||||||
|
label={t('pages.xray.amneziawg.i1')}
|
||||||
|
extra={t('pages.xray.amneziawg.i1Hint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="<r 64>" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'i2']} label={t('pages.xray.amneziawg.i2')}>
|
||||||
|
<Input placeholder="<r 64>" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'i3']} label={t('pages.xray.amneziawg.i3')}>
|
||||||
|
<Input placeholder="<r 64>" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'i4']} label={t('pages.xray.amneziawg.i4')}>
|
||||||
|
<Input placeholder="<r 64>" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'server', 'i5']} label={t('pages.xray.amneziawg.i5')}>
|
||||||
|
<Input placeholder="<r 64>" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'headerProtectionKey']}
|
||||||
|
label={t('pages.xray.amneziawg.headerProtectionKey')}
|
||||||
|
extra={t('pages.xray.amneziawg.headerProtectionKeyHint')}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'contentPaddingAddition']}
|
||||||
|
label={t('pages.xray.amneziawg.contentPaddingAddition')}
|
||||||
|
extra={t('pages.xray.amneziawg.contentPaddingAdditionHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="8-64" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'rekeyAfterTime']}
|
||||||
|
label={t('pages.xray.amneziawg.rekeyAfterTime')}
|
||||||
|
extra={t('pages.xray.amneziawg.timingRangeHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="100-160" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'rekeyTimeout']}
|
||||||
|
label={t('pages.xray.amneziawg.rekeyTimeout')}
|
||||||
|
>
|
||||||
|
<Input placeholder="3-10" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'rejectAfterTime']}
|
||||||
|
label={t('pages.xray.amneziawg.rejectAfterTime')}
|
||||||
|
>
|
||||||
|
<Input placeholder="190-250" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'keepaliveTimeout']}
|
||||||
|
label={t('pages.xray.amneziawg.keepaliveTimeout')}
|
||||||
|
>
|
||||||
|
<Input placeholder="8-20" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'maxHandshakeAttempts']}
|
||||||
|
label={t('pages.xray.amneziawg.maxHandshakeAttempts')}
|
||||||
|
extra={t('pages.xray.amneziawg.maxHandshakeAttemptsHint')}
|
||||||
|
>
|
||||||
|
<Input placeholder="15-50" />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'randomTrailers']}
|
||||||
|
label={t('pages.xray.amneziawg.randomTrailers')}
|
||||||
|
extra={t('pages.xray.amneziawg.randomTrailersHint')}
|
||||||
|
valueProp="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name={['settings', 'server', 'disableCookies']}
|
||||||
|
label={t('pages.xray.amneziawg.disableCookies')}
|
||||||
|
extra={t('pages.xray.amneziawg.disableCookiesHint')}
|
||||||
|
valueProp="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ export { default as HttpFields } from './http';
|
|||||||
export { default as MixedFields } from './mixed';
|
export { default as MixedFields } from './mixed';
|
||||||
export { default as MtprotoFields } from './mtproto';
|
export { default as MtprotoFields } from './mtproto';
|
||||||
export { default as VlessFields } from './vless';
|
export { default as VlessFields } from './vless';
|
||||||
|
export { default as AmneziawgFields } from './amneziawg';
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
|
|||||||
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
|
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
|
||||||
<Input value={wgPubKey} disabled />
|
<Input value={wgPubKey} disabled />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<FormField name={['settings', 'subnetIp']} label={t('pages.xray.wireguard.subnetIp')}>
|
||||||
|
<Input placeholder="10.0.0.0" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name={['settings', 'subnetCidr']} label={t('pages.xray.wireguard.subnetCidr')}>
|
||||||
|
<InputNumber min={1} max={32} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
<FormField name={['settings', 'mtu']} label="MTU">
|
<FormField name={['settings', 'mtu']} label="MTU">
|
||||||
<InputNumber />
|
<InputNumber />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { InfinityIcon } from '@/components/ui';
|
|||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import {
|
import {
|
||||||
genAllLinks,
|
genAllLinks,
|
||||||
|
genAmneziaWGConfigs,
|
||||||
|
genAmneziaWGLinks,
|
||||||
genWireguardConfigs,
|
genWireguardConfigs,
|
||||||
genWireguardLinks,
|
genWireguardLinks,
|
||||||
preferPublicHost,
|
preferPublicHost,
|
||||||
@@ -49,6 +51,8 @@ export default function InboundInfoModal({
|
|||||||
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
||||||
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
|
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
|
||||||
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
||||||
|
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
||||||
|
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
||||||
const [subLink, setSubLink] = useState('');
|
const [subLink, setSubLink] = useState('');
|
||||||
const [subJsonLink, setSubJsonLink] = useState('');
|
const [subJsonLink, setSubJsonLink] = useState('');
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@@ -153,6 +157,28 @@ export default function InboundInfoModal({
|
|||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
}).split('\r\n'),
|
}).split('\r\n'),
|
||||||
);
|
);
|
||||||
|
setAmneziawgConfigs([]);
|
||||||
|
setAmneziawgLinks([]);
|
||||||
|
setLinks([]);
|
||||||
|
} else if (info.protocol === Protocols.AMNEZIAWG) {
|
||||||
|
setAmneziawgConfigs(
|
||||||
|
genAmneziaWGConfigs({
|
||||||
|
inbound: inboundForLinks,
|
||||||
|
remark: dbInbound.remark,
|
||||||
|
hostOverride: nodeAddress,
|
||||||
|
fallbackHostname,
|
||||||
|
}).split('\r\n'),
|
||||||
|
);
|
||||||
|
setAmneziawgLinks(
|
||||||
|
genAmneziaWGLinks({
|
||||||
|
inbound: inboundForLinks,
|
||||||
|
remark: dbInbound.remark,
|
||||||
|
hostOverride: nodeAddress,
|
||||||
|
fallbackHostname,
|
||||||
|
}).split('\r\n'),
|
||||||
|
);
|
||||||
|
setWireguardConfigs([]);
|
||||||
|
setWireguardLinks([]);
|
||||||
setLinks([]);
|
setLinks([]);
|
||||||
} else {
|
} else {
|
||||||
setLinks(
|
setLinks(
|
||||||
@@ -166,6 +192,8 @@ export default function InboundInfoModal({
|
|||||||
);
|
);
|
||||||
setWireguardConfigs([]);
|
setWireguardConfigs([]);
|
||||||
setWireguardLinks([]);
|
setWireguardLinks([]);
|
||||||
|
setAmneziawgConfigs([]);
|
||||||
|
setAmneziawgLinks([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clientSet?.subId) {
|
if (clientSet?.subId) {
|
||||||
@@ -1198,6 +1226,58 @@ export default function InboundInfoModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{inbound?.protocol === Protocols.AMNEZIAWG && amneziawgConfigs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||||
|
{amneziawgConfigs.map((cfg, idx) => (
|
||||||
|
<Fragment key={idx}>
|
||||||
|
{cfg && (
|
||||||
|
<div className="link-panel">
|
||||||
|
<div className="link-panel-header">
|
||||||
|
<Tag color="green">
|
||||||
|
{t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}
|
||||||
|
</Tag>
|
||||||
|
<Tooltip title={t('copy')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
aria-label={t('copy')}
|
||||||
|
onClick={() => copyText(cfg, t)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('download')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
aria-label={t('download')}
|
||||||
|
onClick={() => downloadText(cfg, `peer-${idx + 1}.conf`)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<code className="link-panel-text">{cfg}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{amneziawgLinks[idx] && (
|
||||||
|
<div className="link-panel">
|
||||||
|
<div className="link-panel-header">
|
||||||
|
<Tag color="green">Peer {idx + 1} link</Tag>
|
||||||
|
<Tooltip title={t('copy')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
aria-label={t('copy')}
|
||||||
|
onClick={() => copyText(amneziawgLinks[idx], t)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<code className="link-panel-text">{amneziawgLinks[idx]}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && (
|
{dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
|
|||||||
case 'hysteria':
|
case 'hysteria':
|
||||||
case 'mtproto':
|
case 'mtproto':
|
||||||
case 'wireguard':
|
case 'wireguard':
|
||||||
|
case 'amneziawg':
|
||||||
return true;
|
return true;
|
||||||
case 'shadowsocks':
|
case 'shadowsocks':
|
||||||
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
|
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type ProtocolFlags = {
|
|||||||
isMixed?: boolean;
|
isMixed?: boolean;
|
||||||
isHTTP?: boolean;
|
isHTTP?: boolean;
|
||||||
isWireguard?: boolean;
|
isWireguard?: boolean;
|
||||||
|
isAmneziawg?: boolean;
|
||||||
isTunnel?: boolean;
|
isTunnel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function useInboundColumns({
|
|||||||
{record.protocol}
|
{record.protocol}
|
||||||
</Tag>,
|
</Tag>,
|
||||||
];
|
];
|
||||||
if (record.isWireguard || record.isHysteria) {
|
if (record.isWireguard || record.isAmneziawg || record.isHysteria) {
|
||||||
tags.push(
|
tags.push(
|
||||||
<Tag key="n" color="green">
|
<Tag key="n" color="green">
|
||||||
UDP
|
UDP
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { CollapseProps } from 'antd';
|
|||||||
import { Protocols } from '@/schemas/primitives';
|
import { Protocols } from '@/schemas/primitives';
|
||||||
import {
|
import {
|
||||||
genAllLinks,
|
genAllLinks,
|
||||||
|
genAmneziaWGConfigs,
|
||||||
|
genAmneziaWGLinks,
|
||||||
genWireguardConfigs,
|
genWireguardConfigs,
|
||||||
genWireguardLinks,
|
genWireguardLinks,
|
||||||
isPostQuantumLink,
|
isPostQuantumLink,
|
||||||
@@ -50,6 +52,8 @@ export default function QrCodeModal({
|
|||||||
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
||||||
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
|
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
|
||||||
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
||||||
|
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
||||||
|
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
||||||
const [subLink, setSubLink] = useState('');
|
const [subLink, setSubLink] = useState('');
|
||||||
const [subJsonLink, setSubJsonLink] = useState('');
|
const [subJsonLink, setSubJsonLink] = useState('');
|
||||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
const [activeKey, setActiveKey] = useState<string[]>([]);
|
||||||
@@ -97,6 +101,31 @@ export default function QrCodeModal({
|
|||||||
fallbackHostname,
|
fallbackHostname,
|
||||||
}).split('\r\n'),
|
}).split('\r\n'),
|
||||||
);
|
);
|
||||||
|
setAmneziawgConfigs([]);
|
||||||
|
setAmneziawgLinks([]);
|
||||||
|
setLinks([]);
|
||||||
|
} else if (inbound.protocol === Protocols.AMNEZIAWG) {
|
||||||
|
const peerRemark = client?.email
|
||||||
|
? `${dbInbound.remark}-${client.email}`
|
||||||
|
: dbInbound.remark || '';
|
||||||
|
setAmneziawgConfigs(
|
||||||
|
genAmneziaWGConfigs({
|
||||||
|
inbound,
|
||||||
|
remark: peerRemark,
|
||||||
|
hostOverride: nodeAddress,
|
||||||
|
fallbackHostname,
|
||||||
|
}).split('\r\n'),
|
||||||
|
);
|
||||||
|
setAmneziawgLinks(
|
||||||
|
genAmneziaWGLinks({
|
||||||
|
inbound,
|
||||||
|
remark: peerRemark,
|
||||||
|
hostOverride: nodeAddress,
|
||||||
|
fallbackHostname,
|
||||||
|
}).split('\r\n'),
|
||||||
|
);
|
||||||
|
setWireguardConfigs([]);
|
||||||
|
setWireguardLinks([]);
|
||||||
setLinks([]);
|
setLinks([]);
|
||||||
} else {
|
} else {
|
||||||
setLinks(
|
setLinks(
|
||||||
@@ -110,6 +139,8 @@ export default function QrCodeModal({
|
|||||||
);
|
);
|
||||||
setWireguardConfigs([]);
|
setWireguardConfigs([]);
|
||||||
setWireguardLinks([]);
|
setWireguardLinks([]);
|
||||||
|
setAmneziawgConfigs([]);
|
||||||
|
setAmneziawgLinks([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subId = client?.subId;
|
const subId = client?.subId;
|
||||||
@@ -154,8 +185,33 @@ export default function QrCodeModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
amneziawgConfigs.forEach((cfg, idx) => {
|
||||||
|
items.push({
|
||||||
|
key: `ac${idx}`,
|
||||||
|
header: `Peer ${idx + 1} config`,
|
||||||
|
value: cfg,
|
||||||
|
downloadName: `peer-${idx + 1}.conf`,
|
||||||
|
});
|
||||||
|
if (amneziawgLinks[idx]) {
|
||||||
|
items.push({
|
||||||
|
key: `al${idx}`,
|
||||||
|
header: `Peer ${idx + 1} link`,
|
||||||
|
value: amneziawgLinks[idx],
|
||||||
|
showQr: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
return items;
|
return items;
|
||||||
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
|
}, [
|
||||||
|
subLink,
|
||||||
|
subJsonLink,
|
||||||
|
links,
|
||||||
|
wireguardConfigs,
|
||||||
|
wireguardLinks,
|
||||||
|
amneziawgConfigs,
|
||||||
|
amneziawgLinks,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const collapseItems: CollapseProps['items'] = useMemo(
|
const collapseItems: CollapseProps['items'] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [
|
|||||||
Protocols.HYSTERIA,
|
Protocols.HYSTERIA,
|
||||||
Protocols.WIREGUARD,
|
Protocols.WIREGUARD,
|
||||||
Protocols.MTPROTO,
|
Protocols.MTPROTO,
|
||||||
|
Protocols.AMNEZIAWG,
|
||||||
];
|
];
|
||||||
|
|
||||||
async function fetchSlimInbounds(): Promise<unknown[]> {
|
async function fetchSlimInbounds(): Promise<unknown[]> {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.awglog-events-title {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awglog-event-line {
|
||||||
|
padding: 2px 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xraylog-table .log-row-offline {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
|
||||||
|
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { HttpUtil, FileManager, IntlUtil, PromiseUtil, SizeFormatter } from '@/utils';
|
||||||
|
import { activateOnKey } from '@/utils/a11y';
|
||||||
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
import type { AmneziaWGLogs } from '@/generated/types';
|
||||||
|
import './XrayLogModal.css';
|
||||||
|
import './AmneziaWGLogModal.css';
|
||||||
|
|
||||||
|
interface AmneziaWGLogModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_UPDATE_INTERVAL = 5000;
|
||||||
|
|
||||||
|
function shortTime(value?: number): string {
|
||||||
|
if (!value) return '';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { datepicker } = useDatepicker();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const [rows, setRows] = useState('50');
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [autoUpdate, setAutoUpdate] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [logs, setLogs] = useState<Partial<AmneziaWGLogs>>({});
|
||||||
|
|
||||||
|
const peers = useMemo(() => logs.peers ?? [], [logs.peers]);
|
||||||
|
const events = useMemo(() => logs.events ?? [], [logs.events]);
|
||||||
|
|
||||||
|
const runRefresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<AmneziaWGLogs>(`/panel/api/server/amneziawglogs/${rows}`, {
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
if (msg?.success) setLogs(msg.obj || {});
|
||||||
|
await PromiseUtil.sleep(300);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [rows, filter]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
void runRefresh();
|
||||||
|
}, [runRefresh]);
|
||||||
|
|
||||||
|
const refreshRef = useRef(refresh);
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRef.current = refresh;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The spinner is raised during render so the fetch effect stays side-effect
|
||||||
|
// free until its response lands.
|
||||||
|
const refreshKey = open ? `${rows}|${filter}` : null;
|
||||||
|
const [loadingKey, setLoadingKey] = useState<string | null>(null);
|
||||||
|
if (refreshKey !== loadingKey) {
|
||||||
|
setLoadingKey(refreshKey);
|
||||||
|
if (refreshKey) setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) void runRefresh();
|
||||||
|
}, [open, rows, filter, runRefresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !autoUpdate) return;
|
||||||
|
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [open, autoUpdate]);
|
||||||
|
|
||||||
|
function fullDate(value?: number): string {
|
||||||
|
return value ? IntlUtil.formatDate(value, datepicker) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
const peerLines = peers.map((p) => {
|
||||||
|
const at = p.handshake ? new Date(p.handshake).toISOString() : 'never';
|
||||||
|
return `${at} IFACE=${p.interface || ''} INBOUND=${p.tag || ''} EMAIL=${p.email || ''} ENDPOINT=${p.endpoint || '-'} ALLOWEDIPS=${p.allowedIPs || ''} UP=${p.up ?? 0} DOWN=${p.down ?? 0} ONLINE=${p.online ? 'yes' : 'no'}`;
|
||||||
|
});
|
||||||
|
FileManager.downloadTextFile([...peerLines, '', ...events].join('\n'), 'amneziawg.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '100vw' : '80vw'}
|
||||||
|
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||||
|
className={isMobile ? 'xraylog-modal-mobile' : undefined}
|
||||||
|
onCancel={onClose}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{t('pages.index.amneziawgLogs')}
|
||||||
|
<SyncOutlined
|
||||||
|
spin={loading}
|
||||||
|
className="reload-icon"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t('refresh')}
|
||||||
|
onClick={refresh}
|
||||||
|
onKeyDown={activateOnKey(refresh)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form layout="inline" className="log-toolbar">
|
||||||
|
<Form.Item>
|
||||||
|
<Select
|
||||||
|
value={rows}
|
||||||
|
size="small"
|
||||||
|
style={{ width: 70 }}
|
||||||
|
onChange={setRows}
|
||||||
|
options={[
|
||||||
|
{ value: '20', label: '20' },
|
||||||
|
{ value: '50', label: '50' },
|
||||||
|
{ value: '100', label: '100' },
|
||||||
|
{ value: '500', label: '500' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('filter')} className="filter-item">
|
||||||
|
<Input
|
||||||
|
value={filter}
|
||||||
|
size="small"
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'Enter') refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
|
||||||
|
{t('pages.index.autoUpdate')}
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item className="download-item">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={download}
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
aria-label={t('download')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||||
|
{peers.length === 0 ? (
|
||||||
|
<div className="log-empty">
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={t('pages.index.amneziawgNoPeers')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : isMobile ? (
|
||||||
|
peers.map((peer, idx) => (
|
||||||
|
<div key={idx} className="log-card">
|
||||||
|
<div className="log-card-head">
|
||||||
|
<span className="log-time" title={fullDate(peer.handshake)}>
|
||||||
|
{shortTime(peer.handshake) || '—'}
|
||||||
|
</span>
|
||||||
|
<Tag color={peer.online ? 'green' : 'default'} className="log-event-tag">
|
||||||
|
{peer.online ? t('online') : t('pages.index.amneziawgIdle')}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="log-route">
|
||||||
|
<span className="log-addr">{peer.endpoint || '—'}</span>
|
||||||
|
<span className="log-arrow">→</span>
|
||||||
|
<span className="log-addr">{peer.allowedIPs}</span>
|
||||||
|
</div>
|
||||||
|
<div className="log-meta">
|
||||||
|
<span className="log-meta-pair">
|
||||||
|
<span className="log-meta-key">iface</span>
|
||||||
|
<span className="log-meta-val">{peer.interface}</span>
|
||||||
|
</span>
|
||||||
|
<span className="log-meta-pair">
|
||||||
|
<span className="log-meta-key">inbound</span>
|
||||||
|
<span className="log-meta-val">{peer.tag}</span>
|
||||||
|
</span>
|
||||||
|
{peer.email && (
|
||||||
|
<span className="log-meta-pair">
|
||||||
|
<span className="log-meta-key">email</span>
|
||||||
|
<span className="log-meta-val">{peer.email}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="log-meta-pair">
|
||||||
|
<span className="log-meta-key">↑↓</span>
|
||||||
|
<span className="log-meta-val">
|
||||||
|
{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<table className="xraylog-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('pages.index.amneziawgHandshake')}</th>
|
||||||
|
<th>{t('pages.index.amneziawgInterface')}</th>
|
||||||
|
<th>{t('pages.index.amneziawgInbound')}</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>{t('pages.index.amneziawgEndpoint')}</th>
|
||||||
|
<th>{t('pages.clients.amneziaWgAllowedIPs')}</th>
|
||||||
|
<th>↑ / ↓</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{peers.map((peer, idx) => (
|
||||||
|
<tr key={idx} className={peer.online ? undefined : 'log-row-offline'}>
|
||||||
|
<td>
|
||||||
|
<b>{fullDate(peer.handshake) || '—'}</b>
|
||||||
|
</td>
|
||||||
|
<td>{peer.interface}</td>
|
||||||
|
<td>{peer.tag}</td>
|
||||||
|
<td>{peer.email}</td>
|
||||||
|
<td>{peer.endpoint || '—'}</td>
|
||||||
|
<td>{peer.allowedIPs}</td>
|
||||||
|
<td>{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="awglog-events-title">{t('pages.index.amneziawgEvents')}</div>
|
||||||
|
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="log-empty">{t('pages.index.amneziawgNoEvents')}</div>
|
||||||
|
) : (
|
||||||
|
events.map((line, idx) => (
|
||||||
|
<div key={idx} className="awglog-event-line">
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const BackupModal = lazy(() => import('./BackupModal'));
|
|||||||
const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
|
const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
|
||||||
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
|
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
|
||||||
const XrayLogModal = lazy(() => import('./XrayLogModal'));
|
const XrayLogModal = lazy(() => import('./XrayLogModal'));
|
||||||
|
const AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
|
||||||
const VersionModal = lazy(() => import('./VersionModal'));
|
const VersionModal = lazy(() => import('./VersionModal'));
|
||||||
import './IndexPage.css';
|
import './IndexPage.css';
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export default function IndexPage() {
|
|||||||
const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
|
const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
|
||||||
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
|
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
|
||||||
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
|
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
|
||||||
|
const [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
|
||||||
const [versionOpen, setVersionOpen] = useState(false);
|
const [versionOpen, setVersionOpen] = useState(false);
|
||||||
const [configTextOpen, setConfigTextOpen] = useState(false);
|
const [configTextOpen, setConfigTextOpen] = useState(false);
|
||||||
const [configText, setConfigText] = useState('');
|
const [configText, setConfigText] = useState('');
|
||||||
@@ -202,6 +204,7 @@ export default function IndexPage() {
|
|||||||
onRestartXray={restartXray}
|
onRestartXray={restartXray}
|
||||||
onOpenLogs={() => setLogsOpen(true)}
|
onOpenLogs={() => setLogsOpen(true)}
|
||||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
||||||
|
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
|
||||||
onOpenConfig={openConfig}
|
onOpenConfig={openConfig}
|
||||||
onOpenBackup={() => setBackupOpen(true)}
|
onOpenBackup={() => setBackupOpen(true)}
|
||||||
onOpenSystemHistory={() => setSysHistoryOpen(true)}
|
onOpenSystemHistory={() => setSysHistoryOpen(true)}
|
||||||
@@ -328,6 +331,9 @@ export default function IndexPage() {
|
|||||||
<LazyMount when={xrayLogsOpen}>
|
<LazyMount when={xrayLogsOpen}>
|
||||||
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
|
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
|
<LazyMount when={amneziawgLogsOpen}>
|
||||||
|
<AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
|
||||||
|
</LazyMount>
|
||||||
<LazyMount when={versionOpen}>
|
<LazyMount when={versionOpen}>
|
||||||
<VersionModal
|
<VersionModal
|
||||||
open={versionOpen}
|
open={versionOpen}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Tag, Tooltip } from 'antd';
|
import { Button, Tag, Tooltip } from 'antd';
|
||||||
import {
|
import {
|
||||||
|
ApiOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
AreaChartOutlined,
|
AreaChartOutlined,
|
||||||
BarsOutlined,
|
BarsOutlined,
|
||||||
@@ -28,6 +29,7 @@ interface OverviewActionBarProps {
|
|||||||
onRestartXray: () => void;
|
onRestartXray: () => void;
|
||||||
onOpenLogs: () => void;
|
onOpenLogs: () => void;
|
||||||
onOpenXrayLogs: () => void;
|
onOpenXrayLogs: () => void;
|
||||||
|
onOpenAmneziaWGLogs: () => void;
|
||||||
onOpenConfig: () => void;
|
onOpenConfig: () => void;
|
||||||
onOpenBackup: () => void;
|
onOpenBackup: () => void;
|
||||||
onOpenSystemHistory: () => void;
|
onOpenSystemHistory: () => void;
|
||||||
@@ -61,6 +63,7 @@ export default function OverviewActionBar({
|
|||||||
onRestartXray,
|
onRestartXray,
|
||||||
onOpenLogs,
|
onOpenLogs,
|
||||||
onOpenXrayLogs,
|
onOpenXrayLogs,
|
||||||
|
onOpenAmneziaWGLogs,
|
||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
onOpenBackup,
|
onOpenBackup,
|
||||||
onOpenSystemHistory,
|
onOpenSystemHistory,
|
||||||
@@ -101,6 +104,16 @@ export default function OverviewActionBar({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(status.amneziawg.configured
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'amneziawgLogs',
|
||||||
|
icon: <ApiOutlined />,
|
||||||
|
text: t('pages.index.amneziawgLogs'),
|
||||||
|
onClick: onOpenAmneziaWGLogs,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: 'config',
|
key: 'config',
|
||||||
icon: <ControlOutlined />,
|
icon: <ControlOutlined />,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import TelegramTab from './TelegramTab';
|
|||||||
import EmailTab from './EmailTab';
|
import EmailTab from './EmailTab';
|
||||||
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
||||||
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
||||||
|
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
|
||||||
import './SettingsPage.css';
|
import './SettingsPage.css';
|
||||||
|
|
||||||
interface ApiMsg {
|
interface ApiMsg {
|
||||||
@@ -42,6 +43,7 @@ const tabSlugs = [
|
|||||||
'email',
|
'email',
|
||||||
'subscription',
|
'subscription',
|
||||||
'subscription-formats',
|
'subscription-formats',
|
||||||
|
'subscription-balancers',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isIp(h: string): boolean {
|
function isIp(h: string): boolean {
|
||||||
@@ -219,6 +221,8 @@ export default function SettingsPage() {
|
|||||||
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
case 'subscription-formats':
|
case 'subscription-formats':
|
||||||
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
|
case 'subscription-balancers':
|
||||||
|
return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
default:
|
default:
|
||||||
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
|
||||||
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||||
|
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
|
||||||
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
|
import {
|
||||||
|
SubBalancerFormSchema,
|
||||||
|
SubBalancerStrategySchema,
|
||||||
|
type SubBalancer,
|
||||||
|
type SubBalancerFormValues,
|
||||||
|
type SubBalancerStrategy,
|
||||||
|
} from '@/schemas/subBalancer';
|
||||||
|
|
||||||
|
// The JSON subscription only builds proxy outbounds for these protocols;
|
||||||
|
// mtproto has no proxy-outbound case, so it is excluded from balancer members.
|
||||||
|
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||||
|
'shadowsocks',
|
||||||
|
'vless',
|
||||||
|
'vmess',
|
||||||
|
'trojan',
|
||||||
|
'hysteria',
|
||||||
|
'wireguard',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
|
||||||
|
leastLoad: 'pages.settings.subBalancers.strategyLeastLoad',
|
||||||
|
leastPing: 'pages.settings.subBalancers.strategyLeastPing',
|
||||||
|
random: 'pages.settings.subBalancers.strategyRandom',
|
||||||
|
roundRobin: 'pages.settings.subBalancers.strategyRoundRobin',
|
||||||
|
};
|
||||||
|
|
||||||
|
function initialState(balancer: SubBalancer | null): SubBalancerFormValues {
|
||||||
|
return {
|
||||||
|
remark: balancer?.remark ?? '',
|
||||||
|
strategy: balancer?.strategy ?? 'random',
|
||||||
|
inboundIds: [...(balancer?.inboundIds ?? [])],
|
||||||
|
sortOrder: balancer?.sortOrder ?? 1,
|
||||||
|
enabled: balancer?.enabled ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubBalancerFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
balancer: SubBalancer | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (values: SubBalancerFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubBalancerFormModal({
|
||||||
|
open,
|
||||||
|
balancer,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: SubBalancerFormModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
|
||||||
|
const isEdit = balancer != null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) methods.reset(initialState(balancer));
|
||||||
|
}, [open, balancer, methods]);
|
||||||
|
|
||||||
|
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
||||||
|
|
||||||
|
const { data: inboundOptionsRaw } = useInboundOptions();
|
||||||
|
const inboundOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(inboundOptionsRaw ?? [])
|
||||||
|
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
||||||
|
.filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
|
||||||
|
.map((ib) => ({
|
||||||
|
label: formatInboundLabel(ib.tag, ib.remark),
|
||||||
|
value: ib.id,
|
||||||
|
title: formatInboundLabel(ib.tag, ib.remark),
|
||||||
|
})),
|
||||||
|
[inboundOptionsRaw, inboundIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
function onFinish(values: SubBalancerFormValues) {
|
||||||
|
const parsed = SubBalancerFormSchema.safeParse(values);
|
||||||
|
if (!parsed.success) {
|
||||||
|
messageApi.error(
|
||||||
|
t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConfirm(parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategies = SubBalancerStrategySchema.options.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: t(STRATEGY_LABEL_KEYS[value]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={
|
||||||
|
isEdit
|
||||||
|
? `${t('edit')} ${t('pages.settings.subBalancers.title')}`
|
||||||
|
: `+ ${t('pages.settings.subBalancers.add')}`
|
||||||
|
}
|
||||||
|
okText={isEdit ? t('pages.clients.submitEdit') : t('create')}
|
||||||
|
cancelText={t('close')}
|
||||||
|
mask={{ closable: false }}
|
||||||
|
width="640px"
|
||||||
|
onOk={methods.handleSubmit(onFinish)}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
{messageContextHolder}
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<FormField
|
||||||
|
label={t('pages.settings.subBalancers.remark')}
|
||||||
|
name="remark"
|
||||||
|
required
|
||||||
|
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.remark) }}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('pages.settings.subBalancers.remarkPlaceholder')} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label={t('pages.settings.subBalancers.strategy')} name="strategy" required>
|
||||||
|
<Select options={strategies} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label={t('pages.settings.subBalancers.sortOrder')}
|
||||||
|
name="sortOrder"
|
||||||
|
required
|
||||||
|
tooltip={t('pages.settings.subBalancers.sortOrderHelp')}
|
||||||
|
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.sortOrder) }}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label={t('pages.settings.subBalancers.inbounds')}
|
||||||
|
name="inboundIds"
|
||||||
|
required
|
||||||
|
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.inboundIds) }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={inboundOptions}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
listHeight={220}
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<SelectAllClearButtons
|
||||||
|
options={inboundOptions}
|
||||||
|
value={inboundIds || []}
|
||||||
|
onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label={t('pages.settings.subBalancers.enabled')}
|
||||||
|
name="enabled"
|
||||||
|
valueProp="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
</Form>
|
||||||
|
</FormProvider>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
DeploymentUnitOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
RadarChartOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useSubBalancersQuery } from '@/api/queries/useSubBalancersQuery';
|
||||||
|
import { useSubBalancerMutations } from '@/api/queries/useSubBalancerMutations';
|
||||||
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
|
import type { AllSetting } from '@/models/setting';
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
|
import { SettingListItem } from '@/components/ui';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
import type { SubBalancer, SubBalancerFormValues } from '@/schemas/subBalancer';
|
||||||
|
import { PingConfigSchema, type PingConfigObject } from '@/schemas/observatory';
|
||||||
|
import { DEFAULT_BURST_OBSERVATORY } from '@/pages/xray/balancers/balancer-helpers';
|
||||||
|
import SubBalancerFormModal from './SubBalancerFormModal';
|
||||||
|
import { catTabLabel } from './catTabLabel';
|
||||||
|
import './SubscriptionFormatsTab.css';
|
||||||
|
|
||||||
|
const STRATEGY_COLORS: Record<string, string> = {
|
||||||
|
leastLoad: 'geekblue',
|
||||||
|
leastPing: 'green',
|
||||||
|
random: 'orange',
|
||||||
|
roundRobin: 'purple',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single source for the burst-observatory ping defaults: the Zod schema and
|
||||||
|
// DEFAULT_BURST_OBSERVATORY are kept in sync, so the tab just parses through it.
|
||||||
|
const DEFAULT_PING_CONFIG = PingConfigSchema.parse({ ...DEFAULT_BURST_OBSERVATORY.pingConfig });
|
||||||
|
|
||||||
|
function parsePingConfig(raw: string): PingConfigObject {
|
||||||
|
try {
|
||||||
|
return PingConfigSchema.parse(raw ? JSON.parse(raw) : {});
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PING_CONFIG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionBalancersTabProps {
|
||||||
|
allSetting: AllSetting;
|
||||||
|
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionBalancersTab({
|
||||||
|
allSetting,
|
||||||
|
updateSetting,
|
||||||
|
}: SubscriptionBalancersTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const { balancers, loading, fetched, fetchError, refetch } = useSubBalancersQuery();
|
||||||
|
const { create, update, remove } = useSubBalancerMutations();
|
||||||
|
const { data: inboundOptionsRaw } = useInboundOptions();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<SubBalancer | null>(null);
|
||||||
|
|
||||||
|
const inboundLabels = useMemo(() => {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
for (const ib of inboundOptionsRaw ?? []) {
|
||||||
|
map.set(ib.id, formatInboundLabel(ib.tag, ib.remark));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [inboundOptionsRaw]);
|
||||||
|
|
||||||
|
async function onConfirm(values: SubBalancerFormValues) {
|
||||||
|
const msg = editing ? await update(editing.id, values) : await create(values);
|
||||||
|
if (msg?.success) setModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(balancer: SubBalancer) {
|
||||||
|
await update(balancer.id, {
|
||||||
|
remark: balancer.remark,
|
||||||
|
strategy: balancer.strategy,
|
||||||
|
inboundIds: balancer.inboundIds,
|
||||||
|
sortOrder: balancer.sortOrder,
|
||||||
|
enabled: !balancer.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const observatoryEnabled = allSetting.subJsonObservatory !== '';
|
||||||
|
const observatoryObj = useMemo(
|
||||||
|
() => parsePingConfig(allSetting.subJsonObservatory),
|
||||||
|
[allSetting.subJsonObservatory],
|
||||||
|
);
|
||||||
|
|
||||||
|
function setObservatoryEnabled(v: boolean) {
|
||||||
|
updateSetting({ subJsonObservatory: v ? JSON.stringify(DEFAULT_PING_CONFIG) : '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setObservatoryField<K extends keyof PingConfigObject>(
|
||||||
|
key: K,
|
||||||
|
value: PingConfigObject[K],
|
||||||
|
) {
|
||||||
|
const next = { ...observatoryObj, [key]: value };
|
||||||
|
updateSetting({ subJsonObservatory: JSON.stringify(next) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('pages.settings.subBalancers.sortOrder'),
|
||||||
|
dataIndex: 'sortOrder',
|
||||||
|
key: 'sortOrder',
|
||||||
|
width: 80,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.settings.subBalancers.remark'),
|
||||||
|
dataIndex: 'remark',
|
||||||
|
key: 'remark',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.settings.subBalancers.strategy'),
|
||||||
|
dataIndex: 'strategy',
|
||||||
|
key: 'strategy',
|
||||||
|
width: 120,
|
||||||
|
render: (strategy: string) => (
|
||||||
|
<Tag color={STRATEGY_COLORS[strategy] ?? 'default'}>{strategy}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.settings.subBalancers.inbounds'),
|
||||||
|
key: 'inbounds',
|
||||||
|
render: (_: unknown, r: SubBalancer) => {
|
||||||
|
const labels = r.inboundIds.map((id) => inboundLabels.get(id) ?? `#${id}`);
|
||||||
|
return (
|
||||||
|
<Tooltip title={labels.join(', ')}>
|
||||||
|
<span>{t('pages.settings.subBalancers.inboundsCount', { count: labels.length })}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.settings.subBalancers.enabled'),
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
key: 'enabled',
|
||||||
|
width: 80,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, r: SubBalancer) => (
|
||||||
|
<Switch size="small" checked={r.enabled} onChange={() => toggleEnabled(r)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 96,
|
||||||
|
render: (_: unknown, r: SubBalancer) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
aria-label={t('edit')}
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
title={t('edit')}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title={t('pages.settings.subBalancers.deleteConfirm')}
|
||||||
|
okText={t('delete')}
|
||||||
|
cancelText={t('cancel')}
|
||||||
|
onConfirm={() => remove(r.id)}
|
||||||
|
>
|
||||||
|
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const balancersTab = (
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
title={t('pages.settings.subBalancers.desc')}
|
||||||
|
/>
|
||||||
|
{fetchError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
title={fetchError}
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={() => refetch()}>
|
||||||
|
{t('refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('pages.settings.subBalancers.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
dataSource={balancers}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
pagination={false}
|
||||||
|
loading={loading && !fetched}
|
||||||
|
scroll={{ x: true }}
|
||||||
|
locale={{ emptyText: t('pages.settings.subBalancers.empty') }}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const observatoryTab = (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
title={t('pages.settings.subBalancers.observatory.note')}
|
||||||
|
/>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subBalancers.observatory.title')}
|
||||||
|
description={t('pages.settings.subBalancers.observatory.desc')}
|
||||||
|
>
|
||||||
|
<Switch checked={observatoryEnabled} onChange={setObservatoryEnabled} />
|
||||||
|
</SettingListItem>
|
||||||
|
{observatoryEnabled && (
|
||||||
|
<div className="format-settings">
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subBalancers.observatory.destination')}
|
||||||
|
description={t('pages.settings.subBalancers.observatory.destinationDesc')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={observatoryObj.destination}
|
||||||
|
placeholder="https://www.google.com/generate_204"
|
||||||
|
onChange={(e) => setObservatoryField('destination', e.target.value)}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subBalancers.observatory.connectivity')}
|
||||||
|
description={t('pages.settings.subBalancers.observatory.connectivityDesc')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={observatoryObj.connectivity}
|
||||||
|
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
|
||||||
|
onChange={(e) => setObservatoryField('connectivity', e.target.value)}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subBalancers.observatory.interval')}
|
||||||
|
description={t('pages.settings.subBalancers.observatory.intervalDesc')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={observatoryObj.interval}
|
||||||
|
placeholder="1m"
|
||||||
|
onChange={(e) => setObservatoryField('interval', e.target.value)}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subBalancers.observatory.timeout')}
|
||||||
|
description={t('pages.settings.subBalancers.observatory.timeoutDesc')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={observatoryObj.timeout}
|
||||||
|
placeholder="5s"
|
||||||
|
onChange={(e) => setObservatoryField('timeout', e.target.value)}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subBalancers.observatory.sampling')}
|
||||||
|
description={t('pages.settings.subBalancers.observatory.samplingDesc')}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
value={observatoryObj.sampling}
|
||||||
|
min={1}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={onNumber((v) => setObservatoryField('sampling', v))}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subBalancers.observatory.httpMethod')}
|
||||||
|
description={t('pages.settings.subBalancers.observatory.httpMethodDesc')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={observatoryObj.httpMethod}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(v) => setObservatoryField('httpMethod', v)}
|
||||||
|
options={['HEAD', 'GET'].map((m) => ({ value: m, label: m }))}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="balancers"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'balancers',
|
||||||
|
label: catTabLabel(
|
||||||
|
<DeploymentUnitOutlined />,
|
||||||
|
t('pages.settings.subBalancers.tabBalancers'),
|
||||||
|
isMobile,
|
||||||
|
),
|
||||||
|
children: balancersTab,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'observatory',
|
||||||
|
label: catTabLabel(
|
||||||
|
<RadarChartOutlined />,
|
||||||
|
t('pages.settings.subBalancers.tabObservatory'),
|
||||||
|
isMobile,
|
||||||
|
),
|
||||||
|
children: observatoryTab,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SubBalancerFormModal
|
||||||
|
open={modalOpen}
|
||||||
|
balancer={editing}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,7 +33,11 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||||
import { isPostQuantumLink, wireguardConfigFromLink } from '@/lib/xray/inbound-link';
|
import {
|
||||||
|
amneziawgConfigFromLink,
|
||||||
|
isPostQuantumLink,
|
||||||
|
wireguardConfigFromLink,
|
||||||
|
} from '@/lib/xray/inbound-link';
|
||||||
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
||||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
@@ -533,6 +537,7 @@ export default function SubPage() {
|
|||||||
const canQr = !isPostQuantumLink(link);
|
const canQr = !isPostQuantumLink(link);
|
||||||
const isWireguardLink =
|
const isWireguardLink =
|
||||||
link.startsWith('wireguard://') || link.startsWith('wg://');
|
link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||||
|
const isAmneziawgLink = link.startsWith('vpn://');
|
||||||
return (
|
return (
|
||||||
<Fragment key={link}>
|
<Fragment key={link}>
|
||||||
<div className="sub-link-row">
|
<div className="sub-link-row">
|
||||||
@@ -590,6 +595,15 @@ export default function SubPage() {
|
|||||||
tagColor="cyan"
|
tagColor="cyan"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isAmneziawgLink && (
|
||||||
|
<ConfigBlock
|
||||||
|
label={t('pages.clients.amneziaWgConfig')}
|
||||||
|
text={amneziawgConfigFromLink(link)}
|
||||||
|
fileName={`${rowTitle || 'peer'}.conf`}
|
||||||
|
qrRemark={rowTitle}
|
||||||
|
tagColor="purple"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
detectBalancerCycles,
|
detectBalancerCycles,
|
||||||
} from './balancers/balancer-loopback';
|
} from './balancers/balancer-loopback';
|
||||||
import { DnsTab } from './dns';
|
import { DnsTab } from './dns';
|
||||||
import { WarpModal, NordModal } from './overrides';
|
import { WarpModal, NordModal, PiaModal } from './overrides';
|
||||||
import './XrayPage.css';
|
import './XrayPage.css';
|
||||||
|
|
||||||
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
|
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
|
||||||
@@ -82,6 +82,7 @@ export default function XrayPage() {
|
|||||||
|
|
||||||
const [warpOpen, setWarpOpen] = useState(false);
|
const [warpOpen, setWarpOpen] = useState(false);
|
||||||
const [nordOpen, setNordOpen] = useState(false);
|
const [nordOpen, setNordOpen] = useState(false);
|
||||||
|
const [piaOpen, setPiaOpen] = useState(false);
|
||||||
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
|
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -264,6 +265,7 @@ export default function XrayPage() {
|
|||||||
onTestAll={testAllOutbounds}
|
onTestAll={testAllOutbounds}
|
||||||
onShowWarp={() => setWarpOpen(true)}
|
onShowWarp={() => setWarpOpen(true)}
|
||||||
onShowNord={() => setNordOpen(true)}
|
onShowNord={() => setNordOpen(true)}
|
||||||
|
onShowPia={() => setPiaOpen(true)}
|
||||||
onRefreshXrayData={fetchAll}
|
onRefreshXrayData={fetchAll}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -394,6 +396,13 @@ export default function XrayPage() {
|
|||||||
onRemoveOutbound={onRemoveOutboundByIndex}
|
onRemoveOutbound={onRemoveOutboundByIndex}
|
||||||
onRemoveRoutingRules={onRemoveRoutingRules}
|
onRemoveRoutingRules={onRemoveRoutingRules}
|
||||||
/>
|
/>
|
||||||
|
<PiaModal
|
||||||
|
open={piaOpen}
|
||||||
|
templateSettings={templateSettings}
|
||||||
|
onClose={() => setPiaOpen(false)}
|
||||||
|
onAddOutbound={onAddOutbound}
|
||||||
|
onResetOutbound={onResetOutbound}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
|
|||||||
pingConfig: {
|
pingConfig: {
|
||||||
destination: 'https://www.google.com/generate_204',
|
destination: 'https://www.google.com/generate_204',
|
||||||
interval: '1m',
|
interval: '1m',
|
||||||
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
|
connectivity: '',
|
||||||
timeout: '5s',
|
timeout: '5s',
|
||||||
sampling: 2,
|
sampling: 2,
|
||||||
httpMethod: 'HEAD',
|
httpMethod: 'HEAD',
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ interface OutboundsTabProps {
|
|||||||
onTestAll: (mode: string) => void;
|
onTestAll: (mode: string) => void;
|
||||||
onShowWarp: () => void;
|
onShowWarp: () => void;
|
||||||
onShowNord: () => void;
|
onShowNord: () => void;
|
||||||
|
onShowPia: () => void;
|
||||||
onRefreshXrayData?: () => void;
|
onRefreshXrayData?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ export default function OutboundsTab({
|
|||||||
onTestAll,
|
onTestAll,
|
||||||
onShowWarp,
|
onShowWarp,
|
||||||
onShowNord,
|
onShowNord,
|
||||||
|
onShowPia,
|
||||||
onRefreshXrayData,
|
onRefreshXrayData,
|
||||||
}: OutboundsTabProps) {
|
}: OutboundsTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -550,6 +552,12 @@ export default function OutboundsTab({
|
|||||||
items: [
|
items: [
|
||||||
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
||||||
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
||||||
|
{
|
||||||
|
key: 'pia',
|
||||||
|
icon: <ApiOutlined />,
|
||||||
|
label: t('pages.xray.pia.menu'),
|
||||||
|
onClick: onShowPia,
|
||||||
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
key: 'import',
|
key: 'import',
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
.pia-data-table {
|
||||||
|
margin: 5px 0;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pia-data-table td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pia-data-table td:first-child {
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pia-data-table .row-odd {
|
||||||
|
background: var(--ant-color-fill-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pia-already-added {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pia-added-table {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pia-added-table td {
|
||||||
|
padding: 6px 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pia-added-table td:first-child {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pia-added-table td:last-child {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Divider, Form, Input, message, Modal, Select } from 'antd';
|
||||||
|
import { LoginOutlined } from '@ant-design/icons';
|
||||||
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { FormField } from '@/components/form/rhf';
|
||||||
|
import { countryFlag, countryName } from '../outbounds/outbounds-tab-helpers';
|
||||||
|
import './PiaModal.css';
|
||||||
|
|
||||||
|
interface PiaOutboundRow {
|
||||||
|
tag?: string;
|
||||||
|
piaHostname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PiaModalProps {
|
||||||
|
open: boolean;
|
||||||
|
templateSettings: { outbounds?: PiaOutboundRow[] } | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
||||||
|
onResetOutbound: (payload: {
|
||||||
|
index: number;
|
||||||
|
outbound: Record<string, unknown>;
|
||||||
|
oldTag?: string;
|
||||||
|
newTag: string;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PiaAccount {
|
||||||
|
username?: string;
|
||||||
|
accountHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PiaCountry {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PiaRegion {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PiaServer {
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
regionId: string;
|
||||||
|
regionName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PiaKey {
|
||||||
|
tag: string;
|
||||||
|
hostname: string;
|
||||||
|
secretKey: string;
|
||||||
|
address: string;
|
||||||
|
publicKey: string;
|
||||||
|
endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PiaFormValues {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
countryCode: string | null;
|
||||||
|
regionId: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: PiaFormValues = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
countryCode: null,
|
||||||
|
regionId: null,
|
||||||
|
hostname: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function piaHostnameOf(outbound: PiaOutboundRow): string {
|
||||||
|
if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
|
||||||
|
return outbound.piaHostname.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function piaTagPart(s: string, stripDomain: boolean): string {
|
||||||
|
s = s.trim().toLowerCase();
|
||||||
|
if (stripDomain) {
|
||||||
|
const i = s.indexOf('.');
|
||||||
|
if (i > 0) s = s.slice(0, i);
|
||||||
|
}
|
||||||
|
return s.replaceAll('_', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function piaOutboundTag(regionId: string, hostname: string): string {
|
||||||
|
const region = piaTagPart(regionId, false);
|
||||||
|
const server = piaTagPart(hostname, true);
|
||||||
|
if (!region) return `pia-${server}`;
|
||||||
|
return `pia-${region}-${server}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPiaOutbound(key: PiaKey): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
tag: key.tag || `pia-${key.hostname}`,
|
||||||
|
piaHostname: key.hostname,
|
||||||
|
protocol: 'wireguard',
|
||||||
|
settings: {
|
||||||
|
secretKey: key.secretKey,
|
||||||
|
address: [key.address],
|
||||||
|
mtu: 1420,
|
||||||
|
noKernelTun: true,
|
||||||
|
peers: [
|
||||||
|
{
|
||||||
|
publicKey: key.publicKey,
|
||||||
|
endpoint: key.endpoint,
|
||||||
|
allowedIPs: ['0.0.0.0/0'],
|
||||||
|
keepAlive: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PiaModal({
|
||||||
|
open,
|
||||||
|
templateSettings,
|
||||||
|
onClose,
|
||||||
|
onAddOutbound,
|
||||||
|
onResetOutbound,
|
||||||
|
}: PiaModalProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [piaData, setPiaData] = useState<PiaAccount | null>(null);
|
||||||
|
const [countries, setCountries] = useState<PiaCountry[]>([]);
|
||||||
|
const [regions, setRegions] = useState<PiaRegion[]>([]);
|
||||||
|
const [servers, setServers] = useState<PiaServer[]>([]);
|
||||||
|
const methods = useForm<PiaFormValues>({ defaultValues: EMPTY });
|
||||||
|
const regionId = useWatch({ control: methods.control, name: 'regionId' });
|
||||||
|
const hostname = useWatch({ control: methods.control, name: 'hostname' });
|
||||||
|
const locale = i18n.resolvedLanguage || i18n.language;
|
||||||
|
|
||||||
|
const piaRows = useMemo(() => {
|
||||||
|
const list = templateSettings?.outbounds;
|
||||||
|
if (!list) return [];
|
||||||
|
return list.flatMap((outbound, index) => {
|
||||||
|
if (!outbound?.tag?.startsWith?.('pia-')) return [];
|
||||||
|
return [{ index, tag: outbound.tag, hostname: piaHostnameOf(outbound) }];
|
||||||
|
});
|
||||||
|
}, [templateSettings?.outbounds]);
|
||||||
|
|
||||||
|
const addedHostnames = useMemo(
|
||||||
|
() => new Set(piaRows.map((row) => row.hostname).filter(Boolean)),
|
||||||
|
[piaRows],
|
||||||
|
);
|
||||||
|
const addedTags = useMemo(
|
||||||
|
() => new Set(piaRows.map((row) => row.tag).filter(Boolean)),
|
||||||
|
[piaRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredServers = useMemo(() => {
|
||||||
|
if (!regionId) return servers;
|
||||||
|
return servers.filter((s) => s.regionId === regionId);
|
||||||
|
}, [regionId, servers]);
|
||||||
|
|
||||||
|
const selectedServer = filteredServers.find((s) => s.hostname === hostname);
|
||||||
|
const selectedTag = selectedServer
|
||||||
|
? piaOutboundTag(selectedServer.regionId, selectedServer.hostname)
|
||||||
|
: '';
|
||||||
|
const selectedAlreadyAdded = Boolean(
|
||||||
|
(hostname && addedHostnames.has(hostname)) || (selectedTag && addedTags.has(selectedTag)),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
methods.setValue('hostname', filteredServers.length > 0 ? filteredServers[0].hostname : null);
|
||||||
|
}, [filteredServers, methods]);
|
||||||
|
|
||||||
|
const fetchCountries = useCallback(async () => {
|
||||||
|
const msg = await HttpUtil.post<PiaCountry[]>('/panel/api/xray/pia/countries');
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) setCountries(msg.obj);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<PiaAccount | null>('/panel/api/xray/pia/data');
|
||||||
|
if (msg?.success) {
|
||||||
|
const next = msg.obj ?? null;
|
||||||
|
setPiaData(next);
|
||||||
|
if (next) await fetchCountries();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchCountries]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
await fetchData();
|
||||||
|
if (cancelled) return;
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, fetchData]);
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<PiaAccount>('/panel/api/xray/pia/reg', {
|
||||||
|
username: methods.getValues('username'),
|
||||||
|
password: methods.getValues('password'),
|
||||||
|
});
|
||||||
|
if (msg?.success && msg.obj) {
|
||||||
|
setPiaData(msg.obj);
|
||||||
|
methods.setValue('password', '');
|
||||||
|
await fetchCountries();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/xray/pia/del');
|
||||||
|
if (msg?.success) {
|
||||||
|
setPiaData(null);
|
||||||
|
methods.reset(EMPTY);
|
||||||
|
setCountries([]);
|
||||||
|
setRegions([]);
|
||||||
|
setServers([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchServers(newCountryCode: string) {
|
||||||
|
setLoading(true);
|
||||||
|
setServers([]);
|
||||||
|
setRegions([]);
|
||||||
|
methods.setValue('hostname', null);
|
||||||
|
methods.setValue('regionId', null);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<{ regions?: PiaRegion[]; servers?: PiaServer[] }>(
|
||||||
|
'/panel/api/xray/pia/servers',
|
||||||
|
{ countryCode: newCountryCode },
|
||||||
|
);
|
||||||
|
if (!msg?.success || !msg.obj) return;
|
||||||
|
const nextRegions = msg.obj.regions || [];
|
||||||
|
const nextServers = msg.obj.servers || [];
|
||||||
|
setRegions(nextRegions);
|
||||||
|
setServers(nextServers);
|
||||||
|
if (nextServers.length === 0) messageApi.warning(t('pages.xray.pia.noServers'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function provisionOutbound(
|
||||||
|
selectedHostname: string,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
if (!selectedHostname) return null;
|
||||||
|
const msg = await HttpUtil.post<PiaKey>('/panel/api/xray/pia/addKey', {
|
||||||
|
hostname: selectedHostname,
|
||||||
|
});
|
||||||
|
if (!msg?.success) return null;
|
||||||
|
if (!msg.obj?.secretKey || !msg.obj.publicKey || !msg.obj.endpoint || !msg.obj.address) {
|
||||||
|
messageApi.error(t('pages.xray.pia.provisionFailed'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildPiaOutbound(msg.obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOutbound() {
|
||||||
|
const selected = methods.getValues('hostname');
|
||||||
|
if (!selected || selectedAlreadyAdded) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const ob = await provisionOutbound(selected);
|
||||||
|
if (!ob) return;
|
||||||
|
const tag = typeof ob.tag === 'string' ? ob.tag : '';
|
||||||
|
if (tag && templateSettings?.outbounds?.some((outbound) => outbound?.tag === tag)) return;
|
||||||
|
onAddOutbound(ob);
|
||||||
|
messageApi.success(t('pages.xray.pia.outboundAdded'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetOutbound(index: number, selectedHostname: string) {
|
||||||
|
if (!selectedHostname) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const ob = await provisionOutbound(selectedHostname);
|
||||||
|
if (!ob) return;
|
||||||
|
const oldTag = templateSettings?.outbounds?.[index]?.tag;
|
||||||
|
onResetOutbound({
|
||||||
|
index,
|
||||||
|
outbound: ob,
|
||||||
|
oldTag,
|
||||||
|
newTag: ob.tag as string,
|
||||||
|
});
|
||||||
|
messageApi.success(t('pages.xray.pia.outboundUpdated'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{messageContextHolder}
|
||||||
|
<Modal open={open} title="Private Internet Access WireGuard" footer={null} onCancel={onClose}>
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
{piaData == null ? (
|
||||||
|
<Form
|
||||||
|
colon={false}
|
||||||
|
labelCol={{ md: { span: 6 } }}
|
||||||
|
wrapperCol={{ md: { span: 18 } }}
|
||||||
|
className="mt-20"
|
||||||
|
>
|
||||||
|
<FormField name="username" label={t('pages.xray.pia.username')}>
|
||||||
|
<Input placeholder={t('pages.xray.pia.username')} autoComplete="username" />
|
||||||
|
</FormField>
|
||||||
|
<FormField name="password" label={t('pages.xray.pia.password')}>
|
||||||
|
<Input.Password
|
||||||
|
placeholder={t('pages.xray.pia.password')}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="mt-10"
|
||||||
|
loading={loading}
|
||||||
|
icon={<LoginOutlined />}
|
||||||
|
onClick={() => void login()}
|
||||||
|
>
|
||||||
|
{t('login')}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="pia-data-table">
|
||||||
|
<tbody>
|
||||||
|
<tr className="row-odd">
|
||||||
|
<td>{t('pages.xray.pia.account')}</td>
|
||||||
|
<td>{piaData.accountHint || piaData.username}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={loading}
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
className="mt-8"
|
||||||
|
onClick={() => void logout()}
|
||||||
|
>
|
||||||
|
{t('logout')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
colon={false}
|
||||||
|
labelCol={{ md: { span: 6 } }}
|
||||||
|
wrapperCol={{ md: { span: 18 } }}
|
||||||
|
className="mt-10"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="countryCode"
|
||||||
|
label={t('pages.xray.outbound.country')}
|
||||||
|
transform={{ input: (v) => v ?? undefined }}
|
||||||
|
onAfterChange={(v) => void fetchServers(v as string)}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-testid="pia-country-select"
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
options={countries.map((c) => {
|
||||||
|
const name = countryName(c.code, locale) || c.code;
|
||||||
|
const flag = countryFlag(c.code);
|
||||||
|
return {
|
||||||
|
value: c.code,
|
||||||
|
label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<FormField name="regionId" label={t('pages.xray.pia.region')}>
|
||||||
|
<Select
|
||||||
|
data-testid="pia-region-select"
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
options={[
|
||||||
|
{ value: null, label: t('pages.xray.pia.allRegions') },
|
||||||
|
...regions.map((r) => ({ value: r.id, label: r.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredServers.length > 0 && (
|
||||||
|
<FormField name="hostname" label={t('pages.xray.outbound.server')}>
|
||||||
|
<Select
|
||||||
|
data-testid="pia-server-select"
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
options={filteredServers.map((s) => ({
|
||||||
|
value: s.hostname,
|
||||||
|
label: `${s.regionName} ${s.hostname} ${s.ip}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="mt-10"
|
||||||
|
disabled={!hostname || selectedAlreadyAdded}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => void addOutbound()}
|
||||||
|
>
|
||||||
|
{t('pages.xray.warp.addOutbound')}
|
||||||
|
</Button>
|
||||||
|
{selectedAlreadyAdded && (
|
||||||
|
<div className="pia-already-added">
|
||||||
|
{t('pages.xray.pia.alreadyAdded', { reset: t('reset') })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{piaRows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider className="my-10">{t('pages.xray.pia.addedServers')}</Divider>
|
||||||
|
<table className="pia-added-table" data-testid="pia-added-table">
|
||||||
|
<tbody>
|
||||||
|
{piaRows.map((row) => (
|
||||||
|
<tr key={`${row.index}-${row.tag}`}>
|
||||||
|
<td>{row.tag}</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!row.tag}
|
||||||
|
data-testid={`pia-reset-${row.index}`}
|
||||||
|
onClick={() =>
|
||||||
|
void resetOutbound(row.index, row.hostname || row.tag || '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('reset')}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormProvider>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { default as WarpModal } from './WarpModal';
|
export { default as WarpModal } from './WarpModal';
|
||||||
export { default as NordModal } from './NordModal';
|
export { default as NordModal } from './NordModal';
|
||||||
|
export { default as PiaModal } from './PiaModal';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|||||||
import { InputAddon } from '@/components/ui';
|
import { InputAddon } from '@/components/ui';
|
||||||
import { GeoTokenInput } from '@/components/geodata';
|
import { GeoTokenInput } from '@/components/geodata';
|
||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField } from '@/components/form/rhf';
|
||||||
|
import { useClientOptions } from '@/api/queries/useClientOptions';
|
||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
||||||
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
|
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
|
||||||
@@ -82,6 +83,21 @@ export default function RuleFormModal({
|
|||||||
|
|
||||||
const { data: inboundOptions } = useInboundOptions();
|
const { data: inboundOptions } = useInboundOptions();
|
||||||
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
|
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
|
||||||
|
const {
|
||||||
|
data: clientEmails = [],
|
||||||
|
isFetching: clientsLoading,
|
||||||
|
isError: clientsError,
|
||||||
|
} = useClientOptions(open);
|
||||||
|
const user = useWatch({ control: methods.control, name: 'user' }) ?? '';
|
||||||
|
const selectedUsers = useMemo(() => csv(user), [user]);
|
||||||
|
const userOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
[...new Set([...clientEmails, ...selectedUsers])].map((email) => ({
|
||||||
|
value: email,
|
||||||
|
label: email,
|
||||||
|
})),
|
||||||
|
[clientEmails, selectedUsers],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -287,8 +303,27 @@ export default function RuleFormModal({
|
|||||||
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
|
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
transform={{
|
||||||
|
input: (value) => csv(typeof value === 'string' ? value : ''),
|
||||||
|
output: (value) => (Array.isArray(value) ? value.join(',') : ''),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Input placeholder="email address" />
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
tokenSeparators={[',']}
|
||||||
|
allowClear
|
||||||
|
loading={clientsLoading}
|
||||||
|
placeholder={t('pages.xray.ruleForm.userPlaceholder')}
|
||||||
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
|
notFoundContent={
|
||||||
|
clientsLoading
|
||||||
|
? t('loading')
|
||||||
|
: clientsError
|
||||||
|
? t('pages.xray.ruleForm.userLoadError')
|
||||||
|
: t('pages.xray.ruleForm.userEmpty')
|
||||||
|
}
|
||||||
|
options={userOptions}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/s
|
|||||||
|
|
||||||
// Top-level inbound shape on the wire. Composes:
|
// Top-level inbound shape on the wire. Composes:
|
||||||
// - Per-protocol settings via the InboundSettingsSchema discriminated
|
// - Per-protocol settings via the InboundSettingsSchema discriminated
|
||||||
// union (10 protocols, tagged-wrapper {protocol, settings}).
|
// union (11 protocols, tagged-wrapper {protocol, settings}).
|
||||||
// - StreamSettings as an intersection of the network DU (6 branches),
|
// - StreamSettings as an intersection of the network DU (6 branches),
|
||||||
// security DU (3 branches), and the orthogonal extras (finalmask,
|
// security DU (3 branches), and the orthogonal extras (finalmask,
|
||||||
// sockopt, externalProxy). Zod 4 supports DU intersection — each
|
// sockopt, externalProxy). Zod 4 supports DU intersection — each
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const ClientRecordSchema = z
|
|||||||
allowedIPs: z.string().optional(),
|
allowedIPs: z.string().optional(),
|
||||||
preSharedKey: z.string().optional(),
|
preSharedKey: z.string().optional(),
|
||||||
keepAlive: z.number().optional(),
|
keepAlive: z.number().optional(),
|
||||||
|
forwardedPorts: z.string().optional(),
|
||||||
secret: z.string().optional(),
|
secret: z.string().optional(),
|
||||||
adTag: z.string().optional(),
|
adTag: z.string().optional(),
|
||||||
createdAt: z.number().optional(),
|
createdAt: z.number().optional(),
|
||||||
@@ -59,6 +60,47 @@ export const ClientRecordSchema = z
|
|||||||
})
|
})
|
||||||
.loose();
|
.loose();
|
||||||
|
|
||||||
|
// AmneziaWG's server block, used by the clients page to render a
|
||||||
|
// downloadable per-client .conf without a second round trip. Unlike
|
||||||
|
// WireGuard's flattened wgPublicKey/wgMtu/wgDns below, this stays a nested
|
||||||
|
// object — AmneziaWG has many more fields (the obfuscation parameter set) and
|
||||||
|
// buildAmneziaWGClientConfig (pages/clients/amneziawgConfig.ts) already
|
||||||
|
// expects this exact nested shape. Mirrors the backend's
|
||||||
|
// InboundOption.AwgServer (internal/web/service/inbound.go).
|
||||||
|
export const AwgServerOptionSchema = z
|
||||||
|
.object({
|
||||||
|
publicKey: z.string().optional(),
|
||||||
|
mtu: z.number().optional(),
|
||||||
|
primaryDns: z.string().optional(),
|
||||||
|
secondaryDns: z.string().optional(),
|
||||||
|
jc: z.number().optional(),
|
||||||
|
jmin: z.number().optional(),
|
||||||
|
jmax: z.number().optional(),
|
||||||
|
s1: z.number().optional(),
|
||||||
|
s2: z.number().optional(),
|
||||||
|
s3: z.number().optional(),
|
||||||
|
s4: z.number().optional(),
|
||||||
|
h1: z.string().optional(),
|
||||||
|
h2: z.string().optional(),
|
||||||
|
h3: z.string().optional(),
|
||||||
|
h4: z.string().optional(),
|
||||||
|
i1: z.string().optional(),
|
||||||
|
i2: z.string().optional(),
|
||||||
|
i3: z.string().optional(),
|
||||||
|
i4: z.string().optional(),
|
||||||
|
i5: z.string().optional(),
|
||||||
|
headerProtectionKey: z.string().optional(),
|
||||||
|
contentPaddingAddition: z.string().optional(),
|
||||||
|
rekeyAfterTime: z.string().optional(),
|
||||||
|
rekeyTimeout: z.string().optional(),
|
||||||
|
rejectAfterTime: z.string().optional(),
|
||||||
|
keepaliveTimeout: z.string().optional(),
|
||||||
|
maxHandshakeAttempts: z.string().optional(),
|
||||||
|
randomTrailers: z.boolean().optional(),
|
||||||
|
disableCookies: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.loose();
|
||||||
|
|
||||||
export const InboundOptionSchema = z
|
export const InboundOptionSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -71,6 +113,7 @@ export const InboundOptionSchema = z
|
|||||||
wgPublicKey: z.string().optional(),
|
wgPublicKey: z.string().optional(),
|
||||||
wgMtu: z.number().optional(),
|
wgMtu: z.number().optional(),
|
||||||
wgDns: z.string().optional(),
|
wgDns: z.string().optional(),
|
||||||
|
awgServer: AwgServerOptionSchema.nullable().optional(),
|
||||||
mtprotoDomain: z.string().optional(),
|
mtprotoDomain: z.string().optional(),
|
||||||
// Hosting node id; absent/null for this panel's own inbounds (#4997).
|
// Hosting node id; absent/null for this panel's own inbounds (#4997).
|
||||||
nodeId: z.number().nullable().optional(),
|
nodeId: z.number().nullable().optional(),
|
||||||
@@ -137,10 +180,17 @@ export const ExternalLinkListSchema = z
|
|||||||
.nullable()
|
.nullable()
|
||||||
.transform((v) => v ?? []);
|
.transform((v) => v ?? []);
|
||||||
|
|
||||||
|
// tunnelAllowedIPs carries the real, per-inbound AllowedIPs value (keyed by
|
||||||
|
// inbound id) for every WireGuard/AmneziaWG inbound this client is attached
|
||||||
|
// to. ClientRecord's own allowedIPs is a single string and cannot represent
|
||||||
|
// two different addresses when one identity holds both a WireGuard and an
|
||||||
|
// AmneziaWG attachment at once -- this is what lets the edit form show each
|
||||||
|
// protocol's real, distinct address instead of one ambiguous shared field.
|
||||||
export const ClientHydrateSchema = z.object({
|
export const ClientHydrateSchema = z.object({
|
||||||
client: ClientRecordSchema,
|
client: ClientRecordSchema,
|
||||||
inboundIds: nullableNumberArray,
|
inboundIds: nullableNumberArray,
|
||||||
externalLinks: ExternalLinkListSchema.optional(),
|
externalLinks: ExternalLinkListSchema.optional(),
|
||||||
|
tunnelAllowedIPs: z.record(z.number().int(), z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const BulkAdjustResultSchema = z.object({
|
export const BulkAdjustResultSchema = z.object({
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type ObservatoryHttpMethod = z.infer<typeof ObservatoryHttpMethodSchema>;
|
|||||||
export const PingConfigSchema = z
|
export const PingConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
destination: z.string().default('https://www.google.com/generate_204'),
|
destination: z.string().default('https://www.google.com/generate_204'),
|
||||||
connectivity: z.string().default('http://connectivitycheck.platform.hicloud.com/generate_204'),
|
connectivity: z.string().default(''),
|
||||||
interval: z.string().default('1m'),
|
interval: z.string().default('1m'),
|
||||||
timeout: z.string().default('5s'),
|
timeout: z.string().default('5s'),
|
||||||
sampling: z.number().int().min(1).default(2),
|
sampling: z.number().int().min(1).default(2),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const ProtocolSchema = z.enum([
|
|||||||
'tunnel',
|
'tunnel',
|
||||||
'tun',
|
'tun',
|
||||||
'mtproto',
|
'mtproto',
|
||||||
|
'amneziawg',
|
||||||
]);
|
]);
|
||||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||||
|
|
||||||
@@ -33,4 +34,5 @@ export const Protocols = Object.freeze({
|
|||||||
TUNNEL: 'tunnel',
|
TUNNEL: 'tunnel',
|
||||||
TUN: 'tun',
|
TUN: 'tun',
|
||||||
MTPROTO: 'mtproto',
|
MTPROTO: 'mtproto',
|
||||||
|
AMNEZIAWG: 'amneziawg',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// AntD InputNumber emits null (not undefined) when the user clears it, and
|
||||||
|
// the form store hands that null straight to safeParse on submit — a bare
|
||||||
|
// .optional() would reject it and block the save.
|
||||||
|
const optionalClearedInt = (schema: z.ZodNumber) =>
|
||||||
|
z.preprocess((v) => (v == null ? undefined : v), schema.optional());
|
||||||
|
|
||||||
|
// Same null-absorbing preprocess for fields that keep a schema default:
|
||||||
|
// clearing the InputNumber refills the default instead of blocking the save.
|
||||||
|
const clearedToDefault = <T extends z.ZodType>(schema: T) =>
|
||||||
|
z.preprocess((v) => (v == null ? undefined : v), schema);
|
||||||
|
|
||||||
|
// An AmneziaWG client (multi-client model). Same key/address fields as
|
||||||
|
// WireguardClientSchema — the panel's generic ClientRecord already has those
|
||||||
|
// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
|
||||||
|
// bulk operations, the QR modal and subscriptions all work unmodified — plus
|
||||||
|
// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
|
||||||
|
// inbound has no host-level iptables layer to hang per-client DNAT off of).
|
||||||
|
// Keys are optional on the wire — the backend generates them when absent.
|
||||||
|
export const AmneziawgClientSchema = z.object({
|
||||||
|
privateKey: z.string().optional(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
|
preSharedKey: z.string().optional(),
|
||||||
|
allowedIPs: z.array(z.string()).default([]),
|
||||||
|
keepAlive: optionalClearedInt(z.number().int().min(0)),
|
||||||
|
forwardedPorts: z.string().default(''),
|
||||||
|
email: z.string().min(1),
|
||||||
|
limitIp: z.number().int().min(0).default(0),
|
||||||
|
totalGB: z.number().int().min(0).default(0),
|
||||||
|
expiryTime: z.number().int().default(0),
|
||||||
|
enable: z.boolean().default(true),
|
||||||
|
tgId: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((v) => Number(v) || 0)
|
||||||
|
.default(0),
|
||||||
|
subId: z.string().default(''),
|
||||||
|
comment: z.string().default(''),
|
||||||
|
reset: z.number().int().min(0).default(0),
|
||||||
|
created_at: z.number().int().optional(),
|
||||||
|
updated_at: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
export type AmneziawgClient = z.infer<typeof AmneziawgClientSchema>;
|
||||||
|
|
||||||
|
// Server-wide AmneziaWG 3.1 obfuscation parameters and tunnel identity,
|
||||||
|
// mirroring internal/amneziawg.ServerSettings on the Go side exactly (same
|
||||||
|
// field names) — the listen port is not duplicated here, it's the inbound's
|
||||||
|
// own port like every other protocol. H1-H4 blank falls back to the classic
|
||||||
|
// 1/2/3/4 magic header on save; blank optional fields omit their feature
|
||||||
|
// from the rendered config.
|
||||||
|
export const AmneziawgServerSchema = z.object({
|
||||||
|
privateKey: z.string().optional(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
|
subnetIp: z.string().default('10.8.1.0'),
|
||||||
|
subnetCidr: clearedToDefault(z.number().int().min(1).max(32).default(24)),
|
||||||
|
mtu: optionalClearedInt(z.number().int().min(1)),
|
||||||
|
primaryDns: z.string().default('8.8.8.8'),
|
||||||
|
secondaryDns: z.string().default('8.8.4.4'),
|
||||||
|
externalInterface: z.string().default(''),
|
||||||
|
ipv6Enabled: z.boolean().default(false),
|
||||||
|
ipv6Subnet: z.string().default(''),
|
||||||
|
ipv6ExternalInterface: z.string().default(''),
|
||||||
|
// routeThroughXray is vestigial on the Go side (see ServerSettings' own
|
||||||
|
// doc comment) -- the embedded relay is always on, this field is read by
|
||||||
|
// nothing. Kept here anyway, with no corresponding form control, purely so
|
||||||
|
// z.object's default unknown-key stripping doesn't silently drop it from
|
||||||
|
// an existing stored settings blob on the next save.
|
||||||
|
routeThroughXray: z.boolean().default(false).optional(),
|
||||||
|
jc: clearedToDefault(z.number().int().min(0).default(5)),
|
||||||
|
jmin: clearedToDefault(z.number().int().min(0).default(10)),
|
||||||
|
jmax: clearedToDefault(z.number().int().min(0).default(50)),
|
||||||
|
s1: clearedToDefault(z.number().int().min(0).default(30)),
|
||||||
|
s2: clearedToDefault(z.number().int().min(0).default(45)),
|
||||||
|
s3: clearedToDefault(z.number().int().min(0).max(64).default(10)),
|
||||||
|
s4: clearedToDefault(z.number().int().min(0).max(32).default(5)),
|
||||||
|
h1: z.string().default(''),
|
||||||
|
h2: z.string().default(''),
|
||||||
|
h3: z.string().default(''),
|
||||||
|
h4: z.string().default(''),
|
||||||
|
i1: z.string().default(''),
|
||||||
|
i2: z.string().default(''),
|
||||||
|
i3: z.string().default(''),
|
||||||
|
i4: z.string().default(''),
|
||||||
|
i5: z.string().default(''),
|
||||||
|
headerProtectionKey: z.string().default(''),
|
||||||
|
contentPaddingAddition: z.string().default(''),
|
||||||
|
rekeyAfterTime: z.string().default(''),
|
||||||
|
rekeyTimeout: z.string().default(''),
|
||||||
|
rejectAfterTime: z.string().default(''),
|
||||||
|
keepaliveTimeout: z.string().default(''),
|
||||||
|
maxHandshakeAttempts: z.string().default(''),
|
||||||
|
randomTrailers: z.boolean().default(false),
|
||||||
|
disableCookies: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
export type AmneziawgServer = z.infer<typeof AmneziawgServerSchema>;
|
||||||
|
|
||||||
|
export const AmneziawgInboundSettingsSchema = z.object({
|
||||||
|
server: AmneziawgServerSchema,
|
||||||
|
clients: z.array(AmneziawgClientSchema).default([]),
|
||||||
|
});
|
||||||
|
export type AmneziawgInboundSettings = z.infer<typeof AmneziawgInboundSettingsSchema>;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AmneziawgInboundSettingsSchema } from './amneziawg';
|
||||||
import { HttpInboundSettingsSchema } from './http';
|
import { HttpInboundSettingsSchema } from './http';
|
||||||
import { HysteriaInboundSettingsSchema } from './hysteria';
|
import { HysteriaInboundSettingsSchema } from './hysteria';
|
||||||
import { MixedInboundSettingsSchema } from './mixed';
|
import { MixedInboundSettingsSchema } from './mixed';
|
||||||
@@ -12,6 +13,7 @@ import { VlessInboundSettingsSchema } from './vless';
|
|||||||
import { VmessInboundSettingsSchema } from './vmess';
|
import { VmessInboundSettingsSchema } from './vmess';
|
||||||
import { WireguardInboundSettingsSchema } from './wireguard';
|
import { WireguardInboundSettingsSchema } from './wireguard';
|
||||||
|
|
||||||
|
export * from './amneziawg';
|
||||||
export * from './http';
|
export * from './http';
|
||||||
export * from './hysteria';
|
export * from './hysteria';
|
||||||
export * from './mixed';
|
export * from './mixed';
|
||||||
@@ -41,5 +43,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
|
|||||||
z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
|
z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
|
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
|
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
|
||||||
|
z.object({ protocol: z.literal('amneziawg'), settings: AmneziawgInboundSettingsSchema }),
|
||||||
]);
|
]);
|
||||||
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
|
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
|
||||||
|
|||||||
@@ -69,5 +69,12 @@ export const WireguardInboundSettingsSchema = z.object({
|
|||||||
clients: z.array(WireguardClientSchema).default([]),
|
clients: z.array(WireguardClientSchema).default([]),
|
||||||
noKernelTun: z.boolean().default(false),
|
noKernelTun: z.boolean().default(false),
|
||||||
domainStrategy: WireguardDomainStrategySchema.optional(),
|
domainStrategy: WireguardDomainStrategySchema.optional(),
|
||||||
|
// Admin-configurable base subnet new clients are auto-allocated from —
|
||||||
|
// mirrors AmneziaWG's settings.server.subnetIp/subnetCidr. Optional and
|
||||||
|
// left blank by default: an inbound that never sets this keeps the
|
||||||
|
// pre-existing behavior (infer from existing clients' own addresses, else
|
||||||
|
// fall back to 10.0.0.0/24 server-side).
|
||||||
|
subnetIp: z.string().default(''),
|
||||||
|
subnetCidr: optionalClearedInt(z.number().int().min(1).max(32)),
|
||||||
});
|
});
|
||||||
export type WireguardInboundSettings = z.infer<typeof WireguardInboundSettingsSchema>;
|
export type WireguardInboundSettings = z.infer<typeof WireguardInboundSettingsSchema>;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const AllSettingSchema = z
|
|||||||
subJsonMux: z.string().optional(),
|
subJsonMux: z.string().optional(),
|
||||||
subJsonRules: z.string().optional(),
|
subJsonRules: z.string().optional(),
|
||||||
subJsonFinalMask: z.string().optional(),
|
subJsonFinalMask: z.string().optional(),
|
||||||
|
subJsonObservatory: z.string().optional(),
|
||||||
subHideSettings: z.boolean().optional(),
|
subHideSettings: z.boolean().optional(),
|
||||||
timeLocation: z.string().optional(),
|
timeLocation: z.string().optional(),
|
||||||
ldapEnable: z.boolean().optional(),
|
ldapEnable: z.boolean().optional(),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const SubBalancerStrategySchema = z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']);
|
||||||
|
export type SubBalancerStrategy = z.infer<typeof SubBalancerStrategySchema>;
|
||||||
|
|
||||||
|
export const SubBalancerSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
remark: z.string(),
|
||||||
|
strategy: SubBalancerStrategySchema,
|
||||||
|
inboundIds: z.array(z.number()),
|
||||||
|
sortOrder: z.number(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
createdAt: z.number().optional(),
|
||||||
|
updatedAt: z.number().optional(),
|
||||||
|
});
|
||||||
|
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
|
||||||
|
|
||||||
|
export const SubBalancerListSchema = z.array(SubBalancerSchema);
|
||||||
|
|
||||||
|
export const SubBalancerFormSchema = z.object({
|
||||||
|
remark: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'pages.settings.subBalancers.errRemarkRequired')
|
||||||
|
.max(256, 'pages.settings.subBalancers.errRemarkRequired'),
|
||||||
|
strategy: SubBalancerStrategySchema,
|
||||||
|
inboundIds: z
|
||||||
|
.array(z.number().int().positive())
|
||||||
|
.min(1, 'pages.settings.subBalancers.errInboundsRequired'),
|
||||||
|
sortOrder: z
|
||||||
|
.number({ message: 'pages.settings.subBalancers.errSortOrder' })
|
||||||
|
.int('pages.settings.subBalancers.errSortOrder')
|
||||||
|
.min(1, 'pages.settings.subBalancers.errSortOrder'),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
export type SubBalancerFormValues = z.infer<typeof SubBalancerFormSchema>;
|
||||||
@@ -54,6 +54,8 @@ exports[`createDefault*InboundSettings factories > wireguard 1`] = `
|
|||||||
"noKernelTun": false,
|
"noKernelTun": false,
|
||||||
"peers": [],
|
"peers": [],
|
||||||
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
|
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
|
||||||
|
"subnetCidr": 24,
|
||||||
|
"subnetIp": "10.0.0.0",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`]
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
|
"secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
|
||||||
|
"subnetIp": "",
|
||||||
},
|
},
|
||||||
"shareAddr": "",
|
"shareAddr": "",
|
||||||
"shareAddrStrategy": "node",
|
"shareAddrStrategy": "node",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user