feat(pia): add PIA login-and-add WireGuard outbounds (#6272)

* feat(pia): add login-and-add WireGuard outbounds (#2)

* fix(pia): keep PIA outbounds identifiable after the editor strips hostname

The outbound editor drops piaHostname, so last-segment matching failed for hyphenated servers. Identify rows by the computed tag, re-encrypt stored tokens onto the active key, skip unusable catalog rows, and always release the catalog refresh latch.
This commit is contained in:
Masterain
2026-08-23 05:11:06 +08:00
committed by GitHub
parent a3e617215c
commit bd6a6aba43
73 changed files with 4095 additions and 31 deletions
+1
View File
@@ -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
View File
@@ -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` |
@@ -1,13 +1,13 @@
---
title: Outbounds & Routing
description: Shape egress in 3x-ui — WARP and NordVPN outbounds, outbound subscriptions (server pools), routing rules, and load balancers.
description: Shape egress in 3x-ui — WARP, NordVPN, PIA WireGuard, outbound subscriptions, routing rules, and load balancers.
icon: Route
---
Inbounds accept clients; **outbounds** decide where their traffic goes next.
3x-ui can route traffic through Cloudflare WARP, NordVPN, or arbitrary outbound
pools imported from a subscription, and select between them with routing rules
and balancers.
3x-ui can route traffic through Cloudflare WARP, NordVPN, Private Internet Access
(WireGuard), or arbitrary outbound pools imported from a subscription,
and select between them with routing rules and balancers.
## Editing outbounds & routing
@@ -86,6 +86,23 @@ with a routing rule.
accept a private key directly) and list countries/servers, so you can build a
NordVPN outbound.
## PIA WireGuard
3x-ui can sign in with a PIA username and password, list countries/regions/servers
from the signed PIA server list, and build a WireGuard outbound. Open
**Xray → Outbounds → More → PIA**, sign in, pick a server, and add the outbound.
You can add several servers (one outbound per hostname). The tag is
`pia-<region>-<server>` (for example `pia-us-east-useast1`). Adding or using
**Reset** on a row registers a WireGuard key with PIA `/addKey` for that server.
The same hostname cannot be added twice. Logout clears the stored token only;
delete unused PIA outbounds from the Outbounds list. Reset and delete do not
revoke the WireGuard peer on the PIA account.
The password is not stored. The PIA API token is stored with the same
`NODE_TOKEN_ENCRYPTION` setting as node API tokens. If you retire an old
`XUI_NODE_TOKEN_KEY` without signing into PIA again, Add/Reset fail until you
re-login. Peer `allowedIPs` is IPv4-only (`0.0.0.0/0`).
## Outbound subscriptions (server pools)
An **outbound subscription** imports a remote share-link subscription and injects
@@ -1,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 />
</>
);
}
@@ -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 از راه دور را وارد می‌کند و سرورهای آن را به‌عنوان
@@ -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`.
## Подписки на исходящие соединения (пулы серверов)
**Подписка на исходящие соединения** импортирует удалённую подписку со
@@ -1,11 +1,12 @@
---
title: 出站与路由
description: 在 3x-ui 中调整出口流量——WARPNordVPN 出站、出站订阅(服务器池)、路由规则以及负载均衡器。
description: 在 3x-ui 中调整出口流量——WARPNordVPN、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
View File
@@ -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
View File
@@ -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": [
+38 -1
View File
@@ -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
View File
@@ -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>
);
@@ -95,6 +95,7 @@ interface OutboundsTabProps {
onTestAll: (mode: string) => void;
onShowWarp: () => void;
onShowNord: () => void;
onShowPia: () => void;
onRefreshXrayData?: () => void;
}
@@ -115,6 +116,7 @@ export default function OutboundsTab({
onTestAll,
onShowWarp,
onShowNord,
onShowPia,
onRefreshXrayData,
}: OutboundsTabProps) {
const { t } = useTranslation();
@@ -550,6 +552,12 @@ export default function OutboundsTab({
items: [
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
{
key: 'pia',
icon: <ApiOutlined />,
label: t('pages.xray.pia.menu'),
onClick: onShowPia,
},
{ type: 'divider' },
{
key: 'import',
@@ -0,0 +1,53 @@
.pia-data-table {
margin: 5px 0;
width: 100%;
border-collapse: collapse;
}
.pia-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.pia-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.pia-data-table .row-odd {
background: var(--ant-color-fill-tertiary);
}
.pia-already-added {
margin-top: 8px;
color: var(--ant-color-text-secondary);
font-size: 12px;
}
.pia-added-table {
margin: 0;
width: 100%;
border-collapse: collapse;
}
.pia-added-table td {
padding: 6px 0;
vertical-align: middle;
}
.pia-added-table td:first-child {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
padding-right: 8px;
}
.pia-added-table td:last-child {
width: 1%;
white-space: nowrap;
text-align: right;
}
@@ -0,0 +1,468 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select } from 'antd';
import { LoginOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil } from '@/utils';
import { FormField } from '@/components/form/rhf';
import { countryFlag, countryName } from '../outbounds/outbounds-tab-helpers';
import './PiaModal.css';
interface PiaOutboundRow {
tag?: string;
piaHostname?: string;
}
interface PiaModalProps {
open: boolean;
templateSettings: { outbounds?: PiaOutboundRow[] } | null;
onClose: () => void;
onAddOutbound: (outbound: Record<string, unknown>) => void;
onResetOutbound: (payload: {
index: number;
outbound: Record<string, unknown>;
oldTag?: string;
newTag: string;
}) => void;
}
interface PiaAccount {
username?: string;
accountHint?: string;
}
interface PiaCountry {
code: string;
}
interface PiaRegion {
id: string;
name: string;
}
interface PiaServer {
hostname: string;
ip: string;
regionId: string;
regionName: string;
}
interface PiaKey {
tag: string;
hostname: string;
secretKey: string;
address: string;
publicKey: string;
endpoint: string;
}
interface PiaFormValues {
username: string;
password: string;
countryCode: string | null;
regionId: string | null;
hostname: string | null;
}
const EMPTY: PiaFormValues = {
username: '',
password: '',
countryCode: null,
regionId: null,
hostname: null,
};
function piaHostnameOf(outbound: PiaOutboundRow): string {
if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
return outbound.piaHostname.trim();
}
return '';
}
function piaTagPart(s: string, stripDomain: boolean): string {
s = s.trim().toLowerCase();
if (stripDomain) {
const i = s.indexOf('.');
if (i > 0) s = s.slice(0, i);
}
return s.replaceAll('_', '-');
}
function piaOutboundTag(regionId: string, hostname: string): string {
const region = piaTagPart(regionId, false);
const server = piaTagPart(hostname, true);
if (!region) return `pia-${server}`;
return `pia-${region}-${server}`;
}
function buildPiaOutbound(key: PiaKey): Record<string, unknown> {
return {
tag: key.tag || `pia-${key.hostname}`,
piaHostname: key.hostname,
protocol: 'wireguard',
settings: {
secretKey: key.secretKey,
address: [key.address],
mtu: 1420,
noKernelTun: true,
peers: [
{
publicKey: key.publicKey,
endpoint: key.endpoint,
allowedIPs: ['0.0.0.0/0'],
keepAlive: 25,
},
],
},
};
}
export default function PiaModal({
open,
templateSettings,
onClose,
onAddOutbound,
onResetOutbound,
}: PiaModalProps) {
const { t, i18n } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [piaData, setPiaData] = useState<PiaAccount | null>(null);
const [countries, setCountries] = useState<PiaCountry[]>([]);
const [regions, setRegions] = useState<PiaRegion[]>([]);
const [servers, setServers] = useState<PiaServer[]>([]);
const methods = useForm<PiaFormValues>({ defaultValues: EMPTY });
const regionId = useWatch({ control: methods.control, name: 'regionId' });
const hostname = useWatch({ control: methods.control, name: 'hostname' });
const locale = i18n.resolvedLanguage || i18n.language;
const piaRows = useMemo(() => {
const list = templateSettings?.outbounds;
if (!list) return [];
return list.flatMap((outbound, index) => {
if (!outbound?.tag?.startsWith?.('pia-')) return [];
return [{ index, tag: outbound.tag, hostname: piaHostnameOf(outbound) }];
});
}, [templateSettings?.outbounds]);
const addedHostnames = useMemo(
() => new Set(piaRows.map((row) => row.hostname).filter(Boolean)),
[piaRows],
);
const addedTags = useMemo(
() => new Set(piaRows.map((row) => row.tag).filter(Boolean)),
[piaRows],
);
const filteredServers = useMemo(() => {
if (!regionId) return servers;
return servers.filter((s) => s.regionId === regionId);
}, [regionId, servers]);
const selectedServer = filteredServers.find((s) => s.hostname === hostname);
const selectedTag = selectedServer
? piaOutboundTag(selectedServer.regionId, selectedServer.hostname)
: '';
const selectedAlreadyAdded = Boolean(
(hostname && addedHostnames.has(hostname)) || (selectedTag && addedTags.has(selectedTag)),
);
useEffect(() => {
methods.setValue('hostname', filteredServers.length > 0 ? filteredServers[0].hostname : null);
}, [filteredServers, methods]);
const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post<PiaCountry[]>('/panel/api/xray/pia/countries');
if (msg?.success && Array.isArray(msg.obj)) setCountries(msg.obj);
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post<PiaAccount | null>('/panel/api/xray/pia/data');
if (msg?.success) {
const next = msg.obj ?? null;
setPiaData(next);
if (next) await fetchCountries();
}
} finally {
setLoading(false);
}
}, [fetchCountries]);
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
await fetchData();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [open, fetchData]);
async function login() {
setLoading(true);
try {
const msg = await HttpUtil.post<PiaAccount>('/panel/api/xray/pia/reg', {
username: methods.getValues('username'),
password: methods.getValues('password'),
});
if (msg?.success && msg.obj) {
setPiaData(msg.obj);
methods.setValue('password', '');
await fetchCountries();
}
} finally {
setLoading(false);
}
}
async function logout() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/api/xray/pia/del');
if (msg?.success) {
setPiaData(null);
methods.reset(EMPTY);
setCountries([]);
setRegions([]);
setServers([]);
}
} finally {
setLoading(false);
}
}
async function fetchServers(newCountryCode: string) {
setLoading(true);
setServers([]);
setRegions([]);
methods.setValue('hostname', null);
methods.setValue('regionId', null);
try {
const msg = await HttpUtil.post<{ regions?: PiaRegion[]; servers?: PiaServer[] }>(
'/panel/api/xray/pia/servers',
{ countryCode: newCountryCode },
);
if (!msg?.success || !msg.obj) return;
const nextRegions = msg.obj.regions || [];
const nextServers = msg.obj.servers || [];
setRegions(nextRegions);
setServers(nextServers);
if (nextServers.length === 0) messageApi.warning(t('pages.xray.pia.noServers'));
} finally {
setLoading(false);
}
}
async function provisionOutbound(
selectedHostname: string,
): Promise<Record<string, unknown> | null> {
if (!selectedHostname) return null;
const msg = await HttpUtil.post<PiaKey>('/panel/api/xray/pia/addKey', {
hostname: selectedHostname,
});
if (!msg?.success) return null;
if (!msg.obj?.secretKey || !msg.obj.publicKey || !msg.obj.endpoint || !msg.obj.address) {
messageApi.error(t('pages.xray.pia.provisionFailed'));
return null;
}
return buildPiaOutbound(msg.obj);
}
async function addOutbound() {
const selected = methods.getValues('hostname');
if (!selected || selectedAlreadyAdded) return;
setLoading(true);
try {
const ob = await provisionOutbound(selected);
if (!ob) return;
const tag = typeof ob.tag === 'string' ? ob.tag : '';
if (tag && templateSettings?.outbounds?.some((outbound) => outbound?.tag === tag)) return;
onAddOutbound(ob);
messageApi.success(t('pages.xray.pia.outboundAdded'));
} finally {
setLoading(false);
}
}
async function resetOutbound(index: number, selectedHostname: string) {
if (!selectedHostname) return;
setLoading(true);
try {
const ob = await provisionOutbound(selectedHostname);
if (!ob) return;
const oldTag = templateSettings?.outbounds?.[index]?.tag;
onResetOutbound({
index,
outbound: ob,
oldTag,
newTag: ob.tag as string,
});
messageApi.success(t('pages.xray.pia.outboundUpdated'));
} finally {
setLoading(false);
}
}
return (
<>
{messageContextHolder}
<Modal open={open} title="Private Internet Access WireGuard" footer={null} onCancel={onClose}>
<FormProvider {...methods}>
{piaData == null ? (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<FormField name="username" label={t('pages.xray.pia.username')}>
<Input placeholder={t('pages.xray.pia.username')} autoComplete="username" />
</FormField>
<FormField name="password" label={t('pages.xray.pia.password')}>
<Input.Password
placeholder={t('pages.xray.pia.password')}
autoComplete="current-password"
/>
</FormField>
<Button
type="primary"
className="mt-10"
loading={loading}
icon={<LoginOutlined />}
onClick={() => void login()}
>
{t('login')}
</Button>
</Form>
) : (
<>
<table className="pia-data-table">
<tbody>
<tr className="row-odd">
<td>{t('pages.xray.pia.account')}</td>
<td>{piaData.accountHint || piaData.username}</td>
</tr>
</tbody>
</table>
<Button
loading={loading}
type="primary"
danger
className="mt-8"
onClick={() => void logout()}
>
{t('logout')}
</Button>
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-10"
>
<FormField
name="countryCode"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => void fetchServers(v as string)}
>
<Select
data-testid="pia-country-select"
showSearch={{ optionFilterProp: 'label' }}
options={countries.map((c) => {
const name = countryName(c.code, locale) || c.code;
const flag = countryFlag(c.code);
return {
value: c.code,
label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
};
})}
/>
</FormField>
{regions.length > 0 && (
<FormField name="regionId" label={t('pages.xray.pia.region')}>
<Select
data-testid="pia-region-select"
showSearch={{ optionFilterProp: 'label' }}
options={[
{ value: null, label: t('pages.xray.pia.allRegions') },
...regions.map((r) => ({ value: r.id, label: r.name })),
]}
/>
</FormField>
)}
{filteredServers.length > 0 && (
<FormField name="hostname" label={t('pages.xray.outbound.server')}>
<Select
data-testid="pia-server-select"
showSearch={{ optionFilterProp: 'label' }}
options={filteredServers.map((s) => ({
value: s.hostname,
label: `${s.regionName} ${s.hostname} ${s.ip}`,
}))}
/>
</FormField>
)}
</Form>
<Button
type="primary"
className="mt-10"
disabled={!hostname || selectedAlreadyAdded}
loading={loading}
onClick={() => void addOutbound()}
>
{t('pages.xray.warp.addOutbound')}
</Button>
{selectedAlreadyAdded && (
<div className="pia-already-added">
{t('pages.xray.pia.alreadyAdded', { reset: t('reset') })}
</div>
)}
{piaRows.length > 0 && (
<>
<Divider className="my-10">{t('pages.xray.pia.addedServers')}</Divider>
<table className="pia-added-table" data-testid="pia-added-table">
<tbody>
{piaRows.map((row) => (
<tr key={`${row.index}-${row.tag}`}>
<td>{row.tag}</td>
<td>
<Button
type="primary"
danger
size="small"
loading={loading}
disabled={!row.tag}
data-testid={`pia-reset-${row.index}`}
onClick={() =>
void resetOutbound(row.index, row.hostname || row.tag || '')
}
>
{t('reset')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
)}
</FormProvider>
</Modal>
</>
);
}
@@ -1,2 +1,3 @@
export { default as WarpModal } from './WarpModal';
export { default as NordModal } from './NordModal';
export { default as PiaModal } from './PiaModal';
@@ -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
View File
@@ -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();
});
});
+30 -10
View File
@@ -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() }
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1 @@
<!doctype html><title>upstream error</title>
+1
View File
@@ -0,0 +1 @@
{"token":"test-token-value-that-is-long-enough"}
@@ -0,0 +1,3 @@
{"version":6,"groups":{},"regions":[]}
QUFBQQ==
+1
View File
@@ -0,0 +1 @@
{"version":6,"groups":{},"regions":[{"id":12}]}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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-----
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+295
View File
@@ -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'
}
@@ -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
View File
@@ -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")
}
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1760,6 +1760,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
+14
View File
@@ -1642,6 +1642,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": {
@@ -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)
}
}