Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
40812fc
fix(firewall): flush conntrack table after enabling firewall at conta…
qdm12 Feb 21, 2026
ee8d62e
purevpn: switch updater to linux deb local-data and protocol ports
Feb 26, 2026
2a49d27
purevpn: improve resolver fallback and update OpenVPN port defaults
Feb 26, 2026
388e628
purevpn updater: fetch live inventory from app-derived URL
Feb 27, 2026
dbe31bc
purevpn: add hostname-trait server type selection
Feb 27, 2026
32ff15d
purevpn: add deterministic hostname-code location filters
Feb 27, 2026
f81d061
purevpn: add template ingestion and p2p server type support
Feb 27, 2026
6838917
purevpn: merge app local data and add multi-trait filter tests
Feb 27, 2026
2e88ef2
purevpn: switch to city-only filters and match-all tags
Feb 27, 2026
f7b0e54
chore: remove local purevpn test harness scripts from repo
Feb 27, 2026
5a6b7c9
purevpn: add OpenVPN fallback remote lines for port 1194
Feb 27, 2026
57662fc
Merge origin/master into codex/purevpn-deb-updater
Feb 27, 2026
f9ec7ba
chore: remove CSV/TSV PureVPN export tests from repo
Feb 27, 2026
a4afbe4
purevpn: expand city code mappings and tighten inventory-port tests
Feb 27, 2026
eac0f01
purevpn: remove atom secret env override and related tests
Feb 27, 2026
bfe558d
purevpn: remove fallback ports and reseller uid parsing test
Feb 27, 2026
9c63460
chore(deps): tidy purevpn module dependencies
Feb 27, 2026
b89cbbf
purevpn: apply review feedback and split selector features out
Mar 3, 2026
7253ec1
Merge upstream/master into codex/purevpn-deb-updater
Mar 3, 2026
f0247ff
Merge branch 'master' into codex/purevpn-deb-updater
reedog117 Mar 20, 2026
164d724
Merge branch 'master' into codex/purevpn-deb-updater
reedog117 Mar 27, 2026
4ed9152
Merge branch 'master' into codex/purevpn-deb-updater
qdm12 Jun 11, 2026
9475f9c
wip
qdm12 Jun 23, 2026
0cb48ad
wip2
qdm12 Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.purevpn.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
PUREVPN_USER=your-username
Comment thread
qdm12 marked this conversation as resolved.
Outdated
PUREVPN_PASSWORD=your-password
# Optional timezone for container logs
TZ=UTC
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
scratch.txt
.env.purevpn
Comment thread
qdm12 marked this conversation as resolved.
Outdated
.DS_Store
62 changes: 62 additions & 0 deletions internal/configuration/settings/openvpnselection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package settings

import (
"testing"

"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_OpenVPNSelection_validate(t *testing.T) {
t.Parallel()

testCases := map[string]struct {
selection OpenVPNSelection
provider string
err error
}{
"purevpn default selection is valid": {
Comment thread
qdm12 marked this conversation as resolved.
Outdated
selection: openVPNSelectionForValidation(providers.Purevpn),
provider: providers.Purevpn,
},
"purevpn TCP without custom port is valid": {
selection: func() OpenVPNSelection {
s := openVPNSelectionForValidation(providers.Purevpn)
s.Protocol = constants.TCP
return s
}(),
provider: providers.Purevpn,
},
"purevpn custom port is rejected": {
selection: func() OpenVPNSelection {
s := openVPNSelectionForValidation(providers.Purevpn)
*s.CustomPort = 1194
return s
}(),
provider: providers.Purevpn,
err: ErrOpenVPNCustomPortNotAllowed,
},
}

for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()

err := testCase.selection.validate(testCase.provider)
if testCase.err == nil {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.ErrorIs(t, err, testCase.err)
Comment thread
qdm12 marked this conversation as resolved.
Outdated
})
}
}

func openVPNSelectionForValidation(provider string) OpenVPNSelection {
selection := OpenVPNSelection{}
selection.setDefaults(provider)
return selection
}
201 changes: 149 additions & 52 deletions internal/configuration/settings/serverselection.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"strings"

"github.com/qdm12/gluetun/internal/configuration/settings/helpers"
"github.com/qdm12/gluetun/internal/configuration/settings/validation"
"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gluetun/internal/models"
Expand Down Expand Up @@ -57,6 +56,15 @@ type ServerSelection struct {
// port forwarding should be filtered. This is used with PIA
// and ProtonVPN.
PortForwardOnly *bool `json:"port_forward_only"`
// PureVPNServerTypes selects PureVPN servers by hostname-inferred traits.
// Allowed values are: regular, portforwarding, quantumresistant, obfuscation, p2p.
PureVPNServerTypes []string `json:"purevpn_server_types"`
// PureVPNCountryCodes filters PureVPN servers by deterministic
// 2-letter country code parsed from the hostname prefix.
PureVPNCountryCodes []string `json:"purevpn_country_codes"`
Comment thread
qdm12 marked this conversation as resolved.
Outdated
// PureVPNLocationCodes filters PureVPN servers by deterministic
// location code parsed from the hostname prefix (for example usca, ukm).
PureVPNLocationCodes []string `json:"purevpn_location_codes"`
Comment thread
qdm12 marked this conversation as resolved.
Outdated
// SecureCoreOnly is true if VPN servers without secure core should
// be filtered. This is used with ProtonVPN.
SecureCoreOnly *bool `json:"secure_core_only"`
Expand All @@ -72,15 +80,21 @@ type ServerSelection struct {
}

var (
ErrOwnedOnlyNotSupported = errors.New("owned only filter is not supported")
ErrFreeOnlyNotSupported = errors.New("free only filter is not supported")
ErrPremiumOnlyNotSupported = errors.New("premium only filter is not supported")
ErrStreamOnlyNotSupported = errors.New("stream only filter is not supported")
ErrMultiHopOnlyNotSupported = errors.New("multi hop only filter is not supported")
ErrPortForwardOnlyNotSupported = errors.New("port forwarding only filter is not supported")
ErrFreePremiumBothSet = errors.New("free only and premium only filters are both set")
ErrSecureCoreOnlyNotSupported = errors.New("secure core only filter is not supported")
ErrTorOnlyNotSupported = errors.New("tor only filter is not supported")
ErrOwnedOnlyNotSupported = errors.New("owned only filter is not supported")
ErrFreeOnlyNotSupported = errors.New("free only filter is not supported")
ErrPremiumOnlyNotSupported = errors.New("premium only filter is not supported")
ErrStreamOnlyNotSupported = errors.New("stream only filter is not supported")
ErrMultiHopOnlyNotSupported = errors.New("multi hop only filter is not supported")
ErrPortForwardOnlyNotSupported = errors.New("port forwarding only filter is not supported")
ErrPureVPNServerTypeNotSupported = errors.New("purevpn server type filter is not supported")
ErrPureVPNServerTypeNotValid = errors.New("purevpn server type is not valid")
ErrPureVPNCountryCodesNotSupported = errors.New("purevpn country codes filter is not supported")
ErrPureVPNCountryCodeNotValid = errors.New("purevpn country code is not valid")
ErrPureVPNLocationCodesNotSupported = errors.New("purevpn location codes filter is not supported")
ErrPureVPNLocationCodeNotValid = errors.New("purevpn location code is not valid")
ErrFreePremiumBothSet = errors.New("free only and premium only filters are both set")
ErrSecureCoreOnlyNotSupported = errors.New("secure core only filter is not supported")
ErrTorOnlyNotSupported = errors.New("tor only filter is not supported")
)

func (ss *ServerSelection) validate(vpnServiceProvider string,
Expand Down Expand Up @@ -140,19 +154,8 @@ func getLocationFilterChoices(vpnServiceProvider string,
filterChoices models.FilterChoices, err error,
) {
filterChoices = filterChoicesGetter.GetFilterChoices(vpnServiceProvider)

if vpnServiceProvider == providers.Surfshark {
// // Retro compatibility
// TODO v4 remove
newAndRetroRegions := append(filterChoices.Regions, validation.SurfsharkRetroLocChoices()...) //nolint:gocritic
err := atLeastOneIsOneOfCaseInsensitive(ss.Regions, newAndRetroRegions, warner)
if err != nil {
// Only return error comparing with newer regions, we don't want to confuse the user
// with the retro regions in the error message.
err = atLeastOneIsOneOfCaseInsensitive(ss.Regions, filterChoices.Regions, warner)
return models.FilterChoices{}, fmt.Errorf("%w: %w", ErrRegionNotValid, err)
}
}
_ = ss
_ = warner
Comment thread
qdm12 marked this conversation as resolved.
Outdated

return filterChoices, nil
}
Expand All @@ -167,11 +170,6 @@ func validateServerFilters(settings ServerSelection, filterChoices models.Filter
return fmt.Errorf("%w: %w", ErrCountryNotValid, err)
}

err = atLeastOneIsOneOfCaseInsensitive(settings.Regions, filterChoices.Regions, warner)
Comment thread
qdm12 marked this conversation as resolved.
Outdated
if err != nil {
return fmt.Errorf("%w: %w", ErrRegionNotValid, err)
}

err = atLeastOneIsOneOfCaseInsensitive(settings.Cities, filterChoices.Cities, warner)
if err != nil {
return fmt.Errorf("%w: %w", ErrCityNotValid, err)
Expand Down Expand Up @@ -280,36 +278,71 @@ func validateFeatureFilters(settings ServerSelection, vpnServiceProvider string)
case *settings.PortForwardOnly &&
!helpers.IsOneOf(vpnServiceProvider, providers.PrivateInternetAccess, providers.Protonvpn):
return fmt.Errorf("%w", ErrPortForwardOnlyNotSupported)
case len(settings.PureVPNServerTypes) > 0 && vpnServiceProvider != providers.Purevpn:
return fmt.Errorf("%w", ErrPureVPNServerTypeNotSupported)
case len(settings.PureVPNCountryCodes) > 0 && vpnServiceProvider != providers.Purevpn:
return fmt.Errorf("%w", ErrPureVPNCountryCodesNotSupported)
case len(settings.PureVPNLocationCodes) > 0 && vpnServiceProvider != providers.Purevpn:
return fmt.Errorf("%w", ErrPureVPNLocationCodesNotSupported)
case *settings.SecureCoreOnly && vpnServiceProvider != providers.Protonvpn:
return fmt.Errorf("%w", ErrSecureCoreOnlyNotSupported)
case *settings.TorOnly && vpnServiceProvider != providers.Protonvpn:
return fmt.Errorf("%w", ErrTorOnlyNotSupported)
default:
for _, serverType := range settings.PureVPNServerTypes {
if !helpers.IsOneOf(serverType,
"regular", "portforwarding", "quantumresistant", "obfuscation", "p2p") {
return fmt.Errorf("%w: %q", ErrPureVPNServerTypeNotValid, serverType)
}
}
for _, code := range settings.PureVPNCountryCodes {
if len(code) != 2 || !isASCIIAlpha(code) {
return fmt.Errorf("%w: %q", ErrPureVPNCountryCodeNotValid, code)
}
}
for _, code := range settings.PureVPNLocationCodes {
if len(code) < 2 || len(code) > 5 || !isASCIIAlpha(code) {
return fmt.Errorf("%w: %q", ErrPureVPNLocationCodeNotValid, code)
}
}
return nil
}
}

func isASCIIAlpha(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
return false
}
}
return true
}

func (ss *ServerSelection) copy() (copied ServerSelection) {
return ServerSelection{
VPN: ss.VPN,
Countries: gosettings.CopySlice(ss.Countries),
Categories: gosettings.CopySlice(ss.Categories),
Regions: gosettings.CopySlice(ss.Regions),
Cities: gosettings.CopySlice(ss.Cities),
ISPs: gosettings.CopySlice(ss.ISPs),
Hostnames: gosettings.CopySlice(ss.Hostnames),
Names: gosettings.CopySlice(ss.Names),
Numbers: gosettings.CopySlice(ss.Numbers),
OwnedOnly: gosettings.CopyPointer(ss.OwnedOnly),
FreeOnly: gosettings.CopyPointer(ss.FreeOnly),
PremiumOnly: gosettings.CopyPointer(ss.PremiumOnly),
StreamOnly: gosettings.CopyPointer(ss.StreamOnly),
SecureCoreOnly: gosettings.CopyPointer(ss.SecureCoreOnly),
TorOnly: gosettings.CopyPointer(ss.TorOnly),
PortForwardOnly: gosettings.CopyPointer(ss.PortForwardOnly),
MultiHopOnly: gosettings.CopyPointer(ss.MultiHopOnly),
OpenVPN: ss.OpenVPN.copy(),
Wireguard: ss.Wireguard.copy(),
VPN: ss.VPN,
Countries: gosettings.CopySlice(ss.Countries),
Categories: gosettings.CopySlice(ss.Categories),
Regions: gosettings.CopySlice(ss.Regions),
Cities: gosettings.CopySlice(ss.Cities),
ISPs: gosettings.CopySlice(ss.ISPs),
Hostnames: gosettings.CopySlice(ss.Hostnames),
Names: gosettings.CopySlice(ss.Names),
Numbers: gosettings.CopySlice(ss.Numbers),
OwnedOnly: gosettings.CopyPointer(ss.OwnedOnly),
FreeOnly: gosettings.CopyPointer(ss.FreeOnly),
PremiumOnly: gosettings.CopyPointer(ss.PremiumOnly),
StreamOnly: gosettings.CopyPointer(ss.StreamOnly),
SecureCoreOnly: gosettings.CopyPointer(ss.SecureCoreOnly),
TorOnly: gosettings.CopyPointer(ss.TorOnly),
PortForwardOnly: gosettings.CopyPointer(ss.PortForwardOnly),
PureVPNServerTypes: gosettings.CopySlice(ss.PureVPNServerTypes),
PureVPNCountryCodes: gosettings.CopySlice(ss.PureVPNCountryCodes),
PureVPNLocationCodes: gosettings.CopySlice(ss.PureVPNLocationCodes),
MultiHopOnly: gosettings.CopyPointer(ss.MultiHopOnly),
OpenVPN: ss.OpenVPN.copy(),
Wireguard: ss.Wireguard.copy(),
}
}

