4 Commits a3e617215c ... 02002dc1c3

Autor SHA1 Mensagem Data
  Zane 02002dc1c3 feat(routing): add client picker to user rules (#6271) há 1 dia atrás
  n0ctal 326009e9d3 fix(traffic): clear cross-panel rows only for clients actually renewed (#6263) há 1 dia atrás
  Matt Van Horn 585f4ecdc0 fix: reject Hysteria inbound updates with empty client auth (#6268) há 1 dia atrás
  Masterain bd6a6aba43 feat(pia): add PIA login-and-add WireGuard outbounds (#6272) há 1 dia atrás
80 ficheiros alterados com 4532 adições e 37 exclusões
  1. 1 0
      CLAUDE.md
  2. 4 3
      docs/architecture.md
  3. 21 4
      docs/content/docs/en/operations/outbounds-routing.mdx
  4. 11 3
      docs/content/docs/en/reference/api/xray-settings.mdx
  5. 20 2
      docs/content/docs/fa/operations/outbounds-routing.mdx
  6. 21 2
      docs/content/docs/ru/operations/outbounds-routing.mdx
  7. 16 2
      docs/content/docs/zh/operations/outbounds-routing.mdx
  8. 42 1
      docs/public/openapi.json
  9. 42 1
      frontend/public/openapi.json
  10. 33 0
      frontend/src/api/queries/useClientOptions.ts
  11. 38 1
      frontend/src/pages/api-docs/endpoints.ts
  12. 10 1
      frontend/src/pages/xray/XrayPage.tsx
  13. 8 0
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  14. 53 0
      frontend/src/pages/xray/overrides/PiaModal.css
  15. 468 0
      frontend/src/pages/xray/overrides/PiaModal.tsx
  16. 1 0
      frontend/src/pages/xray/overrides/index.ts
  17. 36 1
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  18. 2 0
      frontend/src/test/outbounds-loopback-index.test.tsx
  19. 391 0
      frontend/src/test/pia-modal.test.tsx
  20. 98 3
      frontend/src/test/rule-form-preserve-fields.test.tsx
  21. 30 10
      internal/crypto/nodetoken/nodetoken.go
  22. 17 0
      internal/crypto/nodetoken/nodetoken_test.go
  23. 84 0
      internal/pia/auth.go
  24. 143 0
      internal/pia/auth_test.go
  25. 86 0
      internal/pia/catalog.go
  26. 125 0
      internal/pia/catalog_test.go
  27. 24 0
      internal/pia/endpoints.go
  28. 73 0
      internal/pia/errors.go
  29. 37 0
      internal/pia/errors_test.go
  30. 100 0
      internal/pia/http_helpers.go
  31. 147 0
      internal/pia/register.go
  32. 260 0
      internal/pia/register_test.go
  33. 79 0
      internal/pia/serverlist_client.go
  34. 108 0
      internal/pia/serverlist_client_test.go
  35. 196 0
      internal/pia/serverlist_parser.go
  36. 100 0
      internal/pia/serverlist_parser_test.go
  37. 63 0
      internal/pia/serverlist_signature.go
  38. 63 0
      internal/pia/serverlist_signature_test.go
  39. 8 0
      internal/pia/testdata/addkey/invalid_dns.json
  40. 8 0
      internal/pia/testdata/addkey/invalid_peer_ip.json
  41. 8 0
      internal/pia/testdata/addkey/invalid_peer_prefix.json
  42. 8 0
      internal/pia/testdata/addkey/invalid_port.json
  43. 8 0
      internal/pia/testdata/addkey/invalid_server_ip.json
  44. 8 0
      internal/pia/testdata/addkey/invalid_server_key.json
  45. 1 0
      internal/pia/testdata/addkey/missing_port.json
  46. 8 0
      internal/pia/testdata/addkey/status_error.json
  47. 1 0
      internal/pia/testdata/addkey/success.json
  48. 1 0
      internal/pia/testdata/auth/html.txt
  49. 1 0
      internal/pia/testdata/auth/success.json
  50. 3 0
      internal/pia/testdata/serverlist/invalid_signature.txt
  51. 1 0
      internal/pia/testdata/serverlist/malformed.json
  52. 1 0
      internal/pia/testdata/serverlist/v6_valid.json
  53. 1 0
      internal/pia/testdata/serverlist/v7_valid.json
  54. 43 0
      internal/pia/trust/ca.rsa.4096.crt
  55. 9 0
      internal/pia/trust/serverlist_public_key.pem
  56. 52 0
      internal/pia/trust_anchors_test.go
  57. 43 0
      internal/pia/types.go
  58. 42 0
      internal/pia/validation.go
  59. 38 1
      internal/web/controller/xray_setting.go
  60. 12 0
      internal/web/service/inbound.go
  61. 107 0
      internal/web/service/inbound_autorenew_globaltraffic_test.go
  62. 106 0
      internal/web/service/inbound_hysteria_auth_test.go
  63. 6 2
      internal/web/service/inbound_traffic.go
  64. 295 0
      internal/web/service/integration/pia.go
  65. 375 0
      internal/web/service/integration/pia_test.go
  66. 9 0
      internal/web/service/setting.go
  67. 17 0
      internal/web/translation/ar-EG.json
  68. 17 0
      internal/web/translation/en-US.json
  69. 17 0
      internal/web/translation/es-ES.json
  70. 17 0
      internal/web/translation/fa-IR.json
  71. 17 0
      internal/web/translation/id-ID.json
  72. 17 0
      internal/web/translation/ja-JP.json
  73. 17 0
      internal/web/translation/pt-BR.json
  74. 17 0
      internal/web/translation/ru-RU.json
  75. 17 0
      internal/web/translation/tr-TR.json
  76. 17 0
      internal/web/translation/uk-UA.json
  77. 17 0
      internal/web/translation/vi-VN.json
  78. 17 0
      internal/web/translation/zh-CN.json
  79. 17 0
      internal/web/translation/zh-TW.json
  80. 57 0
      internal/xray/pia_wireguard_outbound_test.go

+ 1 - 0
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).

+ 4 - 3
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`                                                                             |

+ 21 - 4
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-<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

+ 11 - 3
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}
-      <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/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 />
     </>
   );
 }

+ 20 - 2
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-<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 از راه دور را وارد می‌کند و سرورهای آن را به‌عنوان

+ 21 - 2
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-<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`.
+
 ## Подписки на исходящие соединения (пулы серверов)
 
 **Подписка на исходящие соединения** импортирует удалённую подписку со

+ 16 - 2
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-<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)。
+
 ## 出站订阅(服务器池)
 
 **出站订阅**会导入一个远程分享链接订阅,并将其中的服务器作为**出站**注入到正在运行的

+ 42 - 1
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": [

+ 42 - 1
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": [

+ 33 - 0
frontend/src/api/queries/useClientOptions.ts

@@ -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)),
+  });
+}

+ 38 - 1
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',

+ 10 - 1
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<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>
   );

+ 8 - 0
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: <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',

+ 53 - 0
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;
+}

+ 468 - 0
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<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 - 0
frontend/src/pages/xray/overrides/index.ts

@@ -1,2 +1,3 @@
 export { default as WarpModal } from './WarpModal';
 export { default as NordModal } from './NordModal';
+export { default as PiaModal } from './PiaModal';

+ 36 - 1
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -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

+ 2 - 0
frontend/src/test/outbounds-loopback-index.test.tsx

@@ -39,6 +39,7 @@ describe('OutboundsTab hidden-loopback index mapping', () => {
           onTestAll={vi.fn()}
           onShowWarp={vi.fn()}
           onShowNord={vi.fn()}
+          onShowPia={vi.fn()}
         />
       </QueryClientProvider>,
     );
@@ -77,6 +78,7 @@ describe('OutboundsTab hidden-loopback index mapping', () => {
           onTestAll={vi.fn()}
           onShowWarp={vi.fn()}
           onShowNord={vi.fn()}
+          onShowPia={vi.fn()}
         />
       </QueryClientProvider>,
     );

+ 391 - 0
frontend/src/test/pia-modal.test.tsx

@@ -0,0 +1,391 @@
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+
+import PiaModal from '@/pages/xray/overrides/PiaModal';
+import { HttpUtil, Msg } from '@/utils';
+import { renderWithProviders } from './test-utils';
+
+const ACCOUNT = { username: 'p1234567', accountHint: 'p*****67' };
+const COUNTRIES = [{ code: 'US' }, { code: 'DE' }, { code: 'AL' }];
+const SERVERS = {
+  regions: [
+    { id: 'us-east', name: 'US East' },
+    { id: 'us-west', name: 'US West' },
+    { id: 'al', name: 'Albania' },
+  ],
+  servers: [
+    { hostname: 'useast1', ip: '198.51.100.10', regionId: 'us-east', regionName: 'US East' },
+    { hostname: 'uswest1', ip: '198.51.100.30', regionId: 'us-west', regionName: 'US West' },
+    {
+      hostname: 'Server-12406-1a',
+      ip: '198.51.100.40',
+      regionId: 'al',
+      regionName: 'Albania',
+    },
+  ],
+};
+
+function piaApiPost(url: string, data?: unknown) {
+  if (url === '/panel/api/xray/pia/data') return new Msg(true, '', ACCOUNT);
+  if (url === '/panel/api/xray/pia/countries') return new Msg(true, '', COUNTRIES);
+  if (url === '/panel/api/xray/pia/servers') {
+    const code = (data as { countryCode?: string } | undefined)?.countryCode?.toUpperCase();
+    if (code === 'AL') {
+      return new Msg(true, '', {
+        regions: [SERVERS.regions[2]],
+        servers: [SERVERS.servers[2]],
+      });
+    }
+    if (code === 'US') {
+      return new Msg(true, '', {
+        regions: SERVERS.regions.slice(0, 2),
+        servers: SERVERS.servers.slice(0, 2),
+      });
+    }
+    return new Msg(true, '', { regions: [], servers: [] });
+  }
+  if (url === '/panel/api/xray/pia/addKey') {
+    const hostname = (data as { hostname?: string } | undefined)?.hostname;
+    if (hostname === 'uswest1') {
+      return new Msg(true, '', {
+        tag: 'pia-us-west-uswest1',
+        hostname: 'uswest1',
+        secretKey: 'secret-west',
+        address: '10.8.0.2/32',
+        publicKey: 'pubkey-west',
+        endpoint: '198.51.100.30:1337',
+      });
+    }
+    if (hostname === 'Server-12406-1a' || hostname === 'pia-al-server-12406-1a') {
+      return new Msg(true, '', {
+        tag: 'pia-al-server-12406-1a',
+        hostname: 'Server-12406-1a',
+        secretKey: 'secret-al',
+        address: '10.8.0.3/32',
+        publicKey: 'pubkey-al',
+        endpoint: '198.51.100.40:1337',
+      });
+    }
+    if (hostname === 'useast1' || hostname === 'pia-us-east-useast1') {
+      return new Msg(true, '', {
+        tag: 'pia-us-east-useast1',
+        hostname: 'useast1',
+        secretKey: 'secret',
+        address: '10.8.0.1/32',
+        publicKey: 'pubkey',
+        endpoint: '198.51.100.10:1337',
+      });
+    }
+    return new Msg(false, `Unexpected addKey hostname ${hostname}`, null);
+  }
+  return new Msg(false, `Unexpected POST ${url}`, null);
+}
+
+function mockPiaApi() {
+  vi.mocked(HttpUtil.post).mockImplementation(async (url: string, data?: unknown) =>
+    piaApiPost(url, data),
+  );
+}
+
+function visibleOptions(): HTMLElement[] {
+  return Array.from(
+    document.querySelectorAll<HTMLElement>(
+      '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
+    ),
+  );
+}
+
+async function chooseOption(testId: string, labelPart: string) {
+  const node = screen.getByTestId(testId);
+  const select = node.closest('.ant-select') ?? node;
+  const selector = select.querySelector('.ant-select-selector') ?? select;
+  fireEvent.mouseDown(selector);
+  await waitFor(() => expect(visibleOptions().length).toBeGreaterThan(0));
+  const option = visibleOptions().find((item) =>
+    (item.getAttribute('title') ?? item.textContent ?? '').includes(labelPart),
+  );
+  if (!option) throw new Error(`Missing option containing ${labelPart}`);
+  fireEvent.click(option);
+}
+
+async function clickAddOutbound() {
+  const addButton = await waitFor(() => {
+    const btn = screen.getByRole('button', { name: /Add outbound/ });
+    if ((btn as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
+    return btn;
+  });
+  fireEvent.click(addButton);
+}
+
+function expectPiaOutbound(
+  outbound: Record<string, unknown>,
+  want: {
+    tag: string;
+    hostname: string;
+    secretKey: string;
+    address: string;
+    publicKey: string;
+    endpoint: string;
+  },
+) {
+  expect(outbound).toMatchObject({
+    tag: want.tag,
+    piaHostname: want.hostname,
+    protocol: 'wireguard',
+    settings: {
+      secretKey: want.secretKey,
+      address: [want.address],
+      mtu: 1420,
+      noKernelTun: true,
+      peers: [
+        {
+          publicKey: want.publicKey,
+          endpoint: want.endpoint,
+          allowedIPs: ['0.0.0.0/0'],
+          keepAlive: 25,
+        },
+      ],
+    },
+  });
+}
+
+function PiaHarness({ onAdded }: { onAdded?: (outbound: Record<string, unknown>) => void }) {
+  const [outbounds, setOutbounds] = useState<Record<string, unknown>[]>([]);
+  return (
+    <PiaModal
+      open
+      templateSettings={{ outbounds }}
+      onClose={vi.fn()}
+      onAddOutbound={(outbound) => {
+        onAdded?.(outbound);
+        setOutbounds((prev) => [...prev, outbound]);
+      }}
+      onResetOutbound={vi.fn()}
+    />
+  );
+}
+
+describe('PIA modal', () => {
+  it('shows username and password when not signed in', async () => {
+    vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
+      if (url === '/panel/api/xray/pia/data') return new Msg(true, '', null);
+      return new Msg(false, `Unexpected POST ${url}`, null);
+    });
+
+    renderWithProviders(
+      <PiaModal
+        open
+        templateSettings={{ outbounds: [] }}
+        onClose={vi.fn()}
+        onAddOutbound={vi.fn()}
+        onResetOutbound={vi.fn()}
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByPlaceholderText('PIA username')).toBeTruthy());
+    expect(screen.getByPlaceholderText('PIA password')).toBeTruthy();
+    expect(screen.getByRole('dialog', { name: 'Private Internet Access WireGuard' })).toBeTruthy();
+    expect(screen.getByRole('button', { name: /Log In/ })).toBeTruthy();
+    expect(screen.queryByTestId('pia-country-select')).toBeNull();
+  });
+
+  it('adds two WireGuard outbounds for different servers', async () => {
+    mockPiaApi();
+    const added: Record<string, unknown>[] = [];
+    renderWithProviders(<PiaHarness onAdded={(outbound) => added.push(outbound)} />);
+
+    await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
+    await chooseOption('pia-country-select', 'US');
+    await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
+    await clickAddOutbound();
+
+    await waitFor(() => expect(screen.getByTestId('pia-added-table')).toBeTruthy());
+    expect(screen.getByText('pia-us-east-useast1')).toBeTruthy();
+    expect(screen.getByRole('button', { name: /Add outbound/ })).toBeTruthy();
+
+    await chooseOption('pia-server-select', 'uswest1');
+    await clickAddOutbound();
+    await waitFor(() => expect(screen.getByText('pia-us-west-uswest1')).toBeTruthy());
+    expect(added).toHaveLength(2);
+    expectPiaOutbound(added[0], {
+      tag: 'pia-us-east-useast1',
+      hostname: 'useast1',
+      secretKey: 'secret',
+      address: '10.8.0.1/32',
+      publicKey: 'pubkey',
+      endpoint: '198.51.100.10:1337',
+    });
+    expectPiaOutbound(added[1], {
+      tag: 'pia-us-west-uswest1',
+      hostname: 'uswest1',
+      secretKey: 'secret-west',
+      address: '10.8.0.2/32',
+      publicKey: 'pubkey-west',
+      endpoint: '198.51.100.30:1337',
+    });
+  });
+
+  it('disables Add when the selected server is already in the list', async () => {
+    mockPiaApi();
+    renderWithProviders(
+      <PiaModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1', piaHostname: 'useast1' }] }}
+        onClose={vi.fn()}
+        onAddOutbound={vi.fn()}
+        onResetOutbound={vi.fn()}
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
+    await chooseOption('pia-country-select', 'US');
+    await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
+    await waitFor(() => {
+      const btn = screen.getByRole('button', { name: /Add outbound/ });
+      expect((btn as HTMLButtonElement).disabled).toBe(true);
+    });
+    expect(screen.getByText(/Use Reset to renew its key/)).toBeTruthy();
+  });
+
+  it('resets an existing PIA outbound in place', async () => {
+    const onResetOutbound = vi.fn();
+    mockPiaApi();
+    renderWithProviders(
+      <PiaModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1', piaHostname: 'useast1' }] }}
+        onClose={vi.fn()}
+        onAddOutbound={vi.fn()}
+        onResetOutbound={onResetOutbound}
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByTestId('pia-reset-0')).toBeTruthy());
+    fireEvent.click(screen.getByTestId('pia-reset-0'));
+    await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
+    const payload = onResetOutbound.mock.calls[0][0] as {
+      index: number;
+      outbound: { tag: string; piaHostname: string; settings: { secretKey: string } };
+      oldTag?: string;
+      newTag: string;
+    };
+    expect(payload.index).toBe(0);
+    expect(payload.oldTag).toBe('pia-us-east-useast1');
+    expect(payload.newTag).toBe('pia-us-east-useast1');
+    expectPiaOutbound(payload.outbound as Record<string, unknown>, {
+      tag: 'pia-us-east-useast1',
+      hostname: 'useast1',
+      secretKey: 'secret',
+      address: '10.8.0.1/32',
+      publicKey: 'pubkey',
+      endpoint: '198.51.100.10:1337',
+    });
+  });
+
+  it('disables Add for a hyphenated cn when only the tag remains', async () => {
+    mockPiaApi();
+    renderWithProviders(
+      <PiaModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'pia-al-server-12406-1a' }] }}
+        onClose={vi.fn()}
+        onAddOutbound={vi.fn()}
+        onResetOutbound={vi.fn()}
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
+    await chooseOption('pia-country-select', 'AL');
+    await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
+    await waitFor(() => {
+      const btn = screen.getByRole('button', { name: /Add outbound/ });
+      expect((btn as HTMLButtonElement).disabled).toBe(true);
+    });
+  });
+
+  it('disables Add when only the outbound tag remains', async () => {
+    mockPiaApi();
+    renderWithProviders(
+      <PiaModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1' }] }}
+        onClose={vi.fn()}
+        onAddOutbound={vi.fn()}
+        onResetOutbound={vi.fn()}
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
+    await chooseOption('pia-country-select', 'US');
+    await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
+    await waitFor(() => {
+      const btn = screen.getByRole('button', { name: /Add outbound/ });
+      expect((btn as HTMLButtonElement).disabled).toBe(true);
+    });
+  });
+
+  it('resets from the outbound tag when piaHostname was stripped', async () => {
+    const onResetOutbound = vi.fn();
+    const posts: unknown[] = [];
+    mockPiaApi();
+    vi.mocked(HttpUtil.post).mockImplementation(async (url: string, data?: unknown) => {
+      if (url === '/panel/api/xray/pia/addKey') posts.push(data);
+      return piaApiPost(url, data);
+    });
+    renderWithProviders(
+      <PiaModal
+        open
+        templateSettings={{ outbounds: [{ tag: 'pia-al-server-12406-1a' }] }}
+        onClose={vi.fn()}
+        onAddOutbound={vi.fn()}
+        onResetOutbound={onResetOutbound}
+      />,
+    );
+
+    const reset = await waitFor(() => screen.getByTestId('pia-reset-0'));
+    expect((reset as HTMLButtonElement).disabled).toBe(false);
+    fireEvent.click(reset);
+    await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
+    expect(posts).toEqual([{ hostname: 'pia-al-server-12406-1a' }]);
+    expectPiaOutbound(onResetOutbound.mock.calls[0][0].outbound as Record<string, unknown>, {
+      tag: 'pia-al-server-12406-1a',
+      hostname: 'Server-12406-1a',
+      secretKey: 'secret-al',
+      address: '10.8.0.3/32',
+      publicKey: 'pubkey-al',
+      endpoint: '198.51.100.40:1337',
+    });
+  });
+
+  it('does not add an outbound when addKey omits WireGuard fields', async () => {
+    const onAddOutbound = vi.fn();
+    vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
+      if (url === '/panel/api/xray/pia/data') return new Msg(true, '', ACCOUNT);
+      if (url === '/panel/api/xray/pia/countries') return new Msg(true, '', COUNTRIES);
+      if (url === '/panel/api/xray/pia/servers') return new Msg(true, '', SERVERS);
+      if (url === '/panel/api/xray/pia/addKey') {
+        return new Msg(true, '', { tag: 'pia-us-east-useast1', hostname: 'useast1' });
+      }
+      return new Msg(false, `Unexpected POST ${url}`, null);
+    });
+    renderWithProviders(
+      <PiaModal
+        open
+        templateSettings={{ outbounds: [] }}
+        onClose={vi.fn()}
+        onAddOutbound={onAddOutbound}
+        onResetOutbound={vi.fn()}
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
+    await chooseOption('pia-country-select', 'US');
+    await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
+    await clickAddOutbound();
+    await waitFor(() =>
+      expect(screen.getByText('Could not build the PIA outbound. Try again.')).toBeTruthy(),
+    );
+    expect(onAddOutbound).not.toHaveBeenCalled();
+  });
+});

