Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(protonvpn): Added ProtonVPN feature selection #2182

Merged
merged 2 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ ENV VPN_SERVICE_PROVIDER=pia \
SERVER_NAMES= \
# # ProtonVPN only:
FREE_ONLY= \
SECURE_CORE_ONLY= \
TOR_ONLY= \
P2P_ONLY= \
# # Surfshark only:
MULTIHOP_ONLY= \
# # VPN Secure only:
Expand Down
148 changes: 99 additions & 49 deletions internal/configuration/settings/serverselection.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,23 @@ type ServerSelection struct { //nolint:maligned
// TODO extend to providers using FreeOnly.
PremiumOnly *bool `json:"premium_only"`
// StreamOnly is true if VPN servers not for streaming should
// be filtered. This is used with VPNUnlimited.
// be filtered. This is used with ProtonVPN and VPNUnlimited.
StreamOnly *bool `json:"stream_only"`
// MultiHopOnly is true if VPN servers that are not multihop
// should be filtered. This is used with Surfshark.
MultiHopOnly *bool `json:"multi_hop_only"`
// PortForwardOnly is true if VPN servers that don't support
// port forwarding should be filtered. This is used with PIA.
PortForwardOnly *bool `json:"port_forward_only"`
// SecureCoreOnly is true if VPN servers without secure core should
// be filtered. This is used with ProtonVPN.
SecureCoreOnly *bool `json:"secure_core_only"`
// TorOnly is true if VPN servers without tor should
// be filtered. This is used with ProtonVPN.
TorOnly *bool `json:"tor_only"`
// P2POnly is true if VPN servers not for p2p should
// be filtered. This is used with ProtonVPN.
P2POnly *bool `json:"p2p_only"`
// OpenVPN contains settings to select OpenVPN servers
// and the final connection.
OpenVPN OpenVPNSelection `json:"openvpn"`
Expand All @@ -79,6 +88,9 @@ var (
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")
ErrP2POnlyNotSupported = errors.New("p2p only filter is not supported")
)

func (ss *ServerSelection) validate(vpnServiceProvider string,
Expand Down Expand Up @@ -107,55 +119,14 @@ func (ss *ServerSelection) validate(vpnServiceProvider string,
return fmt.Errorf("for VPN service provider %s: %w", vpnServiceProvider, err)
}

if *ss.OwnedOnly &&
vpnServiceProvider != providers.Mullvad {
return fmt.Errorf("%w: for VPN service provider %s",
ErrOwnedOnlyNotSupported, vpnServiceProvider)
}

if *ss.FreeOnly &&
!helpers.IsOneOf(vpnServiceProvider,
providers.Protonvpn,
providers.VPNUnlimited,
) {
return fmt.Errorf("%w: for VPN service provider %s",
ErrFreeOnlyNotSupported, vpnServiceProvider)
}

if *ss.PremiumOnly &&
!helpers.IsOneOf(vpnServiceProvider,
providers.VPNSecure,
) {
return fmt.Errorf("%w: for VPN service provider %s",
ErrPremiumOnlyNotSupported, vpnServiceProvider)
}

if *ss.FreeOnly && *ss.PremiumOnly {
return fmt.Errorf("%w", ErrFreePremiumBothSet)
}

if *ss.StreamOnly &&
!helpers.IsOneOf(vpnServiceProvider,
providers.Protonvpn,
providers.VPNUnlimited,
) {
return fmt.Errorf("%w: for VPN service provider %s",
ErrStreamOnlyNotSupported, vpnServiceProvider)
}

if *ss.MultiHopOnly &&
vpnServiceProvider != providers.Surfshark {
return fmt.Errorf("%w: for VPN service provider %s",
ErrMultiHopOnlyNotSupported, vpnServiceProvider)
err = validateSubscriptionTierFilters(*ss, vpnServiceProvider)
if err != nil {
return fmt.Errorf("for VPN service provider %s: %w", vpnServiceProvider, err)
kvanzuijlen marked this conversation as resolved.
Show resolved Hide resolved
}

if *ss.PortForwardOnly &&
vpnServiceProvider != providers.PrivateInternetAccess {
// ProtonVPN also supports port forwarding, but on all their servers, so these
// don't have the port forwarding boolean field. As a consequence, we only allow
// the use of PortForwardOnly for Private Internet Access.
return fmt.Errorf("%w: for VPN service provider %s",
ErrPortForwardOnlyNotSupported, vpnServiceProvider)
err = validateFeatureFilters(*ss, vpnServiceProvider)
if err != nil {
return fmt.Errorf("for VPN service provider %s: %w", vpnServiceProvider, err)
}

if ss.VPN == vpn.OpenVPN {
Expand Down Expand Up @@ -242,6 +213,46 @@ func validateServerFilters(settings ServerSelection, filterChoices models.Filter
return nil
}

func validateSubscriptionTierFilters(settings ServerSelection, vpnServiceProvider string) error {
switch {
case *settings.FreeOnly &&
!helpers.IsOneOf(vpnServiceProvider, providers.Protonvpn, providers.VPNUnlimited):
return fmt.Errorf("%w", ErrFreeOnlyNotSupported)
case *settings.PremiumOnly &&
!helpers.IsOneOf(vpnServiceProvider, providers.VPNSecure):
return fmt.Errorf("%w", ErrPremiumOnlyNotSupported)
case *settings.FreeOnly && *settings.PremiumOnly:
return fmt.Errorf("%w", ErrFreePremiumBothSet)
default:
return nil
}
}

func validateFeatureFilters(settings ServerSelection, vpnServiceProvider string) error {
switch {
case *settings.OwnedOnly && vpnServiceProvider != providers.Mullvad:
return fmt.Errorf("%w", ErrOwnedOnlyNotSupported)
case *settings.StreamOnly &&
!helpers.IsOneOf(vpnServiceProvider, providers.Protonvpn, providers.VPNUnlimited):
return fmt.Errorf("%w", ErrStreamOnlyNotSupported)
case *settings.MultiHopOnly && vpnServiceProvider != providers.Surfshark:
return fmt.Errorf("%w", ErrMultiHopOnlyNotSupported)
case *settings.PortForwardOnly && vpnServiceProvider != providers.PrivateInternetAccess:
// ProtonVPN also supports port forwarding, but on all their servers, so these
// don't have the port forwarding boolean field. As a consequence, we only allow
// the use of PortForwardOnly for Private Internet Access.
return fmt.Errorf("%w", ErrPortForwardOnlyNotSupported)
case *settings.SecureCoreOnly && vpnServiceProvider != providers.Protonvpn:
return fmt.Errorf("%w", ErrSecureCoreOnlyNotSupported)
case *settings.TorOnly && vpnServiceProvider != providers.Protonvpn:
return fmt.Errorf("%w", ErrTorOnlyNotSupported)
case *settings.P2POnly && vpnServiceProvider != providers.Protonvpn:
return fmt.Errorf("%w", ErrP2POnlyNotSupported)
default:
return nil
}
}

func (ss *ServerSelection) copy() (copied ServerSelection) {
return ServerSelection{
VPN: ss.VPN,
Expand All @@ -258,6 +269,9 @@ func (ss *ServerSelection) copy() (copied ServerSelection) {
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),
P2POnly: gosettings.CopyPointer(ss.P2POnly),
PortForwardOnly: gosettings.CopyPointer(ss.PortForwardOnly),
MultiHopOnly: gosettings.CopyPointer(ss.MultiHopOnly),
OpenVPN: ss.OpenVPN.copy(),
Expand All @@ -280,6 +294,9 @@ func (ss *ServerSelection) overrideWith(other ServerSelection) {
ss.FreeOnly = gosettings.OverrideWithPointer(ss.FreeOnly, other.FreeOnly)
ss.PremiumOnly = gosettings.OverrideWithPointer(ss.PremiumOnly, other.PremiumOnly)
ss.StreamOnly = gosettings.OverrideWithPointer(ss.StreamOnly, other.StreamOnly)
ss.SecureCoreOnly = gosettings.OverrideWithPointer(ss.SecureCoreOnly, other.SecureCoreOnly)
ss.TorOnly = gosettings.OverrideWithPointer(ss.TorOnly, other.TorOnly)
ss.P2POnly = gosettings.OverrideWithPointer(ss.P2POnly, other.P2POnly)
ss.MultiHopOnly = gosettings.OverrideWithPointer(ss.MultiHopOnly, other.MultiHopOnly)
ss.PortForwardOnly = gosettings.OverrideWithPointer(ss.PortForwardOnly, other.PortForwardOnly)
ss.OpenVPN.overrideWith(other.OpenVPN)
Expand All @@ -293,6 +310,9 @@ func (ss *ServerSelection) setDefaults(vpnProvider string) {
ss.FreeOnly = gosettings.DefaultPointer(ss.FreeOnly, false)
ss.PremiumOnly = gosettings.DefaultPointer(ss.PremiumOnly, false)
ss.StreamOnly = gosettings.DefaultPointer(ss.StreamOnly, false)
ss.SecureCoreOnly = gosettings.DefaultPointer(ss.SecureCoreOnly, false)
ss.TorOnly = gosettings.DefaultPointer(ss.TorOnly, false)
ss.P2POnly = gosettings.DefaultPointer(ss.P2POnly, false)
ss.MultiHopOnly = gosettings.DefaultPointer(ss.MultiHopOnly, false)
ss.PortForwardOnly = gosettings.DefaultPointer(ss.PortForwardOnly, false)
ss.OpenVPN.setDefaults(vpnProvider)
Expand Down Expand Up @@ -361,6 +381,18 @@ func (ss ServerSelection) toLinesNode() (node *gotree.Node) {
node.Appendf("Stream only servers: yes")
}

if *ss.SecureCoreOnly {
node.Appendf("Secure Core only servers: yes")
}

if *ss.TorOnly {
node.Appendf("Tor only servers: yes")
}

if *ss.P2POnly {
node.Appendf("P2P only servers: yes")
}

if *ss.MultiHopOnly {
node.Appendf("Multi-hop only servers: yes")
}
Expand Down Expand Up @@ -432,12 +464,30 @@ func (ss *ServerSelection) read(r *reader.Reader,
return err
}

// VPNUnlimited only
// VPNUnlimited and ProtonVPN only
ss.StreamOnly, err = r.BoolPtr("STREAM_ONLY")
if err != nil {
return err
}

// ProtonVPN only
ss.SecureCoreOnly, err = r.BoolPtr("SECURE_CORE_ONLY")
if err != nil {
return err
}

// ProtonVPN only
ss.TorOnly, err = r.BoolPtr("TOR_ONLY")
if err != nil {
return err
}

// ProtonVPN only
ss.P2POnly, err = r.BoolPtr("P2P_ONLY")
if err != nil {
return err
}

// PIA only
ss.PortForwardOnly, err = r.BoolPtr("PORT_FORWARD_ONLY")
if err != nil {
Expand Down
7 changes: 5 additions & 2 deletions internal/models/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ type Server struct {
RetroLoc string `json:"retroloc,omitempty"` // TODO remove in v4
MultiHop bool `json:"multihop,omitempty"`
WgPubKey string `json:"wgpubkey,omitempty"`
Free bool `json:"free,omitempty"`
Stream bool `json:"stream,omitempty"`
Free bool `json:"free,omitempty"` // TODO v4 create a SubscriptionTier struct
Premium bool `json:"premium,omitempty"`
Stream bool `json:"stream,omitempty"` // TODO v4 create a Features struct
SecureCore bool `json:"secure_core,omitempty"`
Tor bool `json:"tor,omitempty"`
P2P bool `json:"p2p,omitempty"`
PortForward bool `json:"port_forward,omitempty"`
Keep bool `json:"keep,omitempty"`
IPs []netip.Addr `json:"ips,omitempty"`
Expand Down
6 changes: 6 additions & 0 deletions internal/models/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ func Test_Server_Equal(t *testing.T) {
WgPubKey: "wgpubkey",
Free: true,
Stream: true,
SecureCore: true,
Tor: true,
P2P: false,
PortForward: true,
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
Keep: true,
Expand All @@ -82,6 +85,9 @@ func Test_Server_Equal(t *testing.T) {
WgPubKey: "wgpubkey",
Free: true,
Stream: true,
SecureCore: true,
Tor: true,
P2P: false,
PortForward: true,
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
Keep: true,
Expand Down
1 change: 1 addition & 0 deletions internal/provider/protonvpn/updater/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type logicalServer struct {
Region *string `json:"Region"`
City *string `json:"City"`
Servers []physicalServer `json:"Servers"`
Features uint16 `json:"Features"`
}

type physicalServer struct {
Expand Down
13 changes: 12 additions & 1 deletion internal/provider/protonvpn/updater/iptoserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ import (

type ipToServer map[string]models.Server

type features struct {
secureCore bool
tor bool
p2p bool
stream bool
}

func (its ipToServer) add(country, region, city, name, hostname string,
free bool, entryIP netip.Addr) {
free bool, entryIP netip.Addr, features features) {
key := entryIP.String()

server, ok := its[key]
Expand All @@ -25,6 +32,10 @@ func (its ipToServer) add(country, region, city, name, hostname string,
server.ServerName = name
server.Hostname = hostname
server.Free = free
server.SecureCore = features.secureCore
server.Tor = features.tor
server.P2P = features.p2p
server.Stream = features.stream
server.UDP = true
server.TCP = true
server.IPs = []netip.Addr{entryIP}
Expand Down
14 changes: 13 additions & 1 deletion internal/provider/protonvpn/updater/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
// TODO v4 remove `name` field because of
// https://github.com/qdm12/gluetun/issues/1018#issuecomment-1151750179
name := logicalServer.Name

//nolint:lll
// See https://github.com/ProtonVPN/protonvpn-nm-lib/blob/31d5f99fbc89274e4e977a11e7432c0eab5a3ef8/protonvpn_nm_lib/enums.py#L44-L49
featuresBits := logicalServer.Features
features := features{
secureCore: featuresBits&1 != 0,
tor: featuresBits&2 != 0,
p2p: featuresBits&4 != 0,
stream: featuresBits&8 != 0,
// ipv6: featuresBits&16 != 0, - unused.
}

for _, physicalServer := range logicalServer.Servers {
if physicalServer.Status == 0 { // disabled so skip server
u.warner.Warn("ignoring server " + physicalServer.Domain + " with status 0")
Expand All @@ -60,7 +72,7 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
u.warner.Warn(warning)
}

ipToServer.add(country, region, city, name, hostname, free, entryIP)
ipToServer.add(country, region, city, name, hostname, free, entryIP, features)
}
}

Expand Down
12 changes: 12 additions & 0 deletions internal/provider/utils/filtering.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ func filterServer(server models.Server,
return true
}

if *selection.SecureCoreOnly && !server.SecureCore {
return true
}

if *selection.TorOnly && !server.Tor {
return true
}

if *selection.P2POnly && !server.P2P {
return true
}

if filterByPossibilities(server.Country, selection.Countries) {
return true
}
Expand Down
39 changes: 39 additions & 0 deletions internal/provider/utils/filtering_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,45 @@ func Test_FilterServers(t *testing.T) {
{Stream: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by secure core only": {
selection: settings.ServerSelection{
SecureCoreOnly: boolPtr(true),
}.WithDefaults(providers.Protonvpn),
servers: []models.Server{
{SecureCore: false, VPN: vpn.OpenVPN, UDP: true},
{SecureCore: true, VPN: vpn.OpenVPN, UDP: true},
{SecureCore: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{SecureCore: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by tor only": {
selection: settings.ServerSelection{
TorOnly: boolPtr(true),
}.WithDefaults(providers.Protonvpn),
servers: []models.Server{
{Tor: false, VPN: vpn.OpenVPN, UDP: true},
{Tor: true, VPN: vpn.OpenVPN, UDP: true},
{Tor: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Tor: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by P2P only": {
selection: settings.ServerSelection{
P2POnly: boolPtr(true),
}.WithDefaults(providers.Protonvpn),
servers: []models.Server{
{P2P: false, VPN: vpn.OpenVPN, UDP: true},
{P2P: true, VPN: vpn.OpenVPN, UDP: true},
{P2P: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{P2P: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by owned": {
selection: settings.ServerSelection{
OwnedOnly: boolPtr(true),
Expand Down
Loading