-
-
Notifications
You must be signed in to change notification settings - Fork 385
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(privatevpn): native port forwarding support (#2285)
- Loading branch information
Showing
7 changed files
with
303 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters