From 11c2354408506b524032ac0d9fbf583b598d1310 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Fri, 16 Aug 2024 14:20:00 +0200 Subject: [PATCH] feat(privatevpn): native port forwarding support (#2285) --- README.md | 2 +- .../configuration/settings/portforward.go | 1 + internal/portforward/loop.go | 3 + internal/portforward/service/settings.go | 2 +- internal/provider/privatevpn/portforward.go | 81 +++++++ .../provider/privatevpn/portforward_test.go | 215 ++++++++++++++++++ internal/vpn/tunnelup.go | 2 +- 7 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 internal/provider/privatevpn/portforward.go create mode 100644 internal/provider/privatevpn/portforward_test.go diff --git a/README.md b/README.md index c8722db87..4c6515bae 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Lightweight swiss-knife-like VPN client to multiple VPN service providers - [Connect other containers to it](https://github.com/qdm12/gluetun-wiki/blob/main/setup/connect-a-container-to-gluetun.md) - [Connect LAN devices to it](https://github.com/qdm12/gluetun-wiki/blob/main/setup/connect-a-lan-device-to-gluetun.md) - Compatible with amd64, i686 (32 bit), **ARM** 64 bit, ARM 32 bit v6 and v7, and even ppc64le 🎆 -- Custom VPN server side port forwarding for [Perfect Privacy](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/perfect-privacy.md#vpn-server-port-forwarding), [Private Internet Access](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/private-internet-access.md#vpn-server-port-forwarding) and [ProtonVPN](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/protonvpn.md#vpn-server-port-forwarding) +- Custom VPN server side port forwarding for [Perfect Privacy](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/perfect-privacy.md#vpn-server-port-forwarding), [Private Internet Access](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/private-internet-access.md#vpn-server-port-forwarding), [PrivateVPN](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/privatevpn.md#vpn-server-port-forwarding) and [ProtonVPN](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/protonvpn.md#vpn-server-port-forwarding) - Possibility of split horizon DNS by selecting multiple DNS over TLS providers - Unbound subprogram drops root privileges once launched - Can work as a Kubernetes sidecar container, thanks @rorph diff --git a/internal/configuration/settings/portforward.go b/internal/configuration/settings/portforward.go index d8b2457bd..1ba6ed132 100644 --- a/internal/configuration/settings/portforward.go +++ b/internal/configuration/settings/portforward.go @@ -52,6 +52,7 @@ func (p PortForwarding) Validate(vpnProvider string) (err error) { validProviders := []string{ providers.Perfectprivacy, providers.PrivateInternetAccess, + providers.Privatevpn, providers.Protonvpn, } if err = validate.IsOneOf(providerSelected, validProviders...); err != nil { diff --git a/internal/portforward/loop.go b/internal/portforward/loop.go index c8d02a3b6..9b400e3db 100644 --- a/internal/portforward/loop.go +++ b/internal/portforward/loop.go @@ -120,6 +120,9 @@ func (l *Loop) run(runCtx context.Context, runDone chan<- struct{}, if updateReceived { // Signal to the Update call that the service has started // and if it failed to start. + if err != nil { + err = fmt.Errorf("starting port forwarding service: %w", err) + } updateResult <- err } } diff --git a/internal/portforward/service/settings.go b/internal/portforward/service/settings.go index 1782226b0..fbd10279d 100644 --- a/internal/portforward/service/settings.go +++ b/internal/portforward/service/settings.go @@ -12,7 +12,7 @@ type Settings struct { Enabled *bool PortForwarder PortForwarder Filepath string - Interface string // needed for PIA and ProtonVPN, tun0 for example + Interface string // needed for PIA, PrivateVPN and ProtonVPN, tun0 for example ServerName string // needed for PIA CanPortForward bool // needed for PIA ListeningPort uint16 diff --git a/internal/provider/privatevpn/portforward.go b/internal/provider/privatevpn/portforward.go new file mode 100644 index 000000000..4dbc69197 --- /dev/null +++ b/internal/provider/privatevpn/portforward.go @@ -0,0 +1,81 @@ +package privatevpn + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + + "github.com/qdm12/gluetun/internal/provider/common" + "github.com/qdm12/gluetun/internal/provider/utils" +) + +var ( + regexPort = regexp.MustCompile(`[1-9][0-9]{0,4}`) +) + +var ( + ErrPortForwardedNotFound = errors.New("port forwarded not found") +) + +// PortForward obtains a VPN server side port forwarded from the PrivateVPN API. +// It returns 0 if all ports are to forwarded on a dedicated server IP. +func (p *Provider) PortForward(ctx context.Context, objects utils.PortForwardObjects) ( + ports []uint16, err error) { + url := "https://connect.pvdatanet.com/v3/Api/port?ip[]=" + objects.InternalIP.String() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating HTTP request: %w", err) + } + + response, err := objects.Client.Do(request) + if err != nil { + return nil, fmt.Errorf("sending HTTP request: %w", err) + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %d %s", common.ErrHTTPStatusCodeNotOK, + response.StatusCode, response.Status) + } + + defer response.Body.Close() + + bytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + var data struct { + Status string `json:"status"` + Supported bool `json:"supported"` + } + err = json.Unmarshal(bytes, &data) + if err != nil { + return nil, fmt.Errorf("decoding JSON response: %w; data is: %s", + err, string(bytes)) + } else if !data.Supported { + return nil, fmt.Errorf("%w for this VPN server", common.ErrPortForwardNotSupported) + } + + portString := regexPort.FindString(data.Status) + if portString == "" { + return nil, fmt.Errorf("%w: in status %q", ErrPortForwardedNotFound, data.Status) + } + + const base, bitSize = 10, 16 + portUint64, err := strconv.ParseUint(portString, base, bitSize) + if err != nil { + return nil, fmt.Errorf("parsing port: %w", err) + } + return []uint16{uint16(portUint64)}, nil +} + +func (p *Provider) KeepPortForward(ctx context.Context, + _ utils.PortForwardObjects) (err error) { + <-ctx.Done() + return ctx.Err() +} diff --git a/internal/provider/privatevpn/portforward_test.go b/internal/provider/privatevpn/portforward_test.go new file mode 100644 index 000000000..91d69a46e --- /dev/null +++ b/internal/provider/privatevpn/portforward_test.go @@ -0,0 +1,215 @@ +package privatevpn + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/netip" + "testing" + + "github.com/qdm12/gluetun/internal/provider/utils" + "github.com/stretchr/testify/assert" +) + +type roundTripFunc func(r *http.Request) (*http.Response, error) + +func (s roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return s(r) +} + +func Test_Provider_PortForward(t *testing.T) { + t.Parallel() + + errTest := errors.New("test error") + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + testCases := map[string]struct { + ctx context.Context + objects utils.PortForwardObjects + ports []uint16 + errMessage string + }{ + "canceled context": { + ctx: canceledCtx, + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return nil, r.Context().Err() + }), + }, + }, + errMessage: `sending HTTP request: Get ` + + `"https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10": ` + + `context canceled`, + }, + "http_error": { + ctx: context.Background(), + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return nil, errTest + }), + }, + }, + errMessage: `sending HTTP request: Get ` + + `"https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10": ` + + `test error`, + }, + "bad_status_code": { + ctx: context.Background(), + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + }, nil + }), + }, + }, + errMessage: "HTTP status code not OK: 400 Bad Request", + }, + "empty_response": { + ctx: context.Background(), + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(nil)), + }, nil + }), + }, + }, + errMessage: "decoding JSON response: unexpected end of JSON input; data is: ", + }, + "invalid_JSON": { + ctx: context.Background(), + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`invalid json`)), + }, nil + }), + }, + }, + errMessage: "decoding JSON response: invalid character 'i' looking for " + + "beginning of value; data is: invalid json", + }, + "not_supported": { + ctx: context.Background(), + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"supported":false}`)), + }, nil + }), + }, + }, + errMessage: "port forwarding not supported for this VPN server", + }, + "port_not_found": { + ctx: context.Background(), + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"supported":true,"status":"no port here"}`)), + }, nil + }), + }, + }, + errMessage: "port forwarded not found: in status \"no port here\"", + }, + "port_too_big": { + ctx: context.Background(), + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"supported":true,"status":"Port 91527 UDP/TCP"}`)), + }, nil + }), + }, + }, + errMessage: "parsing port: strconv.ParseUint: parsing \"91527\": value out of range", + }, + "success": { + ctx: context.Background(), + objects: utils.PortForwardObjects{ + InternalIP: netip.AddrFrom4([4]byte{10, 10, 10, 10}), + Client: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, + "https://connect.pvdatanet.com/v3/Api/port?ip[]=10.10.10.10", + r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"supported":true,"status":"Port 61527 UDP/TCP"}`)), + }, nil + }), + }, + }, + ports: []uint16{61527}, + }, + } + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + provider := Provider{} + ports, err := provider.PortForward(testCase.ctx, + testCase.objects) + + assert.Equal(t, testCase.ports, ports) + if testCase.errMessage != "" { + assert.EqualError(t, err, testCase.errMessage) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/vpn/tunnelup.go b/internal/vpn/tunnelup.go index 80a4a2190..6e723041f 100644 --- a/internal/vpn/tunnelup.go +++ b/internal/vpn/tunnelup.go @@ -11,7 +11,7 @@ type tunnelUpData struct { // Port forwarding vpnIntf string serverName string // used for PIA - canPortForward bool // used for PIA and ProtonVPN + canPortForward bool // used for PIA username string // used for PIA password string // used for PIA portForwarder PortForwarder