mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-26 21:17: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.
|
||||
|
||||
- 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
|
||||
imports `github.com/xtls/xray-core` for config types and the gRPC
|
||||
stats/handler/router API. The release the panel BUNDLES is pinned in
|
||||
|
||||
@@ -494,13 +494,41 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Read-only: this job holds a write-scoped token, so building or running
|
||||
# anything out of pr-head/ would turn the review into a pwn-request.
|
||||
# checkout v7 refuses a fork PR ref outright unless that risk is accepted
|
||||
# here, and nearly every pull request to this repository is from a fork.
|
||||
# An `@claude review` vouches for the head that existed when it was typed;
|
||||
# a push after it would swap the code out from under that approval.
|
||||
- name: Pin the head this run reviews
|
||||
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
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/head
|
||||
ref: ${{ steps.pinned-sha.outputs.sha }}
|
||||
path: pr-head
|
||||
persist-credentials: false
|
||||
allow-unsafe-pr-checkout: true
|
||||
@@ -534,7 +562,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Fail if the review posted nothing
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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.
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -41,6 +41,14 @@ file locations when it can answer in one hop.
|
||||
- `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
|
||||
category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
|
||||
- `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/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
|
||||
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.
|
||||
- `service/` — business logic (InboundService, SettingService, XrayService,
|
||||
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.
|
||||
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
|
||||
`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)
|
||||
- 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
|
||||
hand-write. Do not edit `src/generated/`.
|
||||
- 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
|
||||
`go test ./...`. Run `make help` for all targets. The local gate:
|
||||
|
||||
make verify # gen-check + lint + format-check + typecheck + test + build
|
||||
# + build-storybook
|
||||
make verify # gen-check + lint + typecheck + test + build + build-storybook
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
|
||||
|
||||
## 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)
|
||||
- **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.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ RUN npm run build
|
||||
# ========================================================
|
||||
# Stage: Builder
|
||||
# ========================================================
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM golang:1.27-alpine AS builder
|
||||
WORKDIR /app
|
||||
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)
|
||||
}
|
||||
rest := doc[i+len(from):]
|
||||
if j := strings.Index(rest, to); j >= 0 {
|
||||
return rest[:j]
|
||||
if before, _, ok := strings.Cut(rest, to); ok {
|
||||
return before
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ services:
|
||||
# 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
|
||||
# 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:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
|
||||
@@ -51,7 +51,7 @@ Two key ideas that explain most of the complexity:
|
||||
|
||||
## 2. Tech stack
|
||||
|
||||
**Backend (Go 1.26):**
|
||||
**Backend (Go 1.27):**
|
||||
|
||||
- Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
|
||||
- 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:
|
||||
│ │ # node_client_traffic.go, node_client_ip.go,
|
||||
│ │ # 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,
|
||||
│ │ # xray.crash, node.down|up, cpu.high, memory.high, login.attempt
|
||||
│ ├── 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)
|
||||
│ │ │ ├── server.go # /panel/api/server (status, xray version, certs, logs, DB import/export)
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ ├── index.go # login/logout/csrf/2FA
|
||||
│ │ │ ├── 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
|
||||
│ │ │ ├── fallback.go # Xray fallback (SNI/ALPN routing on shared port)
|
||||
│ │ │ ├── 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
|
||||
│ │ │ ├── panel/ # Cross-cutting panel services:
|
||||
│ │ │ │ ├── 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 10s` | `check_client_ip_job` | Enforce per-client IP limits |
|
||||
| `@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 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 |
|
||||
@@ -497,7 +499,7 @@ for AutoMigrate in `internal/database/db.go`.
|
||||
| **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` |
|
||||
| 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` |
|
||||
| **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` |
|
||||
|
||||
@@ -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. |
|
||||
| **Shadowsocks** | Includes Shadowsocks-2022 (`2022-blake3-*`) ciphers. |
|
||||
| **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. |
|
||||
| **HTTP** | HTTP proxy. |
|
||||
| **Mixed (SOCKS/HTTP)** | A combined SOCKS + HTTP listener. |
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"ssl-certificates",
|
||||
"inbounds",
|
||||
"reality",
|
||||
"amneziawg",
|
||||
"transports",
|
||||
"clients",
|
||||
"subscription",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
Inbounds accept clients; **outbounds** decide where their traffic goes next.
|
||||
3x-ui can route traffic through Cloudflare WARP, NordVPN, or arbitrary outbound
|
||||
pools imported from a subscription, and select between them with routing rules
|
||||
and balancers.
|
||||
3x-ui can route traffic through Cloudflare WARP, NordVPN, Private Internet Access
|
||||
(WireGuard), or arbitrary outbound pools imported from a subscription,
|
||||
and select between them with routing rules and balancers.
|
||||
|
||||
## 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
|
||||
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)
|
||||
|
||||
An **outbound subscription** imports a remote share-link subscription and injects
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
---
|
||||
title: API Tokens
|
||||
description: 'Manage Bearer tokens used for programmatic auth (bots, central
|
||||
panels acting on this node, CI). Each token has a unique name and an enabled
|
||||
flag — disable to revoke without deleting, delete to revoke permanently.
|
||||
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.'
|
||||
description: Manage scoped Bearer tokens for programmatic auth. Tokens grant
|
||||
admin, monitor, or node-sync access, may expire, and are stored as SHA-256
|
||||
hashes. The plaintext is returned only once at creation.
|
||||
full: true
|
||||
_openapi:
|
||||
preload:
|
||||
|
||||
@@ -14,18 +14,24 @@ _openapi:
|
||||
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
|
||||
title: 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 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 /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-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal'
|
||||
title: '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
|
||||
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 /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
|
||||
title: 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
|
||||
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
|
||||
@@ -48,10 +54,10 @@ _openapi:
|
||||
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'
|
||||
- depth: 2
|
||||
title: Replace a client's external links (per-client share links and remote
|
||||
subscription URLs surfaced in their subscription). Sends the full set;
|
||||
the server replaces all rows.
|
||||
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'
|
||||
title: Replace a client's 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.
|
||||
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
|
||||
title: Reset the up/down counters for every client globally. Quotas and expiry
|
||||
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'
|
||||
- depth: 2
|
||||
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
|
||||
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-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone'
|
||||
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.
|
||||
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
|
||||
title: Return every client as a {client, inboundIds} array — the same shape
|
||||
/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.
|
||||
addDays/addBytes may be negative. Clients with unlimited expiry
|
||||
(expiryTime=0) or unlimited traffic (totalGB=0) 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-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-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'
|
||||
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-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
|
||||
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
|
||||
@@ -188,6 +198,13 @@ _openapi:
|
||||
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
|
||||
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
|
||||
@@ -204,6 +221,17 @@ _openapi:
|
||||
- depth: 2
|
||||
title: 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
|
||||
title: List the emails of currently connected clients (last seen within the
|
||||
heartbeat window), deduped across every node.
|
||||
@@ -248,34 +276,28 @@ _openapi:
|
||||
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:
|
||||
headings:
|
||||
- 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
|
||||
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
|
||||
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 /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-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal
|
||||
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 /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
|
||||
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
|
||||
call. Body is JSON. Per-protocol secrets are generated server-side
|
||||
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
|
||||
- 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
|
||||
- content: Replace a client's external links (per-client share links and remote
|
||||
subscription URLs surfaced in their subscription). Sends the full set;
|
||||
the server replaces all rows.
|
||||
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
|
||||
- content: Replace a client's 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.
|
||||
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
|
||||
are not affected. Triggers an Xray restart if any counter actually
|
||||
moved.
|
||||
@@ -307,10 +329,10 @@ _openapi:
|
||||
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
|
||||
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-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone
|
||||
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.
|
||||
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
|
||||
/bulkCreate and /import accept — so the payload round-trips straight
|
||||
back through /import. Clients with no inbound attachment are included
|
||||
@@ -329,11 +351,15 @@ _openapi:
|
||||
addDays/addBytes may be negative. Clients with unlimited expiry
|
||||
(expiryTime=0) or unlimited traffic (totalGB=0) 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"
|
||||
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
|
||||
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-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
|
||||
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
|
||||
@@ -417,6 +443,13 @@ _openapi:
|
||||
/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
|
||||
across every attached inbound and pushes the change to Xray (or the
|
||||
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
|
||||
- content: 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
|
||||
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,
|
||||
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:
|
||||
- content: >-
|
||||
Fields the server fills in when they are omitted — a valid value sent
|
||||
@@ -536,7 +569,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{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.
|
||||
url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order'
|
||||
- depth: 2
|
||||
title: Fetch a single host by ID.
|
||||
url: '#fetch-a-single-host-by-id'
|
||||
title: Fetch a single host group by Group ID.
|
||||
url: '#fetch-a-single-host-group-by-group-id'
|
||||
- depth: 2
|
||||
title: Fetch one inbound's hosts, ordered by sort order then id.
|
||||
url: '#fetch-one-inbounds-hosts-ordered-by-sort-order-then-id'
|
||||
title: Fetch one inbound's hosts, grouped by host group.
|
||||
url: '#fetch-one-inbounds-hosts-grouped-by-host-group'
|
||||
- depth: 2
|
||||
title: Distinct, sorted set of tags used across all hosts.
|
||||
url: '#distinct-sorted-set-of-tags-used-across-all-hosts'
|
||||
- depth: 2
|
||||
title: Create a host on an inbound. inboundId and remark are required; security
|
||||
defaults to "same" (inherit the inbound).
|
||||
url: '#create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound'
|
||||
title: Create a host group on inbounds.
|
||||
url: '#create-a-host-group-on-inbounds'
|
||||
- depth: 2
|
||||
title: Replace a host’s content. The inbound and sort order are immutable here
|
||||
(use /reorder for ordering).
|
||||
url: '#replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering'
|
||||
title: Replace a host group’s content.
|
||||
url: '#replace-a-host-groups-content'
|
||||
- depth: 2
|
||||
title: Delete a host.
|
||||
url: '#delete-a-host'
|
||||
title: Delete a host group.
|
||||
url: '#delete-a-host-group'
|
||||
- depth: 2
|
||||
title: Enable or disable a single host (disabled hosts are skipped in
|
||||
subscriptions).
|
||||
url: '#enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions'
|
||||
title: Enable or disable a host group.
|
||||
url: '#enable-or-disable-a-host-group'
|
||||
- depth: 2
|
||||
title: Set host sort order by the position of each id in the array.
|
||||
url: '#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-group-sort-order-by-the-position-of-each-groupid-in-the-array'
|
||||
- depth: 2
|
||||
title: Enable or disable many hosts in one call.
|
||||
url: '#enable-or-disable-many-hosts-in-one-call'
|
||||
title: Add a host group to inbounds (same as /add).
|
||||
url: '#add-a-host-group-to-inbounds-same-as-add'
|
||||
- depth: 2
|
||||
title: Delete many hosts in one call.
|
||||
url: '#delete-many-hosts-in-one-call'
|
||||
title: Enable or disable many host groups 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:
|
||||
headings:
|
||||
- content: 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.
|
||||
id: fetch-a-single-host-by-id
|
||||
- content: Fetch one inbound's hosts, ordered by sort order then id.
|
||||
id: fetch-one-inbounds-hosts-ordered-by-sort-order-then-id
|
||||
- content: Fetch a single host group by Group ID.
|
||||
id: fetch-a-single-host-group-by-group-id
|
||||
- content: Fetch one inbound's hosts, grouped by host group.
|
||||
id: fetch-one-inbounds-hosts-grouped-by-host-group
|
||||
- content: 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;
|
||||
security defaults to "same" (inherit the inbound).
|
||||
id: create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound
|
||||
- content: Replace a host’s content. The inbound and sort order are immutable here
|
||||
(use /reorder for ordering).
|
||||
id: replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering
|
||||
- content: Delete a host.
|
||||
id: delete-a-host
|
||||
- content: Enable or disable a single host (disabled hosts are skipped in
|
||||
subscriptions).
|
||||
id: enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions
|
||||
- content: Set host sort order by the position of each id in the array.
|
||||
id: set-host-sort-order-by-the-position-of-each-id-in-the-array
|
||||
- content: Enable or disable many hosts in one call.
|
||||
id: enable-or-disable-many-hosts-in-one-call
|
||||
- content: Delete many hosts in one call.
|
||||
id: delete-many-hosts-in-one-call
|
||||
- content: Create a host group on inbounds.
|
||||
id: create-a-host-group-on-inbounds
|
||||
- content: Replace a host group’s content.
|
||||
id: replace-a-host-groups-content
|
||||
- content: Delete a host group.
|
||||
id: delete-a-host-group
|
||||
- content: Enable or disable a host group.
|
||||
id: enable-or-disable-a-host-group
|
||||
- content: Set host group sort order by the position of each groupId in the array.
|
||||
id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array
|
||||
- content: Add a host group to inbounds (same as /add).
|
||||
id: add-a-host-group-to-inbounds-same-as-add
|
||||
- content: Enable or disable many host groups in one call.
|
||||
id: enable-or-disable-many-host-groups-in-one-call
|
||||
- content: Delete many host groups in one call.
|
||||
id: delete-many-host-groups-in-one-call
|
||||
contents: []
|
||||
---
|
||||
|
||||
@@ -85,7 +84,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
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
|
||||
title: 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.
|
||||
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
|
||||
title: Zero out upload + download counters for a single inbound. Does not touch
|
||||
per-client counters.
|
||||
@@ -94,10 +107,6 @@ _openapi:
|
||||
title: 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:
|
||||
headings:
|
||||
- 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
|
||||
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.
|
||||
id: fetch-a-single-inbound-by-numeric-id
|
||||
- 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
|
||||
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
|
||||
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.
|
||||
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: []
|
||||
---
|
||||
|
||||
@@ -184,7 +201,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{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",
|
||||
"xray-settings",
|
||||
"subscription-server",
|
||||
"subscription-balancers",
|
||||
"hosts",
|
||||
"nodes",
|
||||
"backup",
|
||||
|
||||
@@ -22,6 +22,11 @@ _openapi:
|
||||
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.
|
||||
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
|
||||
title: Fetch a single node by ID.
|
||||
url: '#fetch-a-single-node-by-id'
|
||||
@@ -32,12 +37,15 @@ _openapi:
|
||||
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
|
||||
title: Register a new remote node. Provide its URL, apiToken, and optional
|
||||
remark / allowPrivateAddress flag.
|
||||
url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag'
|
||||
title: Register a new remote node. Provide its URL, write-only apiToken, and
|
||||
optional remark / allowPrivateAddress flag. Responses expose hasApiToken
|
||||
only.
|
||||
url: '#register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only'
|
||||
- depth: 2
|
||||
title: Replace a node’s connection details. Same body shape as /add.
|
||||
url: '#replace-a-nodes-connection-details-same-body-shape-as-add'
|
||||
title: '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.'
|
||||
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
|
||||
title: 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,
|
||||
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:
|
||||
headings:
|
||||
- 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
|
||||
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.
|
||||
id: fetch-a-single-node-by-id
|
||||
- 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
|
||||
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
|
||||
remark / allowPrivateAddress flag.
|
||||
id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
|
||||
- content: Replace a node’s connection details. Same body shape as /add.
|
||||
id: replace-a-nodes-connection-details-same-body-shape-as-add
|
||||
- content: Register a new remote node. Provide its URL, write-only apiToken, and
|
||||
optional remark / allowPrivateAddress flag. Responses expose
|
||||
hasApiToken only.
|
||||
id: register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only
|
||||
- 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.
|
||||
id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
|
||||
- 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,
|
||||
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: []
|
||||
---
|
||||
|
||||
@@ -145,7 +151,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{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:
|
||||
- ./public/openapi.json
|
||||
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
|
||||
title: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load
|
||||
averages, open connections, Xray state. Cached and refreshed every 2
|
||||
@@ -49,12 +55,19 @@ _openapi:
|
||||
- depth: 2
|
||||
title: 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
|
||||
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'
|
||||
- depth: 2
|
||||
title: Stream the SQLite database file as an attachment. Use as a manual backup.
|
||||
url: '#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
|
||||
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
|
||||
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
|
||||
@@ -123,9 +136,16 @@ _openapi:
|
||||
title: Return the last N lines of the Xray process log.
|
||||
url: '#return-the-last-n-lines-of-the-xray-process-log'
|
||||
- depth: 2
|
||||
title: Restore the panel DB from an uploaded SQLite file (multipart form, field
|
||||
name "db"). The panel restarts after restore. Destructive.
|
||||
url: '#restore-the-panel-db-from-an-uploaded-sqlite-file-multipart-form-field-name-db-the-panel-restarts-after-restore-destructive'
|
||||
title: Return live AmneziaWG peer activity (handshake, endpoint, transfer) plus
|
||||
the panel’s own AmneziaWG event lines.
|
||||
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
|
||||
title: Generate a new ECH (Encrypted Client Hello) keypair and config list for
|
||||
the given SNI.
|
||||
@@ -139,6 +159,20 @@ _openapi:
|
||||
title: Run `xray tls ping` against a remote server and return its live
|
||||
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'
|
||||
- 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
|
||||
title: Fetch the fully aggregated inbound_client_ips database table. Used by
|
||||
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'
|
||||
structuredData:
|
||||
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
|
||||
averages, open connections, Xray state. Cached and refreshed every 2
|
||||
seconds in the background.'
|
||||
@@ -181,11 +220,16 @@ _openapi:
|
||||
id: list-xray-binary-versions-available-for-install-on-this-host
|
||||
- content: 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.
|
||||
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
|
||||
backup.
|
||||
id: stream-the-sqlite-database-file-as-an-attachment-use-as-a-manual-backup
|
||||
- content: '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.'
|
||||
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
|
||||
text) on SQLite, or a .db SQLite database built from the live data on
|
||||
PostgreSQL.'
|
||||
@@ -236,9 +280,15 @@ _openapi:
|
||||
id: return-the-last-n-lines-of-the-panels-own-log
|
||||
- content: 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,
|
||||
field name "db"). The panel restarts after restore. Destructive.
|
||||
id: restore-the-panel-db-from-an-uploaded-sqlite-file-multipart-form-field-name-db-the-panel-restarts-after-restore-destructive
|
||||
- content: Return live AmneziaWG peer activity (handshake, endpoint, transfer)
|
||||
plus the panel’s own AmneziaWG event lines.
|
||||
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
|
||||
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
|
||||
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
|
||||
- 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
|
||||
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 (
|
||||
<>
|
||||
{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
|
||||
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
|
||||
title: 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
|
||||
title: Change the panel admin username and password. Requires the current
|
||||
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
|
||||
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
|
||||
/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
|
||||
credentials for verification. The session is refreshed with the new
|
||||
values on success.
|
||||
@@ -83,7 +101,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
title: 'Return base64-encoded subscription links for all enabled clients
|
||||
matching the subscription ID. When the request has an Accept: text/html
|
||||
header or ?html=1, renders a styled info page instead. 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-default-path-subsubid'
|
||||
header or ?html=1, renders a styled info page instead. With
|
||||
?format=info, returns the page view-model as JSON (traffic, expiry,
|
||||
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
|
||||
title: 'Return subscription as a JSON array of proxy configs (one per enabled
|
||||
client). Only when JSON subscription is enabled in settings. Default
|
||||
@@ -30,9 +31,10 @@ _openapi:
|
||||
headings:
|
||||
- content: 'Return base64-encoded subscription links for all enabled clients
|
||||
matching the subscription ID. When the request has an Accept:
|
||||
text/html header or ?html=1, renders a styled info page instead.
|
||||
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-default-path-subsubid
|
||||
text/html header or ?html=1, renders a styled info page instead. With
|
||||
?format=info, returns the page view-model as JSON (traffic, expiry,
|
||||
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
|
||||
client). Only when JSON subscription is enabled in settings. Default
|
||||
path: /json/:subid.'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
full: true
|
||||
_openapi:
|
||||
@@ -35,6 +35,10 @@ _openapi:
|
||||
- depth: 2
|
||||
title: 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
|
||||
title: 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
|
||||
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'
|
||||
- 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
|
||||
title: List all outbound subscriptions (remote URLs that supply additional
|
||||
outbounds), newest first.
|
||||
@@ -79,9 +102,9 @@ _openapi:
|
||||
title: Delete an outbound subscription by id.
|
||||
url: '#delete-an-outbound-subscription-by-id'
|
||||
- depth: 2
|
||||
title: 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-axios-friendly-clients'
|
||||
title: Delete an outbound subscription by id (POST alias of DELETE for clients
|
||||
that cannot send DELETE).
|
||||
url: '#delete-an-outbound-subscription-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete'
|
||||
- depth: 2
|
||||
title: Force an immediate re-fetch of the subscription and return the parsed
|
||||
outbounds. Signals Xray to reload.
|
||||
@@ -117,6 +140,9 @@ _openapi:
|
||||
id: manage-cloudflare-warp-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
|
||||
- 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.
|
||||
id: reset-traffic-counters-for-a-specific-outbound-by-tag
|
||||
- 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
|
||||
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
|
||||
- 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
|
||||
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
|
||||
- content: 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
|
||||
axios-friendly clients).
|
||||
id: delete-an-outbound-subscription-by-id-post-alias-of-delete-for-axios-friendly-clients
|
||||
- content: Delete an outbound subscription by id (POST alias of DELETE for clients
|
||||
that cannot send DELETE).
|
||||
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
|
||||
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 (
|
||||
<>
|
||||
{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: خروجیها و مسیریابی
|
||||
description: مدیریت ترافیک خروجی در 3x-ui — خروجیهای WARP و NordVPN، اشتراکهای خروجی (مجموعه سرورها)، قواعد مسیریابی و متعادلکنندههای بار.
|
||||
description: مدیریت ترافیک خروجی در 3x-ui — خروجیهای WARP، NordVPN، WireGuard PIA، اشتراکهای خروجی (مجموعه سرورها)، قواعد مسیریابی و متعادلکنندههای بار.
|
||||
icon: Route
|
||||
---
|
||||
|
||||
ورودیها کلاینتها را میپذیرند؛ **خروجیها** تعیین میکنند ترافیک آنها در ادامه به کجا برود.
|
||||
3x-ui میتواند ترافیک را از طریق Cloudflare WARP، NordVPN یا مجموعههای خروجی دلخواه
|
||||
3x-ui میتواند ترافیک را از طریق Cloudflare WARP، NordVPN، Private Internet Access
|
||||
(خروجی WireGuard) یا مجموعههای خروجی دلخواه
|
||||
واردشده از یک اشتراک مسیریابی کند و با قواعد مسیریابی و متعادلکنندهها میان آنها
|
||||
انتخاب نماید.
|
||||
|
||||
@@ -86,6 +87,23 @@ WARP به سرور شما امکان میدهد ترافیک خود را از
|
||||
یک کلید خصوصی را مستقیماً بپذیرد) و کشورها/سرورها را فهرست کند تا بتوانید یک خروجی 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 از راه دور را وارد میکند و سرورهای آن را بهعنوان
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{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: Исходящие соединения и маршрутизация
|
||||
description: Управляйте исходящим трафиком в 3x-ui — outbound-соединения WARP и NordVPN, подписки на исходящие соединения (пулы серверов), правила маршрутизации и балансировщики нагрузки.
|
||||
description: Управляйте исходящим трафиком в 3x-ui — WARP, NordVPN, PIA WireGuard, подписки на исходящие соединения (пулы серверов), правила маршрутизации и балансировщики нагрузки.
|
||||
icon: Route
|
||||
---
|
||||
|
||||
Inbound-соединения принимают клиентов; **outbound-соединения** определяют, куда
|
||||
дальше пойдёт их трафик. 3x-ui может направлять трафик через Cloudflare WARP,
|
||||
NordVPN или произвольные пулы исходящих соединений, импортированные из подписки,
|
||||
NordVPN, Private Internet Access (WireGuard) или произвольные пулы
|
||||
исходящих соединений, импортированные из подписки,
|
||||
а также выбирать между ними с помощью правил маршрутизации и балансировщиков.
|
||||
|
||||
## Редактирование исходящих соединений и маршрутизации
|
||||
@@ -93,6 +94,24 @@ WARP. Также можно применить бесплатную лиценз
|
||||
(или принимать приватный ключ напрямую) и выводить список стран/серверов, чтобы вы
|
||||
могли построить 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 (
|
||||
<>
|
||||
{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: 出站与路由
|
||||
description: 在 3x-ui 中调整出口流量——WARP 与 NordVPN 出站、出站订阅(服务器池)、路由规则以及负载均衡器。
|
||||
description: 在 3x-ui 中调整出口流量——WARP、NordVPN、PIA WireGuard、出站订阅(服务器池)、路由规则以及负载均衡器。
|
||||
icon: Route
|
||||
---
|
||||
|
||||
入站负责接受客户端;**出站**则决定客户端的流量接下来发往何处。
|
||||
3x-ui 可以让流量经由 Cloudflare WARP、NordVPN,或从订阅导入的任意出站池转发,
|
||||
3x-ui 可以让流量经由 Cloudflare WARP、NordVPN、Private Internet Access
|
||||
(WireGuard),或从订阅导入的任意出站池转发,
|
||||
并通过路由规则和均衡器在它们之间进行选择。
|
||||
|
||||
## 编辑出站与路由
|
||||
@@ -81,6 +82,19 @@ WARP 账户,并将其接入一个标签为 **`warp`** 的 WireGuard 出站:
|
||||
直接接受一个私钥),并列出国家/服务器,从而让你构建一个
|
||||
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 (
|
||||
<>
|
||||
{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> {
|
||||
return {
|
||||
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",
|
||||
"version": "0.6.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "3x-ui-frontend",
|
||||
"version": "0.6.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"@tanstack/react-query": "^5.102.2",
|
||||
"@tanstack/react-query-devtools": "^5.102.2",
|
||||
"antd": "^6.6.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next": "^26.4.0",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-router": "^8.3.0",
|
||||
"swagger-ui-react": "^5.32.14",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^10.5.9",
|
||||
"@storybook/addon-docs": "^10.5.9",
|
||||
"@storybook/addon-vitest": "^10.5.9",
|
||||
"@storybook/react-vite": "^10.5.9",
|
||||
"@storybook/addon-a11y": "^10.5.10",
|
||||
"@storybook/addon-docs": "^10.5.10",
|
||||
"@storybook/addon-vitest": "^10.5.10",
|
||||
"@storybook/react-vite": "^10.5.10",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@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/coverage-v8": "^4.1.11",
|
||||
"husky": "^9.1.7",
|
||||
@@ -51,9 +51,9 @@
|
||||
"oxlint": "1.79.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"playwright": "^1.62.1",
|
||||
"storybook": "^10.5.9",
|
||||
"storybook": "^10.5.10",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.1",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2051,9 +2051,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz",
|
||||
"integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.2.tgz",
|
||||
"integrity": "sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2065,9 +2065,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-android-arm64": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz",
|
||||
"integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.2.tgz",
|
||||
"integrity": "sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2079,9 +2079,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-darwin-arm64": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz",
|
||||
"integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.2.tgz",
|
||||
"integrity": "sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2093,9 +2093,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-darwin-x64": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz",
|
||||
"integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.2.tgz",
|
||||
"integrity": "sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2107,9 +2107,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-freebsd-x64": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz",
|
||||
"integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.2.tgz",
|
||||
"integrity": "sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2121,9 +2121,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz",
|
||||
"integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.2.tgz",
|
||||
"integrity": "sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2135,9 +2135,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz",
|
||||
"integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.2.tgz",
|
||||
"integrity": "sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2149,9 +2149,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz",
|
||||
"integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.2.tgz",
|
||||
"integrity": "sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2166,9 +2166,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-arm64-musl": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz",
|
||||
"integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.2.tgz",
|
||||
"integrity": "sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2183,9 +2183,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz",
|
||||
"integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.2.tgz",
|
||||
"integrity": "sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2200,9 +2200,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz",
|
||||
"integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.2.tgz",
|
||||
"integrity": "sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2217,9 +2217,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz",
|
||||
"integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.2.tgz",
|
||||
"integrity": "sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2234,9 +2234,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz",
|
||||
"integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.2.tgz",
|
||||
"integrity": "sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2251,9 +2251,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-x64-gnu": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz",
|
||||
"integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.2.tgz",
|
||||
"integrity": "sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2268,9 +2268,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-linux-x64-musl": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz",
|
||||
"integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.2.tgz",
|
||||
"integrity": "sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2285,9 +2285,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-openharmony-arm64": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz",
|
||||
"integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.2.tgz",
|
||||
"integrity": "sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2299,9 +2299,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-wasm32-wasi": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz",
|
||||
"integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.2.tgz",
|
||||
"integrity": "sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2309,18 +2309,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.2",
|
||||
"@emnapi/runtime": "1.11.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
"@emnapi/core": "1.11.0",
|
||||
"@emnapi/runtime": "1.11.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
|
||||
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -2330,9 +2330,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
|
||||
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -2352,9 +2352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz",
|
||||
"integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.2.tgz",
|
||||
"integrity": "sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2366,9 +2366,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@oxc-resolver/binding-win32-x64-msvc": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz",
|
||||
"integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.2.tgz",
|
||||
"integrity": "sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4209,9 +4209,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-a11y": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.9.tgz",
|
||||
"integrity": "sha512-MvXkoJIVRcZrqhWi+wynYAmFkvTWm5aHdJfYohhgPeEJVDRF91zejtOsN7zPRE/lQvBDY+IiomKxeRN3UhWNVw==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.10.tgz",
|
||||
"integrity": "sha512-RpRQV5xUbrl6hCiNrd5FSMIo6pnRZ0VZxWvEW/ASLcreGkKUW5jl2AeLCe5YROE2i80s/dU+6VPzOYKrwWNFbQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4223,20 +4223,20 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.5.9"
|
||||
"storybook": "^10.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.9.tgz",
|
||||
"integrity": "sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.10.tgz",
|
||||
"integrity": "sha512-06JoK3/a7FWI/6GzuidJP9iHp1/Vejboe6lzS1jW+d8ItpecriBt+oXh1VNmUM7i7PjI6pZnet+j51QnLyeOoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.5.9",
|
||||
"@storybook/csf-plugin": "10.5.10",
|
||||
"@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-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -4247,7 +4247,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.5.9"
|
||||
"storybook": "^10.5.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -4256,9 +4256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-vitest": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.9.tgz",
|
||||
"integrity": "sha512-TgHczbDANprH9bEj+Z/DZ4b2LaCHpNrBdMOWK7gd+ZRGZz0ZZnGm8H5WoIh2uW4AHYibZmYCyWDSf69Plblz2w==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.10.tgz",
|
||||
"integrity": "sha512-JNQ9DSkLfxC8qqytBCej91zBExIZ7z97B410U2zgfQPki4HkI9Ffz97a15f5yhVZ79ppIrZ/ssI+WcyWn0ykXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4273,7 +4273,7 @@
|
||||
"@vitest/browser": "^3.0.0 || ^4.0.0",
|
||||
"@vitest/browser-playwright": "^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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -4292,13 +4292,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/builder-vite": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.9.tgz",
|
||||
"integrity": "sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.10.tgz",
|
||||
"integrity": "sha512-O4GgIP0tKLRueom3EmU3OaBUHKjNYj+jkOvmTIkn3PYTiWVkCuHqSKEs4ADvRyaQuLH+peHhFe4JtkNC9KbtrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/csf-plugin": "10.5.9",
|
||||
"@storybook/csf-plugin": "10.5.10",
|
||||
"ts-dedent": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
@@ -4306,14 +4306,14 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.5.9",
|
||||
"storybook": "^10.5.10",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz",
|
||||
"integrity": "sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.10.tgz",
|
||||
"integrity": "sha512-TaCLBrqVEr767+w58QDotDUiCTuE5cyRJuRcDlsKQyUIyGBv+lYD3lu8wBiVCYxgIjB/gu9HmqiC+0yx1rHzaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4326,7 +4326,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "^10.5.9",
|
||||
"storybook": "^10.5.10",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -4363,14 +4363,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.9.tgz",
|
||||
"integrity": "sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.10.tgz",
|
||||
"integrity": "sha512-4MBV5e1SXIMfPynLHzr+Mp0dwGv/FW1bklWAsS4ynBOAbC98W9p/I9vqBnUctsvE3BJkhzHQQyPwMHL5tTcHVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -4383,7 +4383,7 @@
|
||||
"@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-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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -4399,9 +4399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz",
|
||||
"integrity": "sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.10.tgz",
|
||||
"integrity": "sha512-rbu62ILo/VE3iXKmu+kWXFpD1H1Lwi0f19q/x7JnDsD2dxKS9w5znLEqPIq2qxpzi/wjjIb2iUP1cRG1d/9W5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -4413,7 +4413,7 @@
|
||||
"@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-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.5.9"
|
||||
"storybook": "^10.5.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -4425,16 +4425,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-vite": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.9.tgz",
|
||||
"integrity": "sha512-mDI+UssrOEP8IwgBsefLG+gqW1gZbZEPbpH5PoHK/P1WPi8BfrLEqrFi9H9wmMkal/Vh8SmL1awgOi0ir3XwcQ==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.10.tgz",
|
||||
"integrity": "sha512-xOztxefUnqKeuyvcnjspqmlDnER4cExL+liltrpdXLPJVqfFNr9lgM49FyEPajzsUVG9W/vHJWjbaQGGu1UsYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
|
||||
"@rollup/pluginutils": "^5.0.2",
|
||||
"@storybook/builder-vite": "10.5.9",
|
||||
"@storybook/react": "10.5.9",
|
||||
"@storybook/builder-vite": "10.5.10",
|
||||
"@storybook/react": "10.5.10",
|
||||
"empathic": "^2.0.0",
|
||||
"magic-string": "^0.30.0",
|
||||
"react-docgen": "^8.0.2",
|
||||
@@ -4448,7 +4448,7 @@
|
||||
"peerDependencies": {
|
||||
"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",
|
||||
"storybook": "^10.5.9",
|
||||
"storybook": "^10.5.10",
|
||||
"typescript": ">= 4.9.x",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
@@ -5168,9 +5168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
||||
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
|
||||
"version": "5.102.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.2.tgz",
|
||||
"integrity": "sha512-zQ5794PXBlV5Wl7N23SR1/Ss+wPE/h2Ye4XlKpm3omN8i8b/Fd4DAdqpLS2AXj5M/RMObt3k5qy3E7kzt/AvBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -5178,9 +5178,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz",
|
||||
"integrity": "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA==",
|
||||
"version": "5.102.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.102.2.tgz",
|
||||
"integrity": "sha512-sbRVlyRWfhKm2z4cnTdHydHiaMYMcH1mCYVNwreE9oR08VmyCsSz97fhy1253gKB4ycMcaRjbfUxQLO0SSLFYw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -5188,12 +5188,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
|
||||
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
|
||||
"version": "5.102.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.2.tgz",
|
||||
"integrity": "sha512-KxU8ZyOEuJ81eTSgXa8GQbk/jO/rz0elYtNKt3VMtM2pRjeO8ADIu7sqqmEjFKJKqco9h2N1ojIDuHs6VfDItQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.4"
|
||||
"@tanstack/query-core": "5.102.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -5204,19 +5204,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.4.tgz",
|
||||
"integrity": "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA==",
|
||||
"version": "5.102.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.102.2.tgz",
|
||||
"integrity": "sha512-5KzH1NAdess98Jgbo8R0sraP0yto49+eGgSGu6umvrLbXPNdsNJ7iOgAiXegBOoWJrHygp6hW01L2U6MvSIT5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.101.4"
|
||||
"@tanstack/query-devtools": "5.102.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query": "^5.102.2",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
@@ -5456,9 +5456,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -5839,9 +5839,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
||||
"integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
|
||||
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5853,6 +5853,7 @@
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"oxc-transform-react": "^0.145.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -5861,6 +5862,9 @@
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"oxc-transform-react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6390,9 +6394,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
|
||||
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
|
||||
"version": "2.11.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz",
|
||||
"integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -7050,9 +7054,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.411",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
|
||||
"integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==",
|
||||
"version": "1.5.413",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz",
|
||||
"integrity": "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -7651,9 +7655,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"version": "26.4.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
|
||||
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -8826,34 +8830,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-resolver": {
|
||||
"version": "11.24.2",
|
||||
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz",
|
||||
"integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==",
|
||||
"version": "11.21.2",
|
||||
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.2.tgz",
|
||||
"integrity": "sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxc-resolver/binding-android-arm-eabi": "11.24.2",
|
||||
"@oxc-resolver/binding-android-arm64": "11.24.2",
|
||||
"@oxc-resolver/binding-darwin-arm64": "11.24.2",
|
||||
"@oxc-resolver/binding-darwin-x64": "11.24.2",
|
||||
"@oxc-resolver/binding-freebsd-x64": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-arm64-gnu": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-arm64-musl": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-riscv64-musl": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-s390x-gnu": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-x64-gnu": "11.24.2",
|
||||
"@oxc-resolver/binding-linux-x64-musl": "11.24.2",
|
||||
"@oxc-resolver/binding-openharmony-arm64": "11.24.2",
|
||||
"@oxc-resolver/binding-wasm32-wasi": "11.24.2",
|
||||
"@oxc-resolver/binding-win32-arm64-msvc": "11.24.2",
|
||||
"@oxc-resolver/binding-win32-x64-msvc": "11.24.2"
|
||||
"@oxc-resolver/binding-android-arm-eabi": "11.21.2",
|
||||
"@oxc-resolver/binding-android-arm64": "11.21.2",
|
||||
"@oxc-resolver/binding-darwin-arm64": "11.21.2",
|
||||
"@oxc-resolver/binding-darwin-x64": "11.21.2",
|
||||
"@oxc-resolver/binding-freebsd-x64": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-arm-musleabihf": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-arm64-gnu": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-arm64-musl": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-ppc64-gnu": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-riscv64-gnu": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-riscv64-musl": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-s390x-gnu": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-x64-gnu": "11.21.2",
|
||||
"@oxc-resolver/binding-linux-x64-musl": "11.21.2",
|
||||
"@oxc-resolver/binding-openharmony-arm64": "11.21.2",
|
||||
"@oxc-resolver/binding-wasm32-wasi": "11.21.2",
|
||||
"@oxc-resolver/binding-win32-arm64-msvc": "11.21.2",
|
||||
"@oxc-resolver/binding-win32-x64-msvc": "11.21.2"
|
||||
}
|
||||
},
|
||||
"node_modules/oxfmt": {
|
||||
@@ -9381,9 +9385,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.85.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.85.0.tgz",
|
||||
"integrity": "sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==",
|
||||
"version": "7.86.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz",
|
||||
"integrity": "sha512-4kbWJrh5jPZt1+YqVcXcGKffGcXV/XVbozknLh0Yjh0KhpoAkus21TAQhzRYqNwFkkObmnSvRlZZ3GT+ehoIrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -9397,12 +9401,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||
"version": "17.0.12",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
|
||||
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
@@ -9999,9 +10003,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.5.9",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.9.tgz",
|
||||
"integrity": "sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==",
|
||||
"version": "10.5.10",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.10.tgz",
|
||||
"integrity": "sha512-Rz8k9ejFHsi7lbtJTaxZlhCUz4GkbJIKEoKDjXeLfr/ZhXip73E6keKxW0KH8iGeKiCqHAbJCV4YIQrxTOLiig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10017,7 +10021,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"open": "^10.2.0",
|
||||
"oxc-parser": "^0.127.0",
|
||||
"oxc-resolver": "^11.19.1",
|
||||
"oxc-resolver": "11.21.2",
|
||||
"recast": "^0.23.5",
|
||||
"semver": "^7.7.3",
|
||||
"use-sync-external-store": "^1.5.0",
|
||||
@@ -10701,16 +10705,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
||||
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.25",
|
||||
"rolldown": "~1.2.1",
|
||||
"postcss": "^8.5.26",
|
||||
"rolldown": "~1.2.4",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -10727,7 +10731,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
+14
-14
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||
"engines": {
|
||||
@@ -39,34 +39,34 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"@tanstack/react-query": "^5.102.2",
|
||||
"@tanstack/react-query-devtools": "^5.102.2",
|
||||
"antd": "^6.6.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next": "^26.4.0",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-router": "^8.3.0",
|
||||
"swagger-ui-react": "^5.32.14",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^10.5.9",
|
||||
"@storybook/addon-docs": "^10.5.9",
|
||||
"@storybook/addon-vitest": "^10.5.9",
|
||||
"@storybook/react-vite": "^10.5.9",
|
||||
"@storybook/addon-a11y": "^10.5.10",
|
||||
"@storybook/addon-docs": "^10.5.10",
|
||||
"@storybook/addon-vitest": "^10.5.10",
|
||||
"@storybook/react-vite": "^10.5.10",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@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/coverage-v8": "^4.1.11",
|
||||
"husky": "^9.1.7",
|
||||
@@ -77,9 +77,9 @@
|
||||
"oxlint": "1.79.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"playwright": "^1.62.1",
|
||||
"storybook": "^10.5.9",
|
||||
"storybook": "^10.5.10",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.1",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"subJsonMux": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonObservatory": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -438,6 +441,7 @@
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonObservatory",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
@@ -716,6 +720,9 @@
|
||||
"subJsonMux": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonObservatory": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -920,6 +927,7 @@
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonObservatory",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
@@ -962,6 +970,36 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
@@ -1056,6 +1094,16 @@
|
||||
},
|
||||
"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": {
|
||||
"description": "Auth password (Hysteria)",
|
||||
"type": "string"
|
||||
@@ -1086,6 +1134,10 @@
|
||||
"description": "Flow control (XTLS)",
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
"description": "Logical grouping label",
|
||||
"type": "string"
|
||||
@@ -1250,6 +1302,9 @@
|
||||
"flow": {
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1328,6 +1383,7 @@
|
||||
"enable",
|
||||
"expiryTime",
|
||||
"flow",
|
||||
"forwardedPorts",
|
||||
"group",
|
||||
"id",
|
||||
"keepAlive",
|
||||
@@ -2071,7 +2127,8 @@
|
||||
"mixed",
|
||||
"tunnel",
|
||||
"tun",
|
||||
"mtproto"
|
||||
"mtproto",
|
||||
"amneziawg"
|
||||
],
|
||||
"example": "vless",
|
||||
"type": "string"
|
||||
@@ -2223,6 +2280,15 @@
|
||||
},
|
||||
"InboundOption": {
|
||||
"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": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
@@ -2905,6 +2971,68 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"cpuPct": {
|
||||
@@ -3071,6 +3199,150 @@
|
||||
],
|
||||
"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": {
|
||||
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
||||
"properties": {
|
||||
@@ -3091,6 +3363,71 @@
|
||||
],
|
||||
"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": {
|
||||
"description": "User represents a user account in the 3x-ui panel.",
|
||||
"properties": {
|
||||
@@ -3160,7 +3497,11 @@
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@@ -3531,6 +3872,7 @@
|
||||
"success": true,
|
||||
"obj": [
|
||||
{
|
||||
"awgServer": null,
|
||||
"enable": true,
|
||||
"id": 1,
|
||||
"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": {
|
||||
"post": {
|
||||
"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": {
|
||||
"post": {
|
||||
"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}": {
|
||||
"get": {
|
||||
"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,
|
||||
tags: () => ['hosts', 'tags'] as const,
|
||||
},
|
||||
subBalancers: {
|
||||
root: () => ['sub-balancers'] as const,
|
||||
list: () => ['sub-balancers', 'list'] as const,
|
||||
},
|
||||
settings: {
|
||||
root: () => ['settings'] as const,
|
||||
all: () => ['settings', 'all'] as const,
|
||||
|
||||
@@ -66,6 +66,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subJsonEnable": false,
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonObservatory": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
@@ -179,6 +180,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subJsonEnable": false,
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonObservatory": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
@@ -219,6 +221,26 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"webListen": "",
|
||||
"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": {
|
||||
"createdAt": 0,
|
||||
"enabled": false,
|
||||
@@ -242,6 +264,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"allowedIPs": [
|
||||
""
|
||||
],
|
||||
"allowedIPsByInbound": {},
|
||||
"auth": "",
|
||||
"comment": "",
|
||||
"created_at": 0,
|
||||
@@ -249,6 +272,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"enable": false,
|
||||
"expiryTime": 0,
|
||||
"flow": "",
|
||||
"forwardedPorts": "",
|
||||
"group": "",
|
||||
"id": "",
|
||||
"keepAlive": 0,
|
||||
@@ -286,6 +310,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"enable": false,
|
||||
"expiryTime": 0,
|
||||
"flow": "",
|
||||
"forwardedPorts": "",
|
||||
"group": "",
|
||||
"id": 0,
|
||||
"keepAlive": 0,
|
||||
@@ -542,6 +567,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"xver": 0
|
||||
},
|
||||
"InboundOption": {
|
||||
"awgServer": null,
|
||||
"enable": true,
|
||||
"id": 1,
|
||||
"listen": "",
|
||||
@@ -687,6 +713,18 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"runId": "1735689600123456789",
|
||||
"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": {
|
||||
"cpuPct": 12.5,
|
||||
"error": "",
|
||||
@@ -723,11 +761,63 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"tlsVersion": "1.3",
|
||||
"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": {
|
||||
"id": 0,
|
||||
"key": "",
|
||||
"value": ""
|
||||
},
|
||||
"SubBalancer": {
|
||||
"createdAt": 1710000000000,
|
||||
"enabled": true,
|
||||
"id": 1,
|
||||
"inboundIds": [
|
||||
1,
|
||||
3
|
||||
],
|
||||
"remark": "auto-fastest",
|
||||
"sortOrder": 1,
|
||||
"strategy": "random",
|
||||
"updatedAt": 1710000000000
|
||||
},
|
||||
"User": {
|
||||
"id": 0,
|
||||
"password": "",
|
||||
|
||||
@@ -215,6 +215,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonMux": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonObservatory": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -412,6 +415,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonObservatory",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
@@ -690,6 +694,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonMux": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonObservatory": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -894,6 +901,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonObservatory",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
@@ -936,6 +944,36 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
@@ -1030,6 +1068,16 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"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": {
|
||||
"description": "Auth password (Hysteria)",
|
||||
"type": "string"
|
||||
@@ -1060,6 +1108,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Flow control (XTLS)",
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"",
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
"description": "Logical grouping label",
|
||||
"type": "string"
|
||||
@@ -1224,6 +1276,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"flow": {
|
||||
"type": "string"
|
||||
},
|
||||
"forwardedPorts": {
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1302,6 +1357,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"enable",
|
||||
"expiryTime",
|
||||
"flow",
|
||||
"forwardedPorts",
|
||||
"group",
|
||||
"id",
|
||||
"keepAlive",
|
||||
@@ -2045,7 +2101,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"mixed",
|
||||
"tunnel",
|
||||
"tun",
|
||||
"mtproto"
|
||||
"mtproto",
|
||||
"amneziawg"
|
||||
],
|
||||
"example": "vless",
|
||||
"type": "string"
|
||||
@@ -2197,6 +2254,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"InboundOption": {
|
||||
"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": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
@@ -2879,6 +2945,68 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"cpuPct": {
|
||||
@@ -3045,6 +3173,150 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"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": {
|
||||
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
||||
"properties": {
|
||||
@@ -3065,6 +3337,71 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"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": {
|
||||
"description": "User represents a user account in the 3x-ui panel.",
|
||||
"properties": {
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface AllSetting {
|
||||
subJsonEnable: boolean;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonObservatory: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
@@ -188,6 +189,7 @@ export interface AllSettingView {
|
||||
subJsonEnable: boolean;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonObservatory: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
@@ -229,6 +231,12 @@ export interface AllSettingView {
|
||||
webPort: number;
|
||||
}
|
||||
|
||||
export interface AmneziaWGLogs {
|
||||
events: string[];
|
||||
peers: PeerActivity[];
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
@@ -252,6 +260,7 @@ export interface ApiTokenView {
|
||||
export interface Client {
|
||||
adTag?: string;
|
||||
allowedIPs?: string[];
|
||||
allowedIPsByInbound?: Record<number, string[]>;
|
||||
auth?: string;
|
||||
comment: string;
|
||||
created_at?: number;
|
||||
@@ -259,6 +268,7 @@ export interface Client {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
flow?: string;
|
||||
forwardedPorts?: string;
|
||||
group?: string;
|
||||
id?: string;
|
||||
keepAlive?: number;
|
||||
@@ -298,6 +308,7 @@ export interface ClientRecord {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
flow: string;
|
||||
forwardedPorts: string;
|
||||
group: string;
|
||||
id: number;
|
||||
keepAlive: number;
|
||||
@@ -510,6 +521,7 @@ export interface InboundFallback {
|
||||
}
|
||||
|
||||
export interface InboundOption {
|
||||
awgServer?: ServerSettings | null;
|
||||
enable: boolean;
|
||||
id: number;
|
||||
listen?: string;
|
||||
@@ -656,6 +668,19 @@ export interface PanelUpdateStatus {
|
||||
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 {
|
||||
cpuPct: number;
|
||||
error: string;
|
||||
@@ -692,12 +717,63 @@ export interface RealityScanResult {
|
||||
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 {
|
||||
id: number;
|
||||
key: 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 {
|
||||
id: number;
|
||||
password: string;
|
||||
|
||||
@@ -90,6 +90,7 @@ export const AllSettingSchema = z.object({
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonObservatory: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
@@ -205,6 +206,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonObservatory: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
@@ -247,6 +249,13 @@ export const AllSettingViewSchema = z.object({
|
||||
});
|
||||
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({
|
||||
createdAt: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
@@ -272,6 +281,7 @@ export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
|
||||
export const ClientSchema = z.object({
|
||||
adTag: z.string().optional(),
|
||||
allowedIPs: z.array(z.string()).optional(),
|
||||
allowedIPsByInbound: z.record(z.number().int(), z.array(z.string())).optional(),
|
||||
auth: z.string().optional(),
|
||||
comment: z.string(),
|
||||
created_at: z.number().int().optional(),
|
||||
@@ -279,6 +289,7 @@ export const ClientSchema = z.object({
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
flow: z.string().optional(),
|
||||
forwardedPorts: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
keepAlive: z.number().int().optional(),
|
||||
@@ -320,6 +331,7 @@ export const ClientRecordSchema = z.object({
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
flow: z.string(),
|
||||
forwardedPorts: z.string(),
|
||||
group: z.string(),
|
||||
id: z.number().int(),
|
||||
keepAlive: z.number().int(),
|
||||
@@ -511,7 +523,7 @@ export const InboundSchema = z.object({
|
||||
nodeId: z.number().int().nullable().optional(),
|
||||
originNodeGuid: z.string().optional(),
|
||||
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(),
|
||||
settings: z.unknown(),
|
||||
shareAddr: z.string(),
|
||||
@@ -548,6 +560,7 @@ export const InboundFallbackSchema = z.object({
|
||||
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
||||
|
||||
export const InboundOptionSchema = z.object({
|
||||
awgServer: z.lazy(() => ServerSettingsSchema).nullable().optional(),
|
||||
enable: z.boolean(),
|
||||
id: z.number().int(),
|
||||
listen: z.string().optional(),
|
||||
@@ -701,6 +714,20 @@ export const PanelUpdateStatusSchema = z.object({
|
||||
});
|
||||
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({
|
||||
cpuPct: z.number(),
|
||||
error: z.string(),
|
||||
@@ -739,6 +766,47 @@ export const RealityScanResultSchema = z.object({
|
||||
});
|
||||
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({
|
||||
id: z.number().int(),
|
||||
key: z.string(),
|
||||
@@ -746,6 +814,18 @@ export const SettingSchema = z.object({
|
||||
});
|
||||
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({
|
||||
id: z.number().int(),
|
||||
password: z.string(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Drawer, Layout, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ApartmentOutlined,
|
||||
CloseOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
@@ -177,6 +178,7 @@ export default function AppSidebar() {
|
||||
const { pathname, hash } = useLocation();
|
||||
const { allSetting } = useAllSettings();
|
||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
||||
const showSubBalancers = !!allSetting.subJsonEnable;
|
||||
|
||||
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||
const [pinned, setPinned] = useState(readSidebarPinned);
|
||||
@@ -262,8 +264,15 @@ export default function AppSidebar() {
|
||||
label: t('menu.subFormats'),
|
||||
});
|
||||
}
|
||||
if (showSubBalancers) {
|
||||
children.push({
|
||||
key: '/settings#subscription-balancers',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: t('pages.settings.subBalancers.menu'),
|
||||
});
|
||||
}
|
||||
return children;
|
||||
}, [t, showSubFormats]);
|
||||
}, [t, showSubFormats, showSubBalancers]);
|
||||
|
||||
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 { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
|
||||
|
||||
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
||||
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
|
||||
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
|
||||
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
|
||||
@@ -263,12 +265,20 @@ export interface WireguardInboundSeed {
|
||||
mtu?: number;
|
||||
secretKey?: string;
|
||||
noKernelTun?: boolean;
|
||||
subnetIp?: string;
|
||||
subnetCidr?: number;
|
||||
}
|
||||
|
||||
// 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
|
||||
// through the client modal, which generates each one's keypair and a unique
|
||||
// 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(
|
||||
seed: WireguardInboundSeed = {},
|
||||
): WireguardInboundSettings {
|
||||
@@ -278,6 +288,36 @@ export function createDefaultWireguardInboundSettings(
|
||||
peers: [],
|
||||
clients: [],
|
||||
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
|
||||
| TunnelInboundSettings
|
||||
| WireguardInboundSettings
|
||||
| MtprotoInboundSettings;
|
||||
| MtprotoInboundSettings
|
||||
| AmneziawgInboundSettings;
|
||||
|
||||
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
|
||||
switch (protocol) {
|
||||
@@ -323,6 +364,8 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
|
||||
return createDefaultWireguardInboundSettings();
|
||||
case 'mtproto':
|
||||
return createDefaultMtprotoInboundSettings();
|
||||
case 'amneziawg':
|
||||
return createDefaultAmneziawgInboundSettings();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@/schemas/forms/inbound-form';
|
||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||
import {
|
||||
AmneziawgClientSchema,
|
||||
HysteriaClientSchema,
|
||||
MtprotoClientSchema,
|
||||
ShadowsocksClientSchema,
|
||||
@@ -268,6 +269,8 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
||||
return WireguardClientSchema;
|
||||
case 'mtproto':
|
||||
return MtprotoClientSchema;
|
||||
case 'amneziawg':
|
||||
return AmneziawgClientSchema;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Base64, Wireguard } from '@/utils';
|
||||
|
||||
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 { VmessSecurity } from '@/schemas/protocols/shared/vmess';
|
||||
import type {
|
||||
@@ -911,6 +912,168 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
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 {
|
||||
let url: URL;
|
||||
try {
|
||||
@@ -971,6 +1134,34 @@ export function wireguardConfigFromLink(link: string, fallbackRemark = ''): stri
|
||||
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 };
|
||||
|
||||
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
|
||||
// pastes into a client. Iterates per-client for protocols with clients,
|
||||
// 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).
|
||||
export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||
@@ -1308,6 +1499,9 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
if (inbound.protocol === 'wireguard') {
|
||||
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
||||
}
|
||||
if (inbound.protocol === 'amneziawg') {
|
||||
return genAmneziaWGConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function inboundTransports(
|
||||
streamSettings: Record<string, unknown> | undefined,
|
||||
settings: Record<string, unknown> | undefined,
|
||||
): TransportBits {
|
||||
if (protocol === 'hysteria' || protocol === 'wireguard') return UDP;
|
||||
if (protocol === 'hysteria' || protocol === 'wireguard' || protocol === 'amneziawg') return UDP;
|
||||
|
||||
let bits: TransportBits = 0;
|
||||
const network = asString(streamSettings?.network);
|
||||
|
||||
@@ -26,6 +26,7 @@ const PROTOCOL_LABELS: Record<string, string> = {
|
||||
wireguard: 'WireGuard',
|
||||
wg: 'WireGuard',
|
||||
tg: 'MTProto',
|
||||
vpn: 'AmneziaWG',
|
||||
};
|
||||
|
||||
const PROTOCOL_COLORS: Record<string, string> = {
|
||||
@@ -37,6 +38,7 @@ const PROTOCOL_COLORS: Record<string, string> = {
|
||||
Hysteria2: 'magenta',
|
||||
WireGuard: 'cyan',
|
||||
MTProto: 'blue',
|
||||
AmneziaWG: 'yellow',
|
||||
};
|
||||
|
||||
const SECURITY_COLORS: Record<string, string> = {
|
||||
@@ -50,6 +52,18 @@ const TRANSPORT_COLOR = 'gold';
|
||||
|
||||
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
|
||||
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
|
||||
@@ -83,6 +97,20 @@ export function parseLinkParts(link: string): LinkParts | null {
|
||||
} catch {
|
||||
/* 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 {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
|
||||
@@ -75,10 +75,11 @@ export function canEnableStream(values: { protocol: string }): boolean {
|
||||
return STREAM_PROTOCOLS.includes(values.protocol);
|
||||
}
|
||||
|
||||
// mtproto is served by an external mtg process, not Xray, so the Xray sniffing
|
||||
// block does not apply to it. Every other inbound supports sniffing.
|
||||
// mtproto and amneziawg are served by an external process/interface, not
|
||||
// Xray, so the Xray sniffing block does not apply to either. Every other
|
||||
// inbound supports sniffing.
|
||||
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
|
||||
|
||||
@@ -169,6 +169,10 @@ export class DBInbound {
|
||||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isAmneziawg() {
|
||||
return this.protocol === Protocols.AMNEZIAWG;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export class AllSetting {
|
||||
subJsonMux = '';
|
||||
subJsonRules = '';
|
||||
subJsonFinalMask = '';
|
||||
subJsonObservatory = '';
|
||||
subThemeDir = '';
|
||||
subHideSettings = false;
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ export interface XrayInfo {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface AmneziaWGInfo {
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
interface StatusInput {
|
||||
cpu?: number;
|
||||
cpuCores?: number;
|
||||
@@ -79,6 +84,7 @@ interface StatusInput {
|
||||
appUptime?: number;
|
||||
appStats?: AppStats;
|
||||
xray?: Partial<XrayInfo>;
|
||||
amneziawg?: Partial<AmneziaWGInfo>;
|
||||
}
|
||||
|
||||
export class Status {
|
||||
@@ -99,6 +105,7 @@ export class Status {
|
||||
appUptime = 0;
|
||||
appStats: AppStats = { threads: 0, mem: 0, uptime: 0 };
|
||||
xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' };
|
||||
amneziawg: AmneziaWGInfo = { configured: false, running: false };
|
||||
|
||||
constructor(data?: StatusInput | null) {
|
||||
if (data == null) return;
|
||||
@@ -121,5 +128,6 @@ export class Status {
|
||||
this.appStats = data.appStats ?? this.appStats;
|
||||
this.xray = { ...this.xray, ...(data.xray || {}) };
|
||||
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:
|
||||
'{\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',
|
||||
path: '/panel/api/server/importDB',
|
||||
@@ -1699,7 +1721,7 @@ export const sections: readonly Section[] = [
|
||||
id: 'xray-settings',
|
||||
title: 'Xray Settings',
|
||||
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: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1799,6 +1821,43 @@ export const sections: readonly Section[] = [
|
||||
{ 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',
|
||||
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',
|
||||
title: 'Subscription Server',
|
||||
|
||||
@@ -15,6 +15,7 @@ const MULTI_USER_PROTOCOLS = new Set([
|
||||
'shadowsocks',
|
||||
'wireguard',
|
||||
'mtproto',
|
||||
'amneziawg',
|
||||
]);
|
||||
|
||||
interface BulkAttachInboundsModalProps {
|
||||
|
||||
@@ -15,6 +15,7 @@ const MULTI_USER_PROTOCOLS = new Set([
|
||||
'shadowsocks',
|
||||
'wireguard',
|
||||
'mtproto',
|
||||
'amneziawg',
|
||||
]);
|
||||
|
||||
interface BulkDetachInboundsModalProps {
|
||||
|
||||
@@ -36,6 +36,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'trojan',
|
||||
'hysteria',
|
||||
'wireguard',
|
||||
'amneziawg',
|
||||
]);
|
||||
|
||||
const EMPTY: ClientBulkAddFormValues = {
|
||||
|
||||
@@ -60,6 +60,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'hysteria',
|
||||
'wireguard',
|
||||
'mtproto',
|
||||
'amneziawg',
|
||||
]);
|
||||
|
||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||
@@ -110,6 +111,7 @@ interface ClientFormModalProps {
|
||||
inbounds: InboundOption[];
|
||||
attachedExternalLinks?: ExternalLink[];
|
||||
attachedIds?: number[];
|
||||
tunnelAllowedIPs?: Record<number, string>;
|
||||
tgBotEnable?: boolean;
|
||||
groups?: string[];
|
||||
save: (
|
||||
@@ -128,6 +130,8 @@ type Values = ClientFormValues & {
|
||||
wgPublicKey: string;
|
||||
wgPreSharedKey: string;
|
||||
wgAllowedIPs: string;
|
||||
awgAllowedIPs: string;
|
||||
awgForwardedPorts: string;
|
||||
secret: string;
|
||||
adTag: string;
|
||||
};
|
||||
@@ -162,6 +166,8 @@ const EMPTY: Values = {
|
||||
wgPublicKey: '',
|
||||
wgPreSharedKey: '',
|
||||
wgAllowedIPs: '',
|
||||
awgAllowedIPs: '',
|
||||
awgForwardedPorts: '',
|
||||
secret: '',
|
||||
adTag: '',
|
||||
};
|
||||
@@ -189,6 +195,34 @@ export function gbToBytes(gb: number): number {
|
||||
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(
|
||||
originalBytes: number | null | undefined,
|
||||
displayedGB: number,
|
||||
@@ -206,6 +240,7 @@ export default function ClientFormModal({
|
||||
inbounds,
|
||||
attachedExternalLinks = [],
|
||||
attachedIds = [],
|
||||
tunnelAllowedIPs = {},
|
||||
tgBotEnable = false,
|
||||
groups = [],
|
||||
save,
|
||||
@@ -262,6 +297,27 @@ export default function ClientFormModal({
|
||||
const limitIpDisabled = !fail2ban.usable;
|
||||
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') {
|
||||
appendExternalLink({
|
||||
kind,
|
||||
@@ -282,6 +338,13 @@ export default function ClientFormModal({
|
||||
|
||||
if (isEdit && client) {
|
||||
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 = {
|
||||
...EMPTY,
|
||||
email: client.email || '',
|
||||
@@ -312,7 +375,9 @@ export default function ClientFormModal({
|
||||
wgPrivateKey: client.privateKey || '',
|
||||
wgPublicKey: client.publicKey || '',
|
||||
wgPreSharedKey: client.preSharedKey || '',
|
||||
wgAllowedIPs: client.allowedIPs || '',
|
||||
wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
|
||||
awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
|
||||
awgForwardedPorts: client.forwardedPorts || '',
|
||||
secret: client.secret || '',
|
||||
adTag: client.adTag || '',
|
||||
};
|
||||
@@ -369,14 +434,6 @@ export default function ClientFormModal({
|
||||
return ids;
|
||||
}, [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 ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
@@ -431,6 +488,11 @@ export default function ClientFormModal({
|
||||
[inboundIds, wireguardIds],
|
||||
);
|
||||
|
||||
const showAmneziawg = useMemo(
|
||||
() => (inboundIds || []).some((id) => amneziawgIds.has(id)),
|
||||
[inboundIds, amneziawgIds],
|
||||
);
|
||||
|
||||
const showMtproto = useMemo(
|
||||
() => (inboundIds || []).some((id) => mtprotoIds.has(id)),
|
||||
[inboundIds, mtprotoIds],
|
||||
@@ -625,18 +687,40 @@ export default function ClientFormModal({
|
||||
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.publicKey = values.wgPublicKey;
|
||||
if (values.wgPreSharedKey) {
|
||||
clientPayload.preSharedKey = values.wgPreSharedKey;
|
||||
}
|
||||
const allowedIPs = values.wgAllowedIPs
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
if (allowedIPs.length > 0) {
|
||||
clientPayload.allowedIPs = allowedIPs;
|
||||
const wgAllowedIPs = parseAllowedIPsList(values.wgAllowedIPs);
|
||||
if (showWireguard && showAmneziawg) {
|
||||
// Both protocols are attached at once: the two fields hold genuinely
|
||||
// different addresses, so each must land on its own inbound instead
|
||||
// of one broadcast value overwriting the other's (allowedIPsByInbound
|
||||
// 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>
|
||||
)}
|
||||
{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' }}>
|
||||
<Input
|
||||
value={wgPrivateKey}
|
||||
@@ -1129,23 +1219,67 @@ export default function ClientFormModal({
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="wgPublicKey"
|
||||
label={t('pages.clients.wireguardPublicKey')}
|
||||
label={t(
|
||||
showAmneziawg
|
||||
? 'pages.clients.amneziaWgPublicKey'
|
||||
: 'pages.clients.wireguardPublicKey',
|
||||
)}
|
||||
>
|
||||
<Input disabled />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="wgPreSharedKey"
|
||||
label={t('pages.clients.wireguardPreSharedKey')}
|
||||
label={t(
|
||||
showAmneziawg
|
||||
? 'pages.clients.amneziaWgPreSharedKey'
|
||||
: 'pages.clients.wireguardPreSharedKey',
|
||||
)}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="wgAllowedIPs"
|
||||
label={t('pages.clients.wireguardAllowedIPs')}
|
||||
extra={t('pages.clients.wireguardAllowedIPsHint')}
|
||||
>
|
||||
<Input placeholder="10.0.0.2/32" />
|
||||
</FormField>
|
||||
{showWireguard && showAmneziawg ? (
|
||||
<>
|
||||
<FormField
|
||||
name="wgAllowedIPs"
|
||||
label={t('pages.clients.wireguardAllowedIPs')}
|
||||
extra={t('pages.clients.wireguardAllowedIPsHint')}
|
||||
>
|
||||
<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 && (
|
||||
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
findWireguardInbound,
|
||||
isWireguardClient,
|
||||
} from './wireguardConfig';
|
||||
import {
|
||||
buildAmneziaWGClientConfig,
|
||||
findAmneziaWGInbound,
|
||||
isAmneziaWGClient,
|
||||
} from './amneziawgConfig';
|
||||
import './ClientInfoModal.css';
|
||||
|
||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
@@ -35,6 +40,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
hysteria: 'cyan',
|
||||
hysteria2: 'green',
|
||||
wireguard: 'gold',
|
||||
amneziawg: 'yellow',
|
||||
http: 'purple',
|
||||
mixed: 'lime',
|
||||
tunnel: 'orange',
|
||||
@@ -56,6 +62,7 @@ interface ClientInfoModalProps {
|
||||
open: boolean;
|
||||
client: ClientRecord | null;
|
||||
inboundsById: Record<number, InboundOption>;
|
||||
tunnelAllowedIPs?: Record<number, string>;
|
||||
isOnline: boolean;
|
||||
subSettings?: SubSettings;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -86,6 +93,7 @@ export default function ClientInfoModal({
|
||||
open,
|
||||
client,
|
||||
inboundsById,
|
||||
tunnelAllowedIPs,
|
||||
isOnline,
|
||||
subSettings = DEFAULT_SUB,
|
||||
onOpenChange,
|
||||
@@ -186,6 +194,22 @@ export default function ClientInfoModal({
|
||||
);
|
||||
}, [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) {
|
||||
if (!text) return;
|
||||
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>
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
findWireguardInbound,
|
||||
isWireguardClient,
|
||||
} from './wireguardConfig';
|
||||
import {
|
||||
buildAmneziaWGClientConfig,
|
||||
findAmneziaWGInbound,
|
||||
isAmneziaWGClient,
|
||||
} from './amneziawgConfig';
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
@@ -24,6 +29,7 @@ interface ClientQrModalProps {
|
||||
open: boolean;
|
||||
client: ClientRecord | null;
|
||||
inboundsById: Record<number, InboundOption>;
|
||||
tunnelAllowedIPs?: Record<number, string>;
|
||||
subSettings?: SubSettings;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
@@ -45,6 +51,7 @@ export default function ClientQrModal({
|
||||
open,
|
||||
client,
|
||||
inboundsById,
|
||||
tunnelAllowedIPs,
|
||||
subSettings = DEFAULT_SUB,
|
||||
onOpenChange,
|
||||
}: ClientQrModalProps) {
|
||||
@@ -74,7 +81,24 @@ export default function ClientQrModal({
|
||||
);
|
||||
}, [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.
|
||||
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;
|
||||
}, [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.
|
||||
const firstKey = open && items.length > 0 ? items[0].key : null;
|
||||
|
||||
@@ -171,6 +171,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
hysteria: 'cyan',
|
||||
hysteria2: 'green',
|
||||
wireguard: 'gold',
|
||||
amneziawg: 'yellow',
|
||||
http: 'purple',
|
||||
mixed: 'lime',
|
||||
tunnel: 'orange',
|
||||
@@ -349,10 +350,16 @@ export default function ClientsPage() {
|
||||
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
||||
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
||||
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
|
||||
const [editingTunnelAllowedIPs, setEditingTunnelAllowedIPs] = useState<Record<number, string>>(
|
||||
{},
|
||||
);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
|
||||
const [viewingTunnelAllowedIPs, setViewingTunnelAllowedIPs] = useState<Record<number, string>>(
|
||||
{},
|
||||
);
|
||||
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
||||
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
||||
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
||||
@@ -619,6 +626,7 @@ export default function ClientsPage() {
|
||||
setEditingClient(null);
|
||||
setEditingAttachedIds([]);
|
||||
setEditingExternalLinks([]);
|
||||
setEditingTunnelAllowedIPs({});
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
@@ -635,6 +643,7 @@ export default function ClientsPage() {
|
||||
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
|
||||
setEditingAttachedIds([...ids]);
|
||||
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
||||
setEditingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
|
||||
setFormOpen(true);
|
||||
},
|
||||
[hydrate],
|
||||
@@ -686,6 +695,7 @@ export default function ClientsPage() {
|
||||
if (!row) return;
|
||||
const full = await hydrate(row.email);
|
||||
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||
setViewingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
|
||||
setInfoOpen(true);
|
||||
},
|
||||
[hydrate],
|
||||
@@ -697,6 +707,7 @@ export default function ClientsPage() {
|
||||
if (!row) return;
|
||||
const full = await hydrate(row.email);
|
||||
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||
setViewingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
|
||||
setQrOpen(true);
|
||||
},
|
||||
[hydrate],
|
||||
@@ -1838,6 +1849,7 @@ export default function ClientsPage() {
|
||||
client={editingClient}
|
||||
attachedIds={editingAttachedIds}
|
||||
attachedExternalLinks={editingExternalLinks}
|
||||
tunnelAllowedIPs={editingTunnelAllowedIPs}
|
||||
inbounds={inbounds}
|
||||
tgBotEnable={tgBotEnable}
|
||||
groups={allGroups}
|
||||
@@ -1851,6 +1863,7 @@ export default function ClientsPage() {
|
||||
open={infoOpen}
|
||||
client={infoClient}
|
||||
inboundsById={inboundsById}
|
||||
tunnelAllowedIPs={viewingTunnelAllowedIPs}
|
||||
isOnline={infoClient ? isOnline(infoClient.email) : false}
|
||||
subSettings={subSettings}
|
||||
onOpenChange={setInfoOpen}
|
||||
@@ -1861,6 +1874,7 @@ export default function ClientsPage() {
|
||||
open={qrOpen}
|
||||
client={qrClient}
|
||||
inboundsById={inboundsById}
|
||||
tunnelAllowedIPs={viewingTunnelAllowedIPs}
|
||||
subSettings={subSettings}
|
||||
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',
|
||||
hysteria2: 'green',
|
||||
wireguard: 'gold',
|
||||
amneziawg: 'yellow',
|
||||
http: 'purple',
|
||||
mixed: 'lime',
|
||||
tunnel: 'orange',
|
||||
|
||||
@@ -25,8 +25,14 @@ import {
|
||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||
import { buildClonePayload } from '@/lib/xray/inbound-clone';
|
||||
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 { Protocols } from '@/schemas/primitives';
|
||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -335,7 +341,16 @@ export default function InboundsPage() {
|
||||
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({
|
||||
title: t('pages.inbounds.exportLinksTitle'),
|
||||
content,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
|
||||
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 {
|
||||
canEnableReality,
|
||||
@@ -56,6 +57,7 @@ import './InboundFormModal.css';
|
||||
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
|
||||
import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
|
||||
import {
|
||||
AmneziawgFields,
|
||||
HttpFields,
|
||||
HysteriaFields,
|
||||
MixedFields,
|
||||
@@ -347,6 +349,41 @@ export default function InboundFormModal({
|
||||
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 = (
|
||||
block: { id?: string; label?: string } | undefined | null,
|
||||
authId: string,
|
||||
@@ -740,6 +777,14 @@ export default function InboundFormModal({
|
||||
<WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />
|
||||
)}
|
||||
|
||||
{protocol === Protocols.AMNEZIAWG && (
|
||||
<AmneziawgFields
|
||||
awgPubKey={awgPubKey}
|
||||
regenInboundAwg={regenInboundAwg}
|
||||
regenInboundAwgObfuscation={regenInboundAwgObfuscation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{protocol === Protocols.TUN && <TunFields />}
|
||||
|
||||
{protocol === Protocols.TUNNEL && <TunnelFields />}
|
||||
@@ -1077,6 +1122,7 @@ export default function InboundFormModal({
|
||||
Protocols.TUN,
|
||||
Protocols.WIREGUARD,
|
||||
Protocols.MTPROTO,
|
||||
Protocols.AMNEZIAWG,
|
||||
] as string[]
|
||||
).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 MtprotoFields } from './mtproto';
|
||||
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')}>
|
||||
<Input value={wgPubKey} disabled />
|
||||
</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">
|
||||
<InputNumber />
|
||||
</FormField>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { InfinityIcon } from '@/components/ui';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import {
|
||||
genAllLinks,
|
||||
genAmneziaWGConfigs,
|
||||
genAmneziaWGLinks,
|
||||
genWireguardConfigs,
|
||||
genWireguardLinks,
|
||||
preferPublicHost,
|
||||
@@ -49,6 +51,8 @@ export default function InboundInfoModal({
|
||||
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
||||
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
|
||||
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
||||
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
||||
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
||||
const [subLink, setSubLink] = useState('');
|
||||
const [subJsonLink, setSubJsonLink] = useState('');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -153,6 +157,28 @@ export default function InboundInfoModal({
|
||||
fallbackHostname,
|
||||
}).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([]);
|
||||
} else {
|
||||
setLinks(
|
||||
@@ -166,6 +192,8 @@ export default function InboundInfoModal({
|
||||
);
|
||||
setWireguardConfigs([]);
|
||||
setWireguardLinks([]);
|
||||
setAmneziawgConfigs([]);
|
||||
setAmneziawgLinks([]);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
|
||||
@@ -89,6 +89,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
|
||||
case 'hysteria':
|
||||
case 'mtproto':
|
||||
case 'wireguard':
|
||||
case 'amneziawg':
|
||||
return true;
|
||||
case 'shadowsocks':
|
||||
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ProtocolFlags = {
|
||||
isMixed?: boolean;
|
||||
isHTTP?: boolean;
|
||||
isWireguard?: boolean;
|
||||
isAmneziawg?: boolean;
|
||||
isTunnel?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ export function useInboundColumns({
|
||||
{record.protocol}
|
||||
</Tag>,
|
||||
];
|
||||
if (record.isWireguard || record.isHysteria) {
|
||||
if (record.isWireguard || record.isAmneziawg || record.isHysteria) {
|
||||
tags.push(
|
||||
<Tag key="n" color="green">
|
||||
UDP
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { CollapseProps } from 'antd';
|
||||
import { Protocols } from '@/schemas/primitives';
|
||||
import {
|
||||
genAllLinks,
|
||||
genAmneziaWGConfigs,
|
||||
genAmneziaWGLinks,
|
||||
genWireguardConfigs,
|
||||
genWireguardLinks,
|
||||
isPostQuantumLink,
|
||||
@@ -50,6 +52,8 @@ export default function QrCodeModal({
|
||||
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
|
||||
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
|
||||
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
|
||||
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
||||
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
||||
const [subLink, setSubLink] = useState('');
|
||||
const [subJsonLink, setSubJsonLink] = useState('');
|
||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
||||
@@ -97,6 +101,31 @@ export default function QrCodeModal({
|
||||
fallbackHostname,
|
||||
}).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([]);
|
||||
} else {
|
||||
setLinks(
|
||||
@@ -110,6 +139,8 @@ export default function QrCodeModal({
|
||||
);
|
||||
setWireguardConfigs([]);
|
||||
setWireguardLinks([]);
|
||||
setAmneziawgConfigs([]);
|
||||
setAmneziawgLinks([]);
|
||||
}
|
||||
|
||||
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;
|
||||
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
|
||||
}, [
|
||||
subLink,
|
||||
subJsonLink,
|
||||
links,
|
||||
wireguardConfigs,
|
||||
wireguardLinks,
|
||||
amneziawgConfigs,
|
||||
amneziawgLinks,
|
||||
t,
|
||||
]);
|
||||
|
||||
const collapseItems: CollapseProps['items'] = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -66,6 +66,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [
|
||||
Protocols.HYSTERIA,
|
||||
Protocols.WIREGUARD,
|
||||
Protocols.MTPROTO,
|
||||
Protocols.AMNEZIAWG,
|
||||
];
|
||||
|
||||
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 XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
|
||||
const XrayLogModal = lazy(() => import('./XrayLogModal'));
|
||||
const AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
|
||||
const VersionModal = lazy(() => import('./VersionModal'));
|
||||
import './IndexPage.css';
|
||||
|
||||
@@ -67,6 +68,7 @@ export default function IndexPage() {
|
||||
const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
|
||||
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
|
||||
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
|
||||
const [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
|
||||
const [versionOpen, setVersionOpen] = useState(false);
|
||||
const [configTextOpen, setConfigTextOpen] = useState(false);
|
||||
const [configText, setConfigText] = useState('');
|
||||
@@ -202,6 +204,7 @@ export default function IndexPage() {
|
||||
onRestartXray={restartXray}
|
||||
onOpenLogs={() => setLogsOpen(true)}
|
||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
||||
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
|
||||
onOpenConfig={openConfig}
|
||||
onOpenBackup={() => setBackupOpen(true)}
|
||||
onOpenSystemHistory={() => setSysHistoryOpen(true)}
|
||||
@@ -328,6 +331,9 @@ export default function IndexPage() {
|
||||
<LazyMount when={xrayLogsOpen}>
|
||||
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
|
||||
</LazyMount>
|
||||
<LazyMount when={amneziawgLogsOpen}>
|
||||
<AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
|
||||
</LazyMount>
|
||||
<LazyMount when={versionOpen}>
|
||||
<VersionModal
|
||||
open={versionOpen}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ArrowUpOutlined,
|
||||
AreaChartOutlined,
|
||||
BarsOutlined,
|
||||
@@ -28,6 +29,7 @@ interface OverviewActionBarProps {
|
||||
onRestartXray: () => void;
|
||||
onOpenLogs: () => void;
|
||||
onOpenXrayLogs: () => void;
|
||||
onOpenAmneziaWGLogs: () => void;
|
||||
onOpenConfig: () => void;
|
||||
onOpenBackup: () => void;
|
||||
onOpenSystemHistory: () => void;
|
||||
@@ -61,6 +63,7 @@ export default function OverviewActionBar({
|
||||
onRestartXray,
|
||||
onOpenLogs,
|
||||
onOpenXrayLogs,
|
||||
onOpenAmneziaWGLogs,
|
||||
onOpenConfig,
|
||||
onOpenBackup,
|
||||
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',
|
||||
icon: <ControlOutlined />,
|
||||
|
||||
@@ -29,6 +29,7 @@ import TelegramTab from './TelegramTab';
|
||||
import EmailTab from './EmailTab';
|
||||
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
||||
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
||||
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
|
||||
import './SettingsPage.css';
|
||||
|
||||
interface ApiMsg {
|
||||
@@ -42,6 +43,7 @@ const tabSlugs = [
|
||||
'email',
|
||||
'subscription',
|
||||
'subscription-formats',
|
||||
'subscription-balancers',
|
||||
];
|
||||
|
||||
function isIp(h: string): boolean {
|
||||
@@ -219,6 +221,8 @@ export default function SettingsPage() {
|
||||
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription-formats':
|
||||
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription-balancers':
|
||||
return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
default:
|
||||
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';
|
||||
|
||||
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 ConfigBlock from '@/components/clients/ConfigBlock';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
@@ -533,6 +537,7 @@ export default function SubPage() {
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
const isWireguardLink =
|
||||
link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||
const isAmneziawgLink = link.startsWith('vpn://');
|
||||
return (
|
||||
<Fragment key={link}>
|
||||
<div className="sub-link-row">
|
||||
@@ -590,6 +595,15 @@ export default function SubPage() {
|
||||
tagColor="cyan"
|
||||
/>
|
||||
)}
|
||||
{isAmneziawgLink && (
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.amneziaWgConfig')}
|
||||
text={amneziawgConfigFromLink(link)}
|
||||
fileName={`${rowTitle || 'peer'}.conf`}
|
||||
qrRemark={rowTitle}
|
||||
tagColor="purple"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
detectBalancerCycles,
|
||||
} from './balancers/balancer-loopback';
|
||||
import { DnsTab } from './dns';
|
||||
import { WarpModal, NordModal } from './overrides';
|
||||
import { WarpModal, NordModal, PiaModal } from './overrides';
|
||||
import './XrayPage.css';
|
||||
|
||||
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
|
||||
@@ -82,6 +82,7 @@ export default function XrayPage() {
|
||||
|
||||
const [warpOpen, setWarpOpen] = useState(false);
|
||||
const [nordOpen, setNordOpen] = useState(false);
|
||||
const [piaOpen, setPiaOpen] = useState(false);
|
||||
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -264,6 +265,7 @@ export default function XrayPage() {
|
||||
onTestAll={testAllOutbounds}
|
||||
onShowWarp={() => setWarpOpen(true)}
|
||||
onShowNord={() => setNordOpen(true)}
|
||||
onShowPia={() => setPiaOpen(true)}
|
||||
onRefreshXrayData={fetchAll}
|
||||
/>
|
||||
);
|
||||
@@ -394,6 +396,13 @@ export default function XrayPage() {
|
||||
onRemoveOutbound={onRemoveOutboundByIndex}
|
||||
onRemoveRoutingRules={onRemoveRoutingRules}
|
||||
/>
|
||||
<PiaModal
|
||||
open={piaOpen}
|
||||
templateSettings={templateSettings}
|
||||
onClose={() => setPiaOpen(false)}
|
||||
onAddOutbound={onAddOutbound}
|
||||
onResetOutbound={onResetOutbound}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
|
||||
pingConfig: {
|
||||
destination: 'https://www.google.com/generate_204',
|
||||
interval: '1m',
|
||||
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
|
||||
connectivity: '',
|
||||
timeout: '5s',
|
||||
sampling: 2,
|
||||
httpMethod: 'HEAD',
|
||||
|
||||
@@ -95,6 +95,7 @@ interface OutboundsTabProps {
|
||||
onTestAll: (mode: string) => void;
|
||||
onShowWarp: () => void;
|
||||
onShowNord: () => void;
|
||||
onShowPia: () => void;
|
||||
onRefreshXrayData?: () => void;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,7 @@ export default function OutboundsTab({
|
||||
onTestAll,
|
||||
onShowWarp,
|
||||
onShowNord,
|
||||
onShowPia,
|
||||
onRefreshXrayData,
|
||||
}: OutboundsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -550,6 +552,12 @@ export default function OutboundsTab({
|
||||
items: [
|
||||
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
||||
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
||||
{
|
||||
key: 'pia',
|
||||
icon: <ApiOutlined />,
|
||||
label: t('pages.xray.pia.menu'),
|
||||
onClick: onShowPia,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
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 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 { GeoTokenInput } from '@/components/geodata';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useClientOptions } from '@/api/queries/useClientOptions';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
||||
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
|
||||
@@ -82,6 +83,21 @@ export default function RuleFormModal({
|
||||
|
||||
const { data: inboundOptions } = useInboundOptions();
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
@@ -287,8 +303,27 @@ export default function RuleFormModal({
|
||||
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</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
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/s
|
||||
|
||||
// Top-level inbound shape on the wire. Composes:
|
||||
// - 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),
|
||||
// security DU (3 branches), and the orthogonal extras (finalmask,
|
||||
// sockopt, externalProxy). Zod 4 supports DU intersection — each
|
||||
|
||||
@@ -52,6 +52,7 @@ export const ClientRecordSchema = z
|
||||
allowedIPs: z.string().optional(),
|
||||
preSharedKey: z.string().optional(),
|
||||
keepAlive: z.number().optional(),
|
||||
forwardedPorts: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
adTag: z.string().optional(),
|
||||
createdAt: z.number().optional(),
|
||||
@@ -59,6 +60,47 @@ export const ClientRecordSchema = z
|
||||
})
|
||||
.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
|
||||
.object({
|
||||
id: z.number(),
|
||||
@@ -71,6 +113,7 @@ export const InboundOptionSchema = z
|
||||
wgPublicKey: z.string().optional(),
|
||||
wgMtu: z.number().optional(),
|
||||
wgDns: z.string().optional(),
|
||||
awgServer: AwgServerOptionSchema.nullable().optional(),
|
||||
mtprotoDomain: z.string().optional(),
|
||||
// Hosting node id; absent/null for this panel's own inbounds (#4997).
|
||||
nodeId: z.number().nullable().optional(),
|
||||
@@ -137,10 +180,17 @@ export const ExternalLinkListSchema = z
|
||||
.nullable()
|
||||
.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({
|
||||
client: ClientRecordSchema,
|
||||
inboundIds: nullableNumberArray,
|
||||
externalLinks: ExternalLinkListSchema.optional(),
|
||||
tunnelAllowedIPs: z.record(z.number().int(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const BulkAdjustResultSchema = z.object({
|
||||
|
||||
@@ -16,7 +16,7 @@ export type ObservatoryHttpMethod = z.infer<typeof ObservatoryHttpMethodSchema>;
|
||||
export const PingConfigSchema = z
|
||||
.object({
|
||||
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'),
|
||||
timeout: z.string().default('5s'),
|
||||
sampling: z.number().int().min(1).default(2),
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ProtocolSchema = z.enum([
|
||||
'tunnel',
|
||||
'tun',
|
||||
'mtproto',
|
||||
'amneziawg',
|
||||
]);
|
||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
|
||||
@@ -33,4 +34,5 @@ export const Protocols = Object.freeze({
|
||||
TUNNEL: 'tunnel',
|
||||
TUN: 'tun',
|
||||
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 { AmneziawgInboundSettingsSchema } from './amneziawg';
|
||||
import { HttpInboundSettingsSchema } from './http';
|
||||
import { HysteriaInboundSettingsSchema } from './hysteria';
|
||||
import { MixedInboundSettingsSchema } from './mixed';
|
||||
@@ -12,6 +13,7 @@ import { VlessInboundSettingsSchema } from './vless';
|
||||
import { VmessInboundSettingsSchema } from './vmess';
|
||||
import { WireguardInboundSettingsSchema } from './wireguard';
|
||||
|
||||
export * from './amneziawg';
|
||||
export * from './http';
|
||||
export * from './hysteria';
|
||||
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('tun'), settings: TunInboundSettingsSchema }),
|
||||
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
|
||||
z.object({ protocol: z.literal('amneziawg'), settings: AmneziawgInboundSettingsSchema }),
|
||||
]);
|
||||
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
|
||||
|
||||
@@ -69,5 +69,12 @@ export const WireguardInboundSettingsSchema = z.object({
|
||||
clients: z.array(WireguardClientSchema).default([]),
|
||||
noKernelTun: z.boolean().default(false),
|
||||
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>;
|
||||
|
||||
@@ -72,6 +72,7 @@ export const AllSettingSchema = z
|
||||
subJsonMux: z.string().optional(),
|
||||
subJsonRules: z.string().optional(),
|
||||
subJsonFinalMask: z.string().optional(),
|
||||
subJsonObservatory: z.string().optional(),
|
||||
subHideSettings: z.boolean().optional(),
|
||||
timeLocation: z.string().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,
|
||||
"peers": [],
|
||||
"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=",
|
||||
"subnetIp": "",
|
||||
},
|
||||
"shareAddr": "",
|
||||
"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