mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 03:47:15 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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 中调整出口流量——WARP 与 NordVPN 出站、出站订阅(服务器池)、路由规则以及负载均衡器。
|
||||
description: 在 3x-ui 中调整出口流量——WARP、NordVPN、PIA WireGuard、出站订阅(服务器池)、路由规则以及负载均衡器。
|
||||
icon: Route
|
||||
---
|
||||
|
||||
入站负责接受客户端;**出站**则决定客户端的流量接下来发往何处。
|
||||
3x-ui 可以让流量经由 Cloudflare WARP、NordVPN,或从订阅导入的任意出站池转发,
|
||||
3x-ui 可以让流量经由 Cloudflare WARP、NordVPN、Private Internet Access
|
||||
(WireGuard),或从订阅导入的任意出站池转发,
|
||||
并通过路由规则和均衡器在它们之间进行选择。
|
||||
|
||||
## 编辑出站与路由
|
||||
@@ -81,6 +82,19 @@ WARP 账户,并将其接入一个标签为 **`warp`** 的 WireGuard 出站:
|
||||
直接接受一个私钥),并列出国家/服务器,从而让你构建一个
|
||||
NordVPN 出站。
|
||||
|
||||
## PIA WireGuard
|
||||
|
||||
3x-ui 可以用 PIA 用户名和密码登录,从已验签的服务器列表里选择国家/区域/服务器,
|
||||
并生成 WireGuard 出站。打开 **Xray → 出站 → 更多 → PIA**,登录后选服务器并添加出站。
|
||||
可以添加多台服务器(每个 hostname 一条出站)。标签为 `pia-<region>-<server>`(例如
|
||||
`pia-us-east-useast1`)。添加或对该行 **Reset** 会向该服务器的 PIA `/addKey` 注册密钥。
|
||||
同一 hostname 不能添加两次。登出只清除保存的 token;删除出站请在出站列表里操作。
|
||||
Reset 或删除出站不会撤销 PIA 账户侧的 WireGuard peer。
|
||||
|
||||
密码不落库。PIA API token 与节点 API token 共用 `NODE_TOKEN_ENCRYPTION`。
|
||||
若在未重新登录 PIA 的情况下淘汰旧的 `XUI_NODE_TOKEN_KEY`,Add/Reset 会失败,直到再次登录。
|
||||
对端 `allowedIPs` 仅为 `0.0.0.0/0`(IPv4)。
|
||||
|
||||
## 出站订阅(服务器池)
|
||||
|
||||
**出站订阅**会导入一个远程分享链接订阅,并将其中的服务器作为**出站**注入到正在运行的
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ERROR",
|
||||
"peer_ip": "10.0.0.2/32",
|
||||
"server_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"server_ip": "198.51.100.10",
|
||||
"server_port": 51820,
|
||||
"dns_servers": ["10.0.0.1"]
|
||||
}
|
||||
+1
@@ -0,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"]}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><title>upstream error</title>
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"token":"test-token-value-that-is-long-enough"}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"version":6,"groups":{},"regions":[]}
|
||||
|
||||
QUFBQQ==
|
||||
@@ -0,0 +1 @@
|
||||
{"version":6,"groups":{},"regions":[{"id":12}]}
|
||||
@@ -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"}]}}]}
|
||||
@@ -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"}]}}]}
|
||||
@@ -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-----
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user