feat(privatevpn): native port forwarding support (#2285)
This commit is contained in:
@@ -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 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)
|
- [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 🎆
|
- 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
|
- Possibility of split horizon DNS by selecting multiple DNS over TLS providers
|
||||||
- Unbound subprogram drops root privileges once launched
|
- Unbound subprogram drops root privileges once launched
|
||||||
- Can work as a Kubernetes sidecar container, thanks @rorph
|
- Can work as a Kubernetes sidecar container, thanks @rorph
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func (p PortForwarding) Validate(vpnProvider string) (err error) {
|
|||||||
validProviders := []string{
|
validProviders := []string{
|
||||||
providers.Perfectprivacy,
|
providers.Perfectprivacy,
|
||||||
providers.PrivateInternetAccess,
|
providers.PrivateInternetAccess,
|
||||||
|
providers.Privatevpn,
|
||||||
providers.Protonvpn,
|
providers.Protonvpn,
|
||||||
}
|
}
|
||||||
if err = validate.IsOneOf(providerSelected, validProviders...); err != nil {
|
if err = validate.IsOneOf(providerSelected, validProviders...); err != nil {
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ func (l *Loop) run(runCtx context.Context, runDone chan<- struct{},
|
|||||||
if updateReceived {
|
if updateReceived {
|
||||||
// Signal to the Update call that the service has started
|
// Signal to the Update call that the service has started
|
||||||
// and if it failed to start.
|
// and if it failed to start.
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("starting port forwarding service: %w", err)
|
||||||
|
}
|
||||||
updateResult <- err
|
updateResult <- err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Settings struct {
|
|||||||
Enabled *bool
|
Enabled *bool
|
||||||
PortForwarder PortForwarder
|
PortForwarder PortForwarder
|
||||||
Filepath string
|
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
|
ServerName string // needed for PIA
|
||||||
CanPortForward bool // needed for PIA
|
CanPortForward bool // needed for PIA
|
||||||
ListeningPort uint16
|
ListeningPort uint16
|
||||||
|
|||||||
81
internal/provider/privatevpn/portforward.go
Normal file
81
internal/provider/privatevpn/portforward.go
Normal file
@@ -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
internal/provider/privatevpn/portforward_test.go
Normal file
215
internal/provider/privatevpn/portforward_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ type tunnelUpData struct {
|
|||||||
// Port forwarding
|
// Port forwarding
|
||||||
vpnIntf string
|
vpnIntf string
|
||||||
serverName string // used for PIA
|
serverName string // used for PIA
|
||||||
canPortForward bool // used for PIA and ProtonVPN
|
canPortForward bool // used for PIA
|
||||||
username string // used for PIA
|
username string // used for PIA
|
||||||
password string // used for PIA
|
password string // used for PIA
|
||||||
portForwarder PortForwarder
|
portForwarder PortForwarder
|
||||||
|
|||||||
Reference in New Issue
Block a user