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(privatevpn): support natively port forwarding #2285

Merged
merged 6 commits into from
Aug 16, 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/configuration/settings/portforward.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/portforward/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/portforward/service/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions internal/provider/privatevpn/portforward.go
Original file line number Diff line number Diff line change
@@ -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()
}
215 changes: 215 additions & 0 deletions internal/provider/privatevpn/portforward_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion internal/vpn/tunnelup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading