Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 12 additions & 3 deletions internal/configuration/settings/portforward.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +46 to +47

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since cryptostorm appears to support multiple ports, I think it would be nice to support it here. That way that comment can be changed to:

Suggested change
// the corresponding listening port. For Cryptostorm, ListeningPorts[0]
// also specifies the port to request from the forwarding server (30000–65535).
// the corresponding listening port. For VPN providers supporting requesting
// specific ports (Cryptostorm), this is used to request ports.

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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Comment on lines +155 to +162

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the Validate method already takes the vpn provider string as argument, let's just validate that the user sets the ports count to the same number of listening ports, and revert the changes to setDefaults here.
By the way, can a user request a random port instead of a specific port?

}

func (p PortForwarding) String() string {
Expand Down
3 changes: 2 additions & 1 deletion internal/configuration/settings/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}

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
7 changes: 7 additions & 0 deletions internal/portforward/service/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Comment thread
qdm12 marked this conversation as resolved.
case port != internalPort: // auto redirection needed, source and destination ports differ
destinationPort = port
default:
Expand Down
15 changes: 15 additions & 0 deletions internal/provider/cryptostorm/connection.go
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 22 additions & 0 deletions internal/provider/cryptostorm/openvpnconf.go
Original file line number Diff line number Diff line change
@@ -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)
}
264 changes: 264 additions & 0 deletions internal/provider/cryptostorm/portforward.go
Original file line number Diff line number Diff line change
@@ -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:
//
// <input type="hidden" name="delfwd" value="30000">
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",
}
Comment on lines +42 to +45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit just so it's "more" immutable - and then adapt callers to use portForwardURLs()

Suggested change
var portForwardURLs = []string{
"http://10.31.33.7/fwd",
"http://[2001:db8::7]/fwd",
}
func portForwardURLs() []string {
return []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.
Comment on lines +65 to +66

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really see the point of persisting the port then, the user likely would re-use the same VPN_PORT_FORWARDING_LISTENING_PORTS option across restarts

// 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")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems wrong?

Anyway likely remove if we only use objects.ListeningPorts from this point


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.
Comment on lines +89 to +90

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could inject the IPv6 support status to the Provider struct and use it here, to avoid trying a request and logging warnings when IPv6 isn't supported anyway.

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
}
Comment on lines +92 to +102

@qdm12 qdm12 Sep 17, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit do it outside a for loop, this is a bit confusing that index 0 is ipv4 and index 1 is ipv6. Just have two code blocks following each-other, and ideally enable the ipv6 block in an if block if ipv6 is in fact supported.

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,
Comment on lines +123 to +125

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit move this below KeepPortForward and above deletePortFromURL

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return false, fmt.Errorf("port forwarding not supported: no active port forwards found in response")
return false, fmt.Errorf("port forwarding not supported: no active port forwards found in response: %s", bodyStr)

}

// 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can forwardedPort be ever 0 when we call .KeepPortForward? I don't think so 🤔

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()
}
Loading