diff --git a/internal/configuration/settings/portforward.go b/internal/configuration/settings/portforward.go index 71850b758..63f15ae43 100644 --- a/internal/configuration/settings/portforward.go +++ b/internal/configuration/settings/portforward.go @@ -43,7 +43,8 @@ type PortForwarding struct { // forwarded ports. The redirection is disabled if it is the slice [0], // which is its default as well. If set and not [0], its length must match // the PortsCount value, such that each forwarded port is redirected to - // the corresponding listening port. + // the corresponding listening port. For Cryptostorm, ListeningPorts[0] + // also specifies the port to request from the forwarding server (30000–65535). ListeningPorts []uint16 `json:"listening_port"` // PortsCount is the number of ports to forward. It is optional for ProtonVPN // and be between 1 and 5. For other providers, it must be set to 1 if port @@ -66,6 +67,7 @@ func (p PortForwarding) Validate(vpnProvider string) (err error) { providerSelected = *p.Provider } validProviders := []string{ + providers.Cryptostorm, providers.Perfectprivacy, providers.PrivateInternetAccess, providers.Privatevpn, @@ -143,14 +145,21 @@ func (p *PortForwarding) OverrideWith(other PortForwarding) { p.Password = gosettings.OverrideWithComparable(p.Password, other.Password) } -func (p *PortForwarding) setDefaults() { +func (p *PortForwarding) setDefaults(vpnProvider string) { p.Enabled = gosettings.DefaultPointer(p.Enabled, false) p.Provider = gosettings.DefaultPointer(p.Provider, "") p.Filepath = gosettings.DefaultPointer(p.Filepath, "/tmp/gluetun/forwarded_port") p.UpCommand = gosettings.DefaultPointer(p.UpCommand, "") p.DownCommand = gosettings.DefaultPointer(p.DownCommand, "") p.ListeningPorts = gosettings.DefaultSlice(p.ListeningPorts, []uint16{0}) // disabled - p.PortsCount = gosettings.DefaultComparable(p.PortsCount, 1) + // For Cryptostorm, default PortsCount to the number of listening ports + // specified, since each listening port maps directly to a requested port. + defaultPortsCount := uint16(1) + if vpnProvider == providers.Cryptostorm && + len(p.ListeningPorts) > 0 && p.ListeningPorts[0] != 0 { + defaultPortsCount = uint16(len(p.ListeningPorts)) + } + p.PortsCount = gosettings.DefaultComparable(p.PortsCount, defaultPortsCount) } func (p PortForwarding) String() string { diff --git a/internal/configuration/settings/provider.go b/internal/configuration/settings/provider.go index ccc3acac3..01a7083de 100644 --- a/internal/configuration/settings/provider.go +++ b/internal/configuration/settings/provider.go @@ -44,6 +44,7 @@ func (p *Provider) validate(vpnType string, filterChoicesGetter FilterChoicesGet case vpn.Wireguard: validNames = []string{ providers.Airvpn, + providers.Cryptostorm, providers.Custom, providers.Fastestvpn, providers.Ivpn, @@ -87,7 +88,7 @@ func (p *Provider) overrideWith(other Provider) { func (p *Provider) setDefaults() { p.Name = gosettings.DefaultComparable(p.Name, providers.PrivateInternetAccess) - p.PortForwarding.setDefaults() + p.PortForwarding.setDefaults(p.Name) p.ServerSelection.setDefaults(p.Name, *p.PortForwarding.Enabled) } diff --git a/internal/constants/providers/providers.go b/internal/constants/providers/providers.go index b8fdb51f4..e0877d706 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" + Cryptostorm = "cryptostorm" Custom = "custom" Cyberghost = "cyberghost" Example = "example" @@ -34,6 +35,7 @@ const ( func All() []string { return []string{ Airvpn, + Cryptostorm, Cyberghost, Expressvpn, Fastestvpn, diff --git a/internal/portforward/service/start.go b/internal/portforward/service/start.go index 7ee6875a4..035a08678 100644 --- a/internal/portforward/service/start.go +++ b/internal/portforward/service/start.go @@ -44,6 +44,10 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error) Username: s.settings.Username, Password: s.settings.Password, PortsCount: s.settings.PortsCount, + // ListeningPorts is only used for Cryptostorm to request specific ports + // from the forwarding server; for other providers, it is only used to + // redirect forwarded ports to different local ports. + ListeningPorts: s.settings.ListeningPorts, } internalToExternalPorts, err := s.settings.PortForwarder.PortForward(ctx, obj) if err != nil { @@ -109,6 +113,9 @@ func (s *Service) onNewPorts(ctx context.Context, internalToExternalPorts map[ui switch { case userRedirectionEnabled: // precedence over auto redirection destinationPort = s.settings.ListeningPorts[i] + if internalPort == destinationPort { + continue // no-op; avoid self-redirect in firewall + } case port != internalPort: // auto redirection needed, source and destination ports differ destinationPort = port default: diff --git a/internal/provider/cryptostorm/connection.go b/internal/provider/cryptostorm/connection.go new file mode 100644 index 000000000..c3553da47 --- /dev/null +++ b/internal/provider/cryptostorm/connection.go @@ -0,0 +1,15 @@ +package cryptostorm + +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, 443, 443) //nolint:mnd + return utils.GetConnection(p.Name(), + p.storage, selection, defaults, ipv6Supported, p.connPicker) +} diff --git a/internal/provider/cryptostorm/openvpnconf.go b/internal/provider/cryptostorm/openvpnconf.go new file mode 100644 index 000000000..b7e0c10df --- /dev/null +++ b/internal/provider/cryptostorm/openvpnconf.go @@ -0,0 +1,22 @@ +package cryptostorm + +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{ + RemoteCertTLS: true, + AuthUserPass: true, + Ciphers: []string{openvpn.AES256gcm}, + VerifyX509Type: "name", + TLSCrypt: "4875d729589689955012a2ee77f180ecb815c4a336c719c11241a058dafaae00806bbc21d5f1abad085341a3fca4b4f93949151c2979b4ee4390e8d9443acb0061d537f1e9157e45f542c3648f56330505f3eaff97ef82ee063b9d88bb9d5aa0060428455b51a2a4fd929d9af4b94adcb0a4acaa14ff62a9b0f4f9f0b3f01e71fc98a6c60e8584f4deb3de793a5a7bc27014c9369f9724bc810ef0d191b3020478eead725b3ae6aaef2e1030a197e417421f159ed54eb2629afcfb337cf9a0025bf1d5c0d820fffb219d0b4214043d2df27ed367b522945a5dadc748e2ca379e3971789dbdf609b3d9bfe866361b28e3c90589baa925157ad833093a5a7bede5", //nolint:lll + CAs: []string{"MIICCzCCAW2gAwIBAgIUMRTTJ6nuPjmSxaRfbw5f+dZ9d/gwCgYIKoZIzj0EAwQwGTEXMBUGA1UEAwwOY3J5cHRvc3Rvcm0gQ0EwHhcNMTgwOTE3MjAwODU4WhcNMzgwOTE3MjAwODU4WjAZMRcwFQYDVQQDDA5jcnlwdG9zdG9ybSBDQTCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEARKu20PBrr226TP6mQQGtzCqQqBKfGaA05Ml5nrGSV6wzBQDQga4/cPepGrE/tpzRX72KSfZD6nJfQLYen7kdc3PAEvWFBhCovq7e4L6xJ5qV5aMf89QjNhJ/xn//dlxE8Z6UfIx63dJX9q3EHNxateU84lDkbCrqckkckcZF4C1a9Ooo1AwTjAdBgNVHQ4EFgQUdaVDaoi48Yf2RugXqJ4yJ4Z4utgwHwYDVR0jBBgwFoAUdaVDaoi48Yf2RugXqJ4yJ4Z4utgwDAYDVR0TBAUwAwEB/zAKBggqhkjOPQQDBAOBiwAwgYcCQVcCw/8OVpNqltDYczqHmX4sMRsZTY0iIzl1rYY/0/ZPIvzjlMFnouHwb8asJZRMBNECq7u9PCbG3jdu6lYtcCm+AkIB3IYYKuXLKW7ucdttNODBqH2Rail+9oBWTV2ZFKVVwELlKadHx9UvAcpAaV1alkN80CgI2tad2/qVdpSIQpfVvTI="}, //nolint:lll + } + return utils.OpenVPNConfig(providerSettings, connection, settings, ipv6Supported) +} diff --git a/internal/provider/cryptostorm/portforward.go b/internal/provider/cryptostorm/portforward.go new file mode 100644 index 000000000..7821c6bfa --- /dev/null +++ b/internal/provider/cryptostorm/portforward.go @@ -0,0 +1,264 @@ +package cryptostorm + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/qdm12/gluetun/internal/provider/utils" +) + +// portRangePattern matches the valid Cryptostorm port range 30000-65535. +const portRangePattern = `([3-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])` + +// ipPattern matches either an IPv4 address or a bracketed IPv6 address. +const ipPattern = `(?:\d+\.\d+\.\d+\.\d+|\[[0-9a-fA-F:]+\])` + +// regexForwardPlainText matches plain text responses (e.g. from curl): +// +// 37.120.234.253:55555 -> 10.10.123.139:55555 +// [2400:a842:c46e:1::4a]:55555 -> [fd00:10:10::e0e0]:55555 +var regexForwardPlainText = regexp.MustCompile( + ipPattern + `:` + portRangePattern + `\s*->\s*` + ipPattern + `:\d+`) + +// regexForwardHTML matches the HTML response from the port forwarding page. +// Each forwarded port has a hidden delete input: +// +// +var regexForwardHTML = regexp.MustCompile( + `name="delfwd"\s+value="` + portRangePattern + `"`) + +// portForwardURLs lists all port forwarding endpoints. +// IPv4 and IPv6 must both be registered for dual-stack support. +var portForwardURLs = []string{ + "http://10.31.33.7/fwd", + "http://[2001:db8::7]/fwd", +} + +// portForwardData is the data persisted to the port forward JSON file. +type portForwardData struct { + Ports []uint16 `json:"ports"` +} + +// PortForward registers a forwarded port with the Cryptostorm port forwarding server +// and returns the active forwarded ports. The server returns plain text listing +// current forwardings. We POST the desired port and parse the response. +// Valid port range is 30000-65535. +// See: https://cryptostorm.is/portfwd +func (p *Provider) PortForward(ctx context.Context, objects utils.PortForwardObjects) ( + internalToExternalPorts map[uint16]uint16, err error, +) { + const timeout = 10 * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Determine the port to request: + // 1. Use VPN_PORT_FORWARDING_LISTENING_PORTS[0] if set (non-zero). + // 2. Otherwise try to read a previously persisted port. + // 3. Otherwise return an error (Cryptostorm does not auto-assign ports). + var listeningPort uint16 + if len(objects.ListeningPorts) > 0 && objects.ListeningPorts[0] != 0 { + listeningPort = objects.ListeningPorts[0] + } + if listeningPort == 0 { + data, err := readPortForwardData(p.portForwardPath) + if err != nil { + return nil, fmt.Errorf("reading persisted port forward data: %w", err) + } + if len(data.Ports) > 0 { + listeningPort = data.Ports[0] + } + } + + if listeningPort == 0 { + return nil, fmt.Errorf("port forwarding not supported: set VPN_PORT_FORWARDING_LISTENING_PORTS to a value between 30000 and 65535") + } + + postBody := "port=" + strconv.FormatUint(uint64(listeningPort), 10) + + // Register the port with both IPv4 and IPv6 endpoints. + // The IPv6 endpoint may not be reachable if the tunnel has no IPv6, + // so we log a warning rather than failing hard. + requestedFound := false + for _, url := range portForwardURLs { + found, err := p.registerPort(ctx, objects.Client, url, postBody, listeningPort, objects) + if err != nil { + if url == portForwardURLs[0] { + // IPv4 failure is fatal. + return nil, err + } + // IPv6 failure is a warning only. + objects.Logger.Warn("IPv6 port forward registration failed (" + url + "): " + err.Error()) + continue + } + if found { + requestedFound = true + } + } + + if !requestedFound { + return nil, fmt.Errorf("port forwarding not supported: requested port %d not found in server response", listeningPort) + } + + p.forwardedPort = listeningPort + internalToExternalPorts = map[uint16]uint16{listeningPort: listeningPort} + + // Persist so the next restart can reuse this port without re-requesting. + if err := writePortForwardData(p.portForwardPath, portForwardData{Ports: []uint16{listeningPort}}); err != nil { + return nil, fmt.Errorf("persisting port forward data: %w", err) + } + + return internalToExternalPorts, nil +} + +// registerPort POSTs the port to a single forwarding endpoint and returns +// whether the requested port was confirmed in the response. +func (p *Provider) registerPort(ctx context.Context, client *http.Client, + url, postBody string, listeningPort uint16, objects utils.PortForwardObjects, +) (requestedFound bool, err error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, + strings.NewReader(postBody)) + if err != nil { + return false, fmt.Errorf("creating HTTP request: %w", err) + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + response, err := client.Do(request) + if err != nil { + return false, fmt.Errorf("sending HTTP request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return false, fmt.Errorf("HTTP status code not OK: %d %s", + response.StatusCode, response.Status) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return false, fmt.Errorf("reading response body: %w", err) + } + + // Parse forwarded ports from the response. The server returns HTML to + // Go's HTTP client but plain text to curl, so we try both formats. + bodyStr := string(body) + matches := regexForwardPlainText.FindAllStringSubmatch(bodyStr, -1) + if len(matches) == 0 { + matches = regexForwardHTML.FindAllStringSubmatch(bodyStr, -1) + } + if len(matches) == 0 { + return false, fmt.Errorf("port forwarding not supported: no active port forwards found in response") + } + + // The server response lists all currently active forwards for this session, + // which may include stale ports from prior runs. Delete any that are not + // the one we requested, then verify ours is present. + const base, bitSize = 10, 16 + for _, match := range matches { + portUint64, err := strconv.ParseUint(match[1], base, bitSize) + if err != nil { + return false, fmt.Errorf("parsing port number %q: %w", match[1], err) + } + port := uint16(portUint64) + if port == listeningPort { + requestedFound = true + continue + } + // Best-effort delete; log but don't fail if it errors. + if err := p.deletePortFromURL(ctx, client, url, port); err != nil { + objects.Logger.Warn("deleting stale port forward " + + strconv.FormatUint(uint64(port), 10) + " from " + url + ": " + err.Error()) + } + } + + return requestedFound, nil +} + +func (p *Provider) KeepPortForward(ctx context.Context, + objects utils.PortForwardObjects, +) (err error) { + // Cryptostorm port assignments persist for the session; no keepalive needed. + <-ctx.Done() + + // Best-effort deregister on teardown. Use a fresh context since the + // original is already cancelled. + if p.forwardedPort != 0 { + const deleteTimeout = 5 * time.Second + deleteCtx, cancel := context.WithTimeout(context.Background(), deleteTimeout) + defer cancel() + for _, url := range portForwardURLs { + if err := p.deletePortFromURL(deleteCtx, objects.Client, url, p.forwardedPort); err != nil { + objects.Logger.Warn("deregistering port forward on teardown from " + url + ": " + err.Error()) + } + } + p.forwardedPort = 0 + } + + return ctx.Err() +} + +func (p *Provider) deletePortFromURL(ctx context.Context, client *http.Client, url string, port uint16) error { + body := strings.NewReader("delfwd=" + strconv.FormatUint(uint64(port), 10)) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return fmt.Errorf("creating delete request: %w", err) + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("sending delete request: %w", err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP status code not OK: %d %s", + response.StatusCode, response.Status) + } + return nil +} + +func readPortForwardData(path string) (data portForwardData, err error) { + file, err := os.Open(path) + 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 writePortForwardData(path string, data portForwardData) (err error) { + const dirPermission = fs.FileMode(0o755) + if err := os.MkdirAll(filepath.Dir(path), dirPermission); err != nil { + return err + } + + const permission = fs.FileMode(0o644) + file, err := os.OpenFile(path, 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() +} diff --git a/internal/provider/cryptostorm/provider.go b/internal/provider/cryptostorm/provider.go new file mode 100644 index 000000000..8d00fe41d --- /dev/null +++ b/internal/provider/cryptostorm/provider.go @@ -0,0 +1,32 @@ +package cryptostorm + +import ( + "net/http" + + "github.com/qdm12/gluetun/internal/constants/providers" + "github.com/qdm12/gluetun/internal/provider/common" + "github.com/qdm12/gluetun/internal/provider/cryptostorm/updater" + "github.com/qdm12/gluetun/internal/provider/utils" +) + +type Provider struct { + storage common.Storage + connPicker *utils.ConnectionPicker + portForwardPath string + forwardedPort uint16 // set after a successful PortForward, used for teardown + common.Fetcher +} + +func New(storage common.Storage, client *http.Client) *Provider { + const jsonPortForwardPath = "/gluetun/portforward/cryptostorm.json" + return &Provider{ + storage: storage, + connPicker: utils.NewConnectionPicker(), + portForwardPath: jsonPortForwardPath, + Fetcher: updater.New(client), + } +} + +func (p *Provider) Name() string { + return providers.Cryptostorm +} diff --git a/internal/provider/cryptostorm/updater/api.go b/internal/provider/cryptostorm/updater/api.go new file mode 100644 index 000000000..fde02e0ee --- /dev/null +++ b/internal/provider/cryptostorm/updater/api.go @@ -0,0 +1,106 @@ +package updater + +import ( + "context" + "fmt" + "net/http" + "strings" + + htmlutils "github.com/qdm12/gluetun/internal/updater/html" + "golang.org/x/net/html" +) + +// nodeData represents one entry parsed from the Cryptostorm wireguard page. +type nodeData struct { + Location string // e.g. "Canada - Montreal", "Austria", "US - Texas - Dallas" + Hostname string // e.g. "austria.cstorm.is" + WgPubKey string +} + +// fetchNodes retrieves and parses the Cryptostorm node list from their +// wireguard page at https://cryptostorm.is/wireguard. +func fetchNodes(ctx context.Context, client *http.Client) ( + nodes []nodeData, warnings []string, err error) { + const url = "https://cryptostorm.is/wireguard" + + rootNode, err := htmlutils.Fetch(ctx, client, url) + if err != nil { + return nil, nil, fmt.Errorf("fetching HTML: %w", err) + } + + nodes, warnings, err = parseHTML(rootNode) + if err != nil { + return nil, nil, fmt.Errorf("parsing HTML: %w", err) + } + return nodes, warnings, nil +} + +func parseHTML(rootNode *html.Node) (nodes []nodeData, + warnings []string, err error) { + tableNode := htmlutils.BFS(rootNode, htmlutils.MatchData("table")) + if tableNode == nil { + return nil, nil, fmt.Errorf("HTML server table not found") + } + + // html.Parse inserts per the HTML5 spec, so elements + // are not direct children of . + tbody := htmlutils.DirectChild(tableNode, htmlutils.MatchData("tbody")) + rowParent := tableNode + if tbody != nil { + rowParent = tbody + } + rows := htmlutils.DirectChildren(rowParent, htmlutils.MatchData("tr")) + nodes = make([]nodeData, 0, len(rows)-1) + for _, row := range rows[1:] { // skip header row + cells := htmlutils.DirectChildren(row, htmlutils.MatchData("td")) + const expectedCells = 3 + if len(cells) != expectedCells { + warnings = append(warnings, + htmlutils.WrapWarning(fmt.Sprintf("expected %d cells but got %d", + expectedCells, len(cells)), row)) + continue + } + + location := strings.TrimSpace(textContent(cells[0])) + // Remove non-breaking spaces left over from   entities. + location = strings.ReplaceAll(location, "\u00a0", "") + location = strings.TrimSpace(location) + hostname := strings.TrimSpace(textContent(cells[1])) + wgPubKey := strings.TrimSpace(textContent(cells[2])) + + switch { + case location == "": + warnings = append(warnings, + htmlutils.WrapWarning("empty location", row)) + continue + case hostname == "": + warnings = append(warnings, + htmlutils.WrapWarning("empty hostname", row)) + continue + } + + nodes = append(nodes, nodeData{ + Location: location, + Hostname: hostname, + WgPubKey: wgPubKey, + }) + } + + return nodes, warnings, nil +} + +// textContent returns the concatenated text content of a node and +// all its descendants, similar to the DOM's textContent property. +func textContent(node *html.Node) string { + if node == nil { + return "" + } + if node.Type == html.TextNode { + return node.Data + } + var sb strings.Builder + for child := node.FirstChild; child != nil; child = child.NextSibling { + sb.WriteString(textContent(child)) + } + return sb.String() +} diff --git a/internal/provider/cryptostorm/updater/hosttoserver.go b/internal/provider/cryptostorm/updater/hosttoserver.go new file mode 100644 index 000000000..05c6bafc4 --- /dev/null +++ b/internal/provider/cryptostorm/updater/hosttoserver.go @@ -0,0 +1,54 @@ +package updater + +import ( + "net/netip" + "strings" + + "github.com/qdm12/gluetun/internal/models" +) + +// hostToServer maps composite keys (hostname/protocol) to servers. +// The hostname portion is used for DNS resolution. +type hostToServer map[string]models.Server + +func (hts hostToServer) toUniqueHostsSlice() (hosts []string) { + seen := make(map[string]struct{}, len(hts)) + hosts = make([]string, 0, len(hts)) + for key := range hts { + host := extractHost(key) + if _, ok := seen[host]; !ok { + seen[host] = struct{}{} + hosts = append(hosts, host) + } + } + return hosts +} + +func (hts hostToServer) adaptWithIPs(hostToIPs map[string][]netip.Addr) { + for key, server := range hts { + host := extractHost(key) + ips, ok := hostToIPs[host] + if !ok || len(ips) == 0 { + delete(hts, key) + continue + } + server.IPs = ips + hts[key] = server + } +} + +func (hts hostToServer) toServersSlice() (servers []models.Server) { + servers = make([]models.Server, 0, len(hts)) + for _, server := range hts { + servers = append(servers, server) + } + return servers +} + +// extractHost returns the hostname from a composite key like "host.example.com/wg". +func extractHost(key string) string { + if idx := strings.LastIndex(key, "/"); idx >= 0 { + return key[:idx] + } + return key +} diff --git a/internal/provider/cryptostorm/updater/servers.go b/internal/provider/cryptostorm/updater/servers.go new file mode 100644 index 000000000..9585f41bd --- /dev/null +++ b/internal/provider/cryptostorm/updater/servers.go @@ -0,0 +1,128 @@ +package updater + +import ( + "context" + "fmt" + "net" + "net/netip" + "sort" + "strings" + + "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, +) { + nodes, _, err := fetchNodes(ctx, u.client) + if err != nil { + return nil, fmt.Errorf("fetching nodes: %w", err) + } + + hts := make(hostToServer) + + for _, node := range nodes { + country, city := parseLocation(node.Location) + + // WireGuard server entry (only if public key is available). + if node.WgPubKey != "" { + server := models.Server{ + VPN: vpn.Wireguard, + Country: country, + City: city, + Hostname: node.Hostname, + WgPubKey: node.WgPubKey, + PortForward: true, + } + hts[node.Hostname+"/wg"] = server + } + + // OpenVPN server entry. + // Derive x509 name from hostname: "newyork.cstorm.is" -> "cryptostorm newyork server" + location := strings.Split(node.Hostname, ".")[0] + ovpnX509 := "cryptostorm " + location + " server" + openvpnServer := models.Server{ + VPN: vpn.OpenVPN, + Country: country, + City: city, + Hostname: node.Hostname, + OvpnX509: ovpnX509, + TCP: true, + UDP: true, + PortForward: true, + } + hts[node.Hostname+"/ovpn"] = openvpnServer + } + + hosts := hts.toUniqueHostsSlice() + + hostToIPs, err := resolveHosts(ctx, hosts) + if err != nil { + return nil, fmt.Errorf("resolving hosts: %w", err) + } + + hts.adaptWithIPs(hostToIPs) + + servers = hts.toServersSlice() + + if len(servers) < minServers { + return nil, fmt.Errorf("%w: %d and expected at least %d", + common.ErrNotEnoughServers, len(servers), minServers) + } + + sort.Sort(models.SortableServers(servers)) + + return servers, nil +} + +// resolveHosts resolves a list of hostnames to their IP addresses. +func resolveHosts(ctx context.Context, hosts []string) (hostToIPs map[string][]netip.Addr, err error) { + hostToIPs = make(map[string][]netip.Addr, len(hosts)) + for _, host := range hosts { + addrs, resolveErr := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if resolveErr != nil { + // Non-fatal: skip hosts that fail to resolve + continue + } + hostToIPs[host] = addrs + } + return hostToIPs, nil +} + +// parseLocation splits a location string like "Canada - Montreal" into +// country and city. It handles these formats from cryptostorm: +// - "Austria" (country only) +// - "Canada - Montreal" (country - city) +// - "US - Texas - Dallas" (country - state - city) +// - "Sydney - Australia" (city - country, detected by known country names) +func parseLocation(location string) (country, city string) { + parts := strings.Split(location, " - ") + switch len(parts) { + case 1: + return parts[0], "" + case 2: + // Check if the second part is a known country name, indicating + // a reversed "City - Country" format (e.g. "Sydney - Australia"). + if isCountryName(parts[1]) && !isCountryName(parts[0]) { + return parts[1], parts[0] + } + return parts[0], parts[1] + default: + // "US - Texas - Dallas" -> country: first part, city: last part + return parts[0], parts[len(parts)-1] + } +} + +// isCountryName returns true if the string matches a known country name +// that appears in the cryptostorm server list in a potentially reversed +// "City - Country" format. +func isCountryName(s string) bool { + switch s { + case "Australia", "Japan": + return true + default: + return false + } +} diff --git a/internal/provider/cryptostorm/updater/updater.go b/internal/provider/cryptostorm/updater/updater.go new file mode 100644 index 000000000..cb6799094 --- /dev/null +++ b/internal/provider/cryptostorm/updater/updater.go @@ -0,0 +1,13 @@ +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 074020966..7c12e5cf2 100644 --- a/internal/provider/providers.go +++ b/internal/provider/providers.go @@ -10,6 +10,7 @@ import ( "github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/provider/airvpn" "github.com/qdm12/gluetun/internal/provider/common" + "github.com/qdm12/gluetun/internal/provider/cryptostorm" "github.com/qdm12/gluetun/internal/provider/custom" "github.com/qdm12/gluetun/internal/provider/cyberghost" "github.com/qdm12/gluetun/internal/provider/expressvpn" @@ -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.Cryptostorm: cryptostorm.New(storage, client), providers.Custom: custom.New(extractor), providers.Cyberghost: cyberghost.New(storage, updaterWarner, parallelResolver), providers.Expressvpn: expressvpn.New(storage, unzipper, updaterWarner, parallelResolver), diff --git a/internal/provider/utils/portforward.go b/internal/provider/utils/portforward.go index 6f03646f8..ba5f27610 100644 --- a/internal/provider/utils/portforward.go +++ b/internal/provider/utils/portforward.go @@ -27,6 +27,10 @@ type PortForwardObjects struct { Password string // PortsCount is used by ProtonVPN for port forwarding. PortsCount uint16 + // ListeningPorts are the user-specified ports. For Cryptostorm, ListeningPorts[0] + // is the port to request from the forwarding server (must be 30000–65535). + // A nil or [0] value means fall back to the persisted port. + ListeningPorts []uint16 } type Routing interface { diff --git a/internal/storage/hardcoded.go b/internal/storage/hardcoded.go index af75c5496..1157fd9c0 100644 --- a/internal/storage/hardcoded.go +++ b/internal/storage/hardcoded.go @@ -20,7 +20,14 @@ func parseHardcodedServers() (allServers models.AllServers) { filename := provider + ".json" providerFile, err := serversmodule.Files.Open(filename) if err != nil { - panic(fmt.Sprintf("reading embedded provider file %s for %s: %s", filename, provider, err)) + // Provider not yet in the gluetun-servers module; start with an empty list. + // The updater can still populate servers at runtime. + const serversPath = "/gluetun/servers/" + allServers.ProviderToServers[provider] = models.Servers{ + Version: 1, + Filepath: filepath.Join(serversPath, filename), + } + continue } defer providerFile.Close() // no-op