From 6199fdc8531c48747864dd6b2ac7d5b1e5290794 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 23 Oct 2024 09:05:32 +0000 Subject: [PATCH] initial code --- .github/ISSUE_TEMPLATE/bug.yml | 1 + .github/labels.yml | 2 + Dockerfile | 2 +- README.md | 9 +- go.mod | 4 +- go.sum | 4 +- .../settings/openvpnselection.go | 2 +- internal/configuration/settings/provider.go | 1 + .../settings/wireguardselection.go | 18 +- internal/constants/providers/providers.go | 2 + internal/models/server.go | 2 + internal/provider/ovpn/connection.go | 15 ++ internal/provider/ovpn/connection_test.go | 126 +++++++++++ internal/provider/ovpn/openvpnconf.go | 23 ++ internal/provider/ovpn/provider.go | 28 +++ internal/provider/ovpn/updater/api.go | 183 ++++++++++++++++ internal/provider/ovpn/updater/api_test.go | 117 ++++++++++ .../provider/ovpn/updater/roundtrip_test.go | 9 + internal/provider/ovpn/updater/servers.go | 71 ++++++ .../provider/ovpn/updater/servers_test.go | 204 ++++++++++++++++++ internal/provider/ovpn/updater/updater.go | 15 ++ internal/provider/providers.go | 2 + internal/provider/utils/connection.go | 5 +- internal/provider/utils/port.go | 17 +- internal/provider/utils/port_test.go | 45 ++++ internal/storage/filter.go | 23 ++ internal/storage/formatting.go | 11 +- internal/storage/hardcoded.go | 6 +- internal/storage/hardcoded_test.go | 5 +- internal/storage/servers.json | 5 +- 30 files changed, 934 insertions(+), 23 deletions(-) create mode 100644 internal/provider/ovpn/connection.go create mode 100644 internal/provider/ovpn/connection_test.go create mode 100644 internal/provider/ovpn/openvpnconf.go create mode 100644 internal/provider/ovpn/provider.go create mode 100644 internal/provider/ovpn/updater/api.go create mode 100644 internal/provider/ovpn/updater/api_test.go create mode 100644 internal/provider/ovpn/updater/roundtrip_test.go create mode 100644 internal/provider/ovpn/updater/servers.go create mode 100644 internal/provider/ovpn/updater/servers_test.go create mode 100644 internal/provider/ovpn/updater/updater.go diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 9e4f95b6..3c3963ca 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -56,6 +56,7 @@ body: - IVPN - Mullvad - NordVPN + - OVPN - Privado - Private Internet Access - PrivateVPN diff --git a/.github/labels.yml b/.github/labels.yml index 51486f96..647d6ee8 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -64,6 +64,8 @@ color: "cfe8d4" - name: "☁️ NordVPN" color: "cfe8d4" +- name: "☁️ OVPN" + color: "cfe8d4" - name: "☁️ Perfect Privacy" color: "cfe8d4" - name: "☁️ PIA" diff --git a/Dockerfile b/Dockerfile index 19e50dcf..f4aeee10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -186,7 +186,7 @@ ENV VPN_SERVICE_PROVIDER=pia \ # # ProtonVPN only: SECURE_CORE_ONLY= \ TOR_ONLY= \ - # # Surfshark only: + # # Surfshark and ovpn only: MULTIHOP_ONLY= \ # # VPN Secure only: PREMIUM_ONLY= \ diff --git a/README.md b/README.md index 04dcdbfc..78945f36 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,14 @@ Lightweight swiss-army-knife-like VPN client to multiple VPN service providers ## Features - Based on Alpine 3.23 for a small Docker image of 43.1MB -- Supports: **AirVPN**, **Cyberghost**, **ExpressVPN**, **FastestVPN**, **Giganews**, **HideMyAss**, **IPVanish**, **IVPN**, **Mullvad** (Wireguard only), **NordVPN**, **Perfect Privacy**, **Privado**, **Private Internet Access**, **PrivateVPN**, **ProtonVPN**, **PureVPN**, **SlickVPN**, **Surfshark**, **TorGuard**, **VPNSecure.me**, **VPNUnlimited**, **Vyprvpn**, **Windscribe** servers +- Supports: **AirVPN**, **Cyberghost**, **ExpressVPN**, **FastestVPN**, **Giganews**, **HideMyAss**, **IPVanish**, **IVPN**, **Mullvad** (Wireguard only), **NordVPN**, **Ovpn**, **Perfect Privacy**, **Privado**, **Private Internet Access**, **PrivateVPN**, **ProtonVPN**, **PureVPN**, **SlickVPN**, **Surfshark**, **TorGuard**, **VPNSecure.me**, **VPNUnlimited**, **Vyprvpn**, **Windscribe** servers - Supports OpenVPN for all providers listed - Supports Wireguard both kernelspace and userspace - - For **AirVPN**, **FastestVPN**, **Ivpn**, **Mullvad**, **NordVPN**, **Perfect privacy**, **ProtonVPN**, **Surfshark** and **Windscribe** + - For **AirVPN**, **FastestVPN**, **Ivpn**, **Mullvad**, **NordVPN**, **Ovpn**, **Perfect privacy**, **ProtonVPN**, **Surfshark** and **Windscribe** - For **Cyberghost**, **Private Internet Access**, **PrivateVPN**, **PureVPN**, **Torguard**, **VPN Unlimited** and **VyprVPN** using [the custom provider](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/custom.md) - - For custom Wireguard configurations using [the custom provider](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/custom.md) - - More in progress, see [#134](https://github.com/passteque/gluetun/issues/134) + +- For custom Wireguard configurations using [the custom provider](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/custom.md) +- More in progress, see [#134](https://github.com/passteque/gluetun/issues/134) - Supports AmneziaWG only with the custom provider for now - DNS over TLS baked in with service provider(s) of your choice - DNS fine blocking of malicious/ads/surveillance hostnames and IP addresses, with live update every 24 hours diff --git a/go.mod b/go.mod index 5a46d65d..f5d7ef78 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,8 @@ require ( github.com/mdlayher/netlink v1.9.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260421173011-9de8e7fdbe3a - github.com/qdm12/gluetun-servers v0.1.0 + github.com/qdm12/gluetun-servers v0.1.1-0.20260521235724-060a16d4b34c + github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 github.com/qdm12/gosettings v0.4.4 github.com/qdm12/goshutdown v0.3.0 github.com/qdm12/gosplash v0.2.1-0.20260305164749-b713de4fee6c @@ -55,7 +56,6 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.60.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 // indirect github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect diff --git a/go.sum b/go.sum index e2fa6e68..80034656 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260421173011-9de8e7fdbe3a h1:TE157yPQmAbVruH0MWCQzs0vTT/6t96DkoWUXd6PVuc= github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260421173011-9de8e7fdbe3a/go.mod h1:98foWgXJZ+g8gJIuO+fdO+oWpFei5WShMFTeN4Im2lE= -github.com/qdm12/gluetun-servers v0.1.0 h1:w9JLghKZwI0Gzpp9p5rNANgEYUUZ1dxdxsG6NKIojaY= -github.com/qdm12/gluetun-servers v0.1.0/go.mod h1:acttuyHyoFDu6GTbf3kAV+QXeiX8oJeh0MBic67/9z8= +github.com/qdm12/gluetun-servers v0.1.1-0.20260521235724-060a16d4b34c h1:rPgmhMt9uOq6pdPr8k0sXUnZ0fjTBw+yUOaIl0ttFu4= +github.com/qdm12/gluetun-servers v0.1.1-0.20260521235724-060a16d4b34c/go.mod h1:acttuyHyoFDu6GTbf3kAV+QXeiX8oJeh0MBic67/9z8= github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 h1:TRGpCU1l0lNwtogEUSs5U+RFceYxkAJUmrGabno7J5c= github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978/go.mod h1:D1Po4CRQLYjccnAR2JsVlN1sBMgQrcNLONbvyuzcdTg= github.com/qdm12/gosettings v0.4.4 h1:SM6tOZDf6k8qbjWU8KWyBF4mWIixfsKCfh9DGRLHlj4= diff --git a/internal/configuration/settings/openvpnselection.go b/internal/configuration/settings/openvpnselection.go index 268e2032..c0dacb22 100644 --- a/internal/configuration/settings/openvpnselection.go +++ b/internal/configuration/settings/openvpnselection.go @@ -70,7 +70,7 @@ func (o OpenVPNSelection) validate(vpnProvider string) (err error) { switch vpnProvider { // no restriction on port case providers.Custom, providers.Cyberghost, providers.HideMyAss, - providers.Privatevpn, providers.Torguard: + providers.Ovpn, providers.Privatevpn, providers.Torguard: // no custom port allowed case providers.Expressvpn, providers.Fastestvpn, providers.Giganews, providers.Ipvanish, diff --git a/internal/configuration/settings/provider.go b/internal/configuration/settings/provider.go index ccc3acac..8bcc9e1a 100644 --- a/internal/configuration/settings/provider.go +++ b/internal/configuration/settings/provider.go @@ -49,6 +49,7 @@ func (p *Provider) validate(vpnType string, filterChoicesGetter FilterChoicesGet providers.Ivpn, providers.Mullvad, providers.Nordvpn, + providers.Ovpn, providers.Protonvpn, providers.Surfshark, providers.Windscribe, diff --git a/internal/configuration/settings/wireguardselection.go b/internal/configuration/settings/wireguardselection.go index 301893cf..c7afea61 100644 --- a/internal/configuration/settings/wireguardselection.go +++ b/internal/configuration/settings/wireguardselection.go @@ -5,6 +5,7 @@ import ( "fmt" "net/netip" + "github.com/qdm12/gluetun/internal/configuration/settings/helpers" "github.com/qdm12/gluetun/internal/constants/providers" "github.com/qdm12/gosettings" "github.com/qdm12/gosettings/reader" @@ -22,7 +23,7 @@ type WireguardSelection struct { // It can never be the zero value in the internal state. EndpointIP netip.Addr `json:"endpoint_ip"` // EndpointPort is a the server port to use for the VPN server. - // It is optional for VPN providers IVPN, Mullvad, Surfshark + // It is optional for VPN providers IVPN, Mullvad, Ovpn, Surfshark // and Windscribe, and compulsory for the others. // When optional, it can be set to 0 to indicate not use // a custom endpoint port. It cannot be nil in the internal @@ -40,8 +41,9 @@ func (w WireguardSelection) validate(vpnProvider string) (err error) { // Validate EndpointIP switch vpnProvider { case providers.Airvpn, providers.Fastestvpn, providers.Ivpn, - providers.Mullvad, providers.Nordvpn, providers.Protonvpn, - providers.Surfshark, providers.Windscribe: + providers.Mullvad, providers.Nordvpn, providers.Ovpn, + providers.Protonvpn, providers.Surfshark, + providers.Windscribe: // endpoint IP addresses are baked in case providers.Custom: if !w.EndpointIP.IsValid() || w.EndpointIP.IsUnspecified() { @@ -63,12 +65,16 @@ func (w WireguardSelection) validate(vpnProvider string) (err error) { if *w.EndpointPort != 0 { return errors.New("endpoint port is set") } - case providers.Airvpn, providers.Ivpn, providers.Mullvad, providers.Windscribe: + case providers.Airvpn, providers.Ivpn, providers.Mullvad, + providers.Ovpn, providers.Windscribe: // EndpointPort is optional and can be 0 if *w.EndpointPort == 0 { break // no custom endpoint port set } - if vpnProvider == providers.Mullvad { + if helpers.IsOneOf(vpnProvider, + providers.Mullvad, + providers.Ovpn, + ) { break // no restriction on custom endpoint port value } var allowed []uint16 @@ -92,7 +98,7 @@ func (w WireguardSelection) validate(vpnProvider string) (err error) { // Validate PublicKey switch vpnProvider { case providers.Fastestvpn, providers.Ivpn, providers.Mullvad, - providers.Surfshark, providers.Windscribe: + providers.Ovpn, providers.Surfshark, providers.Windscribe: // public keys are baked in case providers.Custom: if w.PublicKey == "" { diff --git a/internal/constants/providers/providers.go b/internal/constants/providers/providers.go index b8fdb51f..71cf64a7 100644 --- a/internal/constants/providers/providers.go +++ b/internal/constants/providers/providers.go @@ -15,6 +15,7 @@ const ( Ivpn = "ivpn" Mullvad = "mullvad" Nordvpn = "nordvpn" + Ovpn = "ovpn" Perfectprivacy = "perfect privacy" Privado = "privado" PrivateInternetAccess = "private internet access" @@ -43,6 +44,7 @@ func All() []string { Ivpn, Mullvad, Nordvpn, + Ovpn, Perfectprivacy, Privado, PrivateInternetAccess, diff --git a/internal/models/server.go b/internal/models/server.go index 8c52ad66..2eb190cc 100644 --- a/internal/models/server.go +++ b/internal/models/server.go @@ -36,6 +36,8 @@ type Server struct { PortForward bool `json:"port_forward,omitempty"` Keep bool `json:"keep,omitempty"` IPs []netip.Addr `json:"ips,omitempty"` + PortsTCP []uint16 `json:"ports_tcp,omitempty"` + PortsUDP []uint16 `json:"ports_udp,omitempty"` } func (s *Server) HasMinimumInformation() (err error) { diff --git a/internal/provider/ovpn/connection.go b/internal/provider/ovpn/connection.go new file mode 100644 index 00000000..f04c224b --- /dev/null +++ b/internal/provider/ovpn/connection.go @@ -0,0 +1,15 @@ +package ovpn + +import ( + "github.com/qdm12/gluetun/internal/configuration/settings" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/utils" +) + +func (p *Provider) GetConnection(selection settings.ServerSelection, ipv6Supported bool) ( + connection models.Connection, err error, +) { + defaults := utils.NewConnectionDefaults(443, 1194, 9929) //nolint:mnd + return utils.GetConnection(p.Name(), + p.storage, selection, defaults, ipv6Supported, p.connPicker) +} diff --git a/internal/provider/ovpn/connection_test.go b/internal/provider/ovpn/connection_test.go new file mode 100644 index 00000000..9ccae3f4 --- /dev/null +++ b/internal/provider/ovpn/connection_test.go @@ -0,0 +1,126 @@ +package ovpn + +import ( + "errors" + "net/http" + "net/netip" + "testing" + + "github.com/golang/mock/gomock" + "github.com/qdm12/gluetun/internal/configuration/settings" + "github.com/qdm12/gluetun/internal/constants" + "github.com/qdm12/gluetun/internal/constants/providers" + "github.com/qdm12/gluetun/internal/constants/vpn" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/common" + "github.com/stretchr/testify/assert" +) + +func Test_Provider_GetConnection(t *testing.T) { + t.Parallel() + + const provider = providers.Ovpn + + errTest := errors.New("test error") + + testCases := map[string]struct { + filteredServers []models.Server + storageErr error + selection settings.ServerSelection + ipv6Supported bool + connection models.Connection + errWrapped error + errMessage string + }{ + "error": { + storageErr: errTest, + errWrapped: errTest, + errMessage: "filtering servers: test error", + }, + "default_openvpn_tcp_port": { + filteredServers: []models.Server{ + {IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1})}}, + }, + selection: settings.ServerSelection{ + OpenVPN: settings.OpenVPNSelection{ + Protocol: constants.TCP, + }, + }.WithDefaults(provider), + connection: models.Connection{ + Type: vpn.OpenVPN, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + Port: 443, + Protocol: constants.TCP, + }, + }, + "default_openvpn_udp_port": { + filteredServers: []models.Server{ + {IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1})}}, + }, + selection: settings.ServerSelection{ + OpenVPN: settings.OpenVPNSelection{ + Protocol: constants.UDP, + }, + }.WithDefaults(provider), + connection: models.Connection{ + Type: vpn.OpenVPN, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + Port: 1194, + Protocol: constants.UDP, + }, + }, + "default_wireguard_port": { + filteredServers: []models.Server{ + {IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1})}, WgPubKey: "x"}, + }, + selection: settings.ServerSelection{ + VPN: vpn.Wireguard, + }.WithDefaults(provider), + connection: models.Connection{ + Type: vpn.Wireguard, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + Port: 9929, + Protocol: constants.UDP, + PubKey: "x", + }, + }, + "default_multihop_port": { + filteredServers: []models.Server{ + {IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1})}, WgPubKey: "x", PortsUDP: []uint16{30044}}, + }, + selection: settings.ServerSelection{ + VPN: vpn.Wireguard, + }.WithDefaults(provider), + connection: models.Connection{ + Type: vpn.Wireguard, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + Port: 30044, + Protocol: constants.UDP, + PubKey: "x", + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + storage := common.NewMockStorage(ctrl) + storage.EXPECT().FilterServers(provider, testCase.selection). + Return(testCase.filteredServers, testCase.storageErr) + + client := (*http.Client)(nil) + provider := New(storage, client) + + connection, err := provider.GetConnection(testCase.selection, testCase.ipv6Supported) + + assert.ErrorIs(t, err, testCase.errWrapped) + if testCase.errWrapped != nil { + assert.EqualError(t, err, testCase.errMessage) + } + + assert.Equal(t, testCase.connection, connection) + }) + } +} diff --git a/internal/provider/ovpn/openvpnconf.go b/internal/provider/ovpn/openvpnconf.go new file mode 100644 index 00000000..3e09d54e --- /dev/null +++ b/internal/provider/ovpn/openvpnconf.go @@ -0,0 +1,23 @@ +package ovpn + +import ( + "github.com/qdm12/gluetun/internal/configuration/settings" + "github.com/qdm12/gluetun/internal/constants/openvpn" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/utils" +) + +func (p *Provider) OpenVPNConfig(connection models.Connection, + settings settings.OpenVPN, ipv6Supported bool, +) (lines []string) { + providerSettings := utils.OpenVPNProviderSettings{ + AuthUserPass: true, + Ciphers: []string{ + openvpn.Chacha20Poly1305, + openvpn.AES256gcm, + openvpn.AES256cbc, + openvpn.AES128gcm, + }, + } + return utils.OpenVPNConfig(providerSettings, connection, settings, ipv6Supported) +} diff --git a/internal/provider/ovpn/provider.go b/internal/provider/ovpn/provider.go new file mode 100644 index 00000000..dd42ee54 --- /dev/null +++ b/internal/provider/ovpn/provider.go @@ -0,0 +1,28 @@ +package ovpn + +import ( + "net/http" + + "github.com/qdm12/gluetun/internal/constants/providers" + "github.com/qdm12/gluetun/internal/provider/common" + "github.com/qdm12/gluetun/internal/provider/ovpn/updater" + "github.com/qdm12/gluetun/internal/provider/utils" +) + +type Provider struct { + storage common.Storage + connPicker *utils.ConnectionPicker + common.Fetcher +} + +func New(storage common.Storage, client *http.Client) *Provider { + return &Provider{ + storage: storage, + connPicker: utils.NewConnectionPicker(), + Fetcher: updater.New(client), + } +} + +func (p *Provider) Name() string { + return providers.Ovpn +} diff --git a/internal/provider/ovpn/updater/api.go b/internal/provider/ovpn/updater/api.go new file mode 100644 index 00000000..d838d2e0 --- /dev/null +++ b/internal/provider/ovpn/updater/api.go @@ -0,0 +1,183 @@ +package updater + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/netip" + "strings" +) + +type apiData struct { + Success bool `json:"success"` + DataCenters []apiDataCenter `json:"datacenters"` +} + +type apiDataCenter struct { + City string `json:"city"` + CountryName string `json:"country_name"` + Servers []apiServer `json:"servers"` +} + +type apiServer struct { + IP netip.Addr `json:"ip"` + Ptr string `json:"ptr"` // hostname + Online bool `json:"online"` + PublicKey string `json:"public_key"` + WireguardPorts []uint16 `json:"wireguard_ports"` + MultiHopOpenvpnPort uint16 `json:"multihop_openvpn_port"` + MultiHopWireguardPort uint16 `json:"multihop_wireguard_port"` +} + +func fetchAPI(ctx context.Context, client *http.Client) ( + data apiData, err error, +) { + const url = "https://www.ovpn.com/v2/api/client/entry" + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return data, err + } + + response, err := client.Do(request) + if err != nil { + return data, err + } + + if response.StatusCode != http.StatusOK { + _ = response.Body.Close() + return data, fmt.Errorf("HTTP response status code is not OK: %d %s", + response.StatusCode, response.Status) + } + + decoder := json.NewDecoder(response.Body) + err = decoder.Decode(&data) + if err != nil { + _ = response.Body.Close() + return data, fmt.Errorf("decoding response body: %w", err) + } + + err = response.Body.Close() + if err != nil { + return data, fmt.Errorf("closing response body: %w", err) + } + + return data, nil +} + +var ( + ErrCityNotSet = errors.New("city is not set") + ErrCountryNameNotSet = errors.New("country name is not set") + ErrServersNotSet = errors.New("servers array is not set") +) + +func (a *apiDataCenter) validate() (err error) { + conditionalErrors := []conditionalError{ + {err: ErrCityNotSet, condition: a.City == ""}, + {err: ErrCountryNameNotSet, condition: a.CountryName == ""}, + {err: ErrServersNotSet, condition: len(a.Servers) == 0}, + } + err = collectErrors(conditionalErrors) + if err != nil { + var dataCenterSetFields []string + if a.CountryName != "" { + dataCenterSetFields = append(dataCenterSetFields, a.CountryName) + } + if a.City != "" { + dataCenterSetFields = append(dataCenterSetFields, a.City) + } + if len(dataCenterSetFields) == 0 { + return err + } + return fmt.Errorf("data center %s: %w", + strings.Join(dataCenterSetFields, ", "), err) + } + + for i, server := range a.Servers { + err = server.validate() + if err != nil { + return fmt.Errorf("datacenter %s, %s: server %d of %d: %w", + a.CountryName, a.City, i+1, len(a.Servers), err) + } + } + + return nil +} + +var ( + ErrIPFieldNotValid = errors.New("ip address is not set") + ErrHostnameFieldNotSet = errors.New("hostname field is not set") + ErrPublicKeyFieldNotSet = errors.New("public key field is not set") + ErrWireguardPortsNotSet = errors.New("wireguard ports array is not set") + ErrWireguardPortNotDefault = errors.New("wireguard port is not the default 9929") + ErrMultiHopOpenVPNPortNotSet = errors.New("multihop OpenVPN port is not set") + ErrMultiHopWireguardPortNotSet = errors.New("multihop WireGuard port is not set") +) + +func (a *apiServer) validate() (err error) { + const defaultWireguardPort = 9929 + conditionalErrors := []conditionalError{ + {err: ErrIPFieldNotValid, condition: !a.IP.IsValid()}, + {err: ErrHostnameFieldNotSet, condition: a.Ptr == ""}, + {err: ErrPublicKeyFieldNotSet, condition: a.PublicKey == ""}, + {err: ErrWireguardPortsNotSet, condition: len(a.WireguardPorts) == 0}, + { + err: ErrWireguardPortNotDefault, + condition: len(a.WireguardPorts) != 1 || a.WireguardPorts[0] != defaultWireguardPort, + }, + {err: ErrMultiHopOpenVPNPortNotSet, condition: a.MultiHopOpenvpnPort == 0}, + {err: ErrMultiHopWireguardPortNotSet, condition: a.MultiHopWireguardPort == 0}, + } + err = collectErrors(conditionalErrors) + switch { + case err == nil: + return nil + case a.Ptr != "": + return fmt.Errorf("server %s: %w", a.Ptr, err) + case a.IP.IsValid(): + return fmt.Errorf("server %s: %w", a.IP.String(), err) + default: + return err + } +} + +type conditionalError struct { + err error + condition bool +} + +type joinedError struct { + errs []error +} + +func (e *joinedError) Unwrap() []error { + return e.errs +} + +func (e *joinedError) Error() string { + errStrings := make([]string, len(e.errs)) + for i, err := range e.errs { + errStrings[i] = err.Error() + } + return strings.Join(errStrings, "; ") +} + +func collectErrors(conditionalErrors []conditionalError) (err error) { + errs := make([]error, 0, len(conditionalErrors)) + for _, conditionalError := range conditionalErrors { + if !conditionalError.condition { + continue + } + errs = append(errs, conditionalError.err) + } + + if len(errs) == 0 { + return nil + } + + return &joinedError{ + errs: errs, + } +} diff --git a/internal/provider/ovpn/updater/api_test.go b/internal/provider/ovpn/updater/api_test.go new file mode 100644 index 00000000..4d7619bf --- /dev/null +++ b/internal/provider/ovpn/updater/api_test.go @@ -0,0 +1,117 @@ +package updater + +import ( + "context" + "errors" + "io" + "net/http" + "net/netip" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_fetchAPI(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + responseStatus int + responseBody io.ReadCloser + data apiData + err error + }{ + "http response status not ok": { + responseStatus: http.StatusNoContent, + err: errors.New("HTTP response status code is not OK: 204 No Content"), + }, + "nil body": { + responseStatus: http.StatusOK, + err: errors.New("decoding response body: EOF"), + }, + "no server": { + responseStatus: http.StatusOK, + responseBody: io.NopCloser(strings.NewReader(`{}`)), + }, + "success": { + responseStatus: http.StatusOK, + responseBody: io.NopCloser(strings.NewReader(`{ + "success": true, + "datacenters": [ + { + "slug": "vienna", + "city": "Vienna", + "country": "AT", + "country_name": "Austria", + "pools": [ + "pool-1.prd.at.vienna.ovpn.com" + ], + "ping_address": "37.120.212.227", + "servers": [ + { + "ip": "37.120.212.227", + "ptr": "vpn44.prd.vienna.ovpn.com", + "name": "VPN44 - Vienna", + "online": true, + "load": 8, + "public_key": "r83LIc0Q2F8s3dY9x5y17Yz8wTADJc7giW1t5eSmoXc=", + "public_key_ipv4": "wFbSRyjSXBmkjJodlqz7DoYn3WNDPYFUIXyIUS2QU2A=", + "wireguard_ports": [ + 9929 + ], + "multihop_openvpn_port": 20044, + "multihop_wireguard_port": 30044 + } + ] + } + ] + }`)), + data: apiData{ + Success: true, + DataCenters: []apiDataCenter{ + {CountryName: "Austria", City: "Vienna", Servers: []apiServer{ + { + IP: netip.MustParseAddr("37.120.212.227"), + Ptr: "vpn44.prd.vienna.ovpn.com", + Online: true, + PublicKey: "r83LIc0Q2F8s3dY9x5y17Yz8wTADJc7giW1t5eSmoXc=", + WireguardPorts: []uint16{9929}, + MultiHopOpenvpnPort: 20044, + MultiHopWireguardPort: 30044, + }, + }}, + }, + }, + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + client := &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, r.URL.String(), "https://www.ovpn.com/v2/api/client/entry") + return &http.Response{ + StatusCode: testCase.responseStatus, + Status: http.StatusText(testCase.responseStatus), + Body: testCase.responseBody, + }, nil + }), + } + + data, err := fetchAPI(ctx, client) + + assert.Equal(t, testCase.data, data) + if testCase.err != nil { + require.Error(t, err) + assert.Equal(t, testCase.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/provider/ovpn/updater/roundtrip_test.go b/internal/provider/ovpn/updater/roundtrip_test.go new file mode 100644 index 00000000..9df23788 --- /dev/null +++ b/internal/provider/ovpn/updater/roundtrip_test.go @@ -0,0 +1,9 @@ +package updater + +import "net/http" + +type roundTripFunc func(r *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} diff --git a/internal/provider/ovpn/updater/servers.go b/internal/provider/ovpn/updater/servers.go new file mode 100644 index 00000000..9815975b --- /dev/null +++ b/internal/provider/ovpn/updater/servers.go @@ -0,0 +1,71 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "net/netip" + "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, +) { + data, err := fetchAPI(ctx, u.client) + if err != nil { + return nil, fmt.Errorf("fetching API: %w", err) + } else if !data.Success { + return nil, errors.New("response success field is false") + } + + for dataCenterIndex, dataCenter := range data.DataCenters { + err = dataCenter.validate() + if err != nil { + return nil, fmt.Errorf("validating data center %d of %d: %w", + dataCenterIndex+1, len(data.DataCenters), err) + } + + for _, apiServer := range dataCenter.Servers { + if !apiServer.Online { + continue + } + + baseServer := models.Server{ + Country: dataCenter.CountryName, + City: dataCenter.City, + Hostname: apiServer.Ptr, + IPs: []netip.Addr{apiServer.IP}, + } + openVPNServer := baseServer + openVPNServer.VPN = vpn.OpenVPN + openVPNServer.TCP = true + openVPNServer.UDP = true + multiHopOpenVPNServer := openVPNServer + multiHopOpenVPNServer.MultiHop = true + multiHopOpenVPNServer.PortsTCP = []uint16{apiServer.MultiHopOpenvpnPort} + multiHopOpenVPNServer.PortsUDP = []uint16{apiServer.MultiHopOpenvpnPort} + servers = append(servers, openVPNServer, multiHopOpenVPNServer) + + wireguardServer := baseServer + wireguardServer.VPN = vpn.Wireguard + wireguardServer.WgPubKey = apiServer.PublicKey + multiHopWireguardServer := wireguardServer + multiHopWireguardServer.MultiHop = true + multiHopWireguardServer.PortsUDP = []uint16{apiServer.MultiHopWireguardPort} + servers = append(servers, wireguardServer, multiHopWireguardServer) + } + } + + 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 +} diff --git a/internal/provider/ovpn/updater/servers_test.go b/internal/provider/ovpn/updater/servers_test.go new file mode 100644 index 00000000..eda9d60a --- /dev/null +++ b/internal/provider/ovpn/updater/servers_test.go @@ -0,0 +1,204 @@ +package updater + +import ( + "context" + "io" + "net/http" + "net/netip" + "strings" + "testing" + + "github.com/qdm12/gluetun/internal/constants/vpn" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/common" + "github.com/stretchr/testify/assert" +) + +func Test_Updater_FetchServers(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + // Inputs + minServers int + + // From API + responseStatus int + responseBody string + + // Output + servers []models.Server + errWrapped error + errMessage string + }{ + "http_response_error": { + responseStatus: http.StatusNoContent, + errMessage: "fetching API: HTTP response status code is not OK: 204 No Content", + }, + "success_field_false": { + responseStatus: http.StatusOK, + responseBody: `{"success": false}`, + errMessage: "response success field is false", + }, + "validation_failed": { + responseStatus: http.StatusOK, + responseBody: `{ + "success": true, + "datacenters": [ + { + "city": "Vienna", + "servers": [ + {} + ] + } + ] +}`, + errMessage: "validating data center 1 of 1: data center Vienna: country name is not set", + }, + "not_enough_servers": { + minServers: 5, + responseStatus: http.StatusOK, + responseBody: `{ + "success": true, + "datacenters": [ + { + "city": "Vienna", + "country_name": "Austria", + "servers": [ + { + "ip": "37.120.212.227", + "ptr": "vpn44.prd.vienna.ovpn.com", + "online": true, + "public_key": "r83LIc0Q2F8s3dY9x5y17Yz8wTADJc7giW1t5eSmoXc=", + "wireguard_ports": [9929], + "multihop_openvpn_port": 20044, + "multihop_wireguard_port": 30044 + } + ] + } + ] +}`, + errWrapped: common.ErrNotEnoughServers, + errMessage: "not enough servers found: 4 and expected at least 5", + }, + "success": { + minServers: 4, + responseBody: `{ + "success": true, + "datacenters": [ + { + "slug": "vienna", + "city": "Vienna", + "country": "AT", + "country_name": "Austria", + "pools": [ + "pool-1.prd.at.vienna.ovpn.com" + ], + "ping_address": "37.120.212.227", + "servers": [ + { + "ip": "37.120.212.227", + "ptr": "vpn44.prd.vienna.ovpn.com", + "name": "VPN44 - Vienna", + "online": true, + "load": 8, + "public_key": "r83LIc0Q2F8s3dY9x5y17Yz8wTADJc7giW1t5eSmoXc=", + "public_key_ipv4": "wFbSRyjSXBmkjJodlqz7DoYn3WNDPYFUIXyIUS2QU2A=", + "wireguard_ports": [ + 9929 + ], + "multihop_openvpn_port": 20044, + "multihop_wireguard_port": 30044 + }, + { + "ip": "37.120.212.228", + "ptr": "vpn45.prd.vienna.ovpn.com", + "online": false, + "public_key": "r93LIc0Q2F8s3dY9x5y17Yz8wTADJc7giW1t5eSmoXc=", + "wireguard_ports": [9929], + "multihop_openvpn_port": 20045, + "multihop_wireguard_port": 30045 + } + ] + } + ] +}`, + responseStatus: http.StatusOK, + servers: []models.Server{ + { + Country: "Austria", + City: "Vienna", + Hostname: "vpn44.prd.vienna.ovpn.com", + IPs: []netip.Addr{netip.MustParseAddr("37.120.212.227")}, + VPN: vpn.OpenVPN, + UDP: true, + TCP: true, + }, + { + Country: "Austria", + City: "Vienna", + Hostname: "vpn44.prd.vienna.ovpn.com", + IPs: []netip.Addr{netip.MustParseAddr("37.120.212.227")}, + VPN: vpn.OpenVPN, + UDP: true, + TCP: true, + MultiHop: true, + PortsTCP: []uint16{20044}, + PortsUDP: []uint16{20044}, + }, + { + Country: "Austria", + City: "Vienna", + Hostname: "vpn44.prd.vienna.ovpn.com", + IPs: []netip.Addr{netip.MustParseAddr("37.120.212.227")}, + VPN: vpn.Wireguard, + WgPubKey: "r83LIc0Q2F8s3dY9x5y17Yz8wTADJc7giW1t5eSmoXc=", + }, + { + Country: "Austria", + City: "Vienna", + Hostname: "vpn44.prd.vienna.ovpn.com", + IPs: []netip.Addr{netip.MustParseAddr("37.120.212.227")}, + VPN: vpn.Wireguard, + WgPubKey: "r83LIc0Q2F8s3dY9x5y17Yz8wTADJc7giW1t5eSmoXc=", + MultiHop: true, + PortsUDP: []uint16{30044}, + }, + }, + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + client := &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, r.URL.String(), "https://www.ovpn.com/v2/api/client/entry") + return &http.Response{ + StatusCode: testCase.responseStatus, + Status: http.StatusText(testCase.responseStatus), + Body: io.NopCloser(strings.NewReader(testCase.responseBody)), + }, nil + }), + } + + updater := &Updater{ + client: client, + } + + servers, err := updater.FetchServers(ctx, testCase.minServers) + + assert.Equal(t, testCase.servers, servers) + if testCase.errMessage == "" { + assert.NoError(t, err) + } else { + assert.Contains(t, err.Error(), testCase.errMessage) + } + if testCase.errWrapped != nil { + assert.ErrorIs(t, err, testCase.errWrapped) + } + }) + } +} diff --git a/internal/provider/ovpn/updater/updater.go b/internal/provider/ovpn/updater/updater.go new file mode 100644 index 00000000..a987c0fd --- /dev/null +++ b/internal/provider/ovpn/updater/updater.go @@ -0,0 +1,15 @@ +package updater + +import ( + "net/http" +) + +type Updater struct { + client *http.Client +} + +func New(client *http.Client) *Updater { + return &Updater{ + client: client, + } +} diff --git a/internal/provider/providers.go b/internal/provider/providers.go index 07402096..87e61604 100644 --- a/internal/provider/providers.go +++ b/internal/provider/providers.go @@ -20,6 +20,7 @@ import ( "github.com/qdm12/gluetun/internal/provider/ivpn" "github.com/qdm12/gluetun/internal/provider/mullvad" "github.com/qdm12/gluetun/internal/provider/nordvpn" + "github.com/qdm12/gluetun/internal/provider/ovpn" "github.com/qdm12/gluetun/internal/provider/perfectprivacy" "github.com/qdm12/gluetun/internal/provider/privado" "github.com/qdm12/gluetun/internal/provider/privateinternetaccess" @@ -67,6 +68,7 @@ func NewProviders(storage Storage, timeNow func() time.Time, providers.Ivpn: ivpn.New(storage, client, updaterWarner, parallelResolver), providers.Mullvad: mullvad.New(storage, client), providers.Nordvpn: nordvpn.New(storage, client, updaterWarner), + providers.Ovpn: ovpn.New(storage, client), providers.Perfectprivacy: perfectprivacy.New(storage, unzipper, updaterWarner), providers.Privado: privado.New(storage, client, updaterWarner), providers.PrivateInternetAccess: privateinternetaccess.New(storage, timeNow, client), diff --git a/internal/provider/utils/connection.go b/internal/provider/utils/connection.go index 65bcb3cf..ea272574 100644 --- a/internal/provider/utils/connection.go +++ b/internal/provider/utils/connection.go @@ -52,8 +52,6 @@ func GetConnection(provider string, }) protocol := getProtocol(selection) - port := getPort(selection, defaults.OpenVPNTCPPort, - defaults.OpenVPNUDPPort, defaults.WireguardPort) connections := make([]models.Connection, 0, len(servers)) for _, server := range servers { @@ -69,6 +67,9 @@ func GetConnection(provider string, hostname = server.OvpnX509 } + port := getPort(selection, server, defaults.OpenVPNTCPPort, + defaults.OpenVPNUDPPort, defaults.WireguardPort) + connection := models.Connection{ Type: selection.VPN, IP: ip, diff --git a/internal/provider/utils/port.go b/internal/provider/utils/port.go index 1f32ef4a..bdbea7c9 100644 --- a/internal/provider/utils/port.go +++ b/internal/provider/utils/port.go @@ -6,29 +6,44 @@ import ( "github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/constants" "github.com/qdm12/gluetun/internal/constants/vpn" + "github.com/qdm12/gluetun/internal/models" ) -func getPort(selection settings.ServerSelection, +func getPort(selection settings.ServerSelection, server models.Server, defaultOpenVPNTCP, defaultOpenVPNUDP, defaultWireguard uint16, ) (port uint16) { switch selection.VPN { case vpn.Wireguard: customPort := *selection.Wireguard.EndpointPort if customPort > 0 { + // Note: servers filtering ensures the custom port is within the + // server ports defined if any is set. return customPort } + + if len(server.PortsUDP) > 0 { + defaultWireguard = server.PortsUDP[0] + } checkDefined("Wireguard", defaultWireguard) return defaultWireguard default: // OpenVPN customPort := *selection.OpenVPN.CustomPort if customPort > 0 { + // Note: servers filtering ensures the custom port is within the + // server ports defined if any is set. return customPort } if selection.OpenVPN.Protocol == constants.TCP { + if len(server.PortsTCP) > 0 { + defaultOpenVPNTCP = server.PortsTCP[0] + } checkDefined("OpenVPN TCP", defaultOpenVPNTCP) return defaultOpenVPNTCP } + if len(server.PortsUDP) > 0 { + defaultOpenVPNUDP = server.PortsUDP[0] + } checkDefined("OpenVPN UDP", defaultOpenVPNUDP) return defaultOpenVPNUDP } diff --git a/internal/provider/utils/port_test.go b/internal/provider/utils/port_test.go index 1967b47b..a6f866e4 100644 --- a/internal/provider/utils/port_test.go +++ b/internal/provider/utils/port_test.go @@ -6,6 +6,7 @@ import ( "github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/constants" "github.com/qdm12/gluetun/internal/constants/vpn" + "github.com/qdm12/gluetun/internal/models" "github.com/stretchr/testify/assert" ) @@ -22,6 +23,7 @@ func Test_GetPort(t *testing.T) { testCases := map[string]struct { selection settings.ServerSelection + server models.Server defaultOpenVPNTCP uint16 defaultOpenVPNUDP uint16 defaultWireguard uint16 @@ -48,6 +50,20 @@ func Test_GetPort(t *testing.T) { defaultWireguard: defaultWireguard, port: defaultOpenVPNUDP, }, + "OpenVPN_server_port_udp": { + selection: settings.ServerSelection{ + VPN: vpn.OpenVPN, + OpenVPN: settings.OpenVPNSelection{ + CustomPort: uint16Ptr(0), + Protocol: constants.UDP, + }, + }, + server: models.Server{ + PortsUDP: []uint16{1234}, + }, + defaultOpenVPNUDP: defaultOpenVPNUDP, + port: 1234, + }, "OpenVPN UDP no default port defined": { selection: settings.ServerSelection{ VPN: vpn.OpenVPN, @@ -88,6 +104,20 @@ func Test_GetPort(t *testing.T) { }, port: 1234, }, + "OpenVPN_server_port_tcp": { + selection: settings.ServerSelection{ + VPN: vpn.OpenVPN, + OpenVPN: settings.OpenVPNSelection{ + CustomPort: uint16Ptr(0), + Protocol: constants.TCP, + }, + }, + server: models.Server{ + PortsTCP: []uint16{1234}, + }, + defaultOpenVPNTCP: defaultOpenVPNTCP, + port: 1234, + }, "Wireguard": { selection: settings.ServerSelection{ VPN: vpn.Wireguard, @@ -105,6 +135,19 @@ func Test_GetPort(t *testing.T) { defaultWireguard: defaultWireguard, port: 1234, }, + "Wireguard_server_port": { + selection: settings.ServerSelection{ + VPN: vpn.Wireguard, + Wireguard: settings.WireguardSelection{ + EndpointPort: uint16Ptr(0), + }, + }, + server: models.Server{ + PortsUDP: []uint16{1234}, + }, + defaultWireguard: defaultWireguard, + port: 1234, + }, "Wireguard no default port defined": { selection: settings.ServerSelection{ VPN: vpn.Wireguard, @@ -120,6 +163,7 @@ func Test_GetPort(t *testing.T) { if testCase.panics != "" { assert.PanicsWithValue(t, testCase.panics, func() { _ = getPort(testCase.selection, + testCase.server, testCase.defaultOpenVPNTCP, testCase.defaultOpenVPNUDP, testCase.defaultWireguard) @@ -128,6 +172,7 @@ func Test_GetPort(t *testing.T) { } port := getPort(testCase.selection, + testCase.server, testCase.defaultOpenVPNTCP, testCase.defaultOpenVPNUDP, testCase.defaultWireguard) diff --git a/internal/storage/filter.go b/internal/storage/filter.go index 81d6cdbc..4ff4d609 100644 --- a/internal/storage/filter.go +++ b/internal/storage/filter.go @@ -3,6 +3,7 @@ package storage import ( "errors" "fmt" + "slices" "strings" "github.com/qdm12/gluetun/internal/configuration/settings" @@ -122,6 +123,10 @@ func filterServer(server models.Server, return true } + if filterByPorts(selection, server.PortsTCP) { + return true + } + // TODO filter port forward server for PIA return false @@ -165,3 +170,21 @@ func filterByProtocol(selection settings.ServerSelection, return (wantTCP && !serverTCP) || (wantUDP && !serverUDP) } } + +func filterByPorts(selection settings.ServerSelection, + serverPorts []uint16, +) (filtered bool) { + if len(serverPorts) == 0 { + return false + } + + customPort := *selection.OpenVPN.CustomPort + if selection.VPN == vpn.Wireguard { + customPort = *selection.Wireguard.EndpointPort + } + if customPort == 0 { + return false + } + + return !slices.Contains(serverPorts, customPort) +} diff --git a/internal/storage/formatting.go b/internal/storage/formatting.go index 44865a5b..bdc4eab3 100644 --- a/internal/storage/formatting.go +++ b/internal/storage/formatting.go @@ -14,7 +14,7 @@ func commaJoin(slice []string) string { return strings.Join(slice, ", ") } -func noServerFoundError(selection settings.ServerSelection) (err error) { +func noServerFoundError(selection settings.ServerSelection) (err error) { //nolint:gocyclo var messageParts []string messageParts = append(messageParts, "VPN "+selection.VPN) @@ -155,6 +155,15 @@ func noServerFoundError(selection settings.ServerSelection) (err error) { "target ip address "+targetIP.String()) } + customPort := *selection.OpenVPN.CustomPort + if selection.VPN == vpn.Wireguard { + customPort = *selection.Wireguard.EndpointPort + } + if customPort > 0 { + messageParts = append(messageParts, + fmt.Sprintf("%s endpoint port %d", selection.VPN, customPort)) + } + message := "for " + strings.Join(messageParts, "; ") return fmt.Errorf("no server found: %s", message) diff --git a/internal/storage/hardcoded.go b/internal/storage/hardcoded.go index cd7079a0..89798bcf 100644 --- a/internal/storage/hardcoded.go +++ b/internal/storage/hardcoded.go @@ -26,10 +26,14 @@ func parseHardcodedServers() (allServers models.AllServers) { } for provider, metadata := range allServers.ProviderToServers { + if metadata.Filepath == "" { + panic(fmt.Sprintf("embedded manifest file servers.json should have the filepath field set for %s", provider)) + } filename := path.Base(metadata.Filepath) providerFile, err := serversmodule.Files.Open(filename) if err != nil { - panic(fmt.Sprintf("reading embedded provider file %s for %s: %s", filename, provider, err)) + const rootURL = "https://github.com/qdm12/gluetun-servers/blob/main/pkg/servers" + panic(fmt.Sprintf("reading embedded provider file defined at %s/%s.json: %s", rootURL, filename, err)) } defer providerFile.Close() // no-op diff --git a/internal/storage/hardcoded_test.go b/internal/storage/hardcoded_test.go index 04a9bc0a..b95ae757 100644 --- a/internal/storage/hardcoded_test.go +++ b/internal/storage/hardcoded_test.go @@ -33,7 +33,10 @@ func Test_parseHardcodedServers(t *testing.T) { func Test_parseHardcodedServers_filepathsAndEmbeddedProviderFiles(t *testing.T) { t.Parallel() - hardcodedServers := parseHardcodedServers() + var hardcodedServers models.AllServers + require.NotPanics(t, func() { + hardcodedServers = parseHardcodedServers() + }) allProviders := providers.All() for _, provider := range allProviders { diff --git a/internal/storage/servers.json b/internal/storage/servers.json index 3ab612f1..79bd5a7b 100644 --- a/internal/storage/servers.json +++ b/internal/storage/servers.json @@ -30,6 +30,9 @@ "nordvpn": { "filepath": "/gluetun/servers/nordvpn.json" }, + "ovpn": { + "filepath": "/gluetun/servers/ovpn.json" + }, "perfect privacy": { "filepath": "/gluetun/servers/perfect privacy.json" }, @@ -69,4 +72,4 @@ "windscribe": { "filepath": "/gluetun/servers/windscribe.json" } -} +} \ No newline at end of file