mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 11:57: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:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user