mirror of
https://github.com/qdm12/gluetun.git
synced 2026-06-18 17:34:02 +02:00
chore(updater): move updater packages to pkg/updaters/<name>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package fastestvpn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type apiServer struct {
|
||||
country string
|
||||
city string
|
||||
hostname string
|
||||
}
|
||||
|
||||
var ErrDataMalformed = errors.New("data is malformed")
|
||||
|
||||
const apiURL = "https://support.fastestvpn.com/wp-admin/admin-ajax.php"
|
||||
|
||||
// The API URL and requests are shamelessly taken from network operations
|
||||
// done on the page https://support.fastestvpn.com/vpn-servers/
|
||||
func fetchAPIServers(ctx context.Context, client *http.Client, protocol string) (
|
||||
servers []apiServer, err error,
|
||||
) {
|
||||
form := url.Values{
|
||||
"action": []string{"vpn_servers"},
|
||||
"protocol": []string{protocol},
|
||||
}
|
||||
body := strings.NewReader(form.Encode())
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
// request.Header.Set("User-Agent", "curl/8.9.0")
|
||||
// request.Header.Set("Accept", "*/*")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_ = response.Body.Close()
|
||||
return nil, fmt.Errorf("%w: %d", common.ErrHTTPStatusCodeNotOK, response.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
_ = response.Body.Close()
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
const usualMaxNumber = 100
|
||||
servers = make([]apiServer, 0, usualMaxNumber)
|
||||
|
||||
for {
|
||||
trBlock := getNextTRBlock(data)
|
||||
if trBlock == nil {
|
||||
break
|
||||
}
|
||||
data = data[len(trBlock):]
|
||||
|
||||
var server apiServer
|
||||
|
||||
const numberOfTDBlocks = 3
|
||||
for i := range numberOfTDBlocks {
|
||||
tdBlock := getNextTDBlock(trBlock)
|
||||
if tdBlock == nil {
|
||||
return nil, fmt.Errorf("%w: expected 3 <td> blocks in <tr> block %q",
|
||||
ErrDataMalformed, string(trBlock))
|
||||
}
|
||||
trBlock = trBlock[len(tdBlock):]
|
||||
|
||||
const startToken, endToken = "<td>", "</td>"
|
||||
tdBlockData := string(tdBlock[len(startToken) : len(tdBlock)-len(endToken)])
|
||||
const countryIndex, cityIndex, hostnameIndex = 0, 1, 2
|
||||
switch i {
|
||||
case countryIndex:
|
||||
server.country = tdBlockData
|
||||
case cityIndex:
|
||||
server.city = tdBlockData
|
||||
case hostnameIndex:
|
||||
server.hostname = tdBlockData
|
||||
}
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func getNextTRBlock(data []byte) (trBlock []byte) {
|
||||
const startToken, endToken = "<tr>", "</tr>"
|
||||
return getNextBlock(data, startToken, endToken)
|
||||
}
|
||||
|
||||
func getNextTDBlock(data []byte) (tdBlock []byte) {
|
||||
const startToken, endToken = "<td>", "</td>"
|
||||
return getNextBlock(data, startToken, endToken)
|
||||
}
|
||||
|
||||
func getNextBlock(data []byte, startToken, endToken string) (nextBlock []byte) {
|
||||
i := bytes.Index(data, []byte(startToken))
|
||||
if i == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nextBlock = data[i:]
|
||||
i = bytes.Index(nextBlock[len(startToken):], []byte(endToken))
|
||||
if i == -1 {
|
||||
return nil
|
||||
}
|
||||
nextBlock = nextBlock[:i+len(startToken)+len(endToken)]
|
||||
return nextBlock
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package fastestvpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type roundTripFunc func(r *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
|
||||
func Test_fechAPIServers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errTest := errors.New("test error")
|
||||
|
||||
testCases := map[string]struct {
|
||||
ctx context.Context
|
||||
protocol string
|
||||
requestBody string
|
||||
responseStatus int
|
||||
responseBody io.ReadCloser
|
||||
transportErr error
|
||||
servers []apiServer
|
||||
errWrapped error
|
||||
errMessage string
|
||||
}{
|
||||
"transport_error": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusOK,
|
||||
transportErr: errTest,
|
||||
errWrapped: errTest,
|
||||
errMessage: `sending request: Post ` +
|
||||
`"https://support.fastestvpn.com/wp-admin/admin-ajax.php": ` +
|
||||
`test error`,
|
||||
},
|
||||
"not_found_status_code": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusNotFound,
|
||||
errWrapped: common.ErrHTTPStatusCodeNotOK,
|
||||
errMessage: "HTTP status code not OK: 404",
|
||||
},
|
||||
"empty_data": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader("")),
|
||||
servers: []apiServer{},
|
||||
},
|
||||
"single_server": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader(
|
||||
"irrelevant<tr><td>Australia</td><td>Sydney</td>" +
|
||||
"<td>au-stream.jumptoserver.com</td></tr>irrelevant")),
|
||||
servers: []apiServer{
|
||||
{country: "Australia", city: "Sydney", hostname: "au-stream.jumptoserver.com"},
|
||||
},
|
||||
},
|
||||
"two_servers": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader(
|
||||
"<tr><td>Australia</td><td>Sydney</td><td>au-stream.jumptoserver.com</td></tr>" +
|
||||
"<tr><td>Australia</td><td>Sydney</td><td>au-01.jumptoserver.com</td></tr>")),
|
||||
servers: []apiServer{
|
||||
{country: "Australia", city: "Sydney", hostname: "au-stream.jumptoserver.com"},
|
||||
{country: "Australia", city: "Sydney", hostname: "au-01.jumptoserver.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
assert.Equal(t, apiURL, r.URL.String())
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testCase.requestBody, string(requestBody))
|
||||
if testCase.transportErr != nil {
|
||||
return nil, testCase.transportErr
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: testCase.responseStatus,
|
||||
Body: testCase.responseBody,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
entries, err := fetchAPIServers(testCase.ctx, client, testCase.protocol)
|
||||
|
||||
assert.ErrorIs(t, err, testCase.errWrapped)
|
||||
if testCase.errWrapped != nil {
|
||||
assert.EqualError(t, err, testCase.errMessage)
|
||||
}
|
||||
assert.Equal(t, testCase.servers, entries)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getNextBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := map[string]struct {
|
||||
data string
|
||||
startToken string
|
||||
endToken string
|
||||
nextBlock []byte
|
||||
}{
|
||||
"empty_data": {
|
||||
startToken: "<a>",
|
||||
endToken: "</a>",
|
||||
},
|
||||
"start_token_not_found": {
|
||||
data: "test</a>",
|
||||
startToken: "<a>",
|
||||
endToken: "</a>",
|
||||
},
|
||||
"end_token_not_found": {
|
||||
data: "<a>test",
|
||||
startToken: "<a>",
|
||||
endToken: "</a>",
|
||||
},
|
||||
"block_found": {
|
||||
data: "xy<a>test</a><a>test2</a>zx",
|
||||
startToken: "<a>",
|
||||
endToken: "</a>",
|
||||
nextBlock: []byte("<a>test</a>"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nextBlock := getNextBlock([]byte(testCase.data), testCase.startToken, testCase.endToken)
|
||||
|
||||
assert.Equal(t, testCase.nextBlock, nextBlock)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package fastestvpn
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type hostToServerData map[string]serverData
|
||||
|
||||
type serverData struct {
|
||||
openvpn bool
|
||||
wireguard bool
|
||||
country string
|
||||
city string
|
||||
openvpnUDP bool
|
||||
openvpnTCP bool
|
||||
ips []netip.Addr
|
||||
}
|
||||
|
||||
func (hts hostToServerData) add(host, vpnType, country, city string, tcp, udp bool) {
|
||||
serverData, ok := hts[host]
|
||||
switch vpnType {
|
||||
case vpn.OpenVPN:
|
||||
serverData.openvpn = true
|
||||
serverData.openvpnTCP = serverData.openvpnTCP || tcp
|
||||
serverData.openvpnUDP = serverData.openvpnUDP || udp
|
||||
case vpn.Wireguard:
|
||||
serverData.wireguard = true
|
||||
default:
|
||||
panic("protocol not supported")
|
||||
}
|
||||
|
||||
if !ok {
|
||||
serverData.country = country
|
||||
serverData.city = city
|
||||
} else if city != "" {
|
||||
// some servers are listed without the city although
|
||||
// they are also listed with the city described, so update
|
||||
// the city field.
|
||||
serverData.city = city
|
||||
}
|
||||
|
||||
hts[host] = serverData
|
||||
}
|
||||
|
||||
func (hts hostToServerData) toHostsSlice() (hosts []string) {
|
||||
hosts = make([]string, 0, len(hts))
|
||||
for host := range hts {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func (hts hostToServerData) adaptWithIPs(hostToIPs map[string][]netip.Addr) {
|
||||
for host, serverData := range hts {
|
||||
ips := hostToIPs[host]
|
||||
if len(ips) == 0 {
|
||||
delete(hts, host)
|
||||
continue
|
||||
}
|
||||
serverData.ips = ips
|
||||
hts[host] = serverData
|
||||
}
|
||||
}
|
||||
|
||||
func (hts hostToServerData) toServersSlice() (servers []models.Server) {
|
||||
servers = make([]models.Server, 0, 2*len(hts)) //nolint:mnd
|
||||
for hostname, serverData := range hts {
|
||||
baseServer := models.Server{
|
||||
Hostname: hostname,
|
||||
Country: serverData.country,
|
||||
City: serverData.city,
|
||||
IPs: serverData.ips,
|
||||
}
|
||||
if serverData.openvpn {
|
||||
openvpnServer := baseServer
|
||||
openvpnServer.VPN = vpn.OpenVPN
|
||||
openvpnServer.TCP = serverData.openvpnTCP
|
||||
openvpnServer.UDP = serverData.openvpnUDP
|
||||
servers = append(servers, openvpnServer)
|
||||
}
|
||||
if serverData.wireguard {
|
||||
wireguardServer := baseServer
|
||||
wireguardServer.VPN = vpn.Wireguard
|
||||
const wireguardPublicKey = "658QxufMbjOTmB61Z7f+c7Rjg7oqWLnepTalqBERjF0="
|
||||
wireguardServer.WgPubKey = wireguardPublicKey
|
||||
servers = append(servers, wireguardServer)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package fastestvpn
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 0.1
|
||||
maxNoNew = 1
|
||||
maxFails = 4
|
||||
maxDuration = 3 * time.Second
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package fastestvpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
|
||||
servers []models.Server, err error,
|
||||
) {
|
||||
protocols := []string{"ikev2", "tcp", "udp"}
|
||||
hts := make(hostToServerData)
|
||||
|
||||
for _, protocol := range protocols {
|
||||
apiServers, err := fetchAPIServers(ctx, u.client, protocol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching %s servers from API: %w", protocol, err)
|
||||
}
|
||||
for _, apiServer := range apiServers {
|
||||
// all hostnames from the protocols TCP, UDP and IKEV2 support Wireguard
|
||||
// per https://github.com/qdm12/gluetun-wiki/issues/76#issuecomment-2125420536
|
||||
const wgTCP, wgUDP = false, false // ignored
|
||||
hts.add(apiServer.hostname, vpn.Wireguard, apiServer.country, apiServer.city, wgTCP, wgUDP)
|
||||
|
||||
tcp := protocol == "tcp"
|
||||
udp := protocol == "udp"
|
||||
if !tcp && !udp { // not an OpenVPN protocol, for example ikev2
|
||||
continue
|
||||
}
|
||||
hts.add(apiServer.hostname, vpn.OpenVPN, apiServer.country, apiServer.city, tcp, udp)
|
||||
}
|
||||
}
|
||||
|
||||
hosts := hts.toHostsSlice()
|
||||
resolveSettings := parallelResolverSettings(hosts)
|
||||
hostToIPs, warnings, err := u.parallelResolver.Resolve(ctx, resolveSettings)
|
||||
for _, warning := range warnings {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hts.adaptWithIPs(hostToIPs)
|
||||
|
||||
servers = hts.toServersSlice()
|
||||
|
||||
if len(servers) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(servers), minServers)
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package fastestvpn
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
client *http.Client
|
||||
parallelResolver common.ParallelResolver
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(client *http.Client, warner common.Warner,
|
||||
parallelResolver common.ParallelResolver,
|
||||
) *Updater {
|
||||
return &Updater{
|
||||
client: client,
|
||||
parallelResolver: parallelResolver,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) Version() uint16 {
|
||||
return 2 //nolint:mnd
|
||||
}
|
||||
Reference in New Issue
Block a user