From bd6a6aba4365cc31f07ef2bfabd96407e80b1256 Mon Sep 17 00:00:00 2001 From: Masterain Date: Sun, 23 Aug 2026 05:11:06 +0800 Subject: [PATCH] feat(pia): add PIA login-and-add WireGuard outbounds (#6272) * feat(pia): add login-and-add WireGuard outbounds (#2) * fix(pia): keep PIA outbounds identifiable after the editor strips hostname The outbound editor drops piaHostname, so last-segment matching failed for hyphenated servers. Identify rows by the computed tag, re-encrypt stored tokens onto the active key, skip unusable catalog rows, and always release the catalog refresh latch. --- CLAUDE.md | 1 + docs/architecture.md | 7 +- .../docs/en/operations/outbounds-routing.mdx | 25 +- .../docs/en/reference/api/xray-settings.mdx | 14 +- .../docs/fa/operations/outbounds-routing.mdx | 22 +- .../docs/ru/operations/outbounds-routing.mdx | 23 +- .../docs/zh/operations/outbounds-routing.mdx | 18 +- docs/public/openapi.json | 43 +- frontend/public/openapi.json | 43 +- frontend/src/pages/api-docs/endpoints.ts | 39 +- frontend/src/pages/xray/XrayPage.tsx | 11 +- .../src/pages/xray/outbounds/OutboundsTab.tsx | 8 + .../src/pages/xray/overrides/PiaModal.css | 53 ++ .../src/pages/xray/overrides/PiaModal.tsx | 468 ++++++++++++++++++ frontend/src/pages/xray/overrides/index.ts | 1 + .../test/outbounds-loopback-index.test.tsx | 2 + frontend/src/test/pia-modal.test.tsx | 391 +++++++++++++++ internal/crypto/nodetoken/nodetoken.go | 40 +- internal/crypto/nodetoken/nodetoken_test.go | 17 + internal/pia/auth.go | 84 ++++ internal/pia/auth_test.go | 143 ++++++ internal/pia/catalog.go | 86 ++++ internal/pia/catalog_test.go | 125 +++++ internal/pia/endpoints.go | 24 + internal/pia/errors.go | 73 +++ internal/pia/errors_test.go | 37 ++ internal/pia/http_helpers.go | 100 ++++ internal/pia/register.go | 147 ++++++ internal/pia/register_test.go | 260 ++++++++++ internal/pia/serverlist_client.go | 79 +++ internal/pia/serverlist_client_test.go | 108 ++++ internal/pia/serverlist_parser.go | 196 ++++++++ internal/pia/serverlist_parser_test.go | 100 ++++ internal/pia/serverlist_signature.go | 63 +++ internal/pia/serverlist_signature_test.go | 63 +++ internal/pia/testdata/addkey/invalid_dns.json | 8 + .../pia/testdata/addkey/invalid_peer_ip.json | 8 + .../testdata/addkey/invalid_peer_prefix.json | 8 + .../pia/testdata/addkey/invalid_port.json | 8 + .../testdata/addkey/invalid_server_ip.json | 8 + .../testdata/addkey/invalid_server_key.json | 8 + .../pia/testdata/addkey/missing_port.json | 1 + .../pia/testdata/addkey/status_error.json | 8 + internal/pia/testdata/addkey/success.json | 1 + internal/pia/testdata/auth/html.txt | 1 + internal/pia/testdata/auth/success.json | 1 + .../testdata/serverlist/invalid_signature.txt | 3 + .../pia/testdata/serverlist/malformed.json | 1 + .../pia/testdata/serverlist/v6_valid.json | 1 + .../pia/testdata/serverlist/v7_valid.json | 1 + internal/pia/trust/ca.rsa.4096.crt | 43 ++ internal/pia/trust/serverlist_public_key.pem | 9 + internal/pia/trust_anchors_test.go | 52 ++ internal/pia/types.go | 43 ++ internal/pia/validation.go | 42 ++ internal/web/controller/xray_setting.go | 39 +- internal/web/service/integration/pia.go | 295 +++++++++++ internal/web/service/integration/pia_test.go | 375 ++++++++++++++ internal/web/service/setting.go | 9 + internal/web/translation/ar-EG.json | 14 + internal/web/translation/en-US.json | 14 + internal/web/translation/es-ES.json | 14 + internal/web/translation/fa-IR.json | 14 + internal/web/translation/id-ID.json | 14 + internal/web/translation/ja-JP.json | 14 + internal/web/translation/pt-BR.json | 14 + internal/web/translation/ru-RU.json | 14 + internal/web/translation/tr-TR.json | 14 + internal/web/translation/uk-UA.json | 14 + internal/web/translation/vi-VN.json | 14 + internal/web/translation/zh-CN.json | 14 + internal/web/translation/zh-TW.json | 14 + internal/xray/pia_wireguard_outbound_test.go | 57 +++ 73 files changed, 4095 insertions(+), 31 deletions(-) create mode 100644 frontend/src/pages/xray/overrides/PiaModal.css create mode 100644 frontend/src/pages/xray/overrides/PiaModal.tsx create mode 100644 frontend/src/test/pia-modal.test.tsx create mode 100644 internal/pia/auth.go create mode 100644 internal/pia/auth_test.go create mode 100644 internal/pia/catalog.go create mode 100644 internal/pia/catalog_test.go create mode 100644 internal/pia/endpoints.go create mode 100644 internal/pia/errors.go create mode 100644 internal/pia/errors_test.go create mode 100644 internal/pia/http_helpers.go create mode 100644 internal/pia/register.go create mode 100644 internal/pia/register_test.go create mode 100644 internal/pia/serverlist_client.go create mode 100644 internal/pia/serverlist_client_test.go create mode 100644 internal/pia/serverlist_parser.go create mode 100644 internal/pia/serverlist_parser_test.go create mode 100644 internal/pia/serverlist_signature.go create mode 100644 internal/pia/serverlist_signature_test.go create mode 100644 internal/pia/testdata/addkey/invalid_dns.json create mode 100644 internal/pia/testdata/addkey/invalid_peer_ip.json create mode 100644 internal/pia/testdata/addkey/invalid_peer_prefix.json create mode 100644 internal/pia/testdata/addkey/invalid_port.json create mode 100644 internal/pia/testdata/addkey/invalid_server_ip.json create mode 100644 internal/pia/testdata/addkey/invalid_server_key.json create mode 100644 internal/pia/testdata/addkey/missing_port.json create mode 100644 internal/pia/testdata/addkey/status_error.json create mode 100644 internal/pia/testdata/addkey/success.json create mode 100644 internal/pia/testdata/auth/html.txt create mode 100644 internal/pia/testdata/auth/success.json create mode 100644 internal/pia/testdata/serverlist/invalid_signature.txt create mode 100644 internal/pia/testdata/serverlist/malformed.json create mode 100644 internal/pia/testdata/serverlist/v6_valid.json create mode 100644 internal/pia/testdata/serverlist/v7_valid.json create mode 100644 internal/pia/trust/ca.rsa.4096.crt create mode 100644 internal/pia/trust/serverlist_public_key.pem create mode 100644 internal/pia/trust_anchors_test.go create mode 100644 internal/pia/types.go create mode 100644 internal/pia/validation.go create mode 100644 internal/web/service/integration/pia.go create mode 100644 internal/web/service/integration/pia_test.go create mode 100644 internal/xray/pia_wireguard_outbound_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 273c95002..6ea3553dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ 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/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). diff --git a/docs/architecture.md b/docs/architecture.md index e9eaf7302..59bb75c5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -497,7 +498,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` | diff --git a/docs/content/docs/en/operations/outbounds-routing.mdx b/docs/content/docs/en/operations/outbounds-routing.mdx index 15a959589..a8153e357 100644 --- a/docs/content/docs/en/operations/outbounds-routing.mdx +++ b/docs/content/docs/en/operations/outbounds-routing.mdx @@ -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--` (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 diff --git a/docs/content/docs/en/reference/api/xray-settings.mdx b/docs/content/docs/en/reference/api/xray-settings.mdx index 525b0ccd9..1ce619437 100644 --- a/docs/content/docs/en/reference/api/xray-settings.mdx +++ b/docs/content/docs/en/reference/api/xray-settings.mdx @@ -1,7 +1,8 @@ --- title: 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. full: true _openapi: preload: @@ -35,6 +36,9 @@ _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' @@ -117,6 +121,10 @@ _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), @@ -175,7 +183,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/fa/operations/outbounds-routing.mdx b/docs/content/docs/fa/operations/outbounds-routing.mdx index 3bef21540..91633a791 100644 --- a/docs/content/docs/fa/operations/outbounds-routing.mdx +++ b/docs/content/docs/fa/operations/outbounds-routing.mdx @@ -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--` است (مثلاً `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 از راه دور را وارد می‌کند و سرورهای آن را به‌عنوان diff --git a/docs/content/docs/ru/operations/outbounds-routing.mdx b/docs/content/docs/ru/operations/outbounds-routing.mdx index 2453aba70..8a4c9eeb9 100644 --- a/docs/content/docs/ru/operations/outbounds-routing.mdx +++ b/docs/content/docs/ru/operations/outbounds-routing.mdx @@ -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--` (например `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`. + ## Подписки на исходящие соединения (пулы серверов) **Подписка на исходящие соединения** импортирует удалённую подписку со diff --git a/docs/content/docs/zh/operations/outbounds-routing.mdx b/docs/content/docs/zh/operations/outbounds-routing.mdx index 3001ae5f2..c8ab1617b 100644 --- a/docs/content/docs/zh/operations/outbounds-routing.mdx +++ b/docs/content/docs/zh/operations/outbounds-routing.mdx @@ -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--`(例如 +`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)。 + ## 出站订阅(服务器池) **出站订阅**会导入一个远程分享链接订阅,并将其中的服务器作为**出站**注入到正在运行的 diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 5a05aa7e5..cbc458191 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -2265,7 +2265,7 @@ }, { "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 Server", @@ -9315,6 +9315,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": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 44a843b2b..45a902b50 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -3160,7 +3160,7 @@ }, { "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 Server", @@ -11143,6 +11143,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": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index b2d58ce69..431e1f31c 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1699,7 +1699,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 +1799,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', diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index 1a4fe6b2d..cdd3cd423 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -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('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} /> + setPiaOpen(false)} + onAddOutbound={onAddOutbound} + onResetOutbound={onResetOutbound} + /> ); diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index 64714876e..fd4d2230e 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -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: , label: 'WARP', onClick: onShowWarp }, { key: 'nord', icon: , label: 'NordVPN', onClick: onShowNord }, + { + key: 'pia', + icon: , + label: t('pages.xray.pia.menu'), + onClick: onShowPia, + }, { type: 'divider' }, { key: 'import', diff --git a/frontend/src/pages/xray/overrides/PiaModal.css b/frontend/src/pages/xray/overrides/PiaModal.css new file mode 100644 index 000000000..f0ec0fc41 --- /dev/null +++ b/frontend/src/pages/xray/overrides/PiaModal.css @@ -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; +} diff --git a/frontend/src/pages/xray/overrides/PiaModal.tsx b/frontend/src/pages/xray/overrides/PiaModal.tsx new file mode 100644 index 000000000..5f23b8f80 --- /dev/null +++ b/frontend/src/pages/xray/overrides/PiaModal.tsx @@ -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) => void; + onResetOutbound: (payload: { + index: number; + outbound: Record; + 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 { + 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(null); + const [countries, setCountries] = useState([]); + const [regions, setRegions] = useState([]); + const [servers, setServers] = useState([]); + const methods = useForm({ 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('/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('/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('/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 | null> { + if (!selectedHostname) return null; + const msg = await HttpUtil.post('/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} + + + {piaData == null ? ( +
+ + + + + + + +
+ ) : ( + <> + + + + + + + +
{t('pages.xray.pia.account')}{piaData.accountHint || piaData.username}
+ + + + {t('pages.xray.warp.settings')} + +
+ v ?? undefined }} + onAfterChange={(v) => void fetchServers(v as string)} + > + ({ value: r.id, label: r.name })), + ]} + /> + + )} + + {filteredServers.length > 0 && ( + +