+ 98 - 3
frontend/src/test/rule-form-preserve-fields.test.tsx

@@ -1,12 +1,16 @@
-import { describe, it, expect, vi } from 'vitest';
-import { fireEvent, screen } from '@testing-library/react';
+import { afterEach, describe, it, expect, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 
 import RuleFormModal from '@/pages/xray/routing/RuleFormModal';
+import { keys } from '@/api/queryKeys';
+import { HttpUtil, Msg } from '@/utils';
 
-import { renderWithProviders } from './test-utils';
+import { chooseSelectOption, renderWithProviders } from './test-utils';
 
 describe('RuleFormModal edit preserves unsurfaced fields', () => {
+  afterEach(() => vi.restoreAllMocks());
+
   it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
     const onConfirm = vi.fn();
     const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -30,4 +34,95 @@ describe('RuleFormModal edit preserves unsurfaced fields', () => {
     expect(onConfirm).toHaveBeenCalledTimes(1);
     expect(onConfirm.mock.calls[0][0]).toMatchObject({ ruleTag: 'my-tag' });
   });
+
+  it('selects existing clients for the user criterion', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(
+      new Msg(true, '', [
+        { id: 1, email: '[email protected]' },
+        { id: 2, email: '[email protected]' },
+      ]),
+    );
+    const onConfirm = vi.fn();
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+    renderWithProviders(
+      <RuleFormModal
+        open
+        rule={null}
+        inboundTags={[]}
+        outboundTags={['direct']}
+        balancerTags={[]}
+        onClose={vi.fn()}
+        onConfirm={onConfirm}
+      />,
+      { queryClient },
+    );
+
+    await waitFor(() =>
+      expect(HttpUtil.get).toHaveBeenCalledWith('/panel/api/clients/list', undefined, {
+        silent: true,
+      }),
+    );
+    await waitFor(() => expect(queryClient.getQueryData(keys.clients.all())).toHaveLength(2));
+    const userField = screen.getByLabelText('User');
+    expect(screen.getByText('Select users')).toBeTruthy();
+    chooseSelectOption(userField.id, '[email protected]');
+    chooseSelectOption(userField.id, '[email protected]');
+    fireEvent.click(screen.getByRole('button', { name: 'Create' }));
+
+    expect(onConfirm).toHaveBeenCalledWith(
+      expect.objectContaining({ user: ['[email protected]', '[email protected]'] }),
+    );
+  });
+
+  it('preserves a saved user that is no longer in the client list', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(
+      new Msg(true, '', [{ id: 1, email: '[email protected]' }]),
+    );
+    const onConfirm = vi.fn();
+
+    renderWithProviders(
+      <RuleFormModal
+        open
+        rule={{ type: 'field', user: ['[email protected]'], outboundTag: 'direct' }}
+        inboundTags={[]}
+        outboundTags={['direct']}
+        balancerTags={[]}
+        onClose={vi.fn()}
+        onConfirm={onConfirm}
+      />,
+    );
+
+    await waitFor(() => expect(screen.getByText('[email protected]')).toBeTruthy());
+    fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
+
+    expect(onConfirm).toHaveBeenCalledWith(
+      expect.objectContaining({ user: ['[email protected]'] }),
+    );
+  });
+
+  it('accepts a custom user identifier that is not a client record', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(
+      new Msg(true, '', [{ id: 1, email: '[email protected]' }]),
+    );
+    const onConfirm = vi.fn();
+
+    renderWithProviders(
+      <RuleFormModal
+        open
+        rule={null}
+        inboundTags={[]}
+        outboundTags={['direct']}
+        balancerTags={[]}
+        onClose={vi.fn()}
+        onConfirm={onConfirm}
+      />,
+    );
+
+    const userField = screen.getByLabelText('User');
+    fireEvent.change(userField, { target: { value: 'office-proxy,' } });
+    fireEvent.click(screen.getByRole('button', { name: 'Create' }));
+
+    expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ user: ['office-proxy'] }));
+  });
 });

+ 30 - 10
internal/crypto/nodetoken/nodetoken.go

