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).
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)
}

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
}
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",
}

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