Skip to content
Draft
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
13 changes: 13 additions & 0 deletions internal/configuration/settings/portforward.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ type PortForwarding struct {
Username string `json:"username"`
// Password is only used for Private Internet Access port forwarding.
Password string `json:"password"`
// ServerName is only used for Private Internet Access port forwarding
// TLS certificate verification. It overrides the server name obtained
// from the VPN connection when set.
ServerName string `json:"server_name"`
}

func (p PortForwarding) Validate(vpnProvider string) (err error) {
Expand Down Expand Up @@ -129,6 +133,7 @@ func (p *PortForwarding) Copy() (copied PortForwarding) {
ListeningPorts: gosettings.CopySlice(p.ListeningPorts),
Username: p.Username,
Password: p.Password,
ServerName: p.ServerName,
}
}

Expand All @@ -141,6 +146,7 @@ func (p *PortForwarding) OverrideWith(other PortForwarding) {
p.ListeningPorts = gosettings.OverrideWithSlice(p.ListeningPorts, other.ListeningPorts)
p.Username = gosettings.OverrideWithComparable(p.Username, other.Username)
p.Password = gosettings.OverrideWithComparable(p.Password, other.Password)
p.ServerName = gosettings.OverrideWithComparable(p.ServerName, other.ServerName)
}

func (p *PortForwarding) setDefaults() {
Expand Down Expand Up @@ -198,6 +204,10 @@ func (p PortForwarding) toLinesNode() (node *gotree.Node) {
credentialsNode.Appendf("Password: %s", gosettings.ObfuscateKey(p.Password))
}

if p.ServerName != "" {
node.Appendf("Server name: %s", p.ServerName)
}

return node
}

Expand Down Expand Up @@ -237,6 +247,9 @@ func (p *PortForwarding) read(r *reader.Reader) (err error) {
return err
}

p.ServerName = r.String("VPN_PORT_FORWARDING_SERVER_NAME",
reader.ForceLowercase(false))

usernameKeys := []string{"VPN_PORT_FORWARDING_USERNAME", "OPENVPN_USER", "USER"}
for _, key := range usernameKeys {
p.Username = r.String(key, reader.ForceLowercase(false))
Expand Down
17 changes: 17 additions & 0 deletions internal/configuration/settings/portforward_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package settings
import (
"testing"

"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gosettings/reader/sources/env"
"github.com/stretchr/testify/assert"
)

Expand All @@ -17,3 +19,18 @@ func Test_PortForwarding_String(t *testing.T) {

assert.Empty(t, s)
}

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

source := env.New(env.Settings{
Environ: []string{"VPN_PORT_FORWARDING_SERVER_NAME=vancouver439"},
})
settingsReader := reader.New(reader.Settings{Sources: []reader.Source{source}})

var settings PortForwarding
err := settings.read(settingsReader)

assert.NoError(t, err)
assert.Equal(t, "vancouver439", settings.ServerName)
}
10 changes: 10 additions & 0 deletions internal/configuration/settings/provider.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package settings

import (
"errors"
"fmt"
"slices"
"sort"
Expand Down Expand Up @@ -49,6 +50,7 @@ func (p *Provider) validate(vpnType string, filterChoicesGetter FilterChoicesGet
providers.Ivpn,
providers.Mullvad,
providers.Nordvpn,
providers.PrivateInternetAccess,
providers.Protonvpn,
providers.Surfshark,
providers.Windscribe,
Expand All @@ -68,6 +70,14 @@ func (p *Provider) validate(vpnType string, filterChoicesGetter FilterChoicesGet
return fmt.Errorf("port forwarding: %w", err)
}

customPIAPortForwarding := *p.PortForwarding.Enabled &&
p.Name == providers.Custom &&
*p.PortForwarding.Provider == providers.PrivateInternetAccess
serverNameMissing := p.PortForwarding.ServerName == "" && len(p.ServerSelection.Names) == 0
if customPIAPortForwarding && serverNameMissing {
return errors.New("port forwarding: server name is empty: set VPN_PORT_FORWARDING_SERVER_NAME")
}

return nil
}

Expand Down
6 changes: 6 additions & 0 deletions internal/configuration/settings/serverselection.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ func getLocationFilterChoices(vpnServiceProvider string,
filterChoices models.FilterChoices, err error,
) {
filterChoices = filterChoicesGetter.GetFilterChoices(vpnServiceProvider)
if vpnServiceProvider == providers.PrivateInternetAccess && ss.VPN == vpn.Wireguard {
// PIA Wireguard region IDs and server names only exist in the live API,
// not in the embedded OpenVPN-only server list used for validation.
filterChoices.Regions = ss.Regions
filterChoices.Names = ss.Names
}

if vpnServiceProvider == providers.Surfshark {
// // Retro compatibility
Expand Down
23 changes: 23 additions & 0 deletions internal/configuration/settings/vpn.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package settings

import (
"errors"
"fmt"

"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
Expand Down Expand Up @@ -57,6 +59,12 @@ func (v *VPN) Validate(filterChoicesGetter FilterChoicesGetter, ipv6Supported bo
return fmt.Errorf("OpenVPN settings: %w", err)
}
case vpn.Wireguard:
if v.Provider.Name == providers.PrivateInternetAccess {
err = v.validatePIAWireguard()
if err != nil {
return fmt.Errorf("Private Internet Access settings: %w", err)
}
}
const amneziawg = false
err := v.Wireguard.validate(v.Provider.Name, ipv6Supported, amneziawg)
if err != nil {
Expand All @@ -72,6 +80,21 @@ func (v *VPN) Validate(filterChoicesGetter FilterChoicesGetter, ipv6Supported bo
return nil
}

func (v VPN) validatePIAWireguard() error {
switch {
case *v.OpenVPN.User == "":
return errors.New("username is empty: set OPENVPN_USER")
case *v.OpenVPN.Password == "":
return errors.New("password is empty: set OPENVPN_PASSWORD")
case len(v.Provider.ServerSelection.Regions) == 0 &&
len(v.Provider.ServerSelection.Names) == 0 &&
len(v.Provider.ServerSelection.Hostnames) == 0:
return errors.New("server selection is empty: set SERVER_REGIONS, SERVER_NAMES or SERVER_HOSTNAMES")
default:
return nil
}
}

func (v *VPN) Copy() (copied VPN) {
return VPN{
Type: v.Type,
Expand Down
108 changes: 108 additions & 0 deletions internal/configuration/settings/vpn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package settings

import (
"testing"

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

type testFilterChoicesGetter struct {
choices models.FilterChoices
}

func (f testFilterChoicesGetter) GetFilterChoices(string) models.FilterChoices {
return f.choices
}

type testWarner struct{}

func (testWarner) Warn(string) {}

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

const username = "user"
const password = "password"
const region = "CA Vancouver"
testCases := map[string]struct {
username string
password string
regions []string
names []string
wireguardKey string
expectedErrPart string
}{
"valid": {
username: username,
password: password,
regions: []string{region},
},
"valid_region_id": {
username: username,
password: password,
regions: []string{"ca_vancouver"},
},
"valid_live_server_name": {
username: username,
password: password,
names: []string{"vancouver439"},
},
"username_missing": {
password: password,
regions: []string{region},
expectedErrPart: "username is empty: set OPENVPN_USER",
},
"password_missing": {
username: username,
regions: []string{region},
expectedErrPart: "password is empty: set OPENVPN_PASSWORD",
},
"server_selection_missing": {
username: username,
password: password,
expectedErrPart: "server selection is empty",
},
"static_private_key_set": {
username: username,
password: password,
regions: []string{region},
wireguardKey: "not-used",
expectedErrPart: "private key must not be set",
},
}

filterChoicesGetter := testFilterChoicesGetter{
choices: models.FilterChoices{Regions: []string{region}},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
vpnSettings := VPN{
Type: vpn.Wireguard,
Provider: Provider{
Name: providers.PrivateInternetAccess,
ServerSelection: ServerSelection{
VPN: vpn.Wireguard,
Regions: testCase.regions,
Names: testCase.names,
},
},
}
vpnSettings.setDefaults()
*vpnSettings.OpenVPN.User = testCase.username
*vpnSettings.OpenVPN.Password = testCase.password
*vpnSettings.Wireguard.PrivateKey = testCase.wireguardKey

err := vpnSettings.Validate(filterChoicesGetter, false, testWarner{})

if testCase.expectedErrPart == "" {
assert.NoError(t, err)
} else {
assert.ErrorContains(t, err, testCase.expectedErrPart)
}
})
}
}
13 changes: 10 additions & 3 deletions internal/configuration/settings/wireguard.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,18 @@ var regexpInterfaceName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
// Validate validates Wireguard settings.
// It should only be ran if the VPN type chosen is Wireguard or AmneziaWg.
func (w Wireguard) validate(vpnProvider string, ipv6Supported, amneziawg bool) (err error) {
dynamicPIAWireguard := vpnProvider == providers.PrivateInternetAccess && !amneziawg
if dynamicPIAWireguard && *w.PrivateKey != "" {
return errors.New("private key must not be set for Private Internet Access")
}

// Validate PrivateKey
if *w.PrivateKey == "" {
if *w.PrivateKey == "" && !dynamicPIAWireguard {
return errors.New("private key is not set")
}
_, err = wgtypes.ParseKey(*w.PrivateKey)
if *w.PrivateKey != "" {
_, err = wgtypes.ParseKey(*w.PrivateKey)
}
if err != nil {
err = fmt.Errorf("private key is not valid: %w", err)
if vpnProvider == providers.Nordvpn &&
Expand All @@ -82,7 +89,7 @@ func (w Wireguard) validate(vpnProvider string, ipv6Supported, amneziawg bool) (
}

// Validate Addresses
if len(w.Addresses) == 0 {
if len(w.Addresses) == 0 && !dynamicPIAWireguard {
return errors.New("interface address is not set")
}
for i, ipNet := range w.Addresses {
Expand Down
4 changes: 2 additions & 2 deletions internal/configuration/settings/wireguardselection.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (w WireguardSelection) validate(vpnProvider string) (err error) {
switch vpnProvider {
case providers.Airvpn, providers.Fastestvpn, providers.Ivpn,
providers.Mullvad, providers.Nordvpn, providers.Protonvpn,
providers.Surfshark, providers.Windscribe:
providers.PrivateInternetAccess, providers.Surfshark, providers.Windscribe:
// endpoint IP addresses are baked in
case providers.Custom:
if !w.EndpointIP.IsValid() || w.EndpointIP.IsUnspecified() {
Expand All @@ -58,7 +58,7 @@ func (w WireguardSelection) validate(vpnProvider string) (err error) {
return errors.New("endpoint port is not set")
}
// EndpointPort cannot be set
case providers.Fastestvpn, providers.Nordvpn,
case providers.Fastestvpn, providers.Nordvpn, providers.PrivateInternetAccess,
providers.Protonvpn, providers.Surfshark:
if *w.EndpointPort != 0 {
return errors.New("endpoint port is set")
Expand Down
10 changes: 9 additions & 1 deletion internal/firewall/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ func (c *Config) SetEnabled(ctx context.Context, enabled bool) (err error) {

if !enabled {
c.logger.Info("disabling...")
c.restore(ctx)
cleanupCtx, cancelCleanup := newTemporaryCleanupContext(ctx)
cleanupErr := c.sweepTemporaryConnectionRules(cleanupCtx, true)
cancelCleanup()
if cleanupErr != nil {
c.logger.Warn(cleanupErr.Error())
}
restoreCtx, cancelRestore := newTemporaryCleanupContext(ctx)
c.restore(restoreCtx)
cancelRestore()
c.enabled = false
c.logger.Info("disabled successfully")
return nil
Expand Down
1 change: 1 addition & 0 deletions internal/firewall/firewall.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Config struct {
outboundSubnets []netip.Prefix
allowedInputPorts map[uint16]map[string]struct{} // port to interfaces set mapping
portRedirections portRedirections
temporaryRules map[*temporaryConnectionRules]struct{}
stateMutex sync.Mutex
}

Expand Down
2 changes: 2 additions & 0 deletions internal/firewall/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type firewallImpl interface { //nolint:interfacebloat
AcceptIpv6MulticastOutput(ctx context.Context, intf string) error
AcceptOutput(ctx context.Context, protocol, intf string,
ip netip.Addr, port uint16, remove bool) error
AcceptOutputMarked(ctx context.Context, protocol, intf string,
ip netip.Addr, port uint16, mark uint32, remove bool) error
AcceptOutputFromIPToSubnet(ctx context.Context, intf string, assignedIP netip.Addr,
subnet netip.Prefix, remove bool) error
AcceptOutputThroughInterface(ctx context.Context, intf string, remove bool) error
Expand Down
24 changes: 24 additions & 0 deletions internal/firewall/iptables/iptables.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,30 @@ func (c *Config) AcceptOutput(ctx context.Context,
return c.runIP6tablesInstruction(ctx, instruction)
}

func (c *Config) AcceptOutputMarked(ctx context.Context,
protocol, intf string, ip netip.Addr, port uint16, mark uint32, remove bool,
) error {
instruction := acceptOutputMarkedInstruction(protocol, intf, ip, port, mark, remove)
if ip.Is4() {
return c.runIptablesInstruction(ctx, instruction)
} else if c.ip6Tables == "" {
return fmt.Errorf("accept marked output to VPN server %s: %s", ip, needIP6Tables)
}
return c.runIP6tablesInstruction(ctx, instruction)
}

func acceptOutputMarkedInstruction(protocol, intf string, ip netip.Addr,
port uint16, mark uint32, remove bool,
) string {
interfaceFlag := "-o " + intf
if intf == "*" { // all interfaces
interfaceFlag = ""
}

return fmt.Sprintf("%s OUTPUT -d %s %s -p %s -m %s --dport %d -m mark --mark %d -j ACCEPT",
appendOrDelete(remove), ip, interfaceFlag, protocol, protocol, port, mark)
}

// AcceptOutputFromIPToSubnet accepts outgoing traffic from sourceIP to destinationSubnet
// on the interface intf. If intf is empty, it is set to "*" which means all interfaces.
// If remove is true, the rule is removed instead of added.
Expand Down
Loading