Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
40df424
feat(cryptostorm): add WireGuard and OpenVPN provider support
Feb 26, 2026
fe98410
fix(docker): remove BUILDPLATFORM default for multi-platform builds
Feb 26, 2026
036b371
fix(cryptostorm): add ListeningPort field to PortForwardObjects
Feb 26, 2026
2842a46
Merge branch 'master' into claude/crazy-brahmagupta
mpatton125 Feb 26, 2026
7a9856f
fix(cryptostorm): add full server list to embedded servers.json
Feb 26, 2026
710228b
Merge branch 'master' into claude/crazy-brahmagupta
mpatton125 Mar 1, 2026
7046396
Merge branch 'master' into claude/crazy-brahmagupta
mpatton125 Mar 2, 2026
7593af3
fix(cryptostorm): address PR review feedback
Mar 9, 2026
bdba18f
fix(cryptostorm): fix port forward response parsing and ListeningPort…
Mar 9, 2026
f262b41
fix(cryptostorm): add empty servers.json entry for cryptostorm
Mar 9, 2026
3f70a4d
feat(cryptostorm): populate servers.json and resolve DNS at connect time
Mar 9, 2026
2c37271
debug(cryptostorm): log port forward response body
Mar 9, 2026
aa86007
fix(cryptostorm): parse port forwards from both HTML and plain text
Mar 9, 2026
2f5ab86
fix(cryptostorm): generate random port when ListeningPort is 0
Mar 9, 2026
edce697
fix(cryptostorm): require VPN_PORT_FORWARDING_LISTENING_PORT to be set
Mar 9, 2026
6bb2ee3
feat(cryptostorm): add port persistence and no-op redirect
Mar 10, 2026
24a9f3b
fix(cryptostorm): create parent directory for port forward data file
Mar 10, 2026
bf75180
fix(cryptostorm): restrict port forward regex to 30000-65535 range
Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion internal/configuration/settings/portforward.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ type PortForwarding struct {
DownCommand *string `json:"down_command"`
// ListeningPort is the port traffic would be redirected to from the
// forwarded port. The redirection is disabled if it is set to 0, which
// is its default as well.
// is its default as well. For Cryptostorm, this also specifies the
// port to request for forwarding (must be between 30000 and 65535).
ListeningPort *uint16 `json:"listening_port"`
// Username is only used for Private Internet Access port forwarding.
Username string `json:"username"`
Expand All @@ -58,6 +59,7 @@ func (p PortForwarding) Validate(vpnProvider string) (err error) {
providerSelected = *p.Provider
}
validProviders := []string{
providers.Cryptostorm,
providers.Perfectprivacy,
providers.PrivateInternetAccess,
providers.Privatevpn,
Expand Down
1 change: 1 addition & 0 deletions internal/configuration/settings/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func (p *Provider) validate(vpnType string, filterChoicesGetter FilterChoicesGet
} else { // Wireguard
validNames = []string{
providers.Airvpn,
providers.Cryptostorm,
providers.Custom,
providers.Fastestvpn,
providers.Ivpn,
Expand Down
1 change: 1 addition & 0 deletions internal/configuration/settings/wireguard.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ var regexpInterfaceName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
func (w Wireguard) validate(vpnProvider string, ipv6Supported bool) (err error) {
if !helpers.IsOneOf(vpnProvider,
providers.Airvpn,
providers.Cryptostorm,
providers.Custom,
providers.Fastestvpn,
providers.Ivpn,
Expand Down
2 changes: 2 additions & 0 deletions internal/constants/providers/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -34,6 +35,7 @@ const (
func All() []string {
return []string{
Airvpn,
Cryptostorm,
Cyberghost,
Expressvpn,
Fastestvpn,
Expand Down
3 changes: 2 additions & 1 deletion internal/portforward/service/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error)
CanPortForward: s.settings.CanPortForward,
Username: s.settings.Username,
Password: s.settings.Password,
ListeningPort: s.settings.ListeningPort,
}
ports, err := s.settings.PortForwarder.PortForward(ctx, obj)
if err != nil {
Expand All @@ -55,7 +56,7 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error)
return nil, fmt.Errorf("allowing port in firewall: %w", err)
}

if s.settings.ListeningPort != 0 {
if s.settings.ListeningPort != 0 && port != s.settings.ListeningPort {
err = s.portAllower.RedirectPort(ctx, s.settings.Interface, port, s.settings.ListeningPort)
if err != nil {
return nil, fmt.Errorf("redirecting port in firewall: %w", err)
Expand Down
88 changes: 88 additions & 0 deletions internal/provider/cryptostorm/connection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cryptostorm

import (
"context"
"fmt"
"math/rand"
"net"
"time"

"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gluetun/internal/models"
)

// GetConnection selects a server matching the given criteria and resolves
// its hostname via DNS at connection time, rather than relying on
// pre-resolved IP addresses in the server list.
func (p *Provider) GetConnection(selection settings.ServerSelection, ipv6Supported bool) (
connection models.Connection, err error,
) {
servers, err := p.storage.FilterServers(p.Name(), selection)
if err != nil {
return connection, fmt.Errorf("filtering servers: %w", err)
}

// Pick a random server from the filtered list.
server := servers[rand.New(p.randSource).Intn(len(servers))] //nolint:gosec

// Resolve the hostname at connection time.
const resolveTimeout = 10 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
defer cancel()

network := "ip4"
if ipv6Supported {
network = "ip"
}
ips, err := net.DefaultResolver.LookupNetIP(ctx, network, server.Hostname)
if err != nil {
return connection, fmt.Errorf("resolving %s: %w", server.Hostname, err)
}
if len(ips) == 0 {
return connection, fmt.Errorf("no IP addresses found for %s", server.Hostname)
}
ip := ips[rand.New(p.randSource).Intn(len(ips))] //nolint:gosec

// Determine protocol.
protocol := constants.UDP
if selection.VPN == vpn.OpenVPN && selection.OpenVPN.Protocol == constants.TCP {
protocol = constants.TCP
}

// Determine port (cryptostorm accepts any port 1-65535, default 443).
const defaultPort uint16 = 443 //nolint:mnd
port := defaultPort
switch selection.VPN {
case vpn.Wireguard:
if custom := *selection.Wireguard.EndpointPort; custom > 0 {
port = custom
}
default: // OpenVPN
if custom := *selection.OpenVPN.CustomPort; custom > 0 {
port = custom
}
}

// Allow explicit endpoint IP override.
switch selection.VPN {
case vpn.OpenVPN:
if t := selection.OpenVPN.EndpointIP; t.IsValid() && !t.IsUnspecified() {
ip = t
}
case vpn.Wireguard:
if t := selection.Wireguard.EndpointIP; t.IsValid() && !t.IsUnspecified() {
ip = t
}
}

return models.Connection{
Type: selection.VPN,
IP: ip,
Port: port,
Protocol: protocol,
Hostname: server.Hostname,
PubKey: server.WgPubKey,
}, nil
}
23 changes: 23 additions & 0 deletions internal/provider/cryptostorm/openvpnconf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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",
TLSCipher: "TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256:TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384",
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)
}
175 changes: 175 additions & 0 deletions internal/provider/cryptostorm/portforward.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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/common"
"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])`

// regexForwardPlainText matches plain text responses (e.g. from curl):
//
// 37.120.234.253:55555 -> 10.10.123.139:55555
var regexForwardPlainText = regexp.MustCompile(
`\d+\.\d+\.\d+\.\d+:` + portRangePattern + `\s*->\s*\d+\.\d+\.\d+\.\d+:\d+`)

// regexForwardHTML matches the HTML response from the port forwarding page.
// Each forwarded port has a hidden delete input:
//
// <input type="hidden" name="delfwd" value="30000">
var regexForwardHTML = regexp.MustCompile(
`name="delfwd"\s+value="` + portRangePattern + `"`)

// 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) (
ports []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_PORT if set.
// 2. Otherwise try to read a previously persisted port.
// 3. Otherwise return an error (Cryptostorm does not auto-assign ports).
listeningPort := objects.ListeningPort
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("%w: set VPN_PORT_FORWARDING_LISTENING_PORT to a value between 30000 and 65535",
common.ErrPortForwardNotSupported)
}

postBody := "port=" + strconv.FormatUint(uint64(listeningPort), 10)

// IPv4: http://10.31.33.7/fwd
// IPv6: http://[2001:db8::7]/fwd (for future use)
const portForwardURL = "http://10.31.33.7/fwd"
request, err := http.NewRequestWithContext(ctx, http.MethodPost, portForwardURL,
strings.NewReader(postBody))
if err != nil {
return nil, fmt.Errorf("creating HTTP request: %w", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

response, err := objects.Client.Do(request)
if err != nil {
return nil, fmt.Errorf("sending HTTP request: %w", err)
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d %s", common.ErrHTTPStatusCodeNotOK,
response.StatusCode, response.Status)
}

body, err := io.ReadAll(response.Body)
if err != nil {
return nil, 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 nil, fmt.Errorf("%w: no active port forwards found in response",
common.ErrPortForwardNotSupported)
}

const base, bitSize = 10, 16
for _, match := range matches {
portUint64, err := strconv.ParseUint(match[1], base, bitSize)
if err != nil {
return nil, fmt.Errorf("parsing port number %q: %w", match[1], err)
}
ports = append(ports, uint16(portUint64))
}

// Persist the resulting ports for future restarts.
if err := writePortForwardData(p.portForwardPath, portForwardData{Ports: ports}); err != nil {
return nil, fmt.Errorf("persisting port forward data: %w", err)
}

return ports, nil
}

func (p *Provider) KeepPortForward(ctx context.Context,
_ utils.PortForwardObjects,
) (err error) {
// Cryptostorm port assignments persist for the session; no keepalive needed.
<-ctx.Done()
return ctx.Err()
}

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()
}
34 changes: 34 additions & 0 deletions internal/provider/cryptostorm/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cryptostorm

import (
"math/rand"
"net/http"

"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gluetun/internal/provider/common"
"github.com/qdm12/gluetun/internal/provider/cryptostorm/updater"
)

type Provider struct {
storage common.Storage
randSource rand.Source
portForwardPath string
common.Fetcher
}

func New(storage common.Storage, randSource rand.Source,
client *http.Client, updaterWarner common.Warner,
parallelResolver common.ParallelResolver,
) *Provider {
const jsonPortForwardPath = "/gluetun/portforward/cryptostorm.json"
return &Provider{
storage: storage,
randSource: randSource,
portForwardPath: jsonPortForwardPath,
Fetcher: updater.New(client, updaterWarner, parallelResolver),
}
}

func (p *Provider) Name() string {
return providers.Cryptostorm
}
Loading