From df7f733d07f257a7f726d465b6c570eac775f821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20K=C3=A5berg?= Date: Wed, 24 Jun 2026 09:10:17 +0200 Subject: [PATCH] feat(azirevpn): add AzireVPN provider Add support for the AzireVPN provider with Wireguard and port forwarding via the AzireVPN API. OpenVPN is not supported since AzireVPN discontinued it as of March 15, 2026. --- .github/ISSUE_TEMPLATE/bug.yml | 1 + .github/labels.yml | 2 + Dockerfile | 1 + README.md | 6 +- .../settings/openvpnselection.go | 1 + .../configuration/settings/portforward.go | 1 + internal/configuration/settings/provider.go | 19 ++ .../configuration/settings/serverselection.go | 6 +- internal/configuration/settings/updater.go | 9 + .../settings/wireguardselection.go | 8 +- internal/constants/providers/providers.go | 2 + internal/models/markdown.go | 2 + internal/provider/azirevpn/api.go | 272 +++++++++++++++++ internal/provider/azirevpn/connection.go | 15 + internal/provider/azirevpn/openvpnconf.go | 12 + internal/provider/azirevpn/portforward.go | 278 ++++++++++++++++++ internal/provider/azirevpn/provider.go | 39 +++ internal/provider/azirevpn/updater/servers.go | 140 +++++++++ internal/provider/azirevpn/updater/updater.go | 21 ++ internal/provider/providers.go | 2 + 20 files changed, 829 insertions(+), 8 deletions(-) create mode 100644 internal/provider/azirevpn/api.go create mode 100644 internal/provider/azirevpn/connection.go create mode 100644 internal/provider/azirevpn/openvpnconf.go create mode 100644 internal/provider/azirevpn/portforward.go create mode 100644 internal/provider/azirevpn/provider.go create mode 100644 internal/provider/azirevpn/updater/servers.go create mode 100644 internal/provider/azirevpn/updater/updater.go diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 9e4f95b6d..a819bed86 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -46,6 +46,7 @@ body: label: VPN service provider options: - AirVPN + - AzireVPN - Custom - Cyberghost - ExpressVPN diff --git a/.github/labels.yml b/.github/labels.yml index 51486f96b..3bec46c20 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -44,6 +44,8 @@ # VPN providers - name: "☁️ AirVPN" color: "cfe8d4" +- name: "☁️ AzireVPN" + color: "cfe8d4" - name: "☁️ Custom" color: "cfe8d4" - name: "☁️ Cyberghost" diff --git a/Dockerfile b/Dockerfile index 3b7d68e82..a479a18ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -98,6 +98,7 @@ ENV VPN_SERVICE_PROVIDER=pia \ OPENVPN_MSSFIX= \ OPENVPN_CUSTOM_CONFIG= \ # Wireguard + AZIREVPN_TOKEN= \ WIREGUARD_ENDPOINT_IP= \ WIREGUARD_ENDPOINT_PORT= \ WIREGUARD_CONF_SECRETFILE=/run/secrets/wg0.conf \ diff --git a/README.md b/README.md index 8e446faa2..e0cf176f4 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,10 @@ 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**, **AzireVPN** (Wireguard only), **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 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**, **AzireVPN**, **FastestVPN**, **Ivpn**, **Mullvad**, **NordVPN**, **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) @@ -78,7 +78,7 @@ Lightweight swiss-army-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), [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) +- Custom VPN server side port forwarding for [AzireVPN](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/azirevpn.md#vpn-server-port-forwarding), [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 - Can work as a Kubernetes sidecar container, thanks @rorph diff --git a/internal/configuration/settings/openvpnselection.go b/internal/configuration/settings/openvpnselection.go index 268e2032f..603f1c849 100644 --- a/internal/configuration/settings/openvpnselection.go +++ b/internal/configuration/settings/openvpnselection.go @@ -57,6 +57,7 @@ func (o OpenVPNSelection) validate(vpnProvider string) (err error) { // Validate TCP if o.Protocol == constants.TCP && helpers.IsOneOf(vpnProvider, + providers.Azirevpn, providers.Giganews, providers.Ipvanish, providers.Perfectprivacy, diff --git a/internal/configuration/settings/portforward.go b/internal/configuration/settings/portforward.go index 71850b758..9dcc8f720 100644 --- a/internal/configuration/settings/portforward.go +++ b/internal/configuration/settings/portforward.go @@ -66,6 +66,7 @@ func (p PortForwarding) Validate(vpnProvider string) (err error) { providerSelected = *p.Provider } validProviders := []string{ + providers.Azirevpn, providers.Perfectprivacy, providers.PrivateInternetAccess, providers.Privatevpn, diff --git a/internal/configuration/settings/provider.go b/internal/configuration/settings/provider.go index ccc3acac3..af9d54f0b 100644 --- a/internal/configuration/settings/provider.go +++ b/internal/configuration/settings/provider.go @@ -1,6 +1,7 @@ package settings import ( + "errors" "fmt" "slices" "sort" @@ -24,6 +25,8 @@ type Provider struct { ServerSelection ServerSelection `json:"server_selection"` // PortForwarding is the settings about port forwarding. PortForwarding PortForwarding `json:"port_forwarding"` + // AzirevpnToken is the API token used by AzireVPN. + AzirevpnToken string `json:"azirevpn_token"` } // TODO v4 remove pointer for receiver (because of Surfshark). @@ -40,10 +43,15 @@ func (p *Provider) validate(vpnType string, filterChoicesGetter FilterChoicesGet mullvadIndex := slices.Index(validNames, providers.Mullvad) validNames[mullvadIndex], validNames[len(validNames)-1] = validNames[len(validNames)-1], validNames[mullvadIndex] validNames = validNames[:len(validNames)-1] + // Remove AzireVPN since it is Wireguard only. + azirevpnIndex := slices.Index(validNames, providers.Azirevpn) + validNames[azirevpnIndex], validNames[len(validNames)-1] = validNames[len(validNames)-1], validNames[azirevpnIndex] + validNames = validNames[:len(validNames)-1] sort.Strings(validNames) case vpn.Wireguard: validNames = []string{ providers.Airvpn, + providers.Azirevpn, providers.Custom, providers.Fastestvpn, providers.Ivpn, @@ -58,6 +66,10 @@ func (p *Provider) validate(vpnType string, filterChoicesGetter FilterChoicesGet return fmt.Errorf("VPN provider name is not valid for %s: %w", vpnType, err) } + if p.Name == providers.Azirevpn && *p.PortForwarding.Enabled && p.AzirevpnToken == "" { + return errors.New("azirevpn token is missing") + } + err = p.ServerSelection.validate(p.Name, filterChoicesGetter, warner) if err != nil { return fmt.Errorf("server selection: %w", err) @@ -76,6 +88,7 @@ func (p *Provider) copy() (copied Provider) { Name: p.Name, ServerSelection: p.ServerSelection.copy(), PortForwarding: p.PortForwarding.Copy(), + AzirevpnToken: p.AzirevpnToken, } } @@ -83,12 +96,14 @@ func (p *Provider) overrideWith(other Provider) { p.Name = gosettings.OverrideWithComparable(p.Name, other.Name) p.ServerSelection.overrideWith(other.ServerSelection) p.PortForwarding.OverrideWith(other.PortForwarding) + p.AzirevpnToken = gosettings.OverrideWithComparable(p.AzirevpnToken, other.AzirevpnToken) } func (p *Provider) setDefaults() { p.Name = gosettings.DefaultComparable(p.Name, providers.PrivateInternetAccess) p.PortForwarding.setDefaults() p.ServerSelection.setDefaults(p.Name, *p.PortForwarding.Enabled) + p.AzirevpnToken = gosettings.DefaultComparable(p.AzirevpnToken, "") } func (p Provider) String() string { @@ -98,6 +113,9 @@ func (p Provider) String() string { func (p Provider) toLinesNode() (node *gotree.Node) { node = gotree.New("VPN provider settings:") node.Appendf("Name: %s", p.Name) + if p.AzirevpnToken != "" { + node.Appendf("AzireVPN token: %s", gosettings.ObfuscateKey(p.AzirevpnToken)) + } node.AppendNode(p.ServerSelection.toLinesNode()) node.AppendNode(p.PortForwarding.toLinesNode()) return node @@ -105,6 +123,7 @@ func (p Provider) toLinesNode() (node *gotree.Node) { func (p *Provider) read(r *reader.Reader, vpnType string) (err error) { p.Name = readVPNServiceProvider(r, vpnType) + p.AzirevpnToken = r.String("AZIREVPN_TOKEN", reader.ForceLowercase(false)) err = p.ServerSelection.read(r, p.Name, vpnType) if err != nil { diff --git a/internal/configuration/settings/serverselection.go b/internal/configuration/settings/serverselection.go index c3e6a6d6c..1c85a8925 100644 --- a/internal/configuration/settings/serverselection.go +++ b/internal/configuration/settings/serverselection.go @@ -256,7 +256,8 @@ func validateSubscriptionTierFilters(settings ServerSelection, vpnServiceProvide func validateFeatureFilters(settings ServerSelection, vpnServiceProvider string) error { switch { - case *settings.OwnedOnly && vpnServiceProvider != providers.Mullvad: + case *settings.OwnedOnly && + !helpers.IsOneOf(vpnServiceProvider, providers.Mullvad, providers.Azirevpn): return errors.New("owned only filter is not supported") case vpnServiceProvider == providers.Protonvpn && *settings.FreeOnly && *settings.PortForwardOnly: return errors.New("port forwarding only filter is not supported: together with free only filter") @@ -266,7 +267,8 @@ func validateFeatureFilters(settings ServerSelection, vpnServiceProvider string) case *settings.MultiHopOnly && vpnServiceProvider != providers.Surfshark: return errors.New("multi hop only filter is not supported") case *settings.PortForwardOnly && - !helpers.IsOneOf(vpnServiceProvider, providers.PrivateInternetAccess, providers.Protonvpn): + !helpers.IsOneOf(vpnServiceProvider, providers.Azirevpn, + providers.PrivateInternetAccess, providers.Protonvpn): return errors.New("port forwarding only filter is not supported") case *settings.SecureCoreOnly && vpnServiceProvider != providers.Protonvpn: return errors.New("secure core only filter is not supported") diff --git a/internal/configuration/settings/updater.go b/internal/configuration/settings/updater.go index ea6930ee4..e87f70d2e 100644 --- a/internal/configuration/settings/updater.go +++ b/internal/configuration/settings/updater.go @@ -36,6 +36,8 @@ type Updater struct { ProtonEmail *string // ProtonPassword is the password to authenticate with the Proton API. ProtonPassword *string + // AzirevpnToken is the token for AzireVPN API. + AzirevpnToken string } func (u Updater) Validate() (err error) { @@ -81,6 +83,7 @@ func (u *Updater) copy() (copied Updater) { PreferDirectDownload: gosettings.CopyPointer(u.PreferDirectDownload), ProtonEmail: gosettings.CopyPointer(u.ProtonEmail), ProtonPassword: gosettings.CopyPointer(u.ProtonPassword), + AzirevpnToken: u.AzirevpnToken, } } @@ -94,6 +97,7 @@ func (u *Updater) overrideWith(other Updater) { u.PreferDirectDownload = gosettings.OverrideWithPointer(u.PreferDirectDownload, other.PreferDirectDownload) u.ProtonEmail = gosettings.OverrideWithPointer(u.ProtonEmail, other.ProtonEmail) u.ProtonPassword = gosettings.OverrideWithPointer(u.ProtonPassword, other.ProtonPassword) + u.AzirevpnToken = gosettings.OverrideWithComparable(u.AzirevpnToken, other.AzirevpnToken) } func (u *Updater) SetDefaults(vpnProvider string) { @@ -112,6 +116,7 @@ func (u *Updater) SetDefaults(vpnProvider string) { u.PreferDirectDownload = gosettings.DefaultPointer(u.PreferDirectDownload, false) u.ProtonEmail = gosettings.DefaultPointer(u.ProtonEmail, "") u.ProtonPassword = gosettings.DefaultPointer(u.ProtonPassword, "") + u.AzirevpnToken = gosettings.DefaultComparable(u.AzirevpnToken, "") } func (u Updater) String() string { @@ -132,6 +137,9 @@ func (u Updater) toLinesNode() (node *gotree.Node) { node.Appendf("Proton API email: %s", *u.ProtonEmail) node.Appendf("Proton API password: %s", gosettings.ObfuscateKey(*u.ProtonPassword)) } + if slices.Contains(u.Providers, providers.Azirevpn) && u.AzirevpnToken != "" { + node.Appendf("AzireVPN API token: %s", gosettings.ObfuscateKey(u.AzirevpnToken)) + } return node } @@ -163,6 +171,7 @@ func (u *Updater) read(r *reader.Reader) (err error) { } } u.ProtonPassword = r.Get("UPDATER_PROTONVPN_PASSWORD") + u.AzirevpnToken = r.String("AZIREVPN_TOKEN", reader.ForceLowercase(false)) return nil } diff --git a/internal/configuration/settings/wireguardselection.go b/internal/configuration/settings/wireguardselection.go index 301893cf1..4b009a6c2 100644 --- a/internal/configuration/settings/wireguardselection.go +++ b/internal/configuration/settings/wireguardselection.go @@ -39,7 +39,8 @@ type WireguardSelection struct { func (w WireguardSelection) validate(vpnProvider string) (err error) { // Validate EndpointIP switch vpnProvider { - case providers.Airvpn, providers.Fastestvpn, providers.Ivpn, + case providers.Airvpn, providers.Azirevpn, + providers.Fastestvpn, providers.Ivpn, providers.Mullvad, providers.Nordvpn, providers.Protonvpn, providers.Surfshark, providers.Windscribe: // endpoint IP addresses are baked in @@ -58,7 +59,7 @@ func (w WireguardSelection) validate(vpnProvider string) (err error) { return errors.New("endpoint port is not set") } // EndpointPort cannot be set - case providers.Fastestvpn, providers.Nordvpn, + case providers.Azirevpn, providers.Fastestvpn, providers.Nordvpn, providers.Protonvpn, providers.Surfshark: if *w.EndpointPort != 0 { return errors.New("endpoint port is set") @@ -91,7 +92,8 @@ func (w WireguardSelection) validate(vpnProvider string) (err error) { // Validate PublicKey switch vpnProvider { - case providers.Fastestvpn, providers.Ivpn, providers.Mullvad, + case providers.Azirevpn, providers.Fastestvpn, + providers.Ivpn, providers.Mullvad, providers.Surfshark, providers.Windscribe: // public keys are baked in case providers.Custom: diff --git a/internal/constants/providers/providers.go b/internal/constants/providers/providers.go index b8fdb51f4..be0a09a6c 100644 --- a/internal/constants/providers/providers.go +++ b/internal/constants/providers/providers.go @@ -4,6 +4,7 @@ const ( // Custom is the VPN provider name for custom // VPN configurations. Airvpn = "airvpn" + Azirevpn = "azirevpn" Custom = "custom" Cyberghost = "cyberghost" Example = "example" @@ -34,6 +35,7 @@ const ( func All() []string { return []string{ Airvpn, + Azirevpn, Cyberghost, Expressvpn, Fastestvpn, diff --git a/internal/models/markdown.go b/internal/models/markdown.go index 27d6b2745..ccb3d8ab3 100644 --- a/internal/models/markdown.go +++ b/internal/models/markdown.go @@ -115,6 +115,8 @@ func getMarkdownHeaders(vpnProvider string) (headers []string, err error) { regionHeader, countryHeader, cityHeader, vpnHeader, udpHeader, tcpHeader, hostnameHeader, nameHeader, }, nil + case providers.Azirevpn: + return []string{countryHeader, cityHeader, hostnameHeader, vpnHeader, ownedHeader, portForwardHeader}, nil case providers.Cyberghost: return []string{countryHeader, hostnameHeader, tcpHeader, udpHeader}, nil case providers.Expressvpn: diff --git a/internal/provider/azirevpn/api.go b/internal/provider/azirevpn/api.go new file mode 100644 index 000000000..0dea2c7e1 --- /dev/null +++ b/internal/provider/azirevpn/api.go @@ -0,0 +1,272 @@ +package azirevpn + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const apiBaseURL = "https://api.azirevpn.com/v3" + +type apiHTTPStatusError struct { + statusCode int + status string + body string +} + +func (e *apiHTTPStatusError) Error() string { + return fmt.Sprintf("HTTP status code not OK: %d %s: %s", + e.statusCode, e.status, e.body) +} + +func (e *apiHTTPStatusError) StatusCode() int { + return e.statusCode +} + +func (e *apiHTTPStatusError) Body() string { + return e.body +} + +func statusCodeOf(err error) (statusCode int, ok bool) { + var statusErr *apiHTTPStatusError + if !errors.As(err, &statusErr) { + return 0, false + } + return statusErr.statusCode, true +} + +type responseEnvelope struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Locations json.RawMessage `json:"locations,omitempty"` +} + +type location struct { + Name string `json:"name"` + City string `json:"city"` + Country string `json:"country"` + ISO string `json:"iso"` + Pool string `json:"pool"` + PubKey string `json:"pubkey"` +} + +type ipData struct { + ID string `json:"id"` + IPv4Address string `json:"ipv4_address"` + IPv4Netmask int `json:"ipv4_netmask"` + IPv6Address string `json:"ipv6_address"` + IPv6Netmask int `json:"ipv6_netmask"` + DNS []string `json:"dns"` + DeviceName string `json:"device_name"` + Keys []ipKey `json:"keys"` +} + +type ipKey struct { + Key string `json:"key"` + CreatedAt int64 `json:"created_at"` +} + +type portForwardData struct { + InternalIPv4 string `json:"internal_ipv4"` + InternalIPv6 string `json:"internal_ipv6"` + Ports []portForward `json:"ports,omitempty"` + Port *uint16 `json:"port,omitempty"` + Hidden bool `json:"hidden"` + ExpiresAt int64 `json:"expires_at"` +} + +type portForward struct { + Port uint16 `json:"port"` + Hidden bool `json:"hidden"` + ExpiresAt int64 `json:"expires_at"` +} + +type persistedData struct { + InternalIPv4 string `json:"internal_ipv4,omitempty"` + Port uint16 `json:"port,omitempty"` + PortExpiresAt int64 `json:"port_expires_at,omitempty"` +} + +func readPersistedData(dataPath string) (data persistedData, err error) { + file, err := os.Open(dataPath) + if os.IsNotExist(err) { + return data, nil + } else if err != nil { + return data, err + } + + decoder := json.NewDecoder(file) + if err := decoder.Decode(&data); err != nil { + _ = file.Close() + return data, err + } + + return data, file.Close() +} + +func writePersistedData(dataPath string, data persistedData) (err error) { + const permission = fs.FileMode(0o600) + file, err := os.OpenFile(dataPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, permission) + if err != nil { + return err + } + + encoder := json.NewEncoder(file) + if err := encoder.Encode(data); err != nil { + _ = file.Close() + return err + } + + return file.Close() +} + +func (p *Provider) doAPIRequest(ctx context.Context, client *http.Client, + method, path string, query url.Values, requestBody any, responseData any, +) (err error) { + if p.token == "" { + return fmt.Errorf("AZIREVPN_TOKEN is required") + } + + requestURL, err := url.Parse(apiBaseURL + path) + if err != nil { + return fmt.Errorf("parsing URL: %w", err) + } + if query != nil { + requestURL.RawQuery = query.Encode() + } + + var bodyReader io.Reader + if requestBody != nil { + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + bodyReader = bytes.NewReader(bodyBytes) + } + + const timeout = 10 * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), bodyReader) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + request.Header.Set("Authorization", "Bearer "+p.token) + if requestBody != nil { + request.Header.Set("Content-Type", "application/json") + } + + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("doing request: %w", err) + } + defer response.Body.Close() + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return &apiHTTPStatusError{ + statusCode: response.StatusCode, + status: response.Status, + body: strings.TrimSpace(string(responseBytes)), + } + } + + if responseData == nil || len(responseBytes) == 0 { + return nil + } + + if err := json.Unmarshal(responseBytes, responseData); err != nil { + return fmt.Errorf("decoding response body: %w", err) + } + + return nil +} + +func (p *Provider) listPortForwardings(ctx context.Context, + client *http.Client, internalIPv4 string, +) (data portForwardData, err error) { + query := make(url.Values) + query.Set("internal_ipv4", internalIPv4) + + var envelope responseEnvelope + err = p.doAPIRequest(ctx, client, http.MethodGet, "/portforwardings", query, nil, &envelope) + if err != nil { + return data, err + } + + err = json.Unmarshal(envelope.Data, &data) + if err != nil { + return data, fmt.Errorf("decoding port forwarding data: %w", err) + } + + return data, nil +} + +func (p *Provider) createPortForwarding(ctx context.Context, + client *http.Client, internalIPv4 string, +) (data portForwardData, err error) { + requestBody := map[string]string{"internal_ipv4": internalIPv4} + + var envelope responseEnvelope + err = p.doAPIRequest(ctx, client, http.MethodPost, "/portforwardings", nil, requestBody, &envelope) + if err != nil { + return data, err + } + + err = json.Unmarshal(envelope.Data, &data) + if err != nil { + return data, fmt.Errorf("decoding created port forwarding data: %w", err) + } + + return data, nil +} + +func (p *Provider) renewPortForwarding(ctx context.Context, + client *http.Client, internalIPv4 string, port uint16, +) (data portForwardData, err error) { + requestBody := map[string]any{ + "internal_ipv4": internalIPv4, + "port": port, + "expires_in": 365, + } + + var envelope responseEnvelope + err = p.doAPIRequest(ctx, client, http.MethodPut, "/portforwardings", nil, requestBody, &envelope) + if err != nil { + return data, err + } + + err = json.Unmarshal(envelope.Data, &data) + if err != nil { + return data, fmt.Errorf("decoding renewed port forwarding data: %w", err) + } + + return data, nil +} + +func (p *Provider) deletePortForwarding(ctx context.Context, + client *http.Client, internalIPv4 string, port uint16, +) (err error) { + requestBody := map[string]any{ + "internal_ipv4": internalIPv4, + "port": port, + } + + return p.doAPIRequest(ctx, client, http.MethodDelete, + "/portforwardings", nil, requestBody, nil) +} diff --git a/internal/provider/azirevpn/connection.go b/internal/provider/azirevpn/connection.go new file mode 100644 index 000000000..58970edef --- /dev/null +++ b/internal/provider/azirevpn/connection.go @@ -0,0 +1,15 @@ +package azirevpn + +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(0, 0, 51820) + return utils.GetConnection(p.Name(), + p.storage, selection, defaults, ipv6Supported, p.connPicker) +} diff --git a/internal/provider/azirevpn/openvpnconf.go b/internal/provider/azirevpn/openvpnconf.go new file mode 100644 index 000000000..18907b9d3 --- /dev/null +++ b/internal/provider/azirevpn/openvpnconf.go @@ -0,0 +1,12 @@ +package azirevpn + +import ( + "github.com/qdm12/gluetun/internal/configuration/settings" + "github.com/qdm12/gluetun/internal/models" +) + +func (p *Provider) OpenVPNConfig(_ models.Connection, + _ settings.OpenVPN, _ bool, +) (lines []string) { + return nil +} diff --git a/internal/provider/azirevpn/portforward.go b/internal/provider/azirevpn/portforward.go new file mode 100644 index 000000000..a5b1cddd8 --- /dev/null +++ b/internal/provider/azirevpn/portforward.go @@ -0,0 +1,278 @@ +package azirevpn + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/netip" + "strconv" + "strings" + "time" + + "github.com/qdm12/gluetun/internal/provider/utils" +) + +var ( + ErrPortForwardingNotFound = errors.New("port forwarding not found") +) + +func (p *Provider) PortForward(ctx context.Context, + objects utils.PortForwardObjects, +) (internalToExternalPorts map[uint16]uint16, err error) { + persisted, err := readPersistedData(p.dataPath) + if err != nil { + return nil, fmt.Errorf("reading persisted azirevpn state: %w", err) + } + + internalIPv4, err := determineInternalIPv4(persisted, objects.InternalIP) + if err != nil { + return nil, err + } + persisted.InternalIPv4 = internalIPv4 + + portForwardingData, err := p.listPortForwardings(ctx, objects.Client, internalIPv4) + if err != nil { + statusCode, hasStatusCode := statusCodeOf(err) + if !(hasStatusCode && statusCode == http.StatusNotFound) { + return nil, fmt.Errorf("listing port forwardings: %w", err) + } + objects.Logger.Info("fetching existing port forwards, got []") + objects.Logger.Debug("no existing azirevpn port forwarding found, creating one") + } else { + objects.Logger.Info("fetching existing port forwards, got " + formatPortsForLog(portForwardingData.Ports)) + } + + nowUnix := time.Now().Unix() + persistedPortIsActive := persisted.Port != 0 && persisted.PortExpiresAt > nowUnix + if persistedPortIsActive { + for _, apiPort := range portForwardingData.Ports { + if apiPort.Port == persisted.Port && apiPort.ExpiresAt > nowUnix { + persisted.PortExpiresAt = apiPort.ExpiresAt + err = writePersistedData(p.dataPath, persisted) + if err != nil { + return nil, fmt.Errorf("persisting azirevpn state: %w", err) + } + objects.Logger.Info(fmt.Sprintf("reusing existing forwarded port: %d", persisted.Port)) + return map[uint16]uint16{persisted.Port: persisted.Port}, nil + } + } + } + + for _, apiPort := range portForwardingData.Ports { + if apiPort.ExpiresAt > nowUnix { + persisted.Port = apiPort.Port + persisted.PortExpiresAt = apiPort.ExpiresAt + err = writePersistedData(p.dataPath, persisted) + if err != nil { + return nil, fmt.Errorf("persisting azirevpn state: %w", err) + } + objects.Logger.Info(fmt.Sprintf("reusing existing forwarded port: %d", persisted.Port)) + return map[uint16]uint16{persisted.Port: persisted.Port}, nil + } + } + + const maxAttempts = 5 + const retryDelay = 3 * time.Minute + var created portForwardData + for attempt := 1; attempt <= maxAttempts; attempt++ { + created, err = p.createPortForwarding(ctx, objects.Client, internalIPv4) + if err == nil { + break + } + + if isCreatePortForwardingDailyLimitReachedError(err) { + persisted.Port = 0 + persisted.PortExpiresAt = 0 + persistErr := writePersistedData(p.dataPath, persisted) + if persistErr != nil { + return nil, fmt.Errorf("persisting azirevpn state: %w", persistErr) + } + objects.Logger.Warn("azirevpn API daily creation limit reached, continuing without port forwarding for now") + return nil, nil //nolint:nilnil + } + + statusCode, hasStatusCode := statusCodeOf(err) + if !(hasStatusCode && statusCode == http.StatusTooManyRequests) { + return nil, fmt.Errorf("creating port forwarding: %w", err) + } + + if attempt == maxAttempts { + return nil, fmt.Errorf("azirevpn API rate limit reached while creating port forwarding after %d attempts: %w", + maxAttempts, err) + } + + objects.Logger.Warn(fmt.Sprintf("azirevpn API rate limit reached while creating port forwarding (attempt %d/%d), retrying in %s", + attempt, maxAttempts, retryDelay)) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryDelay): + } + } + + if created.Port == nil { + return nil, errors.New("port forwarding API did not return assigned port") + } + + persisted.Port = *created.Port + persisted.PortExpiresAt = created.ExpiresAt + err = writePersistedData(p.dataPath, persisted) + if err != nil { + return nil, fmt.Errorf("persisting azirevpn state: %w", err) + } + + return []uint16{persisted.Port}, nil +} + +func determineInternalIPv4(persisted persistedData, + assignedIP netip.Addr, +) (internalIPv4 string, err error) { + if persisted.InternalIPv4 != "" { + return persisted.InternalIPv4, nil + } + + if !assignedIP.IsValid() { + return "", errors.New("internal VPN IP address is not valid") + } + if assignedIP.Is6() { + return "", errors.New("internal VPN IPv4 address is required for azirevpn port forwarding") + } + + return assignedIP.String(), nil +} + +func (p *Provider) KeepPortForward(ctx context.Context, + objects utils.PortForwardObjects, +) (err error) { + persisted, err := readPersistedData(p.dataPath) + if err != nil { + return fmt.Errorf("reading persisted azirevpn state: %w", err) + } + + if persisted.Port == 0 { + objects.Logger.Info("no azirevpn forwarded port to maintain") + <-ctx.Done() + return ctx.Err() + } + + internalIPv4, err := determineInternalIPv4(persisted, objects.InternalIP) + if err != nil { + return err + } + + const checkPeriod = 15 * time.Minute + checkTicker := time.NewTicker(checkPeriod) + defer checkTicker.Stop() + + const renewPeriod = 30 * 24 * time.Hour + renewTicker := time.NewTicker(renewPeriod) + defer renewTicker.Stop() + + for { + select { + case <-ctx.Done(): + cleanupErr := p.cleanupOnStop(objects.Client, internalIPv4, persisted.Port, objects.Logger) + if cleanupErr != nil { + objects.Logger.Warn("cleanup on stop failed: " + cleanupErr.Error()) + } + return ctx.Err() + case <-checkTicker.C: + err = p.checkPortForwarding(ctx, objects.Client, internalIPv4, persisted.Port) + if err != nil { + statusCode, hasStatusCode := statusCodeOf(err) + if hasStatusCode && statusCode >= http.StatusBadRequest && + statusCode < http.StatusInternalServerError && statusCode != http.StatusTooManyRequests { + return fmt.Errorf("checking port forwarding: %w", err) + } + if hasStatusCode && statusCode == http.StatusTooManyRequests { + objects.Logger.Warn("azirevpn API rate limit reached while checking port forwarding, retrying on next interval") + continue + } + objects.Logger.Warn("transient error while checking port forwarding: " + err.Error()) + continue + } + objects.Logger.Debug(fmt.Sprintf("port %d still active", persisted.Port)) + case <-renewTicker.C: + data, renewErr := p.renewPortForwarding(ctx, objects.Client, internalIPv4, persisted.Port) + if renewErr != nil { + objects.Logger.Warn("failed renewing port forwarding, continuing with existing lease: " + renewErr.Error()) + continue + } + if data.ExpiresAt != 0 { + persisted.PortExpiresAt = data.ExpiresAt + persistErr := writePersistedData(p.dataPath, persisted) + if persistErr != nil { + objects.Logger.Warn("failed persisting renewed port forwarding expiry: " + persistErr.Error()) + } + } + objects.Logger.Debug(fmt.Sprintf("renewed port %d for 365 days", persisted.Port)) + } + } +} + +func (p *Provider) checkPortForwarding(ctx context.Context, + client *http.Client, internalIPv4 string, expectedPort uint16, +) (err error) { + data, err := p.listPortForwardings(ctx, client, internalIPv4) + if err != nil { + return err + } + + nowUnix := time.Now().Unix() + for _, apiPort := range data.Ports { + if apiPort.Port == expectedPort && apiPort.ExpiresAt > nowUnix { + return nil + } + } + + return fmt.Errorf("%w: expected %d", ErrPortForwardingNotFound, expectedPort) +} + +func (p *Provider) cleanupOnStop(client *http.Client, + internalIPv4 string, port uint16, + logger utils.Logger, +) (err error) { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err = p.deletePortForwarding(cleanupCtx, client, internalIPv4, port) + if err != nil { + logger.Warn("failed to delete azirevpn port forwarding: " + err.Error()) + } + + persistErr := writePersistedData(p.dataPath, persistedData{}) + if persistErr != nil { + logger.Warn("failed to clear azirevpn persisted state: " + persistErr.Error()) + } + + return nil +} + +func formatPortsForLog(apiPorts []portForward) (s string) { + if len(apiPorts) == 0 { + return "[]" + } + + ports := make([]string, len(apiPorts)) + for i, apiPort := range apiPorts { + ports[i] = strconv.FormatUint(uint64(apiPort.Port), 10) + } + + return "[" + strings.Join(ports, ", ") + "]" +} + +func isCreatePortForwardingDailyLimitReachedError(err error) bool { + var statusErr *apiHTTPStatusError + if !errors.As(err, &statusErr) { + return false + } + + if statusErr.StatusCode() != http.StatusNotAcceptable { + return false + } + + body := strings.ToLower(statusErr.Body()) + return strings.Contains(body, "todays limit reached") +} diff --git a/internal/provider/azirevpn/provider.go b/internal/provider/azirevpn/provider.go new file mode 100644 index 000000000..37b57850f --- /dev/null +++ b/internal/provider/azirevpn/provider.go @@ -0,0 +1,39 @@ +package azirevpn + +import ( + "net/http" + + "github.com/qdm12/gluetun/internal/constants/providers" + "github.com/qdm12/gluetun/internal/provider/azirevpn/updater" + "github.com/qdm12/gluetun/internal/provider/common" + "github.com/qdm12/gluetun/internal/provider/utils" +) + +type Provider struct { + storage common.Storage + connPicker *utils.ConnectionPicker + common.Fetcher + + client *http.Client + token string + + dataPath string +} + +func New(storage common.Storage, client *http.Client, + updaterWarner common.Warner, token string, +) *Provider { + const jsonDataPath = "/tmp/gluetun/azirevpn_data.json" + return &Provider{ + storage: storage, + connPicker: utils.NewConnectionPicker(), + Fetcher: updater.New(client, updaterWarner, token), + client: client, + token: token, + dataPath: jsonDataPath, + } +} + +func (p *Provider) Name() string { + return providers.Azirevpn +} diff --git a/internal/provider/azirevpn/updater/servers.go b/internal/provider/azirevpn/updater/servers.go new file mode 100644 index 000000000..67ffdaf58 --- /dev/null +++ b/internal/provider/azirevpn/updater/servers.go @@ -0,0 +1,140 @@ +package updater + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/netip" + "sort" + "time" + + "github.com/qdm12/gluetun/internal/constants/vpn" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/common" +) + +const locationsURL = "https://api.azirevpn.com/v3/locations" + +type locationsResponse struct { + Status string `json:"status"` + Locations []location `json:"locations"` +} + +type location struct { + Name string `json:"name"` + City string `json:"city"` + Country string `json:"country"` + ISO string `json:"iso"` + Pool string `json:"pool"` + PubKey string `json:"pubkey"` +} + +func (u *Updater) FetchServers(ctx context.Context, minServers int) ( + servers []models.Server, err error, +) { + locations, err := u.fetchLocations(ctx) + if err != nil { + return nil, fmt.Errorf("fetching locations: %w", err) + } + + servers = make([]models.Server, 0, len(locations)) + for _, location := range locations { + hostname := location.Pool + if hostname == "" { + u.warner.Warn("location has empty pool, skipping location " + location.Name) + continue + } + + ips, resolveErr := resolveIPs(ctx, hostname) + if resolveErr != nil { + u.warner.Warn("resolving host " + hostname + ": " + resolveErr.Error()) + continue + } + + server := models.Server{ + VPN: vpn.Wireguard, + Country: location.Country, + City: location.City, + ServerName: location.Name, + Hostname: hostname, + WgPubKey: location.PubKey, + Owned: true, + PortForward: true, + IPs: ips, + } + servers = append(servers, server) + } + + 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 +} + +func (u *Updater) fetchLocations(ctx context.Context) ( + locations []location, err error, +) { + const timeout = 15 * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, locationsURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if u.token != "" { + request.Header.Set("Authorization", "Bearer "+u.token) + } + + response, err := u.client.Do(request) + if err != nil { + return nil, fmt.Errorf("doing request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP status code not OK: %d %s", + response.StatusCode, response.Status) + } + + decoder := json.NewDecoder(response.Body) + var responseData locationsResponse + err = decoder.Decode(&responseData) + if err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return responseData.Locations, nil +} + +func resolveIPs(ctx context.Context, hostname string) (ips []netip.Addr, err error) { + lookupIPs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", hostname) + if err != nil { + return nil, err + } + + ipSet := make(map[netip.Addr]struct{}, len(lookupIPs)) + for _, ip := range lookupIPs { + ipSet[ip.Unmap()] = struct{}{} + } + + if len(ipSet) == 0 { + return nil, fmt.Errorf("host %s resolved to no IP addresses", hostname) + } + + ips = make([]netip.Addr, 0, len(ipSet)) + for ip := range ipSet { + ips = append(ips, ip) + } + sort.Slice(ips, func(i, j int) bool { + return ips[i].Compare(ips[j]) < 0 + }) + + return ips, nil +} diff --git a/internal/provider/azirevpn/updater/updater.go b/internal/provider/azirevpn/updater/updater.go new file mode 100644 index 000000000..2c3be60fc --- /dev/null +++ b/internal/provider/azirevpn/updater/updater.go @@ -0,0 +1,21 @@ +package updater + +import ( + "net/http" + + "github.com/qdm12/gluetun/internal/provider/common" +) + +type Updater struct { + client *http.Client + warner common.Warner + token string +} + +func New(client *http.Client, warner common.Warner, token string) *Updater { + return &Updater{ + client: client, + warner: warner, + token: token, + } +} diff --git a/internal/provider/providers.go b/internal/provider/providers.go index 074020966..a02425635 100644 --- a/internal/provider/providers.go +++ b/internal/provider/providers.go @@ -9,6 +9,7 @@ import ( "github.com/qdm12/gluetun/internal/constants/providers" "github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/provider/airvpn" + "github.com/qdm12/gluetun/internal/provider/azirevpn" "github.com/qdm12/gluetun/internal/provider/common" "github.com/qdm12/gluetun/internal/provider/custom" "github.com/qdm12/gluetun/internal/provider/cyberghost" @@ -57,6 +58,7 @@ func NewProviders(storage Storage, timeNow func() time.Time, //nolint:lll providerNameToProvider := map[string]Provider{ providers.Airvpn: airvpn.New(storage, client), + providers.Azirevpn: azirevpn.New(storage, client, updaterWarner, credentials.AzirevpnToken), providers.Custom: custom.New(extractor), providers.Cyberghost: cyberghost.New(storage, updaterWarner, parallelResolver), providers.Expressvpn: expressvpn.New(storage, unzipper, updaterWarner, parallelResolver),