@@ -97,12 +97,26 @@ func IsEncrypted(stored string) bool { return strings.HasPrefix(stored, encPrefi
 // Encrypt returns plaintext in ModeOff or row-bound enc:v1 ciphertext otherwise.
 // Empty and already-valid encrypted values remain unchanged.
 func (c *Codec) Encrypt(nodeID int, plaintext string) (string, error) {
+	return c.EncryptBound(aad(nodeID), plaintext)
+}
+
+// Decrypt passes legacy plaintext through; enc: values must authenticate and
+// are never reinterpreted as plaintext after an error.
+func (c *Codec) Decrypt(nodeID int, stored string) (string, error) {
+	pt, err := c.DecryptBound(aad(nodeID), stored)
+	if err != nil {
+		return "", fmt.Errorf("nodetoken: node %d: %w", nodeID, err)
+	}
+	return pt, nil
+}
+
+// EncryptBound is Encrypt with an explicit AAD (e.g. settings/pia_token).
+func (c *Codec) EncryptBound(bound []byte, plaintext string) (string, error) {
 	if c.mode == ModeOff || plaintext == "" {
 		return plaintext, nil
 	}
 	if IsEncrypted(plaintext) {
-		// Validate it actually decrypts for this node; if so keep verbatim.
-		if _, err := c.Decrypt(nodeID, plaintext); err != nil {
+		if _, err := c.DecryptBound(bound, plaintext); err != nil {
 			return "", fmt.Errorf("nodetoken: refusing to store undecryptable ciphertext: %w", err)
 		}
 		return plaintext, nil
@@ -119,14 +133,13 @@ func (c *Codec) Encrypt(nodeID int, plaintext string) (string, error) {
 	if _, err := rand.Read(nonce); err != nil {
 		return "", err
 	}
-	ct := gcm.Seal(nil, nonce, []byte(plaintext), aad(nodeID))
+	ct := gcm.Seal(nil, nonce, []byte(plaintext), bound)
 	blob := append(nonce, ct...)
 	return encScheme + c.ring.ActiveID + ":" + base64.RawURLEncoding.EncodeToString(blob), nil
 }
 
-// Decrypt passes legacy plaintext through; enc: values must authenticate and
-// are never reinterpreted as plaintext after an error.
-func (c *Codec) Decrypt(nodeID int, stored string) (string, error) {
+// DecryptBound is Decrypt with an explicit AAD.
+func (c *Codec) DecryptBound(bound []byte, stored string) (string, error) {
 	if c.mode == ModeOff {
 		return stored, nil
 	}
@@ -159,9 +172,9 @@ func (c *Codec) Decrypt(nodeID int, stored string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	pt, err := gcm.Open(nil, blob[:nonceLen], blob[nonceLen:], aad(nodeID))
+	pt, err := gcm.Open(nil, blob[:nonceLen], blob[nonceLen:], bound)
 	if err != nil {
-		return "", fmt.Errorf("nodetoken: authentication failed for node %d: %w", nodeID, err)
+		return "", fmt.Errorf("nodetoken: authentication failed: %w", err)
 	}
 	return string(pt), nil
 }
@@ -232,5 +245,12 @@ func get() *Codec {
 // Encrypt/Decrypt/Enabled operate on the process-wide codec.
 func Encrypt(nodeID int, plaintext string) (string, error) { return get().Encrypt(nodeID, plaintext) }
 func Decrypt(nodeID int, stored string) (string, error)    { return get().Decrypt(nodeID, stored) }
-func Enabled() bool                                        { return get().Enabled() }
-func Active() *Codec                                       { return get() }
+func EncryptBound(bound []byte, plaintext string) (string, error) {
+	return get().EncryptBound(bound, plaintext)
+}
+
+func DecryptBound(bound []byte, stored string) (string, error) {
+	return get().DecryptBound(bound, stored)
+}
+func Enabled() bool  { return get().Enabled() }
+func Active() *Codec { return get() }

+ 17 - 0
internal/crypto/nodetoken/nodetoken_test.go

@@ -49,6 +49,23 @@ func TestAADBindsToNode(t *testing.T) {
 	// Decrypting under a different node id must fail (ciphertext bound to row).
 	if _, err := c.Decrypt(8, enc); err == nil {
 		t.Fatal("expected AAD mismatch error decrypting under wrong node id")
+	} else if !strings.Contains(err.Error(), "node 8") || !strings.Contains(err.Error(), "authentication failed") {
+		t.Fatalf("wrong-node decrypt error: %v", err)
+	}
+}
+
+func TestAADBindsSettingsApartFromNodes(t *testing.T) {
+	c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	enc, err := c.EncryptBound([]byte("settings/pia_token"), "tok")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := c.Decrypt(1, enc); err == nil {
+		t.Fatal("settings/pia_token ciphertext must not decrypt under nodes/api_token/1")
+	}
+	pt, err := c.DecryptBound([]byte("settings/pia_token"), enc)
+	if err != nil || pt != "tok" {
+		t.Fatalf("pia AAD round-trip: %q err=%v", pt, err)
 	}
 }
 

+ 84 - 0
internal/pia/auth.go

@@ -0,0 +1,84 @@
+package pia
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"mime/multipart"
+	"net/http"
+	"strings"
+	"time"
+)
+
+type AuthClient struct {
+	Endpoint   string
+	HTTPClient *http.Client
+	MaxBody    int64
+	UserAgent  string
+	Now        func() time.Time
+}
+
+func NewAuthClient(endpoint string) *AuthClient {
+	return &AuthClient{
+		Endpoint:   endpoint,
+		MaxBody:    DefaultMaxResponseBody,
+		UserAgent:  DefaultUserAgent,
+		Now:        time.Now,
+		HTTPClient: &http.Client{Timeout: DefaultRequestTimeout, CheckRedirect: noRedirect},
+	}
+}
+
+func (c *AuthClient) Authenticate(ctx context.Context, username string, password []byte) (Token, error) {
+	username = strings.TrimSpace(username)
+	if !validSecret([]byte(username), 1, 256) || !validSecret(password, 1, 1024) {
+		return Token{}, NewError(CodeInvalidCredentials, "Enter a valid PIA username and password.")
+	}
+	var body bytes.Buffer
+	writer := multipart.NewWriter(&body)
+	if err := writer.WriteField("username", username); err != nil {
+		return Token{}, WrapError(CodeAuthenticationUnavailable, "Could not prepare the authentication request.", err)
+	}
+	if err := writer.WriteField("password", string(password)); err != nil {
+		return Token{}, WrapError(CodeAuthenticationUnavailable, "Could not prepare the authentication request.", err)
+	}
+	if err := writer.Close(); err != nil {
+		return Token{}, WrapError(CodeAuthenticationUnavailable, "Could not prepare the authentication request.", err)
+	}
+
+	request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint, &body)
+	if err != nil {
+		return Token{}, WrapError(CodeAuthenticationUnavailable, "The authentication endpoint is invalid.", err)
+	}
+	request.Header.Set("Content-Type", writer.FormDataContentType())
+	request.Header.Set("Accept", "application/json")
+	request.Header.Set("User-Agent", c.UserAgent)
+	response, err := c.HTTPClient.Do(request)
+	if err != nil {
+		return Token{}, classifyNetworkError(ctx, CodeAuthenticationUnavailable, "PIA authentication could not be reached.", err)
+	}
+	defer response.Body.Close()
+	if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
+		return Token{}, NewError(CodeInvalidCredentials, "The PIA username or password was rejected.")
+	}
+	if response.StatusCode != http.StatusOK {
+		return Token{}, NewError(CodeAuthenticationUnavailable, fmt.Sprintf("PIA authentication returned HTTP %d.", response.StatusCode))
+	}
+	if !expectedContentType(response.Header.Get("Content-Type"), "application/json") {
+		return Token{}, NewError(CodeAuthenticationUnavailable, "PIA authentication returned an unexpected content type.")
+	}
+	raw, err := readLimitedBody(response.Body, c.MaxBody)
+	if err != nil {
+		return Token{}, WrapError(CodeAuthenticationUnavailable, "PIA authentication returned an invalid response.", err)
+	}
+	var payload struct {
+		Token string `json:"token"`
+	}
+	if err := decodeSingleJSON(raw, &payload); err != nil || !validSecret([]byte(payload.Token), 16, 4096) {
+		return Token{}, NewError(CodeAuthenticationUnavailable, "PIA authentication returned an invalid token response.")
+	}
+	now := time.Now
+	if c.Now != nil {
+		now = c.Now
+	}
+	return Token{Value: []byte(payload.Token), ExpiresAt: now().Add(DefaultTokenTTL)}, nil
+}

+ 143 - 0
internal/pia/auth_test.go

@@ -0,0 +1,143 @@
+package pia
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func TestAuthClientSuccessAndReject(t *testing.T) {
+	successFixture, err := os.ReadFile(filepath.Join("testdata", "auth", "success.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if err := r.ParseMultipartForm(1 << 20); err != nil {
+			t.Errorf("parse form: %v", err)
+		}
+		if r.FormValue("username") != "p123" || r.FormValue("password") != "password" {
+			t.Errorf("unexpected credentials")
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write(successFixture)
+	}))
+	defer server.Close()
+	client := NewAuthClient(server.URL)
+	token, err := client.Authenticate(context.Background(), "p123", []byte("password"))
+	if err != nil || string(token.Value) != "test-token-value-that-is-long-enough" {
+		t.Fatalf("unexpected auth result: token=%q err=%v", token.Value, err)
+	}
+
+	rejected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) }))
+	defer rejected.Close()
+	client = NewAuthClient(rejected.URL)
+	_, err = client.Authenticate(context.Background(), "p123", []byte("wrong"))
+	if CodeOf(err) != CodeInvalidCredentials {
+		t.Fatalf("got %s, want %s", CodeOf(err), CodeInvalidCredentials)
+	}
+}
+
+func TestAuthClientRejectsInvalidResponsesAndTimeout(t *testing.T) {
+	htmlFixture, err := os.ReadFile(filepath.Join("testdata", "auth", "html.txt"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	tests := []struct {
+		name, contentType, body string
+		status                  int
+		maxBody                 int64
+		wantCode                string
+	}{
+		{name: "forbidden", status: http.StatusForbidden, contentType: "application/json", body: `{}`, wantCode: CodeInvalidCredentials},
+		{name: "html fixture", status: http.StatusOK, contentType: "text/html", body: string(htmlFixture), wantCode: CodeAuthenticationUnavailable},
+		{name: "malformed JSON", status: http.StatusOK, contentType: "application/json", body: `{`, wantCode: CodeAuthenticationUnavailable},
+		{name: "trailing JSON", status: http.StatusOK, contentType: "application/json", body: `{"token":"test-token-value-that-is-long-enough"}{}`, wantCode: CodeAuthenticationUnavailable},
+		{name: "short token", status: http.StatusOK, contentType: "application/json", body: `{"token":"short"}`, wantCode: CodeAuthenticationUnavailable},
+		{name: "oversized", status: http.StatusOK, contentType: "application/json", body: `{"token":"` + strings.Repeat("a", 100) + `"}`, maxBody: 32, wantCode: CodeAuthenticationUnavailable},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+				w.Header().Set("Content-Type", test.contentType)
+				w.WriteHeader(test.status)
+				_, _ = w.Write([]byte(test.body))
+			}))
+			defer server.Close()
+			client := NewAuthClient(server.URL)
+			if test.maxBody > 0 {
+				client.MaxBody = test.maxBody
+			}
+			_, err := client.Authenticate(context.Background(), "p123", []byte("password"))
+			if CodeOf(err) != test.wantCode {
+				t.Fatalf("got %s, want %s: %v", CodeOf(err), test.wantCode, err)
+			}
+		})
+	}
+
+	timeoutServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		time.Sleep(100 * time.Millisecond)
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"token":"test-token-value-that-is-long-enough"}`))
+	}))
+	defer timeoutServer.Close()
+	client := NewAuthClient(timeoutServer.URL)
+	client.HTTPClient.Timeout = 25 * time.Millisecond
+	_, err = client.Authenticate(context.Background(), "p123", []byte("password"))
+	if CodeOf(err) != CodeTimeout {
+		t.Fatalf("timeout returned %s, want %s: %v", CodeOf(err), CodeTimeout, err)
+	}
+}
+
+func TestAuthClientDoesNotFollowRedirectWithSecrets(t *testing.T) {
+	var destinationHits atomic.Int32
+	destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		destinationHits.Add(1)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer destination.Close()
+	origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, destination.URL, http.StatusTemporaryRedirect)
+	}))
+	defer origin.Close()
+	client := NewAuthClient(origin.URL)
+	_, _ = client.Authenticate(context.Background(), "p123", []byte("password"))
+	if destinationHits.Load() != 0 {
+		t.Fatal("authentication request followed a redirect and exposed credentials")
+	}
+}
+
+func TestAuthClientRejectsControlCharactersBeforeNetwork(t *testing.T) {
+	var hits atomic.Int32
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		hits.Add(1)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+	client := NewAuthClient(server.URL)
+	_, err := client.Authenticate(context.Background(), "p123\r\nInjected", []byte("password"))
+	if CodeOf(err) != CodeInvalidCredentials || hits.Load() != 0 {
+		t.Fatalf("invalid credentials reached the network: code=%s hits=%d err=%v", CodeOf(err), hits.Load(), err)
+	}
+}
+
+func TestAuthErrorsOmitPassword(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer server.Close()
+	client := NewAuthClient(server.URL)
+	password := "TEST-PIA-PASSWORD-MUST-NOT-LEAK"
+	_, err := client.Authenticate(context.Background(), "p123", []byte(password))
+	if err == nil {
+		t.Fatal("expected error")
+	}
+	if containsSecret(err.Error(), password) {
+		t.Fatalf("password leaked in error: %v", err)
+	}
+}

+ 86 - 0
internal/pia/catalog.go

@@ -0,0 +1,86 @@
+package pia
+
+import (
+	"context"
+	"sync"
+	"time"
+)
+
+type Catalog struct {
+	Source   ServerListSource
+	CacheTTL time.Duration
+	Now      func() time.Time
+
+	mu         sync.Mutex
+	cached     []Region
+	schema     string
+	verified   bool
+	fetchedAt  time.Time
+	refreshing chan struct{}
+}
+
+func NewCatalog(source ServerListSource) *Catalog {
+	return &Catalog{Source: source, CacheTTL: DefaultCatalogFreshTTL, Now: time.Now}
+}
+
+func (c *Catalog) ListRegions(ctx context.Context) ([]Region, string, error) {
+	for {
+		c.mu.Lock()
+		age := c.Now().Sub(c.fetchedAt)
+		if len(c.cached) > 0 && c.verified && c.CacheTTL > 0 && age >= 0 && age < c.CacheTTL {
+			regions, schema := cloneRegions(c.cached), c.schema
+			c.mu.Unlock()
+			return regions, schema, nil
+		}
+		if wait := c.refreshing; wait != nil {
+			c.mu.Unlock()
+			select {
+			case <-ctx.Done():
+				return nil, "", ctx.Err()
+			case <-wait:
+				continue
+			}
+		}
+		done := make(chan struct{})
+		c.refreshing = done
+		c.mu.Unlock()
+		return c.fetchAndPublish(ctx, done)
+	}
+}
+
+func (c *Catalog) fetchAndPublish(ctx context.Context, done chan struct{}) ([]Region, string, error) {
+	defer func() {
+		c.mu.Lock()
+		c.refreshing = nil
+		close(done)
+		c.mu.Unlock()
+	}()
+	snapshot, err := c.Source.Fetch(ctx)
+	var regions []Region
+	var schema string
+	if err == nil && !snapshot.SignatureVerified {
+		err = NewError(CodeCatalogSignatureInvalid, "The PIA region list was not signature-verified.")
+	}
+	if err == nil {
+		regions, schema, err = ParseServerList(snapshot.Payload, snapshot.SchemaHint)
+	}
+	if err != nil {
+		return nil, "", err
+	}
+	c.mu.Lock()
+	c.cached = cloneRegions(regions)
+	c.schema = schema
+	c.verified = true
+	c.fetchedAt = c.Now()
+	c.mu.Unlock()
+	return cloneRegions(regions), schema, nil
+}
+
+func cloneRegions(regions []Region) []Region {
+	result := make([]Region, len(regions))
+	for i, region := range regions {
+		result[i] = region
+		result[i].WireGuard = append([]WireGuardServer(nil), region.WireGuard...)
+	}
+	return result
+}

+ 125 - 0
internal/pia/catalog_test.go

@@ -0,0 +1,125 @@
+package pia
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+type fakeServerListSource struct {
+	snapshot ServerListSnapshot
+	err      error
+	calls    int
+}
+
+func (f *fakeServerListSource) Fetch(context.Context) (ServerListSnapshot, error) {
+	f.calls++
+	return f.snapshot, f.err
+}
+
+func TestCatalogCachesOnlyVerifiedParsedSnapshots(t *testing.T) {
+	raw, err := os.ReadFile(filepath.Join("testdata", "serverlist", "v6_valid.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	source := &fakeServerListSource{snapshot: ServerListSnapshot{Payload: raw, SchemaHint: "6", SignatureVerified: true}}
+	now := time.Unix(1_700_000_000, 0)
+	catalog := NewCatalog(source)
+	catalog.CacheTTL = 30 * time.Minute
+	catalog.Now = func() time.Time { return now }
+
+	first, schema, err := catalog.ListRegions(context.Background())
+	if err != nil || schema != "v6" || len(first) != 1 {
+		t.Fatalf("unexpected first result: schema=%q regions=%v err=%v", schema, first, err)
+	}
+	first[0].WireGuard[0].Hostname = "mutated-by-caller"
+	second, _, err := catalog.ListRegions(context.Background())
+	if err != nil || source.calls != 1 {
+		t.Fatalf("verified snapshot was not cached: calls=%d err=%v", source.calls, err)
+	}
+	if second[0].WireGuard[0].Hostname == "mutated-by-caller" {
+		t.Fatal("catalog returned mutable cached storage")
+	}
+
+	now = now.Add(-time.Second)
+	if _, _, err := catalog.ListRegions(context.Background()); err != nil || source.calls != 2 {
+		t.Fatalf("backward clock movement incorrectly extended the cache: calls=%d err=%v", source.calls, err)
+	}
+
+	now = now.Add(catalog.CacheTTL + time.Second)
+	if _, _, err := catalog.ListRegions(context.Background()); err != nil || source.calls != 3 {
+		t.Fatalf("expired snapshot was not refreshed: calls=%d err=%v", source.calls, err)
+	}
+}
+
+type gatedServerListSource struct {
+	snapshot  ServerListSnapshot
+	started   chan struct{}
+	release   chan struct{}
+	startOnce sync.Once
+	calls     atomic.Int32
+}
+
+func (g *gatedServerListSource) Fetch(context.Context) (ServerListSnapshot, error) {
+	g.calls.Add(1)
+	g.startOnce.Do(func() { close(g.started) })
+	<-g.release
+	return g.snapshot, nil
+}
+
+func TestCatalogCoalescesConcurrentRefresh(t *testing.T) {
+	raw, err := os.ReadFile(filepath.Join("testdata", "serverlist", "v6_valid.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	source := &gatedServerListSource{
+		snapshot: ServerListSnapshot{Payload: raw, SchemaHint: "6", SignatureVerified: true},
+		started:  make(chan struct{}),
+		release:  make(chan struct{}),
+	}
+	catalog := NewCatalog(source)
+	catalog.CacheTTL = time.Hour
+
+	errc := make(chan error, 2)
+	go func() {
+		_, _, err := catalog.ListRegions(context.Background())
+		errc <- err
+	}()
+	<-source.started
+	go func() {
+		_, _, err := catalog.ListRegions(context.Background())
+		errc <- err
+	}()
+	deadline := time.Now().Add(200 * time.Millisecond)
+	for time.Now().Before(deadline) {
+		if source.calls.Load() > 1 {
+			close(source.release)
+			t.Fatalf("concurrent refresh issued %d fetches, want 1", source.calls.Load())
+		}
+		time.Sleep(time.Millisecond)
+	}
+	close(source.release)
+	for i := 0; i < 2; i++ {
+		if err := <-errc; err != nil {
+			t.Fatal(err)
+		}
+	}
+	if source.calls.Load() != 1 {
+		t.Fatalf("concurrent refresh issued %d fetches, want 1", source.calls.Load())
+	}
+}
+
+func TestCatalogRejectsUnverifiedSnapshot(t *testing.T) {
+	source := &fakeServerListSource{snapshot: ServerListSnapshot{
+		Payload: []byte(`{"version":6,"groups":{"wg":[]},"regions":[]}`), SchemaHint: "6", SignatureVerified: false,
+	}}
+	catalog := NewCatalog(source)
+	_, _, err := catalog.ListRegions(context.Background())
+	if CodeOf(err) != CodeCatalogSignatureInvalid {
+		t.Fatalf("unverified snapshot returned %s, want %s: %v", CodeOf(err), CodeCatalogSignatureInvalid, err)
+	}
+}

+ 24 - 0
internal/pia/endpoints.go

@@ -0,0 +1,24 @@
+package pia
+
+import (
+	_ "embed"
+	"time"
+)
+
+const (
+	DefaultTokenEndpoint      = "https://www.privateinternetaccess.com/api/client/v2/token"
+	DefaultServerListEndpoint = "https://serverlist.piaservers.net/vpninfo/servers/v6"
+	DefaultAddKeyPort         = uint16(1337)
+	DefaultUserAgent          = "3x-ui-pia/1.0"
+	DefaultMaxServerListBody  = int64(8 << 20)
+	DefaultMaxResponseBody    = int64(64 << 10)
+	DefaultRequestTimeout     = 20 * time.Second
+	DefaultCatalogFreshTTL    = 6 * time.Hour
+	DefaultTokenTTL           = 24 * time.Hour
+)
+
+//go:embed trust/ca.rsa.4096.crt
+var EmbeddedPIACA []byte
+
+//go:embed trust/serverlist_public_key.pem
+var EmbeddedServerListPublicKey []byte

+ 73 - 0
internal/pia/errors.go

@@ -0,0 +1,73 @@
+package pia
+
+import (
+	"errors"
+	"fmt"
+)
+
+const (
+	CodeInvalidInput              = "pia_invalid_input"
+	CodeInvalidCredentials        = "pia_invalid_credentials"
+	CodeAuthenticationUnavailable = "pia_authentication_unavailable"
+	CodeTokenRejected             = "pia_token_rejected"
+	CodeCatalogUnavailable        = "pia_catalog_unavailable"
+	CodeCatalogSignatureInvalid   = "pia_catalog_signature_invalid"
+	CodeCatalogSchemaUnsupported  = "pia_catalog_schema_unsupported"
+	CodeServerNotFound            = "pia_server_not_found"
+	CodeTLSValidation             = "pia_tls_validation"
+	CodeRegistrationRejected      = "pia_registration_rejected"
+	CodeRegistrationInvalid       = "pia_registration_response_invalid"
+	CodeTimeout                   = "pia_timeout"
+	CodeCancelled                 = "pia_cancelled"
+	CodeNetworkUnavailable        = "pia_network_unavailable"
+)
+
+type Error struct {
+	Code    string
+	Message string
+	cause   error
+}
+
+func NewError(code, message string) *Error {
+	return &Error{Code: code, Message: message}
+}
+
+func WrapError(code, message string, cause error) *Error {
+	return &Error{Code: code, Message: message, cause: cause}
+}
+
+func (e *Error) Error() string {
+	if e == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s: %s", e.Code, e.Message)
+}
+
+func (e *Error) Unwrap() error {
+	if e == nil {
+		return nil
+	}
+	return e.cause
+}
+
+func CodeOf(err error) string {
+	if err == nil {
+		return ""
+	}
+	var pe *Error
+	if errors.As(err, &pe) && pe != nil {
+		return pe.Code
+	}
+	return CodeNetworkUnavailable
+}
+
+func MessageOf(err error) string {
+	var pe *Error
+	if errors.As(err, &pe) && pe != nil {
+		return pe.Message
+	}
+	if err == nil {
+		return ""
+	}
+	return "An unexpected error occurred."
+}

+ 37 - 0
internal/pia/errors_test.go

@@ -0,0 +1,37 @@
+package pia
+
+import (
+	"encoding/base64"
+	"errors"
+	"testing"
+)
+
+func TestNilErrorHelpers(t *testing.T) {
+	if code := CodeOf(nil); code != "" {
+		t.Fatalf("CodeOf(nil)=%q, want empty", code)
+	}
+	var typed *Error
+	var err error = typed
+	if code := CodeOf(err); code != CodeNetworkUnavailable {
+		t.Fatalf("CodeOf(nil *Error)=%q, want %q", code, CodeNetworkUnavailable)
+	}
+	if unwrapped := errors.Unwrap(err); unwrapped != nil {
+		t.Fatalf("errors.Unwrap(nil *Error)=%v, want nil", unwrapped)
+	}
+}
+
+func TestValidWGKeyRequiresBase64Encoded32Bytes(t *testing.T) {
+	valid := base64.StdEncoding.EncodeToString(make([]byte, 32))
+	if !validWGKey(valid) {
+		t.Fatalf("valid WireGuard key rejected: %q", valid)
+	}
+	for _, invalid := range []string{
+		"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!",
+		base64.StdEncoding.EncodeToString(make([]byte, 31)),
+		base64.StdEncoding.EncodeToString(make([]byte, 33)),
+	} {
+		if validWGKey(invalid) {
+			t.Fatalf("invalid WireGuard key accepted: %q", invalid)
+		}
+	}
+}

+ 100 - 0
internal/pia/http_helpers.go

@@ -0,0 +1,100 @@
+package pia
+
+import (
+	"bytes"
+	"context"
+	"crypto/x509"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"mime"
+	"net"
+	"net/http"
+	"strings"
+)
+
+func readLimitedBody(body io.Reader, limit int64) ([]byte, error) {
+	raw, err := io.ReadAll(io.LimitReader(body, limit+1))
+	if err != nil {
+		return nil, err
+	}
+	if int64(len(raw)) > limit {
+		return nil, fmt.Errorf("response exceeds %d bytes", limit)
+	}
+	return raw, nil
+}
+
+func expectedContentType(header string, accepted ...string) bool {
+	mediaType, _, err := mime.ParseMediaType(header)
+	if err != nil {
+		return false
+	}
+	for _, candidate := range accepted {
+		if strings.EqualFold(mediaType, candidate) {
+			return true
+		}
+	}
+	return false
+}
+
+func noRedirect(_ *http.Request, _ []*http.Request) error {
+	return errors.New("redirects are disabled for this request")
+}
+
+func decodeSingleJSON(raw []byte, target any) error {
+	decoder := json.NewDecoder(bytes.NewReader(raw))
+	decoder.UseNumber()
+	if err := decoder.Decode(target); err != nil {
+		return err
+	}
+	var extra any
+	if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
+		if err == nil {
+			return errors.New("multiple JSON values are not allowed")
+		}
+		return err
+	}
+	return nil
+}
+
+func classifyNetworkError(ctx context.Context, fallback, message string, err error) error {
+	cause := redactNetErr(err)
+	if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) {
+		return WrapError(CodeCancelled, "The operation was cancelled.", cause)
+	}
+	if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
+		return WrapError(CodeTimeout, "The network request timed out.", cause)
+	}
+	var netErr net.Error
+	if errors.As(err, &netErr) && netErr.Timeout() {
+		return WrapError(CodeTimeout, "The network request timed out.", cause)
+	}
+	var unknownAuthority x509.UnknownAuthorityError
+	var hostnameError x509.HostnameError
+	var invalidCertificate x509.CertificateInvalidError
+	if errors.As(err, &unknownAuthority) || errors.As(err, &hostnameError) || errors.As(err, &invalidCertificate) {
+		return WrapError(CodeTLSValidation, "PIA's server identity could not be verified.", cause)
+	}
+	return WrapError(fallback, message, cause)
+}
+
+type redactedCause struct{ kind string }
+
+func (e redactedCause) Error() string { return e.kind }
+
+func redactNetErr(err error) error {
+	if err == nil {
+		return nil
+	}
+	return redactedCause{kind: "network error"}
+}
+
+func containsSecret(s string, secrets ...string) bool {
+	for _, secret := range secrets {
+		if secret != "" && strings.Contains(s, secret) {
+			return true
+		}
+	}
+	return false
+}

+ 147 - 0
internal/pia/register.go

@@ -0,0 +1,147 @@
+package pia
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"net"
+	"net/http"
+	"net/netip"
+	"net/url"
+	"strconv"
+	"time"
+)
+
+type RegistrationClient struct {
+	CAPEM     []byte
+	Port      uint16
+	MaxBody   int64
+	Timeout   time.Duration
+	UserAgent string
+}
+
+func NewRegistrationClient(caPEM []byte) *RegistrationClient {
+	return &RegistrationClient{
+		CAPEM: caPEM, Port: DefaultAddKeyPort, MaxBody: DefaultMaxResponseBody,
+		Timeout: DefaultRequestTimeout, UserAgent: DefaultUserAgent,
+	}
+}
+
+func (c *RegistrationClient) RegisterKey(ctx context.Context, server WireGuardServer, token string, publicKey string) (Registration, error) {
+	if !server.IP.IsValid() || !server.IP.Is4() || !validHostname(server.Hostname) {
+		return Registration{}, NewError(CodeInvalidInput, "The selected PIA WireGuard server is invalid.")
+	}
+	if !validSecret([]byte(token), 16, 4096) {
+		return Registration{}, NewError(CodeTokenRejected, "The PIA authentication token is invalid.")
+	}
+	if !validWGKey(publicKey) {
+		return Registration{}, NewError(CodeInvalidInput, "The WireGuard public key is invalid.")
+	}
+	roots := x509.NewCertPool()
+	if !roots.AppendCertsFromPEM(c.CAPEM) {
+		return Registration{}, NewError(CodeTLSValidation, "The built-in PIA certificate authority is invalid.")
+	}
+	port := c.Port
+	if port == 0 {
+		port = DefaultAddKeyPort
+	}
+	dialer := &net.Dialer{Timeout: 8 * time.Second, KeepAlive: 30 * time.Second}
+	transport := &http.Transport{
+		Proxy: nil,
+		DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
+			return dialer.DialContext(ctx, network, net.JoinHostPort(server.IP.String(), strconv.Itoa(int(port))))
+		},
+		TLSClientConfig:     &tls.Config{ServerName: server.Hostname, RootCAs: roots, MinVersion: tls.VersionTLS12},
+		TLSHandshakeTimeout: 8 * time.Second, ResponseHeaderTimeout: 12 * time.Second, ForceAttemptHTTP2: true,
+	}
+	defer transport.CloseIdleConnections()
+	client := &http.Client{Transport: transport, Timeout: c.Timeout, CheckRedirect: noRedirect}
+	endpoint := url.URL{Scheme: "https", Host: net.JoinHostPort(server.Hostname, strconv.Itoa(int(port))), Path: "/addKey"}
+	query := endpoint.Query()
+	query.Set("pt", token)
+	query.Set("pubkey", publicKey)
+	endpoint.RawQuery = query.Encode()
+	request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+	if err != nil {
+		return Registration{}, WrapError(CodeRegistrationRejected, "Could not prepare PIA key registration.", err)
+	}
+	request.Header.Set("Accept", "application/json")
+	request.Header.Set("User-Agent", c.UserAgent)
+	response, err := client.Do(request)
+	if err != nil {
+		return Registration{}, classifyNetworkError(ctx, CodeNetworkUnavailable, "The selected PIA WireGuard server could not be reached.", err)
+	}
+	defer response.Body.Close()
+	if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
+		return Registration{}, NewError(CodeTokenRejected, "The PIA authentication token was rejected.")
+	}
+	if response.StatusCode != http.StatusOK {
+		return Registration{}, NewError(CodeRegistrationRejected, fmt.Sprintf("PIA key registration returned HTTP %d.", response.StatusCode))
+	}
+	if !expectedContentType(response.Header.Get("Content-Type"), "application/json") {
+		return Registration{}, NewError(CodeRegistrationInvalid, "PIA key registration returned an unexpected content type.")
+	}
+	raw, err := readLimitedBody(response.Body, c.MaxBody)
+	if err != nil {
+		return Registration{}, WrapError(CodeRegistrationInvalid, "PIA key registration returned an invalid response.", err)
+	}
+	return parseRegistration(raw)
+}
+
+func parseRegistration(raw []byte) (Registration, error) {
+	var payload struct {
+		Status     string   `json:"status"`
+		PeerIP     string   `json:"peer_ip"`
+		ServerKey  string   `json:"server_key"`
+		ServerIP   string   `json:"server_ip"`
+		ServerPort int      `json:"server_port"`
+		DNSServers []string `json:"dns_servers"`
+	}
+	if err := decodeSingleJSON(raw, &payload); err != nil {
+		return Registration{}, NewError(CodeRegistrationInvalid, "PIA key registration returned malformed JSON.")
+	}
+	if payload.Status != "OK" {
+		return Registration{}, NewError(CodeRegistrationRejected, "The PIA server rejected WireGuard key registration.")
+	}
+	peerIP, err := parsePeerIP(payload.PeerIP)
+	if err != nil {
+		return Registration{}, NewError(CodeRegistrationInvalid, "PIA returned an invalid WireGuard peer address.")
+	}
+	if !validWGKey(payload.ServerKey) {
+		return Registration{}, NewError(CodeRegistrationInvalid, "PIA returned an invalid WireGuard server key.")
+	}
+	serverIP, err := netip.ParseAddr(payload.ServerIP)
+	if err != nil || !serverIP.Is4() || serverIP.IsUnspecified() {
+		return Registration{}, NewError(CodeRegistrationInvalid, "PIA returned an invalid WireGuard server address.")
+	}
+	if payload.ServerPort < 1 || payload.ServerPort > 65535 {
+		return Registration{}, NewError(CodeRegistrationInvalid, "PIA returned an invalid WireGuard server port.")
+	}
+	dns := make([]netip.Addr, 0, len(payload.DNSServers))
+	for _, value := range payload.DNSServers {
+		address, err := netip.ParseAddr(value)
+		if err != nil || !address.Is4() || address.IsUnspecified() {
+			continue
+		}
+		if len(dns) == 8 {
+			break
+		}
+		dns = append(dns, address)
+	}
+	return Registration{PeerIP: peerIP, ServerKey: payload.ServerKey, ServerIP: serverIP, ServerPort: uint16(payload.ServerPort), DNSServers: dns}, nil
+}
+
+func parsePeerIP(value string) (netip.Prefix, error) {
+	if address, err := netip.ParseAddr(value); err == nil {
+		if !address.Is4() || address.IsUnspecified() {
+			return netip.Prefix{}, fmt.Errorf("peer address is not a usable IPv4 address")
+		}
+		return netip.PrefixFrom(address, 32), nil
+	}
+	prefix, err := netip.ParsePrefix(value)
+	if err != nil || !prefix.Addr().Is4() || prefix.Addr().IsUnspecified() || prefix.Bits() != 32 {
+		return netip.Prefix{}, fmt.Errorf("peer address is not an IPv4 host prefix")
+	}
+	return prefix, nil
+}

+ 260 - 0
internal/pia/register_test.go

@@ -0,0 +1,260 @@
+package pia
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/base64"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"net/netip"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func testPubKey() string {
+	raw := make([]byte, 32)
+	raw[0] = 1
+	return base64.StdEncoding.EncodeToString(raw)
+}
+
+func TestParseRegistrationFixture(t *testing.T) {
+	raw, err := os.ReadFile(filepath.Join("testdata", "addkey", "success.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	result, err := parseRegistration(raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if result.PeerIP.String() != "10.42.0.2/32" || result.ServerPort != 51820 || result.ServerIP.String() != "198.51.100.10" || len(result.DNSServers) != 2 {
+		t.Fatalf("unexpected registration result: %+v", result)
+	}
+	prefixed := []byte(`{"status":"OK","peer_ip":"10.42.0.3/32","server_key":"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","server_ip":"198.51.100.10","server_port":51820,"dns_servers":["10.0.0.242"]}`)
+	prefixedResult, err := parseRegistration(prefixed)
+	if err != nil || prefixedResult.PeerIP.String() != "10.42.0.3/32" {
+		t.Fatalf("expected an explicit /32 peer address to remain supported: result=%+v err=%v", prefixedResult, err)
+	}
+	missing, err := os.ReadFile(filepath.Join("testdata", "addkey", "missing_port.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := parseRegistration(missing); err == nil || CodeOf(err) != CodeRegistrationInvalid {
+		t.Fatalf("expected missing server port to be rejected: %v", err)
+	}
+
+	dnsRaw, err := os.ReadFile(filepath.Join("testdata", "addkey", "invalid_dns.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	dnsResult, err := parseRegistration(dnsRaw)
+	if err != nil || len(dnsResult.DNSServers) != 0 {
+		t.Fatalf("invalid dns_servers must be ignored: result=%+v err=%v", dnsResult, err)
+	}
+
+	invalidFixtures := []struct{ file, wantCode string }{
+		{"status_error.json", CodeRegistrationRejected},
+		{"invalid_peer_ip.json", CodeRegistrationInvalid},
+		{"invalid_peer_prefix.json", CodeRegistrationInvalid},
+		{"invalid_server_key.json", CodeRegistrationInvalid},
+		{"invalid_server_ip.json", CodeRegistrationInvalid},
+		{"invalid_port.json", CodeRegistrationInvalid},
+	}
+	for _, test := range invalidFixtures {
+		t.Run(test.file, func(t *testing.T) {
+			raw, readErr := os.ReadFile(filepath.Join("testdata", "addkey", test.file))
+			if readErr != nil {
+				t.Fatal(readErr)
+			}
+			if _, parseErr := parseRegistration(raw); CodeOf(parseErr) != test.wantCode {
+				t.Fatalf("expected %s, got %s: %v", test.wantCode, CodeOf(parseErr), parseErr)
+			}
+		})
+	}
+}
+
+func TestRegistrationTLSHostnameAndCA(t *testing.T) {
+	fixture, err := os.ReadFile(filepath.Join("testdata", "addkey", "success.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	const token = "test-token-value-that-is-long-enough"
+	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Query().Get("pt") != token || r.URL.Query().Get("pubkey") == "" {
+			t.Errorf("registration query is missing required values")
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write(fixture)
+	}))
+	server.StartTLS()
+	defer server.Close()
+	certificate := server.Certificate()
+	caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw})
+	host, portText, err := net.SplitHostPort(server.Listener.Addr().String())
+	if err != nil {
+		t.Fatal(err)
+	}
+	port, err := net.LookupPort("tcp", portText)
+	if err != nil {
+		t.Fatal(err)
+	}
+	client := NewRegistrationClient(caPEM)
+	client.Port = uint16(port)
+	key := testPubKey()
+	t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
+	_, err = client.RegisterKey(context.Background(), WireGuardServer{Hostname: "example.com", IP: netip.MustParseAddr(host)}, token, key)
+	if err != nil {
+		t.Fatalf("expected TLS registration to succeed: %v", err)
+	}
+	_, err = client.RegisterKey(context.Background(), WireGuardServer{Hostname: "example.com", IP: netip.MustParseAddr(host)}, token, "")
+	if CodeOf(err) != CodeInvalidInput {
+		t.Fatalf("zero public key returned %s, want %s", CodeOf(err), CodeInvalidInput)
+	}
+	_, err = client.RegisterKey(context.Background(), WireGuardServer{Hostname: "wrong.example", IP: netip.MustParseAddr(host)}, token, key)
+	if CodeOf(err) != CodeTLSValidation {
+		t.Fatalf("wrong hostname returned %s, want %s: %v", CodeOf(err), CodeTLSValidation, err)
+	}
+	client = NewRegistrationClient([]byte("-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"))
+	client.Port = uint16(port)
+	_, err = client.RegisterKey(context.Background(), WireGuardServer{Hostname: "example.com", IP: netip.MustParseAddr(host)}, token, key)
+	if CodeOf(err) != CodeTLSValidation {
+		t.Fatalf("wrong CA returned %s, want %s", CodeOf(err), CodeTLSValidation)
+	}
+
+	expiredServer, expiredCA := newExpiredTLSServer(t, fixture)
+	defer expiredServer.Close()
+	expiredHost, expiredPortText, err := net.SplitHostPort(expiredServer.Listener.Addr().String())
+	if err != nil {
+		t.Fatal(err)
+	}
+	expiredPort, err := net.LookupPort("tcp", expiredPortText)
+	if err != nil {
+		t.Fatal(err)
+	}
+	client = NewRegistrationClient(expiredCA)
+	client.Port = uint16(expiredPort)
+	_, err = client.RegisterKey(context.Background(), WireGuardServer{Hostname: "example.com", IP: netip.MustParseAddr(expiredHost)}, token, key)
+	if CodeOf(err) != CodeTLSValidation {
+		t.Fatalf("expired certificate returned %s, want %s: %v", CodeOf(err), CodeTLSValidation, err)
+	}
+}
+
+func TestRegistrationResponseGuardsAndRedirect(t *testing.T) {
+	var destinationHits atomic.Int32
+	destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		destinationHits.Add(1)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer destination.Close()
+
+	tests := []struct {
+		name, contentType, body, redirect string
+		maxBody                           int64
+		delay                             time.Duration
+		wantCode                          string
+	}{
+		{name: "HTML", contentType: "text/html", body: "<html>maintenance</html>", wantCode: CodeRegistrationInvalid},
+		{name: "oversized", contentType: "application/json", body: strings.Repeat("x", 65), maxBody: 64, wantCode: CodeRegistrationInvalid},
+		{name: "redirect", contentType: "application/json", redirect: destination.URL, wantCode: CodeNetworkUnavailable},
+		{name: "timeout", contentType: "application/json", body: `{"status":"OK"}`, delay: 100 * time.Millisecond, wantCode: CodeTimeout},
+	}
+	const token = "test-token-value-that-is-long-enough"
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				if test.delay > 0 {
+					time.Sleep(test.delay)
+				}
+				if test.redirect != "" {
+					http.Redirect(w, r, test.redirect, http.StatusTemporaryRedirect)
+					return
+				}
+				w.Header().Set("Content-Type", test.contentType)
+				_, _ = w.Write([]byte(test.body))
+			}))
+			server.StartTLS()
+			defer server.Close()
+			caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
+			host, portText, err := net.SplitHostPort(server.Listener.Addr().String())
+			if err != nil {
+				t.Fatal(err)
+			}
+			port, err := net.LookupPort("tcp", portText)
+			if err != nil {
+				t.Fatal(err)
+			}
+			client := NewRegistrationClient(caPEM)
+			client.Port = uint16(port)
+			if test.maxBody > 0 {
+				client.MaxBody = test.maxBody
+			}
+			if test.delay > 0 {
+				client.Timeout = 25 * time.Millisecond
+			}
+			_, err = client.RegisterKey(
+				context.Background(),
+				WireGuardServer{Hostname: "example.com", IP: netip.MustParseAddr(host)},
+				token,
+				testPubKey(),
+			)
+			if err == nil {
+				t.Fatal("expected registration error")
+			}
+			if CodeOf(err) != test.wantCode {
+				t.Fatalf("got %s, want %s: %v", CodeOf(err), test.wantCode, err)
+			}
+			if containsSecret(err.Error(), token) {
+				t.Fatalf("token leaked in error: %v", err)
+			}
+		})
+	}
+	if destinationHits.Load() != 0 {
+		t.Fatal("registration request followed a redirect and exposed secrets")
+	}
+}
+
+func newExpiredTLSServer(t *testing.T, response []byte) (*httptest.Server, []byte) {
+	t.Helper()
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatal(err)
+	}
+	template := &x509.Certificate{
+		SerialNumber:          big.NewInt(1),
+		Subject:               pkix.Name{CommonName: "example.com"},
+		DNSNames:              []string{"example.com"},
+		NotBefore:             time.Now().Add(-48 * time.Hour),
+		NotAfter:              time.Now().Add(-24 * time.Hour),
+		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		IsCA:                  true,
+		BasicConstraintsValid: true,
+	}
+	der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
+	if err != nil {
+		t.Fatal(err)
+	}
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
+	certificate, err := tls.X509KeyPair(certPEM, keyPEM)
+	if err != nil {
+		t.Fatal(err)
+	}
+	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write(response)
+	}))
+	server.TLS = &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
+	server.StartTLS()
+	return server, certPEM
+}

+ 79 - 0
internal/pia/serverlist_client.go

@@ -0,0 +1,79 @@
+package pia
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"strings"
+)
+
+type ServerListSource interface {
+	Fetch(ctx context.Context) (ServerListSnapshot, error)
+}
+
+type ServerListSnapshot struct {
+	Payload           []byte
+	SchemaHint        string
+	SignatureVerified bool
+}
+
+type CatalogClient struct {
+	Endpoint     string
+	PublicKeyPEM []byte
+	HTTPClient   *http.Client
+	MaxBody      int64
+	UserAgent    string
+}
+
+func NewCatalogClient(endpoint string, publicKey []byte) *CatalogClient {
+	return &CatalogClient{
+		Endpoint:     endpoint,
+		PublicKeyPEM: publicKey,
+		MaxBody:      DefaultMaxServerListBody,
+		UserAgent:    DefaultUserAgent,
+		HTTPClient:   &http.Client{Timeout: DefaultRequestTimeout, CheckRedirect: noRedirect},
+	}
+}
+
+func (c *CatalogClient) Fetch(ctx context.Context) (ServerListSnapshot, error) {
+	request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoint, nil)
+	if err != nil {
+		return ServerListSnapshot{}, WrapError(CodeCatalogUnavailable, "The PIA region-list endpoint is invalid.", err)
+	}
+	request.Header.Set("Accept", "application/json, text/plain;q=0.9")
+	request.Header.Set("User-Agent", c.UserAgent)
+	response, err := c.HTTPClient.Do(request)
+	if err != nil {
+		return ServerListSnapshot{}, classifyNetworkError(ctx, CodeCatalogUnavailable, "The PIA region list could not be downloaded.", err)
+	}
+	defer response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		return ServerListSnapshot{}, NewError(CodeCatalogUnavailable, fmt.Sprintf("PIA returned HTTP %d for the region list.", response.StatusCode))
+	}
+	if !expectedContentType(response.Header.Get("Content-Type"), "application/json", "text/plain", "application/octet-stream") {
+		return ServerListSnapshot{}, NewError(CodeCatalogSchemaUnsupported, "PIA returned an unexpected region-list content type.")
+	}
+	raw, err := readLimitedBody(response.Body, c.MaxBody)
+	if err != nil {
+		return ServerListSnapshot{}, WrapError(CodeCatalogUnavailable, "The PIA region-list response is too large or incomplete.", err)
+	}
+	verified, err := VerifySignedServerList(raw, c.PublicKeyPEM)
+	if err != nil {
+		return ServerListSnapshot{}, err
+	}
+	return ServerListSnapshot{Payload: verified, SchemaHint: schemaHint(c.Endpoint), SignatureVerified: true}, nil
+}
+
+func schemaHint(endpoint string) string {
+	parsed, err := url.Parse(endpoint)
+	if err != nil {
+		return ""
+	}
+	base := strings.ToLower(path.Base(parsed.Path))
+	if base == "v6" || base == "v7" {
+		return strings.TrimPrefix(base, "v")
+	}
+	return ""
+}

+ 108 - 0
internal/pia/serverlist_client_test.go

@@ -0,0 +1,108 @@
+package pia
+
+import (
+	"context"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestCatalogClientReturnsExplicitlyVerifiedSnapshot(t *testing.T) {
+	payload := []byte(`{"version":6,"groups":{"wg":[]},"regions":[]}`)
+	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatal(err)
+	}
+	digest := sha256.Sum256(payload)
+	signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:])
+	if err != nil {
+		t.Fatal(err)
+	}
+	publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	signed := append(append(append([]byte{}, payload...), '\n'), []byte(base64.StdEncoding.EncodeToString(signature))...)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/octet-stream")
+		_, _ = w.Write(signed)
+	}))
+	defer server.Close()
+	client := NewCatalogClient(server.URL+"/v6", pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
+	snapshot, err := client.Fetch(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !snapshot.SignatureVerified || snapshot.SchemaHint != "6" || string(snapshot.Payload) != string(payload) {
+		t.Fatalf("unexpected verified snapshot: %+v", snapshot)
+	}
+}
+
+func TestCatalogClientRejectsUnsafeResponses(t *testing.T) {
+	tests := []struct {
+		name, contentType, body string
+		maxBody                 int64
+		wantCode                string
+	}{
+		{name: "html", contentType: "text/html", body: "<html>maintenance</html>", wantCode: CodeCatalogSchemaUnsupported},
+		{name: "oversized", contentType: "application/octet-stream", body: strings.Repeat("x", 65), maxBody: 64, wantCode: CodeCatalogUnavailable},
+		{name: "unsigned", contentType: "application/json", body: `{"version":6,"groups":{},"regions":[]}`, wantCode: CodeCatalogSignatureInvalid},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+				w.Header().Set("Content-Type", test.contentType)
+				_, _ = w.Write([]byte(test.body))
+			}))
+			defer server.Close()
+			client := NewCatalogClient(server.URL+"/v6", []byte("invalid public key"))
+			if test.maxBody > 0 {
+				client.MaxBody = test.maxBody
+			}
+			_, err := client.Fetch(context.Background())
+			if CodeOf(err) != test.wantCode {
+				t.Fatalf("got %s, want %s: %v", CodeOf(err), test.wantCode, err)
+			}
+		})
+	}
+
+	payload := []byte(`{"version":6,"groups":{"wg":[]},"regions":[]}`)
+	signingKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatal(err)
+	}
+	digest := sha256.Sum256(payload)
+	signature, err := rsa.SignPKCS1v15(rand.Reader, signingKey, crypto.SHA256, digest[:])
+	if err != nil {
+		t.Fatal(err)
+	}
+	signed := append(append(append([]byte{}, payload...), '\n'), []byte(base64.StdEncoding.EncodeToString(signature))...)
+	wrongKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatal(err)
+	}
+	wrongDER, err := x509.MarshalPKIXPublicKey(&wrongKey.PublicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Run("valid signature from unpinned key", func(t *testing.T) {
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+			w.Header().Set("Content-Type", "application/octet-stream")
+			_, _ = w.Write(signed)
+		}))
+		defer server.Close()
+		client := NewCatalogClient(server.URL+"/v6", pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: wrongDER}))
+		_, err := client.Fetch(context.Background())
+		if CodeOf(err) != CodeCatalogSignatureInvalid {
+			t.Fatalf("got %s, want %s: %v", CodeOf(err), CodeCatalogSignatureInvalid, err)
+		}
+	})
+}

+ 196 - 0
internal/pia/serverlist_parser.go

@@ -0,0 +1,196 @@
+package pia
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/netip"
+	"regexp"
+	"sort"
+	"strings"
+)
+
+var (
+	regionIDPattern    = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
+	countryCodePattern = regexp.MustCompile(`^[A-Za-z]{2}$`)
+)
+
+type ServerListParser interface {
+	Schema() string
+	CanParse(raw []byte) bool
+	Parse(raw []byte) ([]Region, error)
+}
+
+type (
+	V6Parser struct{}
+	V7Parser struct{}
+)
+
+func (V6Parser) Schema() string { return "v6" }
+func (V7Parser) Schema() string { return "v7" }
+
+func (V6Parser) CanParse(raw []byte) bool { return schemaVersion(raw) == 0 || schemaVersion(raw) == 6 }
+func (V7Parser) CanParse(raw []byte) bool { return schemaVersion(raw) == 7 }
+
+func (V6Parser) Parse(raw []byte) ([]Region, error) { return parseCatalog(raw, false) }
+func (V7Parser) Parse(raw []byte) ([]Region, error) { return parseCatalog(raw, true) }
+
+type catalogEnvelope struct {
+	Version json.RawMessage            `json:"version"`
+	Groups  map[string]json.RawMessage `json:"groups"`
+	Regions []rawRegion                `json:"regions"`
+}
+
+type rawRegion struct {
+	ID             string     `json:"id"`
+	Name           string     `json:"name"`
+	Country        string     `json:"country"`
+	Geo            *bool      `json:"geo"`
+	Offline        *bool      `json:"offline"`
+	PortForward    *bool      `json:"port_forward"`
+	PortForwarding *bool      `json:"port_forwarding"`
+	Servers        rawServers `json:"servers"`
+}
+
+type rawServers struct {
+	WireGuard []rawServer `json:"wg"`
+}
+
+type rawServer struct {
+	IP       string `json:"ip"`
+	CN       string `json:"cn"`
+	Hostname string `json:"hostname"`
+}
+
+func ParseServerList(raw []byte, schemaHint string) ([]Region, string, error) {
+	parsers := []ServerListParser{V7Parser{}, V6Parser{}}
+	version, present, err := detectSchemaVersion(raw)
+	if err != nil {
+		return nil, "", WrapError(CodeCatalogSchemaUnsupported, "PIA returned an invalid server-list version.", err)
+	}
+	if present {
+		for _, parser := range parsers {
+			if strings.TrimPrefix(parser.Schema(), "v") == fmt.Sprint(version) {
+				regions, parseErr := parser.Parse(raw)
+				return regions, parser.Schema(), parseErr
+			}
+		}
+		return nil, "", NewError(CodeCatalogSchemaUnsupported, "This PIA server-list schema is not supported.")
+	}
+
+	hint := strings.ToLower(strings.TrimPrefix(schemaHint, "v"))
+	if hint != "" {
+		for _, parser := range parsers {
+			if strings.TrimPrefix(parser.Schema(), "v") != hint {
+				continue
+			}
+			regions, err := parser.Parse(raw)
+			return regions, parser.Schema(), err
+		}
+	}
+	for _, parser := range parsers {
+		if parser.CanParse(raw) {
+			regions, err := parser.Parse(raw)
+			return regions, parser.Schema(), err
+		}
+	}
+	return nil, "", NewError(CodeCatalogSchemaUnsupported, "This PIA server-list schema is not supported.")
+}
+
+func schemaVersion(raw []byte) int {
+	version, present, err := detectSchemaVersion(raw)
+	if err != nil || !present {
+		return 0
+	}
+	return version
+}
+
+func detectSchemaVersion(raw []byte) (int, bool, error) {
+	var envelope struct {
+		Version json.RawMessage `json:"version"`
+	}
+	if err := json.Unmarshal(raw, &envelope); err != nil {
+		return 0, false, err
+	}
+	if len(envelope.Version) == 0 || string(envelope.Version) == "null" {
+		return 0, false, nil
+	}
+	var number int
+	if json.Unmarshal(envelope.Version, &number) == nil {
+		if number < 1 {
+			return 0, true, fmt.Errorf("version must be positive")
+		}
+		return number, true, nil
+	}
+	var text string
+	if json.Unmarshal(envelope.Version, &text) == nil {
+		text = strings.TrimPrefix(strings.ToLower(text), "v")
+		if _, err := fmt.Sscanf(text, "%d", &number); err == nil && fmt.Sprint(number) == text && number > 0 {
+			return number, true, nil
+		}
+	}
+	return 0, true, fmt.Errorf("version has an unsupported type or value")
+}
+
+func parseCatalog(raw []byte, allowV7Aliases bool) ([]Region, error) {
+	var envelope catalogEnvelope
+	if err := decodeSingleJSON(raw, &envelope); err != nil {
+		return nil, WrapError(CodeCatalogSchemaUnsupported, "PIA returned an invalid region list.", err)
+	}
+	if len(envelope.Groups) == 0 || len(envelope.Regions) == 0 {
+		return nil, NewError(CodeCatalogSchemaUnsupported, "The PIA region list is missing required fields.")
+	}
+
+	seen := make(map[string]struct{}, len(envelope.Regions))
+	regions := make([]Region, 0, len(envelope.Regions))
+	for _, rawRegion := range envelope.Regions {
+		if !regionIDPattern.MatchString(rawRegion.ID) || strings.TrimSpace(rawRegion.Name) == "" || len(rawRegion.Name) > 128 {
+			continue
+		}
+		idKey := strings.ToLower(rawRegion.ID)
+		if _, duplicate := seen[idKey]; duplicate {
+			continue
+		}
+		seen[idKey] = struct{}{}
+		if !countryCodePattern.MatchString(rawRegion.Country) || rawRegion.Geo == nil || rawRegion.Offline == nil {
+			continue
+		}
+		if *rawRegion.Offline {
+			continue
+		}
+		portForwarding := false
+		if rawRegion.PortForward != nil {
+			portForwarding = *rawRegion.PortForward
+		} else if allowV7Aliases && rawRegion.PortForwarding != nil {
+			portForwarding = *rawRegion.PortForwarding
+		}
+		servers := make([]WireGuardServer, 0, len(rawRegion.Servers.WireGuard))
+		for _, rawServer := range rawRegion.Servers.WireGuard {
+			hostname := rawServer.CN
+			if hostname == "" && allowV7Aliases {
+				hostname = rawServer.Hostname
+			}
+			ip, err := netip.ParseAddr(rawServer.IP)
+			if err != nil || !ip.Is4() || ip.IsUnspecified() || !validHostname(hostname) {
+				continue
+			}
+			servers = append(servers, WireGuardServer{Hostname: hostname, IP: ip})
+		}
+		if len(servers) == 0 {
+			continue
+		}
+		regions = append(regions, Region{
+			ID: rawRegion.ID, Name: rawRegion.Name, CountryCode: strings.ToUpper(rawRegion.Country), Geo: *rawRegion.Geo,
+			PortForwarding: portForwarding, WireGuard: servers,
+		})
+	}
+	if len(regions) == 0 {
+		return nil, NewError(CodeCatalogSchemaUnsupported, "The PIA region list contains no available WireGuard regions.")
+	}
+	sort.Slice(regions, func(i, j int) bool {
+		if regions[i].CountryCode == regions[j].CountryCode {
+			return regions[i].Name < regions[j].Name
+		}
+		return regions[i].CountryCode < regions[j].CountryCode
+	})
+	return regions, nil
+}

+ 100 - 0
internal/pia/serverlist_parser_test.go

@@ -0,0 +1,100 @@
+package pia
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestServerListAdapters(t *testing.T) {
+	tests := []struct {
+		file, hint, schema, id, hostname string
+	}{
+		{"v6_valid.json", "6", "v6", "us-east", "useast401"},
+		{"v7_valid.json", "7", "v7", "de-berlin", "berlin501"},
+	}
+	for _, test := range tests {
+		raw, err := os.ReadFile(filepath.Join("testdata", "serverlist", test.file))
+		if err != nil {
+			t.Fatal(err)
+		}
+		regions, schema, err := ParseServerList(raw, test.hint)
+		if err != nil {
+			t.Fatalf("%s: %v", test.file, err)
+		}
+		if schema != test.schema || len(regions) != 1 || regions[0].ID != test.id || regions[0].WireGuard[0].Hostname != test.hostname {
+			t.Fatalf("unexpected parsed result for %s: schema=%s regions=%+v", test.file, schema, regions)
+		}
+	}
+
+	raw, err := os.ReadFile(filepath.Join("testdata", "serverlist", "v7_valid.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	regions, schema, err := ParseServerList(raw, "6")
+	if err != nil || schema != "v7" || regions[0].ID != "de-berlin" {
+		t.Fatalf("detected schema did not override a stale endpoint hint: schema=%q regions=%v err=%v", schema, regions, err)
+	}
+
+	legacy := []byte(`{"groups":{"wg":[]},"regions":[{"id":"legacy","name":"Legacy","country":"US","geo":false,"offline":false,"servers":{"wg":[{"ip":"198.51.100.9","cn":"legacy.example"}]}}]}`)
+	regions, schema, err = ParseServerList(legacy, "")
+	if err != nil || schema != "v6" || regions[0].ID != "legacy" {
+		t.Fatalf("versionless v6 fallback failed: schema=%q regions=%v err=%v", schema, regions, err)
+	}
+}
+
+func TestServerListRejectsMalformedFields(t *testing.T) {
+	raw, err := os.ReadFile(filepath.Join("testdata", "serverlist", "malformed.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, _, err := ParseServerList(raw, "6"); err == nil || CodeOf(err) != CodeCatalogSchemaUnsupported {
+		t.Fatalf("expected %s for malformed server list, got %s: %v", CodeCatalogSchemaUnsupported, CodeOf(err), err)
+	}
+}
+
+func TestServerListRejectsUnsupportedDuplicateAndTrailingData(t *testing.T) {
+	tests := []struct {
+		name, raw, hint string
+	}{
+		{"unsupported schema", `{"version":99,"groups":{},"regions":[]}`, ""},
+		{"invalid version value", `{"version":"v7beta","groups":{"wg":[]},"regions":[]}`, ""},
+		{"trailing JSON", `{"version":6,"groups":{},"regions":[]} {}`, "6"},
+		{"wrong groups type", `{"version":6,"groups":[],"regions":[]}`, "6"},
+		{"wrong field type", `{"version":6,"groups":{"wg":[]},"regions":[{"id":7,"name":"One","country":"US","geo":false,"offline":false,"servers":{"wg":[]}}]}`, "6"},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			if _, _, err := ParseServerList([]byte(test.raw), test.hint); err == nil || CodeOf(err) != CodeCatalogSchemaUnsupported {
+				t.Fatalf("expected %s, got %s: %v", CodeCatalogSchemaUnsupported, CodeOf(err), err)
+			}
+		})
+	}
+}
+
+func TestServerListSkipsBadRows(t *testing.T) {
+	duplicate := []byte(`{"version":6,"groups":{"wg":[]},"regions":[{"id":"same","name":"One","country":"US","geo":false,"offline":false,"servers":{"wg":[{"ip":"198.51.100.1","cn":"one.example"}]}},{"id":"SAME","name":"Two","country":"US","geo":false,"offline":false,"servers":{"wg":[{"ip":"198.51.100.2","cn":"two.example"}]}}]}`)
+	regions, _, err := ParseServerList(duplicate, "6")
+	if err != nil || len(regions) != 1 || regions[0].ID != "same" || regions[0].WireGuard[0].Hostname != "one.example" {
+		t.Fatalf("duplicate region id should keep the first: regions=%+v err=%v", regions, err)
+	}
+
+	mixed := []byte(`{"version":6,"groups":{"wg":[]},"regions":[{"id":"us-east","name":"US East","country":"US","geo":false,"offline":false,"servers":{"wg":[{"ip":"2001:db8::1","cn":"bad6"},{"ip":"198.51.100.10","cn":"useast1"}]}}]}`)
+	regions, _, err = ParseServerList(mixed, "6")
+	if err != nil || len(regions) != 1 || len(regions[0].WireGuard) != 1 || regions[0].WireGuard[0].Hostname != "useast1" {
+		t.Fatalf("invalid WireGuard server should be skipped: regions=%+v err=%v", regions, err)
+	}
+}
+
+func FuzzParseServerList(f *testing.F) {
+	for _, name := range []string{"v6_valid.json", "v7_valid.json", "malformed.json"} {
+		raw, err := os.ReadFile(filepath.Join("testdata", "serverlist", name))
+		if err != nil {
+			f.Fatal(err)
+		}
+		f.Add(raw)
+	}
+	f.Fuzz(func(t *testing.T, raw []byte) {
+		_, _, _ = ParseServerList(raw, "6")
+	})
+}

+ 63 - 0
internal/pia/serverlist_signature.go

@@ -0,0 +1,63 @@
+package pia
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"fmt"
+	"unicode"
+)
+
+func VerifySignedServerList(raw, publicKeyPEM []byte) ([]byte, error) {
+	jsonBody, signature, err := splitSignedServerList(raw)
+	if err != nil {
+		return nil, WrapError(CodeCatalogSignatureInvalid, "The PIA region list signature is missing or invalid.", err)
+	}
+	block, _ := pem.Decode(publicKeyPEM)
+	if block == nil {
+		return nil, NewError(CodeCatalogSignatureInvalid, "The built-in region-list public key is invalid.")
+	}
+	parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, WrapError(CodeCatalogSignatureInvalid, "The built-in region-list public key is invalid.", err)
+	}
+	publicKey, ok := parsed.(*rsa.PublicKey)
+	if !ok {
+		return nil, NewError(CodeCatalogSignatureInvalid, "The region-list public key is not RSA.")
+	}
+	digest := sha256.Sum256(jsonBody)
+	if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature); err != nil {
+		return nil, WrapError(CodeCatalogSignatureInvalid, "The PIA region list signature does not match its content.", err)
+	}
+	return jsonBody, nil
+}
+
+func splitSignedServerList(raw []byte) ([]byte, []byte, error) {
+	if len(raw) == 0 || raw[0] != '{' {
+		return nil, nil, fmt.Errorf("response does not start with a JSON object")
+	}
+	end := bytes.LastIndexByte(raw, '}')
+	if end < 0 || end == len(raw)-1 {
+		return nil, nil, fmt.Errorf("appended signature is absent")
+	}
+	jsonBody := append([]byte(nil), raw[:end+1]...)
+	encoded := bytes.Map(func(r rune) rune {
+		if unicode.IsSpace(r) {
+			return -1
+		}
+		return r
+	}, raw[end+1:])
+	if len(encoded) == 0 {
+		return nil, nil, fmt.Errorf("appended signature is empty")
+	}
+	signature := make([]byte, base64.StdEncoding.DecodedLen(len(encoded)))
+	n, err := base64.StdEncoding.Decode(signature, encoded)
+	if err != nil {
+		return nil, nil, fmt.Errorf("decode signature: %w", err)
+	}
+	return jsonBody, signature[:n], nil
+}

+ 63 - 0
internal/pia/serverlist_signature_test.go

@@ -0,0 +1,63 @@
+package pia
+
+import (
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestVerifySignedServerList(t *testing.T) {
+	payload := []byte(`{"version":6,"groups":{},"regions":[]}`)
+	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatal(err)
+	}
+	digest := sha256.Sum256(payload)
+	signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:])
+	if err != nil {
+		t.Fatal(err)
+	}
+	publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	publicPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER})
+	signed := append(append(append([]byte{}, payload...), '\n', '\n'), []byte(base64.StdEncoding.EncodeToString(signature))...)
+	validSigned := append([]byte(nil), signed...)
+	verified, err := VerifySignedServerList(signed, publicPEM)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(verified) != string(payload) {
+		t.Fatalf("verified payload changed: %s", verified)
+	}
+	signed[10] ^= 1
+	if _, err := VerifySignedServerList(signed, publicPEM); err == nil {
+		t.Fatal("expected tampered payload to fail signature verification")
+	}
+	for name, input := range map[string][]byte{
+		"missing signature": payload,
+		"invalid base64":    append(append([]byte{}, payload...), []byte("\nnot-base64!")...),
+		"trailing garbage":  append(append([]byte{}, validSigned...), []byte("\nextra")...),
+	} {
+		t.Run(name, func(t *testing.T) {
+			if _, err := VerifySignedServerList(input, publicPEM); err == nil {
+				t.Fatal("expected malformed signed response to be rejected")
+			}
+		})
+	}
+	fixture, err := os.ReadFile(filepath.Join("testdata", "serverlist", "invalid_signature.txt"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := VerifySignedServerList(fixture, publicPEM); err == nil {
+		t.Fatal("expected invalid-signature fixture to be rejected")
+	}
+}

+ 8 - 0
internal/pia/testdata/addkey/invalid_dns.json

@@ -0,0 +1,8 @@
+{
+  "status": "OK",
+  "peer_ip": "10.0.0.2/32",
+  "server_key": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+  "server_ip": "198.51.100.10",
+  "server_port": 51820,
+  "dns_servers": ["not-an-ip"]
+}

+ 8 - 0
internal/pia/testdata/addkey/invalid_peer_ip.json

@@ -0,0 +1,8 @@
+{
+  "status": "OK",
+  "peer_ip": "not-an-ip",
+  "server_key": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+  "server_ip": "198.51.100.10",
+  "server_port": 51820,
+  "dns_servers": ["10.0.0.1"]
+}

+ 8 - 0
internal/pia/testdata/addkey/invalid_peer_prefix.json

@@ -0,0 +1,8 @@
+{
+  "status": "OK",
+  "peer_ip": "10.0.0.0/24",
+  "server_key": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+  "server_ip": "198.51.100.10",
+  "server_port": 51820,
+  "dns_servers": ["10.0.0.1"]
+}

+ 8 - 0
internal/pia/testdata/addkey/invalid_port.json

@@ -0,0 +1,8 @@
+{
+  "status": "OK",
+  "peer_ip": "10.0.0.2/32",
+  "server_key": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+  "server_ip": "198.51.100.10",
+  "server_port": 65536,
+  "dns_servers": ["10.0.0.1"]
+}

+ 8 - 0
internal/pia/testdata/addkey/invalid_server_ip.json

@@ -0,0 +1,8 @@
+{
+  "status": "OK",
+  "peer_ip": "10.0.0.2/32",
+  "server_key": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+  "server_ip": "not-an-ip",
+  "server_port": 51820,
+  "dns_servers": ["10.0.0.1"]
+}

+ 8 - 0
internal/pia/testdata/addkey/invalid_server_key.json

@@ -0,0 +1,8 @@
+{
+  "status": "OK",
+  "peer_ip": "10.0.0.2/32",
+  "server_key": "not-a-wireguard-key",
+  "server_ip": "198.51.100.10",
+  "server_port": 51820,
+  "dns_servers": ["10.0.0.1"]
+}

+ 1 - 0
internal/pia/testdata/addkey/missing_port.json

@@ -0,0 +1 @@
+{"status":"OK","peer_ip":"10.42.0.2/32","server_key":"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","server_ip":"198.51.100.10","dns_servers":["10.0.0.242"]}

+ 8 - 0
internal/pia/testdata/addkey/status_error.json

@@ -0,0 +1,8 @@
+{
+  "status": "ERROR",
+  "peer_ip": "10.0.0.2/32",
+  "server_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+  "server_ip": "198.51.100.10",
+  "server_port": 51820,
+  "dns_servers": ["10.0.0.1"]
+}

+ 1 - 0
internal/pia/testdata/addkey/success.json

@@ -0,0 +1 @@
+{"status":"OK","server_key":"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","server_port":51820,"server_ip":"198.51.100.10","server_vip":"10.42.0.1","peer_ip":"10.42.0.2","peer_pubkey":"AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","dns_servers":["10.0.0.242","10.0.0.243"]}

+ 1 - 0
internal/pia/testdata/auth/html.txt

@@ -0,0 +1 @@
+<!doctype html><title>upstream error</title>

+ 1 - 0
internal/pia/testdata/auth/success.json

@@ -0,0 +1 @@
+{"token":"test-token-value-that-is-long-enough"}

+ 3 - 0
internal/pia/testdata/serverlist/invalid_signature.txt

@@ -0,0 +1,3 @@
+{"version":6,"groups":{},"regions":[]}
+
+QUFBQQ==

+ 1 - 0
internal/pia/testdata/serverlist/malformed.json

@@ -0,0 +1 @@
+{"version":6,"groups":{},"regions":[{"id":12}]}

+ 1 - 0
internal/pia/testdata/serverlist/v6_valid.json

@@ -0,0 +1 @@
+{"version":6,"groups":{"wg":[{"name":"wireguard","ports":[1337]}]},"regions":[{"id":"us-east","name":"US East","country":"US","geo":false,"offline":false,"port_forward":true,"servers":{"wg":[{"ip":"198.51.100.10","cn":"useast401"}]}}]}

+ 1 - 0
internal/pia/testdata/serverlist/v7_valid.json

@@ -0,0 +1 @@
+{"version":"v7","groups":{"wg":[{"name":"wireguard","ports":[1337]}]},"regions":[{"id":"de-berlin","name":"Germany Berlin","country":"DE","geo":true,"offline":false,"port_forwarding":false,"servers":{"wg":[{"ip":"203.0.113.20","hostname":"berlin501"}]}}]}

+ 43 - 0
internal/pia/trust/ca.rsa.4096.crt

@@ -0,0 +1,43 @@
+-----BEGIN CERTIFICATE-----
+MIIHqzCCBZOgAwIBAgIJAJ0u+vODZJntMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYD
+VQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNV
+BAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIElu
+dGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3Mx
+IDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkB
+FiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzQw
+MzNaFw0zNDA0MTIxNzQwMzNaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex
+EzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQg
+QWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UE
+AxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50
+ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVy
+bmV0YWNjZXNzLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALVk
+hjumaqBbL8aSgj6xbX1QPTfTd1qHsAZd2B97m8Vw31c/2yQgZNf5qZY0+jOIHULN
+De4R9TIvyBEbvnAg/OkPw8n/+ScgYOeH876VUXzjLDBnDb8DLr/+w9oVsuDeFJ9K
+V2UFM1OYX0SnkHnrYAN2QLF98ESK4NCSU01h5zkcgmQ+qKSfA9Ny0/UpsKPBFqsQ
+25NvjDWFhCpeqCHKUJ4Be27CDbSl7lAkBuHMPHJs8f8xPgAbHRXZOxVCpayZ2SND
+fCwsnGWpWFoMGvdMbygngCn6jA/W1VSFOlRlfLuuGe7QFfDwA0jaLCxuWt/BgZyl
+p7tAzYKR8lnWmtUCPm4+BtjyVDYtDCiGBD9Z4P13RFWvJHw5aapx/5W/CuvVyI7p
+Kwvc2IT+KPxCUhH1XI8ca5RN3C9NoPJJf6qpg4g0rJH3aaWkoMRrYvQ+5PXXYUzj
+tRHImghRGd/ydERYoAZXuGSbPkm9Y/p2X8unLcW+F0xpJD98+ZI+tzSsI99Zs5wi
+jSUGYr9/j18KHFTMQ8n+1jauc5bCCegN27dPeKXNSZ5riXFL2XX6BkY68y58UaNz
+meGMiUL9BOV1iV+PMb7B7PYs7oFLjAhh0EdyvfHkrh/ZV9BEhtFa7yXp8XR0J6vz
+1YV9R6DYJmLjOEbhU8N0gc3tZm4Qz39lIIG6w3FDAgMBAAGjggFUMIIBUDAdBgNV
+HQ4EFgQUrsRtyWJftjpdRM0+925Y6Cl08SUwggEfBgNVHSMEggEWMIIBEoAUrsRt
+yWJftjpdRM0+925Y6Cl08SWhge6kgeswgegxCzAJBgNVBAYTAlVTMQswCQYDVQQI
+EwJDQTETMBEGA1UEBxMKTG9zQW5nZWxlczEgMB4GA1UEChMXUHJpdmF0ZSBJbnRl
+cm5ldCBBY2Nlc3MxIDAeBgNVBAsTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAw
+HgYDVQQDExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEKRMXUHJpdmF0
+ZSBJbnRlcm5ldCBBY2Nlc3MxLzAtBgkqhkiG9w0BCQEWIHNlY3VyZUBwcml2YXRl
+aW50ZXJuZXRhY2Nlc3MuY29tggkAnS7684Nkme0wDAYDVR0TBAUwAwEB/zANBgkq
+hkiG9w0BAQ0FAAOCAgEAJsfhsPk3r8kLXLxY+v+vHzbr4ufNtqnL9/1Uuf8NrsCt
+pXAoyZ0YqfbkWx3NHTZ7OE9ZRhdMP/RqHQE1p4N4Sa1nZKhTKasV6KhHDqSCt/dv
+Em89xWm2MVA7nyzQxVlHa9AkcBaemcXEiyT19XdpiXOP4Vhs+J1R5m8zQOxZlV1G
+tF9vsXmJqWZpOVPmZ8f35BCsYPvv4yMewnrtAC8PFEK/bOPeYcKN50bol22QYaZu
+LfpkHfNiFTnfMh8sl/ablPyNY7DUNiP5DRcMdIwmfGQxR5WEQoHL3yPJ42LkB5zs
+6jIm26DGNXfwura/mi105+ENH1CaROtRYwkiHb08U6qLXXJz80mWJkT90nr8Asj3
+5xN2cUppg74nG3YVav/38P48T56hG1NHbYF5uOCske19F6wi9maUoto/3vEr0rnX
+JUp2KODmKdvBI7co245lHBABWikk8VfejQSlCtDBXn644ZMtAdoxKNfR2WTFVEwJ
+iyd1Fzx0yujuiXDROLhISLQDRjVVAvawrAtLZWYK31bY7KlezPlQnl/D9Asxe85l
+8jO5+0LdJ6VyOs/Hd4w52alDW/MFySDZSfQHMTIc30hLBJ8OnCEIvluVQQ2UQvoW
++no177N9L2Y+M9TcTA62ZyMXShHQGeh20rb4kK8f+iFX8NxtdHVSkxMEFSfDDyQ=
+-----END CERTIFICATE-----

+ 9 - 0
internal/pia/trust/serverlist_public_key.pem

@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzLYHwX5Ug/oUObZ5eH5P
+rEwmfj4E/YEfSKLgFSsyRGGsVmmjiXBmSbX2s3xbj/ofuvYtkMkP/VPFHy9E/8ox
+Y+cRjPzydxz46LPY7jpEw1NHZjOyTeUero5e1nkLhiQqO/cMVYmUnuVcuFfZyZvc
+8Apx5fBrIp2oWpF/G9tpUZfUUJaaHiXDtuYP8o8VhYtyjuUu3h7rkQFoMxvuoOFH
+6nkc0VQmBsHvCfq4T9v8gyiBtQRy543leapTBMT34mxVIQ4ReGLPVit/6sNLoGLb
+gSnGe9Bk/a5V/5vlqeemWF0hgoRtUxMtU1hFbe7e8tSq1j+mu0SHMyKHiHd+OsmU
+IQIDAQAB
+-----END PUBLIC KEY-----

+ 52 - 0
internal/pia/trust_anchors_test.go

@@ -0,0 +1,52 @@
+package pia
+
+import (
+	"bytes"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"testing"
+	"time"
+)
+
+func TestEmbeddedTrustAnchorsAreUsable(t *testing.T) {
+	t.Run("server list public key", func(t *testing.T) {
+		block, rest := pem.Decode(EmbeddedServerListPublicKey)
+		if block == nil {
+			t.Fatal("serverlist_public_key.pem does not decode as PEM")
+		}
+		if block.Type != "PUBLIC KEY" {
+			t.Fatalf("PEM block type = %q, want PUBLIC KEY", block.Type)
+		}
+		if len(bytes.TrimSpace(rest)) != 0 {
+			t.Fatalf("trailing data after the public key: %q", rest)
+		}
+		parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
+		if err != nil {
+			t.Fatalf("ParsePKIXPublicKey: %v", err)
+		}
+		if _, ok := parsed.(*rsa.PublicKey); !ok {
+			t.Fatalf("public key type = %T, want *rsa.PublicKey", parsed)
+		}
+	})
+
+	t.Run("addKey certificate authority", func(t *testing.T) {
+		if !x509.NewCertPool().AppendCertsFromPEM(EmbeddedPIACA) {
+			t.Fatal("ca.rsa.4096.crt was rejected by AppendCertsFromPEM")
+		}
+		block, _ := pem.Decode(EmbeddedPIACA)
+		if block == nil {
+			t.Fatal("ca.rsa.4096.crt does not decode as PEM")
+		}
+		cert, err := x509.ParseCertificate(block.Bytes)
+		if err != nil {
+			t.Fatalf("ParseCertificate: %v", err)
+		}
+		if !cert.IsCA {
+			t.Fatal("the embedded PIA certificate is not a CA")
+		}
+		if !cert.NotAfter.After(time.Now()) {
+			t.Fatalf("the embedded PIA CA expired on %s", cert.NotAfter)
+		}
+	})
+}

+ 43 - 0
internal/pia/types.go

@@ -0,0 +1,43 @@
+// Package pia is a standalone PIA WireGuard control-plane client.
+package pia
+
+import (
+	"context"
+	"net/netip"
+	"time"
+)
+
+type Region struct {
+	ID             string
+	Name           string
+	CountryCode    string
+	Geo            bool
+	PortForwarding bool
+	WireGuard      []WireGuardServer
+}
+
+type WireGuardServer struct {
+	Hostname string
+	IP       netip.Addr
+}
+
+type Token struct {
+	Value     []byte
+	ExpiresAt time.Time
+}
+
+type Registration struct {
+	PeerIP     netip.Prefix
+	ServerKey  string
+	ServerIP   netip.Addr
+	ServerPort uint16
+	DNSServers []netip.Addr
+}
+
+type Authenticator interface {
+	Authenticate(ctx context.Context, username string, password []byte) (Token, error)
+}
+
+type Registrar interface {
+	RegisterKey(ctx context.Context, server WireGuardServer, token string, publicKey string) (Registration, error)
+}

+ 42 - 0
internal/pia/validation.go

@@ -0,0 +1,42 @@
+package pia
+
+import (
+	"encoding/base64"
+	"net"
+	"strings"
+	"unicode"
+)
+
+func validSecret(value []byte, min, max int) bool {
+	if len(value) < min || len(value) > max {
+		return false
+	}
+	for _, b := range value {
+		if b == 0 || b == '\r' || b == '\n' {
+			return false
+		}
+	}
+	return true
+}
+
+func validHostname(host string) bool {
+	if host == "" || len(host) > 253 || net.ParseIP(host) != nil || strings.HasSuffix(host, ".") {
+		return false
+	}
+	for _, label := range strings.Split(host, ".") {
+		if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+			return false
+		}
+		for _, r := range label {
+			if r > unicode.MaxASCII || (!unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-') {
+				return false
+			}
+		}
+	}
+	return true
+}
+
+func validWGKey(key string) bool {
+	decoded, err := base64.StdEncoding.DecodeString(key)
+	return err == nil && len(decoded) == 32
+}

+ 38 - 1
internal/web/controller/xray_setting.go

@@ -2,11 +2,13 @@ package controller
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"strconv"
 	"strings"
 	"time"
 
+	piaprotocol "github.com/mhsanaei/3x-ui/v3/internal/pia"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
@@ -25,13 +27,14 @@ type XraySettingController struct {
 	XrayService                 service.XrayService
 	WarpService                 integration.WarpService
 	NordService                 integration.NordService
+	PiaService                  integration.PiaService
 	OutboundSubscriptionService service.OutboundSubscriptionService
 	GeodataService              service.GeodataService
 }
 
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
 func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
-	a := &XraySettingController{}
+	a := &XraySettingController{PiaService: *integration.NewPiaService()}
 	a.initRouter(g)
 	return a
 }
@@ -46,6 +49,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/", a.getXraySetting)
 	g.POST("/warp/:action", a.warp)
 	g.POST("/nord/:action", a.nord)
+	g.POST("/pia/:action", a.pia)
 	g.POST("/update", a.updateSetting)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/testOutbound", a.testOutbound)
@@ -241,6 +245,39 @@ func (a *XraySettingController) nord(c *gin.Context) {
 	jsonObj(c, resp, err)
 }
 
+func (a *XraySettingController) pia(c *gin.Context) {
+	action := c.Param("action")
+	var resp any
+	var err error
+	switch action {
+	case "countries":
+		resp, err = a.PiaService.GetCountries()
+	case "servers":
+		resp, err = a.PiaService.GetServers(c.PostForm("countryCode"))
+	case "reg":
+		resp, err = a.PiaService.Login(c.PostForm("username"), c.PostForm("password"))
+	case "data":
+		resp, err = a.PiaService.GetPiaData()
+	case "del":
+		err = a.PiaService.DelPiaData()
+	case "addKey":
+		resp, err = a.PiaService.AddKey(c.PostForm("hostname"))
+	default:
+		jsonMsg(c, "unknown action", common.NewError("unknown action"))
+		return
+	}
+	if err != nil {
+		var pe *piaprotocol.Error
+		if errors.As(err, &pe) && pe != nil {
+			jsonObj(c, nil, common.NewError(pe.Message))
+			return
+		}
+		jsonObj(c, nil, err)
+		return
+	}
+	jsonObj(c, resp, nil)
+}
+
 // getOutboundsTraffic retrieves the traffic statistics for outbounds.
 func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
 	outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()

+ 12 - 0
internal/web/service/inbound.go

@@ -1410,6 +1410,18 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	s.normalizeMtprotoSecret(inbound)
 	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
 
+	clients, err := s.GetClients(inbound)
+	if err != nil {
+		return inbound, false, err
+	}
+	if inbound.Protocol == model.Hysteria {
+		for _, client := range clients {
+			if client.Auth == "" {
+				return inbound, false, common.NewError("empty client ID")
+			}
+		}
+	}
+
 	oldInbound, err := s.GetInbound(inbound.Id)
 	if err != nil {
 		return inbound, false, err

+ 107 - 0
internal/web/service/inbound_autorenew_globaltraffic_test.go

@@ -0,0 +1,107 @@
+package service
+
+import (
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func seedGlobalTraffic(t *testing.T, email string, up, down int64) {
+	t.Helper()
+	if err := database.GetDB().Create(&model.ClientGlobalTraffic{
+		MasterGuid: "peer-" + email, Email: email, Up: up, Down: down,
+	}).Error; err != nil {
+		t.Fatalf("seed client_global_traffics: %v", err)
+	}
+}
+
+func countGlobalTraffic(t *testing.T, email string) int64 {
+	t.Helper()
+	var n int64
+	if err := database.GetDB().Model(&model.ClientGlobalTraffic{}).
+		Where("email = ?", email).Count(&n).Error; err != nil {
+		t.Fatalf("count client_global_traffics: %v", err)
+	}
+	return n
+}
+
+// A capped catch-up keeps its counters and stays expired, so its cross-panel
+// rows still describe a window this client has not spent.
+func TestAutoRenewClients_TruncatedCatchUpKeepsCrossPanelTraffic(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-150 * 24 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "capped@x", ID: "66666666-6666-6666-6666-666666666666", Enable: false, Reset: 30, ResetMax: 3, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30126, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "capped@x", Enable: false, Reset: 30, ResetMax: 3, ResetCount: 2,
+		Up: 111, Down: 222, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+	seedGlobalTraffic(t, "capped@x", 111, 222)
+
+	if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "capped@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.Up != 111 || row.Down != 222 {
+		t.Fatalf("local counters were reset, so this case no longer exercises the cap: up=%d down=%d", row.Up, row.Down)
+	}
+	if got := countGlobalTraffic(t, "capped@x"); got != 1 {
+		t.Fatalf("cross-panel rows=%d, want 1: the window was dropped for a client that never got it", got)
+	}
+}
+
+// The counterpart: a real renewal must drop the rows, or the stale pushed
+// totals re-deplete the fresh window at once.
+func TestAutoRenewClients_RenewedClientLosesCrossPanelTraffic(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-40 * 24 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "rolled@x", ID: "77777777-7777-7777-7777-777777777777", Enable: false, Reset: 30, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30127, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "rolled@x", Enable: false, Reset: 30,
+		Up: 333, Down: 444, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+	seedGlobalTraffic(t, "rolled@x", 333, 444)
+
+	if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "rolled@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.Up != 0 || row.Down != 0 {
+		t.Fatalf("counters survived a renewal: up=%d down=%d", row.Up, row.Down)
+	}
+	if got := countGlobalTraffic(t, "rolled@x"); got != 0 {
+		t.Fatalf("cross-panel rows=%d, want 0: stale pushed totals would re-deplete the fresh window", got)
+	}
+}

+ 106 - 0
internal/web/service/inbound_hysteria_auth_test.go

@@ -0,0 +1,106 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestUpdateInbound_RejectsHysteriaClientWithoutAuth(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "in-45001-tcp", "0.0.0.0", 45001, model.VLESS,
+		`{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-45001-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	update := existing
+	update.Protocol = model.Hysteria
+	update.Settings = `{"clients":[{"email":"hysteria@x","enable":true,"password":"not-hysteria-auth"}]}`
+
+	svc := &InboundService{}
+	if _, _, err := svc.UpdateInbound(&update); err == nil || !strings.Contains(err.Error(), "empty client ID") {
+		t.Fatalf("UpdateInbound error = %v, want empty client ID", err)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.Protocol != existing.Protocol {
+		t.Fatalf("persisted protocol = %q, want unchanged %q", reloaded.Protocol, existing.Protocol)
+	}
+	if reloaded.Settings != existing.Settings {
+		t.Fatalf("rejected settings were persisted\ngot:  %s\nwant: %s", reloaded.Settings, existing.Settings)
+	}
+}
+
+func TestUpdateInbound_PreservesHysteriaClientAuth(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "in-45002-udp", "0.0.0.0", 45002, model.Hysteria,
+		`{"network":"hysteria"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-45002-udp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	const wantAuth = "hysteria-auth"
+	const password = "not-hysteria-auth"
+	update := existing
+	update.Settings = `{"clients":[{"email":"hysteria@x","enable":true,"password":"` + password + `","auth":"` + wantAuth + `"}]}`
+
+	svc := &InboundService{}
+	if _, _, err := svc.UpdateInbound(&update); err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	clients, err := ParseInboundSettingsClients(reloaded.Settings)
+	if err != nil {
+		t.Fatalf("parse persisted clients: %v", err)
+	}
+	if len(clients) != 1 {
+		t.Fatalf("persisted clients = %d, want 1", len(clients))
+	}
+	if clients[0].Auth != wantAuth {
+		t.Fatalf("persisted auth = %q, want %q", clients[0].Auth, wantAuth)
+	}
+	if clients[0].Password != password {
+		t.Fatalf("persisted password = %q, want %q", clients[0].Password, password)
+	}
+}
+
+func TestUpdateInbound_AllowsHysteriaWithoutClients(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "in-45003-udp", "0.0.0.0", 45003, model.Hysteria,
+		`{"network":"hysteria"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-45003-udp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	update := existing
+	update.Remark = "updated without clients"
+
+	svc := &InboundService{}
+	if _, _, err := svc.UpdateInbound(&update); err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.Remark != update.Remark {
+		t.Fatalf("persisted remark = %q, want %q", reloaded.Remark, update.Remark)
+	}
+}

+ 6 - 2
internal/web/service/inbound_traffic.go

@@ -325,6 +325,8 @@ func apiUserFromClient(client map[string]any, cipher string) map[string]any {
 	return user
 }
 
+// Candidates and renewals are not the same set: a skipped candidate keeps its
+// counters, so only the clients actually reset may lose their cross-panel rows.
 func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMutationBatch) (bool, int64, error) {
 	// check for time expired
 	var traffics []*xray.ClientTraffic
@@ -408,6 +410,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 	for i := range traffics {
 		trafficByEmail[traffics[i].Email] = traffics[i]
 	}
+	renewedEmails := make([]string, 0, len(traffics))
 	for inbound_index := range inbounds {
 		settings := map[string]any{}
 		_ = json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
@@ -464,6 +467,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 			}
 			traffic.Down = 0
 			traffic.Up = 0
+			renewedEmails = append(renewedEmails, email)
 			if !traffic.Enable {
 				traffic.Enable = true
 				c["enable"] = true
@@ -508,7 +512,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 	}
 	// A renewed client starts a fresh quota window: drop the cross-panel rows
 	// too, or the stale pushed totals would re-deplete it immediately.
-	if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
+	if err = clearGlobalTraffic(tx, renewedEmails...); err != nil {
 		return false, 0, err
 	}
 	for _, clientToAdd := range clientsToAdd {
@@ -520,7 +524,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 			action: trafficAddUser, inbound: clientToAdd.inbound, client: clientToAdd.client,
 		})
 	}
