mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 11:57:15 +00:00
bd6a6aba43
* 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.
296 lines
8.1 KiB
Go
296 lines
8.1 KiB
Go
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'
|
|
}
|