Expand All @@ -331,6 +364,9 @@ func (ss *ServerSelection) overrideWith(other ServerSelection) {
ss.TorOnly = gosettings.OverrideWithPointer(ss.TorOnly, other.TorOnly)
ss.MultiHopOnly = gosettings.OverrideWithPointer(ss.MultiHopOnly, other.MultiHopOnly)
ss.PortForwardOnly = gosettings.OverrideWithPointer(ss.PortForwardOnly, other.PortForwardOnly)
ss.PureVPNServerTypes = gosettings.OverrideWithSlice(ss.PureVPNServerTypes, other.PureVPNServerTypes)
ss.PureVPNCountryCodes = gosettings.OverrideWithSlice(ss.PureVPNCountryCodes, other.PureVPNCountryCodes)
ss.PureVPNLocationCodes = gosettings.OverrideWithSlice(ss.PureVPNLocationCodes, other.PureVPNLocationCodes)
ss.OpenVPN.overrideWith(other.OpenVPN)
ss.Wireguard.overrideWith(other.Wireguard)
}
Expand All @@ -347,6 +383,9 @@ func (ss *ServerSelection) setDefaults(vpnProvider string, portForwardingEnabled
defaultPortForwardOnly := portForwardingEnabled &&
helpers.IsOneOf(vpnProvider, providers.PrivateInternetAccess, providers.Protonvpn)
ss.PortForwardOnly = gosettings.DefaultPointer(ss.PortForwardOnly, defaultPortForwardOnly)
ss.PureVPNServerTypes = gosettings.DefaultSlice(ss.PureVPNServerTypes, nil)
ss.PureVPNCountryCodes = gosettings.DefaultSlice(ss.PureVPNCountryCodes, nil)
ss.PureVPNLocationCodes = gosettings.DefaultSlice(ss.PureVPNLocationCodes, nil)
ss.OpenVPN.setDefaults(vpnProvider)
ss.Wireguard.setDefaults()
}
Expand All @@ -367,10 +406,6 @@ func (ss ServerSelection) toLinesNode() (node *gotree.Node) {
node.Appendf("Categories: %s", strings.Join(ss.Categories, ", "))
}