-	return needRestart, int64(len(traffics)), nil
+	return needRestart, int64(len(renewedEmails)), nil
 }
 
 // AddClientStat inserts a per-client accounting row, or refreshes the

+ 295 - 0
internal/web/service/integration/pia.go

@@ -0,0 +1,295 @@
+package integration
+
+import (
+	"context"
+	"encoding/json"
+	"net"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
+	piaprotocol "github.com/mhsanaei/3x-ui/v3/internal/pia"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+var piaTokenAAD = []byte("settings/pia_token")
+
+type PiaService struct {
+	service.SettingService
+	Auth      piaprotocol.Authenticator
+	Catalog   *piaprotocol.Catalog
+	Registrar piaprotocol.Registrar
+}
+
+type piaStored struct {
+	Username       string `json:"username"`
+	Token          string `json:"token"`
+	TokenExpiresAt int64  `json:"tokenExpiresAt"`
+}
+
+type PiaAccountView struct {
+	Username    string `json:"username"`
+	AccountHint string `json:"accountHint"`
+}
+
+type PiaCountryView struct {
+	Code string `json:"code"`
+}
+
+type PiaRegionView struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+type PiaServerView struct {
+	Hostname   string `json:"hostname"`
+	IP         string `json:"ip"`
+	RegionID   string `json:"regionId"`
+	RegionName string `json:"regionName"`
+}
+
+type PiaServersView struct {
+	Regions []PiaRegionView `json:"regions"`
+	Servers []PiaServerView `json:"servers"`
+}
+
+type PiaKeyView struct {
+	Tag       string `json:"tag"`
+	Hostname  string `json:"hostname"`
+	SecretKey string `json:"secretKey"`
+	Address   string `json:"address"`
+	PublicKey string `json:"publicKey"`
+	Endpoint  string `json:"endpoint"`
+}
+
+func NewPiaService() *PiaService {
+	return &PiaService{
+		Auth:      piaprotocol.NewAuthClient(piaprotocol.DefaultTokenEndpoint),
+		Catalog:   piaprotocol.NewCatalog(piaprotocol.NewCatalogClient(piaprotocol.DefaultServerListEndpoint, piaprotocol.EmbeddedServerListPublicKey)),
+		Registrar: piaprotocol.NewRegistrationClient(piaprotocol.EmbeddedPIACA),
+	}
+}
+
+func (s *PiaService) Login(username, password string) (*PiaAccountView, error) {
+	tok, err := s.Auth.Authenticate(context.Background(), username, []byte(password))
+	if err != nil {
+		return nil, err
+	}
+	stored := piaStored{
+		Username:       strings.TrimSpace(username),
+		Token:          string(tok.Value),
+		TokenExpiresAt: tok.ExpiresAt.Unix(),
+	}
+	if err := s.saveStored(stored); err != nil {
+		return nil, err
+	}
+	return accountView(stored.Username), nil
+}
+
+func (s *PiaService) GetPiaData() (*PiaAccountView, error) {
+	stored, err := s.loadStored()
+	if err != nil {
+		return nil, err
+	}
+	if stored == nil || stored.Token == "" {
+		return nil, nil
+	}
+	return accountView(stored.Username), nil
+}
+
+func (s *PiaService) DelPiaData() error {
+	return s.SetPia("")
+}
+
+func (s *PiaService) GetCountries() ([]PiaCountryView, error) {
+	regions, err := s.regions()
+	if err != nil {
+		return nil, err
+	}
+	seen := map[string]struct{}{}
+	out := make([]PiaCountryView, 0)
+	for _, region := range regions {
+		code := strings.ToUpper(strings.TrimSpace(region.CountryCode))
+		if !validCountryCode(code) {
+			continue
+		}
+		if _, ok := seen[code]; ok {
+			continue
+		}
+		seen[code] = struct{}{}
+		out = append(out, PiaCountryView{Code: code})
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
+	return out, nil
+}
+
+func (s *PiaService) GetServers(countryCode string) (*PiaServersView, error) {
+	code := strings.ToUpper(strings.TrimSpace(countryCode))
+	if !validCountryCode(code) {
+		return nil, piaprotocol.NewError(piaprotocol.CodeInvalidInput, "Select a country.")
+	}
+	regions, err := s.regions()
+	if err != nil {
+		return nil, err
+	}
+	view := &PiaServersView{Regions: []PiaRegionView{}, Servers: []PiaServerView{}}
+	for _, region := range regions {
+		if strings.ToUpper(strings.TrimSpace(region.CountryCode)) != code {
+			continue
+		}
+		view.Regions = append(view.Regions, PiaRegionView{ID: region.ID, Name: region.Name})
+		for _, server := range region.WireGuard {
+			view.Servers = append(view.Servers, PiaServerView{
+				Hostname:   server.Hostname,
+				IP:         server.IP.String(),
+				RegionID:   region.ID,
+				RegionName: region.Name,
+			})
+		}
+	}
+	sort.Slice(view.Regions, func(i, j int) bool { return view.Regions[i].Name < view.Regions[j].Name })
+	return view, nil
+}
+
+func (s *PiaService) AddKey(hostname string) (*PiaKeyView, error) {
+	hostname = strings.TrimSpace(hostname)
+	if hostname == "" {
+		return nil, piaprotocol.NewError(piaprotocol.CodeInvalidInput, "Select a PIA server.")
+	}
+	stored, err := s.loadStored()
+	if err != nil {
+		return nil, err
+	}
+	if stored == nil || stored.Token == "" {
+		return nil, piaprotocol.NewError(piaprotocol.CodeTokenRejected, "Sign in with a PIA account first.")
+	}
+	if stored.TokenExpiresAt > 0 && time.Now().Unix() >= stored.TokenExpiresAt {
+		return nil, piaprotocol.NewError(piaprotocol.CodeTokenRejected, "The PIA token has expired. Sign in again.")
+	}
+	region, server, err := s.findServer(hostname)
+	if err != nil {
+		return nil, err
+	}
+	priv, pub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		return nil, err
+	}
+	reg, err := s.Registrar.RegisterKey(context.Background(), server, stored.Token, pub)
+	if err != nil {
+		return nil, err
+	}
+	return &PiaKeyView{
+		Tag:       piaOutboundTag(region.ID, server.Hostname),
+		Hostname:  server.Hostname,
+		SecretKey: priv,
+		Address:   reg.PeerIP.String(),
+		PublicKey: reg.ServerKey,
+		Endpoint:  net.JoinHostPort(reg.ServerIP.String(), strconv.Itoa(int(reg.ServerPort))),
+	}, nil
+}
+
+func (s *PiaService) regions() ([]piaprotocol.Region, error) {
+	if s.Catalog == nil {
+		return nil, piaprotocol.NewError(piaprotocol.CodeCatalogUnavailable, "The PIA server list is not available.")
+	}
+	regions, _, err := s.Catalog.ListRegions(context.Background())
+	return regions, err
+}
+
+func (s *PiaService) findServer(hostname string) (piaprotocol.Region, piaprotocol.WireGuardServer, error) {
+	regions, err := s.regions()
+	if err != nil {
+		return piaprotocol.Region{}, piaprotocol.WireGuardServer{}, err
+	}
+	for _, region := range regions {
+		for _, server := range region.WireGuard {
+			if server.Hostname == hostname || piaOutboundTag(region.ID, server.Hostname) == hostname {
+				return region, server, nil
+			}
+		}
+	}
+	return piaprotocol.Region{}, piaprotocol.WireGuardServer{}, piaprotocol.NewError(piaprotocol.CodeServerNotFound, "The selected PIA server was not found.")
+}
+
+func piaOutboundTag(regionID, hostname string) string {
+	region := piaTagPart(regionID, false)
+	server := piaTagPart(hostname, true)
+	if region == "" {
+		return "pia-" + server
+	}
+	return "pia-" + region + "-" + server
+}
+
+func piaTagPart(s string, stripDomain bool) string {
+	s = strings.ToLower(strings.TrimSpace(s))
+	if stripDomain {
+		if i := strings.IndexByte(s, '.'); i > 0 {
+			s = s[:i]
+		}
+	}
+	return strings.ReplaceAll(s, "_", "-")
+}
+
+func (s *PiaService) saveStored(stored piaStored) error {
+	enc, err := nodetoken.EncryptBound(piaTokenAAD, stored.Token)
+	if err != nil {
+		return err
+	}
+	stored.Token = enc
+	raw, err := json.Marshal(stored)
+	if err != nil {
+		return err
+	}
+	return s.SetPia(string(raw))
+}
+
+func (s *PiaService) loadStored() (*piaStored, error) {
+	raw, err := s.GetPia()
+	if err != nil || strings.TrimSpace(raw) == "" {
+		return nil, err
+	}
+	var stored piaStored
+	if err := json.Unmarshal([]byte(raw), &stored); err != nil {
+		return nil, err
+	}
+	atRest := stored.Token
+	if atRest == "" {
+		return &stored, nil
+	}
+	if nodetoken.IsEncrypted(atRest) && !nodetoken.Enabled() {
+		return nil, piaprotocol.NewError(piaprotocol.CodeTokenRejected, "The PIA token is encrypted but NODE_TOKEN_ENCRYPTION is off. Sign in again.")
+	}
+	plain, err := nodetoken.DecryptBound(piaTokenAAD, atRest)
+	if err != nil {
+		return nil, err
+	}
+	stored.Token = plain
+	if nodetoken.Enabled() && (!nodetoken.IsEncrypted(atRest) || !nodetoken.Active().EncryptedWithActive(atRest)) {
+		if err := s.saveStored(stored); err != nil {
+			return nil, err
+		}
+	}
+	return &stored, nil
+}
+
+func accountView(username string) *PiaAccountView {
+	return &PiaAccountView{Username: username, AccountHint: piaAccountHint(username)}
+}
+
+func piaAccountHint(username string) string {
+	u := strings.TrimSpace(username)
+	if len(u) <= 4 {
+		return strings.Repeat("*", len(u))
+	}
+	return u[:2] + strings.Repeat("*", len(u)-4) + u[len(u)-2:]
+}
+
+func validCountryCode(code string) bool {
+	if len(code) != 2 {
+		return false
+	}
+	return code[0] >= 'A' && code[0] <= 'Z' && code[1] >= 'A' && code[1] <= 'Z'
+}

+ 375 - 0
internal/web/service/integration/pia_test.go

@@ -0,0 +1,375 @@
+package integration
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"net/netip"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	piaprotocol "github.com/mhsanaei/3x-ui/v3/internal/pia"
+)
+
+type fakePiaAuth struct{ token string }
+
+func (f fakePiaAuth) Authenticate(context.Context, string, []byte) (piaprotocol.Token, error) {
+	return piaprotocol.Token{Value: []byte(f.token), ExpiresAt: time.Now().Add(24 * time.Hour)}, nil
+}
+
+type fakePiaCatalog struct{ payload []byte }
+
+func (f fakePiaCatalog) Fetch(context.Context) (piaprotocol.ServerListSnapshot, error) {
+	return piaprotocol.ServerListSnapshot{Payload: f.payload, SchemaHint: "6", SignatureVerified: true}, nil
+}
+
+type fakePiaRegistrar struct {
+	n     int
+	token string
+}
+
+func (f *fakePiaRegistrar) RegisterKey(_ context.Context, server piaprotocol.WireGuardServer, token string, _ string) (piaprotocol.Registration, error) {
+	f.n++
+	f.token = token
+	key := make([]byte, 32)
+	key[0] = byte(f.n)
+	return piaprotocol.Registration{
+		PeerIP:     netip.MustParsePrefix("10.8.0." + strconv.Itoa(f.n) + "/32"),
+		ServerKey:  base64.StdEncoding.EncodeToString(key),
+		ServerIP:   server.IP,
+		ServerPort: 1337,
+	}, nil
+}
+
+func setupPiaService(t *testing.T) *PiaService {
+	t.Helper()
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	payload := []byte(`{"version":6,"groups":{"wg":[{"name":"wireguard","ports":[1337]}]},"regions":[{"id":"us-east","name":"US East","country":"US","geo":false,"offline":false,"port_forward":true,"servers":{"wg":[{"ip":"198.51.100.10","cn":"useast1"},{"ip":"198.51.100.20","cn":"useast2"}]}},{"id":"de-berlin","name":"Berlin","country":"DE","geo":false,"offline":false,"port_forward":false,"servers":{"wg":[{"ip":"203.0.113.10","cn":"berlin1"}]}}]}`)
+	svc := NewPiaService()
+	svc.Auth = fakePiaAuth{token: "tokentokentokentoken12"}
+	svc.Catalog = piaprotocol.NewCatalog(fakePiaCatalog{payload: payload})
+	svc.Registrar = &fakePiaRegistrar{}
+	return svc
+}
+
+func TestPiaLoginStoresTokenAndHidesItFromData(t *testing.T) {
+	svc := setupPiaService(t)
+	view, err := svc.Login("p1234567", "TEST-PIA-PASSWORD-MUST-NOT-LEAK")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if view.Username != "p1234567" || view.AccountHint != "p1****67" {
+		t.Fatalf("account view: %+v", view)
+	}
+	data, err := svc.GetPiaData()
+	if err != nil || data == nil || data.AccountHint != "p1****67" {
+		t.Fatalf("data: %+v err=%v", data, err)
+	}
+	raw, _ := json.Marshal(data)
+	if strings.Contains(string(raw), "TEST-PIA-PASSWORD-MUST-NOT-LEAK") || strings.Contains(string(raw), "tokentokentokentoken12") {
+		t.Fatalf("secret leaked in data: %s", raw)
+	}
+	stored, err := svc.GetPia()
+	if err != nil || !strings.Contains(stored, "tokentokentokentoken12") {
+		t.Fatalf("token must be stored in settings: %q err=%v", stored, err)
+	}
+}
+
+func TestPiaCountriesAndServers(t *testing.T) {
+	svc := setupPiaService(t)
+	countries, err := svc.GetCountries()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(countries) != 2 || countries[0].Code != "DE" || countries[1].Code != "US" {
+		t.Fatalf("countries: %+v", countries)
+	}
+	servers, err := svc.GetServers("US")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(servers.Regions) != 1 || servers.Regions[0].ID != "us-east" || len(servers.Servers) != 2 {
+		t.Fatalf("us servers: %+v", servers)
+	}
+	if servers.Servers[0].Hostname != "useast1" || servers.Servers[0].RegionID != "us-east" {
+		t.Fatalf("first server: %+v", servers.Servers[0])
+	}
+}
+
+func TestPiaAddKeyRegistersWireGuardPeer(t *testing.T) {
+	svc := setupPiaService(t)
+	if _, err := svc.AddKey("useast1"); err == nil || piaprotocol.CodeOf(err) != piaprotocol.CodeTokenRejected {
+		t.Fatalf("addKey before login: %v", err)
+	}
+	if _, err := svc.Login("p1234567", "password-long-enough"); err != nil {
+		t.Fatal(err)
+	}
+	key, err := svc.AddKey("useast1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if key.Tag != "pia-us-east-useast1" || key.Hostname != "useast1" || key.SecretKey == "" || key.PublicKey == "" {
+		t.Fatalf("key: %+v", key)
+	}
+	if key.Address != "10.8.0.1/32" || key.Endpoint != "198.51.100.10:1337" {
+		t.Fatalf("peer: %+v", key)
+	}
+	byTag, err := svc.AddKey("pia-us-east-useast1")
+	if err != nil || byTag.Hostname != "useast1" || byTag.Tag != "pia-us-east-useast1" {
+		t.Fatalf("addKey by tag: %+v err=%v", byTag, err)
+	}
+	if _, err := svc.AddKey("1a"); err == nil || piaprotocol.CodeOf(err) != piaprotocol.CodeServerNotFound {
+		t.Fatalf("truncated hostname must not match: %v", err)
+	}
+}
+
+func TestPiaExpiredTokenNeverReachesRegistrar(t *testing.T) {
+	svc := setupPiaService(t)
+	if _, err := svc.Login("p1234567", "password-long-enough"); err != nil {
+		t.Fatal(err)
+	}
+	raw, err := svc.GetPia()
+	if err != nil {
+		t.Fatal(err)
+	}
+	var stored piaStored
+	if err := json.Unmarshal([]byte(raw), &stored); err != nil {
+		t.Fatal(err)
+	}
+	stored.TokenExpiresAt = time.Now().Add(-time.Minute).Unix()
+	rewritten, err := json.Marshal(stored)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := svc.SetPia(string(rewritten)); err != nil {
+		t.Fatal(err)
+	}
+	reg := svc.Registrar.(*fakePiaRegistrar)
+	before := reg.n
+	_, err = svc.AddKey("useast1")
+	if err == nil || piaprotocol.CodeOf(err) != piaprotocol.CodeTokenRejected {
+		t.Fatalf("expired token: %v", err)
+	}
+	if piaprotocol.MessageOf(err) != "The PIA token has expired. Sign in again." {
+		t.Fatalf("expired token message: %v", err)
+	}
+	if reg.n != before {
+		t.Fatalf("expired token reached registrar: calls=%d", reg.n)
+	}
+}
+
+func TestPiaDelClearsAccount(t *testing.T) {
+	svc := setupPiaService(t)
+	if _, err := svc.Login("p1234567", "password-long-enough"); err != nil {
+		t.Fatal(err)
+	}
+	if err := svc.DelPiaData(); err != nil {
+		t.Fatal(err)
+	}
+	data, err := svc.GetPiaData()
+	if err != nil || data != nil {
+		t.Fatalf("want nil data after logout, got %+v err=%v", data, err)
+	}
+}
+
+func TestPiaOutboundTag(t *testing.T) {
+	tests := []struct {
+		region, host, want string
+	}{
+		{"us-east", "useast1", "pia-us-east-useast1"},
+		{"US-East", "useast401.privacy.network", "pia-us-east-useast401"},
+		{"us_california", "silicon_valley", "pia-us-california-silicon-valley"},
+		{"", "berlin1", "pia-berlin1"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.want, func(t *testing.T) {
+			if got := piaOutboundTag(tt.region, tt.host); got != tt.want {
+				t.Fatalf("piaOutboundTag(%q, %q) = %q, want %q", tt.region, tt.host, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestPiaCorruptSettingIsNotTreatedAsLoggedOut(t *testing.T) {
+	svc := setupPiaService(t)
+	if err := svc.SetPia(`{"username":`); err != nil {
+		t.Fatal(err)
+	}
+	data, err := svc.GetPiaData()
+	if err == nil || data != nil {
+		t.Fatalf("corrupt pia setting must not look logged-out: data=%+v err=%v", data, err)
+	}
+}
+
+func enablePiaTokenEncryption(t *testing.T) {
+	t.Helper()
+	var k [32]byte
+	for i := range k {
+		k[i] = byte(i + 1)
+	}
+	ring := &nodetoken.Keyring{ActiveID: "t1", Keys: map[string][32]byte{"t1": k}}
+	codec, err := nodetoken.NewCodec(nodetoken.ModeRequired, ring)
+	if err != nil {
+		t.Fatalf("new codec: %v", err)
+	}
+	nodetoken.Init(codec)
+	t.Cleanup(func() {
+		off, _ := nodetoken.NewCodec(nodetoken.ModeOff, nil)
+		nodetoken.Init(off)
+	})
+}
+
+func TestPiaLoginEncryptsTokenWhenRequired(t *testing.T) {
+	svc := setupPiaService(t)
+	enablePiaTokenEncryption(t)
+	if _, err := svc.Login("p1234567", "password-long-enough"); err != nil {
+		t.Fatal(err)
+	}
+	stored, err := svc.GetPia()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(stored, "tokentokentokentoken12") {
+		t.Fatalf("plaintext token at rest: %s", stored)
+	}
+	var parsed piaStored
+	if err := json.Unmarshal([]byte(stored), &parsed); err != nil {
+		t.Fatal(err)
+	}
+	if !nodetoken.IsEncrypted(parsed.Token) {
+		t.Fatalf("token at rest is not encrypted: %q", parsed.Token)
+	}
+	data, err := svc.GetPiaData()
+	if err != nil || data == nil || data.AccountHint != "p1****67" {
+		t.Fatalf("data: %+v err=%v", data, err)
+	}
+	raw, _ := json.Marshal(data)
+	if strings.Contains(string(raw), "tokentokentokentoken12") {
+		t.Fatalf("secret leaked in data: %s", raw)
+	}
+}
+
+func TestPiaAddKeyDecryptsEncryptedToken(t *testing.T) {
+	svc := setupPiaService(t)
+	enablePiaTokenEncryption(t)
+	if _, err := svc.Login("p1234567", "password-long-enough"); err != nil {
+		t.Fatal(err)
+	}
+	key, err := svc.AddKey("useast1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if key.Tag != "pia-us-east-useast1" {
+		t.Fatalf("key: %+v", key)
+	}
+	reg := svc.Registrar.(*fakePiaRegistrar)
+	if reg.token != "tokentokentokentoken12" {
+		t.Fatalf("addKey must decrypt the stored token, got %q", reg.token)
+	}
+}
+
+func TestPiaEncryptedTokenRejectedWhenEncryptionOff(t *testing.T) {
+	svc := setupPiaService(t)
+	enablePiaTokenEncryption(t)
+	if _, err := svc.Login("p1234567", "password-long-enough"); err != nil {
+		t.Fatal(err)
+	}
+	off, _ := nodetoken.NewCodec(nodetoken.ModeOff, nil)
+	nodetoken.Init(off)
+	if _, err := svc.AddKey("useast1"); err == nil || piaprotocol.CodeOf(err) != piaprotocol.CodeTokenRejected {
+		t.Fatalf("addKey with encrypted token and encryption off: %v", err)
+	}
+}
+
+func TestPiaWrongAADCiphertextRejected(t *testing.T) {
+	svc := setupPiaService(t)
+	enablePiaTokenEncryption(t)
+	enc, err := nodetoken.Encrypt(1, "tokentokentokentoken12")
+	if err != nil {
+		t.Fatal(err)
+	}
+	raw, _ := json.Marshal(piaStored{Username: "p1234567", Token: enc, TokenExpiresAt: time.Now().Add(time.Hour).Unix()})
+	if err := svc.SetPia(string(raw)); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := svc.AddKey("useast1"); err == nil {
+		t.Fatal("node-bound ciphertext must not decrypt as a PIA token")
+	} else if !strings.Contains(err.Error(), "authentication failed") {
+		t.Fatalf("wrong-AAD error: %v", err)
+	}
+}
+
+func TestPiaPlaintextMigratesWhenEncryptionEnabled(t *testing.T) {
+	svc := setupPiaService(t)
+	if _, err := svc.Login("p1234567", "password-long-enough"); err != nil {
+		t.Fatal(err)
+	}
+	before, err := svc.GetPia()
+	if err != nil || !strings.Contains(before, "tokentokentokentoken12") {
+		t.Fatalf("want plaintext before migrate: %q err=%v", before, err)
+	}
+	enablePiaTokenEncryption(t)
+	if _, err := svc.GetPiaData(); err != nil {
+		t.Fatal(err)
+	}
+	after, err := svc.GetPia()
+	if err != nil || strings.Contains(after, "tokentokentokentoken12") {
+		t.Fatalf("want ciphertext after migrate: %q err=%v", after, err)
+	}
+	var parsed piaStored
+	if err := json.Unmarshal([]byte(after), &parsed); err != nil || !nodetoken.IsEncrypted(parsed.Token) {
+		t.Fatalf("migrated token: %+v err=%v", parsed, err)
+	}
+}
+
+func TestPiaReencryptsTokenToActiveKey(t *testing.T) {
+	svc := setupPiaService(t)
+	var k1, k2 [32]byte
+	for i := range k1 {
+		k1[i] = byte(i + 1)
+		k2[i] = byte(i + 2)
+	}
+	c1, err := nodetoken.NewCodec(nodetoken.ModeRequired, &nodetoken.Keyring{
+		ActiveID: "k1", Keys: map[string][32]byte{"k1": k1, "k2": k2},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	nodetoken.Init(c1)
+	t.Cleanup(func() {
+		off, _ := nodetoken.NewCodec(nodetoken.ModeOff, nil)
+		nodetoken.Init(off)
+	})
+	if _, err := svc.Login("p1234567", "password-long-enough"); err != nil {
+		t.Fatal(err)
+	}
+	before, err := svc.GetPia()
+	if err != nil || !strings.Contains(before, "enc:v1:k1:") {
+		t.Fatalf("want k1 ciphertext: %q err=%v", before, err)
+	}
+	c2, err := nodetoken.NewCodec(nodetoken.ModeRequired, &nodetoken.Keyring{
+		ActiveID: "k2", Keys: map[string][32]byte{"k1": k1, "k2": k2},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	nodetoken.Init(c2)
+	if _, err := svc.GetPiaData(); err != nil {
+		t.Fatal(err)
+	}
+	after, err := svc.GetPia()
+	if err != nil || !strings.Contains(after, "enc:v1:k2:") {
+		t.Fatalf("want k2 ciphertext: %q err=%v", after, err)
+	}
+	if strings.Contains(after, "tokentokentokentoken12") {
+		t.Fatalf("plaintext leaked after rotation: %s", after)
+	}
+}

+ 9 - 0
internal/web/service/setting.go

@@ -123,6 +123,7 @@ var defaultValueMap = map[string]string{
 	"warp":                        "",
 	"warpUpdateInterval":          "0",
 	"nord":                        "",
+	"pia":                         "",
 	"externalTrafficInformEnable": "false",
 	"externalTrafficInformURI":    "",
 	"restartXrayOnClientDisable":  "true",
@@ -916,6 +917,14 @@ func (s *SettingService) SetNord(data string) error {
 	return s.setString("nord", data)
 }
 
+func (s *SettingService) GetPia() (string, error) {
+	return s.getString("pia")
+}
+
+func (s *SettingService) SetPia(data string) error {
+	return s.setString("pia", data)
+}
+
 func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
 	return s.getBool("externalTrafficInformEnable")
 }

+ 17 - 0
internal/web/translation/ar-EG.json

@@ -1520,6 +1520,9 @@
         "attributes": "الخصائص",
         "value": "القيمة",
         "user": "المستخدم",
+        "userPlaceholder": "اختر المستخدمين",
+        "userEmpty": "لا يوجد مستخدمون متاحون",
+        "userLoadError": "تعذر تحميل المستخدمين",
         "inboundTags": "وسوم الواردات",
         "outboundTag": "وسم الصادر",
         "balancerTag": "وسم الموازن",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "تم الحذف",
         "toastDeleteFailed": "فشل الحذف"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "اسم مستخدم PIA",
+        "password": "كلمة مرور PIA",
+        "account": "الحساب",
+        "region": "المنطقة",
+        "allRegions": "كل المناطق",
+        "noServers": "لا توجد خوادم للدولة المحددة",
+        "outboundAdded": "تمت إضافة مسار PIA الصادر",
+        "outboundUpdated": "تم تحديث مسار PIA الصادر",
+        "addedServers": "الخوادم المضافة",
+        "alreadyAdded": "هذا الخادم موجود بالفعل في قائمة الصادر. استخدم {reset} لتجديد المفتاح.",
+        "provisionFailed": "تعذر إنشاء مسار PIA الصادر. حاول مرة أخرى."
+      },
       "tabBalancerSettings": "إعدادات الموازن",
       "tabObservatory": "المرصد",
       "observatory": {

+ 17 - 0
internal/web/translation/en-US.json

@@ -1638,6 +1638,9 @@
         "attributes": "Attributes",
         "value": "Value",
         "user": "User",
+        "userPlaceholder": "Select users",
+        "userEmpty": "No users available",
+        "userLoadError": "Could not load users",
         "inboundTags": "Inbound tags",
         "outboundTag": "Outbound tag",
         "balancerTag": "Balancer tag",
@@ -1760,6 +1763,20 @@
         "toastDeleted": "Deleted",
         "toastDeleteFailed": "Delete failed"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "PIA username",
+        "password": "PIA password",
+        "account": "Account",
+        "region": "Region",
+        "allRegions": "All regions",
+        "noServers": "No servers found for the selected country",
+        "outboundAdded": "PIA outbound added",
+        "outboundUpdated": "PIA outbound updated",
+        "addedServers": "Added servers",
+        "alreadyAdded": "This server is already in the outbound list. Use {reset} to renew its key.",
+        "provisionFailed": "Could not build the PIA outbound. Try again."
+      },
       "tabBalancerSettings": "Balancer Settings",
       "tabObservatory": "Observatory",
       "observatory": {

+ 17 - 0
internal/web/translation/es-ES.json

@@ -1520,6 +1520,9 @@
         "attributes": "Atributos",
         "value": "Valor",
         "user": "Usuario",
+        "userPlaceholder": "Seleccionar usuarios",
+        "userEmpty": "No hay usuarios disponibles",
+        "userLoadError": "No se pudieron cargar los usuarios",
         "inboundTags": "Etiquetas de entradas",
         "outboundTag": "Etiqueta de salida",
         "balancerTag": "Etiqueta de balanceador",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "Eliminada",
         "toastDeleteFailed": "Error al eliminar"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "Usuario de PIA",
+        "password": "Contraseña de PIA",
+        "account": "Cuenta",
+        "region": "Región",
+        "allRegions": "Todas las regiones",
+        "noServers": "No hay servidores para el país seleccionado",
+        "outboundAdded": "Salida PIA añadida",
+        "outboundUpdated": "Salida PIA actualizada",
+        "addedServers": "Servidores añadidos",
+        "alreadyAdded": "Este servidor ya está en la lista de salidas. Usa {reset} para renovar la clave.",
+        "provisionFailed": "No se pudo crear la salida PIA. Inténtalo de nuevo."
+      },
       "tabBalancerSettings": "Ajustes del balanceador",
       "tabObservatory": "Observatorio",
       "observatory": {

+ 17 - 0
internal/web/translation/fa-IR.json

@@ -1520,6 +1520,9 @@
         "attributes": "صفت‌ها",
         "value": "مقدار",
         "user": "کاربر",
+        "userPlaceholder": "انتخاب کاربران",
+        "userEmpty": "هیچ کاربری موجود نیست",
+        "userLoadError": "بارگیری کاربران ناموفق بود",
         "inboundTags": "تگ‌های ورودی",
         "outboundTag": "تگ خروجی",
         "balancerTag": "تگ بالانسر",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "حذف شد",
         "toastDeleteFailed": "حذف ناموفق بود"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "نام کاربری PIA",
+        "password": "رمز عبور PIA",
+        "account": "حساب",
+        "region": "منطقه",
+        "allRegions": "همه منطقه‌ها",
+        "noServers": "برای کشور انتخاب‌شده سروری پیدا نشد",
+        "outboundAdded": "خروجی PIA افزوده شد",
+        "outboundUpdated": "خروجی PIA به‌روز شد",
+        "addedServers": "سرورهای افزوده‌شده",
+        "alreadyAdded": "این سرور از قبل در فهرست خروجی است. برای تمدید کلید {reset} را بزنید.",
+        "provisionFailed": "ساخت خروجی PIA ممکن نشد. دوباره تلاش کنید."
+      },
       "tabBalancerSettings": "تنظیمات بالانسر",
       "tabObservatory": "رصدخانه",
       "observatory": {

+ 17 - 0
internal/web/translation/id-ID.json

@@ -1520,6 +1520,9 @@
         "attributes": "Atribut",
         "value": "Nilai",
         "user": "Pengguna",
+        "userPlaceholder": "Pilih pengguna",
+        "userEmpty": "Tidak ada pengguna tersedia",
+        "userLoadError": "Gagal memuat pengguna",
         "inboundTags": "Tag inbound",
         "outboundTag": "Tag outbound",
         "balancerTag": "Tag balancer",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "Dihapus",
         "toastDeleteFailed": "Gagal menghapus"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "Nama pengguna PIA",
+        "password": "Kata sandi PIA",
+        "account": "Akun",
+        "region": "Wilayah",
+        "allRegions": "Semua wilayah",
+        "noServers": "Tidak ada server untuk negara yang dipilih",
+        "outboundAdded": "Outbound PIA ditambahkan",
+        "outboundUpdated": "Outbound PIA diperbarui",
+        "addedServers": "Server yang ditambahkan",
+        "alreadyAdded": "Server ini sudah ada di daftar outbound. Gunakan {reset} untuk memperbarui kuncinya.",
+        "provisionFailed": "Gagal membuat outbound PIA. Coba lagi."
+      },
       "tabBalancerSettings": "Pengaturan Balancer",
       "tabObservatory": "Observatory",
       "observatory": {

+ 17 - 0
internal/web/translation/ja-JP.json

@@ -1520,6 +1520,9 @@
         "attributes": "属性",
         "value": "値",
         "user": "ユーザー",
+        "userPlaceholder": "ユーザーを選択",
+        "userEmpty": "利用可能なユーザーがいません",
+        "userLoadError": "ユーザーを読み込めませんでした",
         "inboundTags": "インバウンドタグ",
         "outboundTag": "アウトバウンドタグ",
         "balancerTag": "バランサータグ",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "削除しました",
         "toastDeleteFailed": "削除に失敗しました"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "PIA ユーザー名",
+        "password": "PIA パスワード",
+        "account": "アカウント",
+        "region": "リージョン",
+        "allRegions": "すべてのリージョン",
+        "noServers": "選択した国にサーバーがありません",
+        "outboundAdded": "PIA アウトバウンドを追加しました",
+        "outboundUpdated": "PIA アウトバウンドを更新しました",
+        "addedServers": "追加済みサーバー",
+        "alreadyAdded": "このサーバーは既にアウトバウンド一覧にあります。鍵を更新するには {reset} を使ってください。",
+        "provisionFailed": "PIA アウトバウンドを作成できませんでした。もう一度お試しください。"
+      },
       "tabBalancerSettings": "バランサー設定",
       "tabObservatory": "オブザーバトリ",
       "observatory": {

+ 17 - 0
internal/web/translation/pt-BR.json

@@ -1520,6 +1520,9 @@
         "attributes": "Atributos",
         "value": "Valor",
         "user": "Usuário",
+        "userPlaceholder": "Selecionar usuários",
+        "userEmpty": "Nenhum usuário disponível",
+        "userLoadError": "Falha ao carregar usuários",
         "inboundTags": "Tags de entradas",
         "outboundTag": "Tag de saída",
         "balancerTag": "Tag de balanceador",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "Excluído",
         "toastDeleteFailed": "Falha ao excluir"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "Usuário PIA",
+        "password": "Senha PIA",
+        "account": "Conta",
+        "region": "Região",
+        "allRegions": "Todas as regiões",
+        "noServers": "Nenhum servidor para o país selecionado",
+        "outboundAdded": "Saída PIA adicionada",
+        "outboundUpdated": "Saída PIA atualizada",
+        "addedServers": "Servidores adicionados",
+        "alreadyAdded": "Este servidor já está na lista de saídas. Use {reset} para renovar a chave.",
+        "provisionFailed": "Não foi possível criar a saída PIA. Tente novamente."
+      },
       "tabBalancerSettings": "Configurações do balanceador",
       "tabObservatory": "Observatório",
       "observatory": {

+ 17 - 0
internal/web/translation/ru-RU.json

@@ -1520,6 +1520,9 @@
         "attributes": "Атрибуты",
         "value": "Значение",
         "user": "Пользователь",
+        "userPlaceholder": "Выберите пользователей",
+        "userEmpty": "Нет доступных пользователей",
+        "userLoadError": "Не удалось загрузить пользователей",
         "inboundTags": "Теги входящих",
         "outboundTag": "Тег исходящего",
         "balancerTag": "Тег балансировщика",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "Удалено",
         "toastDeleteFailed": "Не удалось удалить"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "Имя пользователя PIA",
+        "password": "Пароль PIA",
+        "account": "Аккаунт",
+        "region": "Регион",
+        "allRegions": "Все регионы",
+        "noServers": "Для выбранной страны серверы не найдены",
+        "outboundAdded": "Исходящее соединение PIA добавлено",
+        "outboundUpdated": "Исходящее соединение PIA обновлено",
+        "addedServers": "Добавленные серверы",
+        "alreadyAdded": "Этот сервер уже в списке исходящих. Чтобы обновить ключ, нажмите {reset}.",
+        "provisionFailed": "Не удалось создать исходящее PIA. Попробуйте ещё раз."
+      },
       "tabBalancerSettings": "Настройки балансировщика",
       "tabObservatory": "Обсерватория",
       "observatory": {

+ 17 - 0
internal/web/translation/tr-TR.json

@@ -1520,6 +1520,9 @@
         "attributes": "Öznitelikler",
         "value": "Değer",
         "user": "Kullanıcı",
+        "userPlaceholder": "Kullanıcıları seçin",
+        "userEmpty": "Kullanılabilir kullanıcı yok",
+        "userLoadError": "Kullanıcılar yüklenemedi",
         "inboundTags": "Gelen Bağlantı Etiketleri",
         "outboundTag": "Giden Bağlantı Etiketi",
         "balancerTag": "Dengeleyici Etiketi",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "Silindi",
         "toastDeleteFailed": "Silme işlemi başarısız"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "PIA kullanıcı adı",
+        "password": "PIA parolası",
+        "account": "Hesap",
+        "region": "Bölge",
+        "allRegions": "Tüm bölgeler",
+        "noServers": "Seçilen ülke için sunucu bulunamadı",
+        "outboundAdded": "PIA çıkışı eklendi",
+        "outboundUpdated": "PIA çıkışı güncellendi",
+        "addedServers": "Eklenen sunucular",
+        "alreadyAdded": "Bu sunucu zaten çıkış listesinde. Anahtarı yenilemek için {reset} kullanın.",
+        "provisionFailed": "PIA çıkışı oluşturulamadı. Yeniden deneyin."
+      },
       "tabBalancerSettings": "Dengeleyici Ayarları",
       "tabObservatory": "Gözlemci",
       "observatory": {

+ 17 - 0
internal/web/translation/uk-UA.json

@@ -1520,6 +1520,9 @@
         "attributes": "Атрибути",
         "value": "Значення",
         "user": "Користувач",
+        "userPlaceholder": "Виберіть користувачів",
+        "userEmpty": "Немає доступних користувачів",
+        "userLoadError": "Не вдалося завантажити користувачів",
         "inboundTags": "Теги вхідних",
         "outboundTag": "Тег вихідного",
         "balancerTag": "Тег балансувальника",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "Видалено",
         "toastDeleteFailed": "Не вдалося видалити"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "Ім’я користувача PIA",
+        "password": "Пароль PIA",
+        "account": "Обліковий запис",
+        "region": "Регіон",
+        "allRegions": "Усі регіони",
+        "noServers": "Для вибраної країни серверів немає",
+        "outboundAdded": "Вихідний PIA додано",
+        "outboundUpdated": "Вихідний PIA оновлено",
+        "addedServers": "Додані сервери",
+        "alreadyAdded": "Цей сервер уже в списку вихідних. Щоб оновити ключ, натисніть {reset}.",
+        "provisionFailed": "Не вдалося створити вихідний PIA. Спробуйте ще раз."
+      },
       "tabBalancerSettings": "Налаштування балансувальника",
       "tabObservatory": "Обсерваторія",
       "observatory": {

+ 17 - 0
internal/web/translation/vi-VN.json

@@ -1520,6 +1520,9 @@
         "attributes": "Thuộc tính",
         "value": "Giá trị",
         "user": "Người dùng",
+        "userPlaceholder": "Chọn người dùng",
+        "userEmpty": "Không có người dùng khả dụng",
+        "userLoadError": "Không thể tải người dùng",
         "inboundTags": "Tag inbound",
         "outboundTag": "Tag outbound",
         "balancerTag": "Tag balancer",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "Đã xóa",
         "toastDeleteFailed": "Xóa thất bại"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "Tên người dùng PIA",
+        "password": "Mật khẩu PIA",
+        "account": "Tài khoản",
+        "region": "Khu vực",
+        "allRegions": "Tất cả khu vực",
+        "noServers": "Không có máy chủ cho quốc gia đã chọn",
+        "outboundAdded": "Đã thêm outbound PIA",
+        "outboundUpdated": "Đã cập nhật outbound PIA",
+        "addedServers": "Máy chủ đã thêm",
+        "alreadyAdded": "Máy chủ này đã có trong danh sách outbound. Dùng {reset} để gia hạn khóa.",
+        "provisionFailed": "Không tạo được outbound PIA. Hãy thử lại."
+      },
       "tabBalancerSettings": "Cài đặt Balancer",
       "tabObservatory": "Observatory",
       "observatory": {

+ 17 - 0
internal/web/translation/zh-CN.json

@@ -1520,6 +1520,9 @@
         "attributes": "属性",
         "value": "值",
         "user": "用户",
+        "userPlaceholder": "选择用户",
+        "userEmpty": "暂无可用用户",
+        "userLoadError": "无法加载用户",
         "inboundTags": "入站标签",
         "outboundTag": "出站标签",
         "balancerTag": "均衡器标签",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "已删除",
         "toastDeleteFailed": "删除失败"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "PIA 用户名",
+        "password": "PIA 密码",
+        "account": "账号",
+        "region": "区域",
+        "allRegions": "全部区域",
+        "noServers": "所选国家没有可用服务器",
+        "outboundAdded": "已添加 PIA 出站",
+        "outboundUpdated": "已更新 PIA 出站",
+        "addedServers": "已添加的服务器",
+        "alreadyAdded": "该服务器已在出站列表中。要用新密钥请点 {reset}。",
+        "provisionFailed": "无法生成 PIA 出站,请重试。"
+      },
       "tabBalancerSettings": "负载均衡设置",
       "tabObservatory": "观测器",
       "observatory": {

+ 17 - 0
internal/web/translation/zh-TW.json

@@ -1520,6 +1520,9 @@
         "attributes": "屬性",
         "value": "值",
         "user": "使用者",
+        "userPlaceholder": "選擇使用者",
+        "userEmpty": "暫無可用使用者",
+        "userLoadError": "無法載入使用者",
         "inboundTags": "入站標籤",
         "outboundTag": "出站標籤",
         "balancerTag": "均衡器標籤",
@@ -1642,6 +1645,20 @@
         "toastDeleted": "已刪除",
         "toastDeleteFailed": "刪除失敗"
       },
+      "pia": {
+        "menu": "PIA",
+        "username": "PIA 使用者名稱",
+        "password": "PIA 密碼",
+        "account": "帳號",
+        "region": "區域",
+        "allRegions": "全部區域",
+        "noServers": "所選國家沒有可用伺服器",
+        "outboundAdded": "已新增 PIA 出站",
+        "outboundUpdated": "已更新 PIA 出站",
+        "addedServers": "已新增的伺服器",
+        "alreadyAdded": "此伺服器已在出站清單中。若要換新金鑰請按 {reset}。",
+        "provisionFailed": "無法產生 PIA 出站,請重試。"
+      },
       "tabBalancerSettings": "負載平衡設定",
       "tabObservatory": "觀測器",
       "observatory": {

+ 57 - 0
internal/xray/pia_wireguard_outbound_test.go

@@ -0,0 +1,57 @@
+package xray
+
+import (
+	"encoding/base64"
+	"testing"
+)
+
+func testWGKey(seed byte) string {
+	raw := make([]byte, 32)
+	for i := range raw {
+		raw[i] = seed
+	}
+	return base64.StdEncoding.EncodeToString(raw)
+}
+
+func TestValidateOutboundConfig_PiaUserspaceWireGuard(t *testing.T) {
+	piaOutbound := `{
+		"tag": "pia-us-east-useast1",
+		"piaHostname": "useast1",
+		"protocol": "wireguard",
+		"settings": {
+			"secretKey": "` + testWGKey(1) + `",
+			"address": ["10.0.0.2/32"],
+			"mtu": 1420,
+			"noKernelTun": true,
+			"peers": [{
+				"publicKey": "` + testWGKey(2) + `",
+				"endpoint": "198.51.100.10:51820",
+				"allowedIPs": ["0.0.0.0/0"],
+				"keepAlive": 25
+			}]
+		}
+	}`
+	if err := ValidateOutboundConfig([]byte(piaOutbound)); err != nil {
+		t.Fatalf("xray-core rejected the PIA WireGuard outbound the panel emits: %v", err)
+	}
+	second := `{
+		"tag": "pia-us-west-uswest1",
+		"piaHostname": "uswest1",
+		"protocol": "wireguard",
+		"settings": {
+			"secretKey": "` + testWGKey(1) + `",
+			"address": ["10.0.0.2/32"],
+			"mtu": 1420,
+			"noKernelTun": true,
+			"peers": [{
+				"publicKey": "` + testWGKey(2) + `",
+				"endpoint": "198.51.100.20:51820",
+				"allowedIPs": ["0.0.0.0/0"],
+				"keepAlive": 25
+			}]
+		}
+	}`
+	if err := ValidateOutboundConfig([]byte(second)); err != nil {
+		t.Fatalf("second PIA WireGuard outbound: %v", err)
+	}
+}