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),