if len(ss.Regions) > 0 {
node.Appendf("Regions: %s", strings.Join(ss.Regions, ", "))
}

if len(ss.Cities) > 0 {
node.Appendf("Cities: %s", strings.Join(ss.Cities, ", "))
}
Expand Down Expand Up @@ -426,6 +461,16 @@ func (ss ServerSelection) toLinesNode() (node *gotree.Node) {
node.Appendf("Port forwarding only servers: yes")
}

if len(ss.PureVPNServerTypes) > 0 {
node.Appendf("PureVPN server types: %s", strings.Join(ss.PureVPNServerTypes, ", "))
}
if len(ss.PureVPNCountryCodes) > 0 {
node.Appendf("PureVPN country codes: %s", strings.Join(ss.PureVPNCountryCodes, ", "))
}
if len(ss.PureVPNLocationCodes) > 0 {
node.Appendf("PureVPN location codes: %s", strings.Join(ss.PureVPNLocationCodes, ", "))
}

if ss.VPN == vpn.OpenVPN {
node.AppendNode(ss.OpenVPN.toLinesNode())
} else {
Expand Down Expand Up @@ -454,7 +499,7 @@ func (ss *ServerSelection) read(r *reader.Reader,
}
ss.Countries = r.CSV("SERVER_COUNTRIES", reader.RetroKeys(countriesRetroKeys...))

ss.Regions = r.CSV("SERVER_REGIONS", reader.RetroKeys("REGION"))
ss.Regions = nil
Comment thread
qdm12 marked this conversation as resolved.
Outdated
ss.Cities = r.CSV("SERVER_CITIES", reader.RetroKeys("CITY"))
ss.ISPs = r.CSV("ISP")
ss.Hostnames = r.CSV("SERVER_HOSTNAMES", reader.RetroKeys("SERVER_HOSTNAME"))
Expand Down Expand Up @@ -513,6 +558,10 @@ func (ss *ServerSelection) read(r *reader.Reader,
return err
}

ss.PureVPNServerTypes = parsePureVPNServerTypes(r.CSV("SERVER_TYPES"))
ss.PureVPNCountryCodes = normalizePureVPNCodes(r.CSV("PUREVPN_COUNTRY_CODES"))
ss.PureVPNLocationCodes = normalizePureVPNCodes(r.CSV("PUREVPN_LOCATION_CODES"))

err = ss.OpenVPN.read(r)
if err != nil {
return err
Expand All @@ -525,3 +574,51 @@ func (ss *ServerSelection) read(r *reader.Reader,

return nil
}

func parsePureVPNServerTypes(rawValues []string) (types []string) {
set := make(map[string]struct{}, len(rawValues))
for _, raw := range rawValues {
value := strings.ToLower(strings.TrimSpace(raw))
value = strings.ReplaceAll(value, "_", "")
value = strings.ReplaceAll(value, "-", "")
value = strings.ReplaceAll(value, " ", "")

switch value {
case "":
continue
case "regular":
value = "regular"
case "portforwarding", "portforward", "pf":
value = "portforwarding"
case "quantumresistant", "quantum", "qr":
value = "quantumresistant"
case "obfuscation", "obfuscated", "obf":
value = "obfuscation"
case "p2p":
value = "p2p"
}

if _, ok := set[value]; ok {
continue
}
set[value] = struct{}{}
types = append(types, value)
}
return types
}

func normalizePureVPNCodes(rawCodes []string) (codes []string) {
set := make(map[string]struct{}, len(rawCodes))
for _, raw := range rawCodes {
code := strings.TrimSpace(strings.ToLower(raw))
if code == "" {
continue
}
if _, ok := set[code]; ok {
continue
}
set[code] = struct{}{}
codes = append(codes, code)
}
return codes
}
Loading