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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user