Compare commits

..

3 Commits

Author SHA1 Message Date
Quentin McGaw
be935e70e6 Fix lint error 2025-11-07 21:54:30 +00:00
Quentin McGaw
5ca13021e7 feat(netlink): detect IPv6 using query to address
- If a default IPv6 route is found, query the ip:port defined by `IPV6_CHECK_ADDRESS` to check for internet access
2025-11-07 21:50:58 +00:00
Quentin McGaw
dae44051f6 feat(netlink): detect ipv6 support level
- 'supported' if one ipv6 route is found that is not loopback and not a default route
- 'internet' if one default ipv6 route is found
2025-11-07 21:50:58 +00:00
82 changed files with 4637 additions and 8621 deletions

View File

@@ -93,9 +93,6 @@ jobs:
- name: Run Gluetun container with Mullvad configuration - name: Run Gluetun container with Mullvad configuration
run: echo -e "${{ secrets.MULLVAD_WIREGUARD_PRIVATE_KEY }}\n${{ secrets.MULLVAD_WIREGUARD_ADDRESS }}" | ./ci/runner mullvad run: echo -e "${{ secrets.MULLVAD_WIREGUARD_PRIVATE_KEY }}\n${{ secrets.MULLVAD_WIREGUARD_ADDRESS }}" | ./ci/runner mullvad
- name: Run Gluetun container with ProtonVPN configuration
run: echo -e "${{ secrets.PROTONVPN_WIREGUARD_PRIVATE_KEY }}" | ./ci/runner protonvpn
codeql: codeql:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -121,7 +118,7 @@ jobs:
github.event_name == 'release' || github.event_name == 'release' ||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')
) )
needs: [verify, verify-private, codeql] needs: [verify, codeql]
permissions: permissions:
actions: read actions: read
contents: read contents: read

View File

@@ -20,7 +20,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: DavidAnson/markdownlint-cli2-action@v21 - uses: DavidAnson/markdownlint-cli2-action@v20
with: with:
globs: "**.md" globs: "**.md"
config: .markdownlint-cli2.jsonc config: .markdownlint-cli2.jsonc

View File

@@ -56,9 +56,6 @@ linters:
- revive - revive
path: internal\/provider\/(common|utils)\/.+\.go path: internal\/provider\/(common|utils)\/.+\.go
text: "var-naming: avoid (bad|meaningless) package names" text: "var-naming: avoid (bad|meaningless) package names"
- linters:
- lll
source: "^// https://.+$"
- linters: - linters:
- err113 - err113
- mnd - mnd

View File

@@ -159,27 +159,27 @@ ENV VPN_SERVICE_PROVIDER=pia \
FIREWALL_INPUT_PORTS= \ FIREWALL_INPUT_PORTS= \
FIREWALL_OUTBOUND_SUBNETS= \ FIREWALL_OUTBOUND_SUBNETS= \
FIREWALL_DEBUG=off \ FIREWALL_DEBUG=off \
# IPv6
IPV6_CHECK_ADDRESS=[2606:4700::6810:84e5]:443 \
# Logging # Logging
LOG_LEVEL=info \ LOG_LEVEL=info \
# Health # Health
HEALTH_SERVER_ADDRESS=127.0.0.1:9999 \ HEALTH_SERVER_ADDRESS=127.0.0.1:9999 \
HEALTH_TARGET_ADDRESSES=cloudflare.com:443,github.com:443 \ HEALTH_TARGET_ADDRESS=cloudflare.com:443 \
HEALTH_ICMP_TARGET_IPS=1.1.1.1,8.8.8.8 \ HEALTH_ICMP_TARGET_IP=1.1.1.1 \
HEALTH_SMALL_CHECK_TYPE=icmp \
HEALTH_RESTART_VPN=on \ HEALTH_RESTART_VPN=on \
# DNS # DNS
DNS_SERVER=on \ DNS_SERVER=on \
DNS_UPSTREAM_RESOLVER_TYPE=DoT \ DNS_UPSTREAM_RESOLVER_TYPE=DoT \
DNS_UPSTREAM_RESOLVERS=cloudflare \ DNS_UPSTREAM_RESOLVERS=cloudflare \
DNS_BLOCK_IPS= \ DNS_BLOCK_IPS= \
DNS_BLOCK_IP_PREFIXES= \ DNS_BLOCK_IP_PREFIXES=127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1/128,fc00::/7,fe80::/10,::ffff:7f00:1/104,::ffff:a00:0/104,::ffff:a9fe:0/112,::ffff:ac10:0/108,::ffff:c0a8:0/112 \
DNS_CACHING=on \ DNS_CACHING=on \
DNS_UPSTREAM_IPV6=off \ DNS_UPSTREAM_IPV6=off \
BLOCK_MALICIOUS=on \ BLOCK_MALICIOUS=on \
BLOCK_SURVEILLANCE=off \ BLOCK_SURVEILLANCE=off \
BLOCK_ADS=off \ BLOCK_ADS=off \
DNS_UNBLOCK_HOSTNAMES= \ DNS_UNBLOCK_HOSTNAMES= \
DNS_REBINDING_PROTECTION_EXEMPT_HOSTNAMES= \
DNS_UPDATE_PERIOD=24h \ DNS_UPDATE_PERIOD=24h \
DNS_ADDRESS=127.0.0.1 \ DNS_ADDRESS=127.0.0.1 \
DNS_KEEP_NAMESERVER=off \ DNS_KEEP_NAMESERVER=off \
@@ -203,13 +203,10 @@ ENV VPN_SERVICE_PROVIDER=pia \
HTTP_CONTROL_SERVER_LOG=on \ HTTP_CONTROL_SERVER_LOG=on \
HTTP_CONTROL_SERVER_ADDRESS=":8000" \ HTTP_CONTROL_SERVER_ADDRESS=":8000" \
HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH=/gluetun/auth/config.toml \ HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH=/gluetun/auth/config.toml \
HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE="{}" \
# Server data updater # Server data updater
UPDATER_PERIOD=0 \ UPDATER_PERIOD=0 \
UPDATER_MIN_RATIO=0.8 \ UPDATER_MIN_RATIO=0.8 \
UPDATER_VPN_SERVICE_PROVIDERS= \ UPDATER_VPN_SERVICE_PROVIDERS= \
UPDATER_PROTONVPN_EMAIL= \
UPDATER_PROTONVPN_PASSWORD= \
# Public IP # Public IP
PUBLICIP_FILE="/tmp/gluetun/ip" \ PUBLICIP_FILE="/tmp/gluetun/ip" \
PUBLICIP_ENABLED=on \ PUBLICIP_ENABLED=on \
@@ -225,8 +222,8 @@ ENV VPN_SERVICE_PROVIDER=pia \
# Extras # Extras
VERSION_INFORMATION=on \ VERSION_INFORMATION=on \
TZ= \ TZ= \
PUID=1000 \ PUID= \
PGID=1000 PGID=
ENTRYPOINT ["/gluetun-entrypoint"] ENTRYPOINT ["/gluetun-entrypoint"]
EXPOSE 8000/tcp 8888/tcp 8388/tcp 8388/udp EXPOSE 8000/tcp 8888/tcp 8388/tcp 8388/udp
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=3 CMD /gluetun-entrypoint healthcheck HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=3 CMD /gluetun-entrypoint healthcheck

View File

@@ -1,7 +1,5 @@
# Gluetun VPN client # Gluetun VPN client
⚠️ This and [gluetun-wiki](https://github.com/qdm12/gluetun-wiki) are the only websites for Gluetun, other websites claiming to be official are scams ⚠️
Lightweight swiss-army-knife-like VPN client to multiple VPN service providers Lightweight swiss-army-knife-like VPN client to multiple VPN service providers
![Title image](https://raw.githubusercontent.com/qdm12/gluetun/master/title.svg) ![Title image](https://raw.githubusercontent.com/qdm12/gluetun/master/title.svg)

View File

@@ -21,8 +21,6 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "mullvad": case "mullvad":
err = internal.MullvadTest(ctx) err = internal.MullvadTest(ctx)
case "protonvpn":
err = internal.ProtonVPNTest(ctx)
default: default:
err = fmt.Errorf("unknown command: %s", os.Args[1]) err = fmt.Errorf("unknown command: %s", os.Args[1])
} }

View File

@@ -1,27 +1,193 @@
package internal package internal
import ( import (
"bufio"
"context" "context"
"fmt" "fmt"
"io"
"os"
"regexp"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
) )
func MullvadTest(ctx context.Context) error { func MullvadTest(ctx context.Context) error {
expectedSecrets := []string{ secrets, err := readSecrets(ctx)
"Wireguard private key",
"Wireguard address",
}
secrets, err := readSecrets(ctx, expectedSecrets)
if err != nil { if err != nil {
return fmt.Errorf("reading secrets: %w", err) return fmt.Errorf("reading secrets: %w", err)
} }
env := []string{ const timeout = 15 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("creating Docker client: %w", err)
}
defer client.Close()
config := &container.Config{
Image: "qmcgaw/gluetun",
StopTimeout: ptrTo(3),
Env: []string{
"VPN_SERVICE_PROVIDER=mullvad", "VPN_SERVICE_PROVIDER=mullvad",
"VPN_TYPE=wireguard", "VPN_TYPE=wireguard",
"LOG_LEVEL=debug", "LOG_LEVEL=debug",
"SERVER_COUNTRIES=USA", "SERVER_COUNTRIES=USA",
"WIREGUARD_PRIVATE_KEY=" + secrets[0], "WIREGUARD_PRIVATE_KEY=" + secrets.mullvadWireguardPrivateKey,
"WIREGUARD_ADDRESSES=" + secrets[1], "WIREGUARD_ADDRESSES=" + secrets.mullvadWireguardAddress,
},
}
hostConfig := &container.HostConfig{
AutoRemove: true,
CapAdd: []string{"NET_ADMIN", "NET_RAW"},
}
networkConfig := (*network.NetworkingConfig)(nil)
platform := (*v1.Platform)(nil)
const containerName = "" // auto-generated name
response, err := client.ContainerCreate(ctx, config, hostConfig, networkConfig, platform, containerName)
if err != nil {
return fmt.Errorf("creating container: %w", err)
}
for _, warning := range response.Warnings {
fmt.Println("Warning during container creation:", warning)
}
containerID := response.ID
defer stopContainer(client, containerID)
beforeStartTime := time.Now()
err = client.ContainerStart(ctx, containerID, container.StartOptions{})
if err != nil {
return fmt.Errorf("starting container: %w", err)
}
return waitForLogLine(ctx, client, containerID, beforeStartTime)
}
func ptrTo[T any](v T) *T { return &v }
type secrets struct {
mullvadWireguardPrivateKey string
mullvadWireguardAddress string
}
func readSecrets(ctx context.Context) (secrets, error) {
expectedSecrets := [...]string{
"Mullvad Wireguard private key",
"Mullvad Wireguard address",
}
scanner := bufio.NewScanner(os.Stdin)
lines := make([]string, 0, len(expectedSecrets))
for i := range expectedSecrets {
fmt.Println("🤫 reading", expectedSecrets[i], "from Stdin...")
if !scanner.Scan() {
break
}
lines = append(lines, strings.TrimSpace(scanner.Text()))
if ctx.Err() != nil {
return secrets{}, ctx.Err()
}
}
if err := scanner.Err(); err != nil {
return secrets{}, fmt.Errorf("reading secrets from stdin: %w", err)
}
if len(lines) < len(expectedSecrets) {
return secrets{}, fmt.Errorf("expected %d secrets via Stdin, but only received %d",
len(expectedSecrets), len(lines))
}
for i, line := range lines {
if line == "" {
return secrets{}, fmt.Errorf("secret on line %d/%d was empty", i+1, len(lines))
}
}
return secrets{
mullvadWireguardPrivateKey: lines[0],
mullvadWireguardAddress: lines[1],
}, nil
}
func stopContainer(client *client.Client, containerID string) {
const stopTimeout = 5 * time.Second // must be higher than 3s, see above [container.Config]'s StopTimeout field
stopCtx, stopCancel := context.WithTimeout(context.Background(), stopTimeout)
defer stopCancel()
err := client.ContainerStop(stopCtx, containerID, container.StopOptions{})
if err != nil {
fmt.Println("failed to stop container:", err)
}
}
var successRegexp = regexp.MustCompile(`^.+Public IP address is .+$`)
func waitForLogLine(ctx context.Context, client *client.Client, containerID string,
beforeStartTime time.Time,
) error {
logOptions := container.LogsOptions{
ShowStdout: true,
Follow: true,
Since: beforeStartTime.Format(time.RFC3339Nano),
}
reader, err := client.ContainerLogs(ctx, containerID, logOptions)
if err != nil {
return fmt.Errorf("error getting container logs: %w", err)
}
defer reader.Close()
var linesSeen []string
scanner := bufio.NewScanner(reader)
for ctx.Err() == nil {
if scanner.Scan() {
line := scanner.Text()
if len(line) > 8 { // remove Docker log prefix
line = line[8:]
}
linesSeen = append(linesSeen, line)
if successRegexp.MatchString(line) {
fmt.Println("✅ Success line logged")
return nil
}
continue
}
err := scanner.Err()
if err != nil && err != io.EOF {
logSeenLines(linesSeen)
return fmt.Errorf("reading log stream: %w", err)
}
// The scanner is either done or cannot read because of EOF
fmt.Println("The log scanner stopped")
logSeenLines(linesSeen)
// Check if the container is still running
inspect, err := client.ContainerInspect(ctx, containerID)
if err != nil {
return fmt.Errorf("inspecting container: %w", err)
}
if !inspect.State.Running {
return fmt.Errorf("container stopped unexpectedly while waiting for log line. Exit code: %d", inspect.State.ExitCode)
}
}
return ctx.Err()
}
func logSeenLines(lines []string) {
fmt.Println("Logs seen so far:")
for _, line := range lines {
fmt.Println(" " + line)
} }
return simpleTest(ctx, env)
} }

View File

@@ -1,25 +0,0 @@
package internal
import (
"context"
"fmt"
)
func ProtonVPNTest(ctx context.Context) error {
expectedSecrets := []string{
"Wireguard private key",
}
secrets, err := readSecrets(ctx, expectedSecrets)
if err != nil {
return fmt.Errorf("reading secrets: %w", err)
}
env := []string{
"VPN_SERVICE_PROVIDER=protonvpn",
"VPN_TYPE=wireguard",
"LOG_LEVEL=debug",
"SERVER_COUNTRIES=United States",
"WIREGUARD_PRIVATE_KEY=" + secrets[0],
}
return simpleTest(ctx, env)
}

View File

@@ -1,42 +0,0 @@
package internal
import (
"bufio"
"context"
"fmt"
"os"
"strings"
)
func readSecrets(ctx context.Context, expectedSecrets []string) (lines []string, err error) {
scanner := bufio.NewScanner(os.Stdin)
lines = make([]string, 0, len(expectedSecrets))
for i := range expectedSecrets {
fmt.Println("🤫 reading", expectedSecrets[i], "from Stdin...")
if !scanner.Scan() {
break
}
lines = append(lines, strings.TrimSpace(scanner.Text()))
fmt.Println("🤫 "+expectedSecrets[i], "secret read successfully")
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading secrets from stdin: %w", err)
}
if len(lines) < len(expectedSecrets) {
return nil, fmt.Errorf("expected %d secrets via Stdin, but only received %d",
len(expectedSecrets), len(lines))
}
for i, line := range lines {
if line == "" {
return nil, fmt.Errorf("secret on line %d/%d was empty", i+1, len(lines))
}
}
return lines, nil
}

View File

@@ -1,134 +0,0 @@
package internal
import (
"bufio"
"context"
"fmt"
"io"
"regexp"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
func ptrTo[T any](v T) *T { return &v }
func simpleTest(ctx context.Context, env []string) error {
const timeout = 30 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("creating Docker client: %w", err)
}
defer client.Close()
config := &container.Config{
Image: "qmcgaw/gluetun",
StopTimeout: ptrTo(3),
Env: env,
}
hostConfig := &container.HostConfig{
AutoRemove: true,
CapAdd: []string{"NET_ADMIN", "NET_RAW"},
}
networkConfig := (*network.NetworkingConfig)(nil)
platform := (*v1.Platform)(nil)
const containerName = "" // auto-generated name
response, err := client.ContainerCreate(ctx, config, hostConfig, networkConfig, platform, containerName)
if err != nil {
return fmt.Errorf("creating container: %w", err)
}
for _, warning := range response.Warnings {
fmt.Println("Warning during container creation:", warning)
}
containerID := response.ID
defer stopContainer(client, containerID)
beforeStartTime := time.Now()
err = client.ContainerStart(ctx, containerID, container.StartOptions{})
if err != nil {
return fmt.Errorf("starting container: %w", err)
}
return waitForLogLine(ctx, client, containerID, beforeStartTime)
}
func stopContainer(client *client.Client, containerID string) {
const stopTimeout = 5 * time.Second // must be higher than 3s, see above [container.Config]'s StopTimeout field
stopCtx, stopCancel := context.WithTimeout(context.Background(), stopTimeout)
defer stopCancel()
err := client.ContainerStop(stopCtx, containerID, container.StopOptions{})
if err != nil {
fmt.Println("failed to stop container:", err)
}
}
var successRegexp = regexp.MustCompile(`^.+Public IP address is .+$`)
func waitForLogLine(ctx context.Context, client *client.Client, containerID string,
beforeStartTime time.Time,
) error {
logOptions := container.LogsOptions{
ShowStdout: true,
Follow: true,
Since: beforeStartTime.Format(time.RFC3339Nano),
}
reader, err := client.ContainerLogs(ctx, containerID, logOptions)
if err != nil {
return fmt.Errorf("error getting container logs: %w", err)
}
defer reader.Close()
var linesSeen []string
scanner := bufio.NewScanner(reader)
for ctx.Err() == nil {
if scanner.Scan() {
line := scanner.Text()
if len(line) > 8 { // remove Docker log prefix
line = line[8:]
}
linesSeen = append(linesSeen, line)
if successRegexp.MatchString(line) {
fmt.Println("✅ Success line logged")
return nil
}
continue
}
err := scanner.Err()
if err != nil && err != io.EOF {
logSeenLines(linesSeen)
return fmt.Errorf("reading log stream: %w", err)
}
// The scanner is either done or cannot read because of EOF
fmt.Println("The log scanner stopped")
logSeenLines(linesSeen)
// Check if the container is still running
inspect, err := client.ContainerInspect(ctx, containerID)
if err != nil {
return fmt.Errorf("inspecting container: %w", err)
}
if !inspect.State.Running {
return fmt.Errorf("container stopped unexpectedly while waiting for log line. Exit code: %d", inspect.State.ExitCode)
}
}
return ctx.Err()
}
func logSeenLines(lines []string) {
fmt.Println("Logs seen so far:")
for _, line := range lines {
fmt.Println(" " + line)
}
}

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"net/netip"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
@@ -164,8 +165,6 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
} }
} }
defer fmt.Println(gluetunLogo)
announcementExp, err := time.Parse(time.RFC3339, "2024-12-01T00:00:00Z") announcementExp, err := time.Parse(time.RFC3339, "2024-12-01T00:00:00Z")
if err != nil { if err != nil {
return err return err
@@ -244,10 +243,13 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
return err return err
} }
ipv6Supported, err := netLinker.IsIPv6Supported() ipv6SupportLevel, err := netLinker.FindIPv6SupportLevel(ctx,
allSettings.IPv6.CheckAddress, firewallConf)
if err != nil { if err != nil {
return fmt.Errorf("checking for IPv6 support: %w", err) return fmt.Errorf("checking for IPv6 support: %w", err)
} }
ipv6Supported := ipv6SupportLevel == netlink.IPv6Supported ||
ipv6SupportLevel == netlink.IPv6Internet
err = allSettings.Validate(storage, ipv6Supported, logger) err = allSettings.Validate(storage, ipv6Supported, logger)
if err != nil { if err != nil {
@@ -429,11 +431,10 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
parallelResolver := resolver.NewParallelResolver(allSettings.Updater.DNSAddress) parallelResolver := resolver.NewParallelResolver(allSettings.Updater.DNSAddress)
openvpnFileExtractor := extract.New() openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, updaterLogger, providers := provider.NewProviders(storage, time.Now, updaterLogger,
httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(), httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(), openvpnFileExtractor)
openvpnFileExtractor, allSettings.Updater)
vpnLogger := logger.New(log.SetComponent("vpn")) vpnLogger := logger.New(log.SetComponent("vpn"))
vpnLooper := vpn.NewLoop(allSettings.VPN, ipv6Supported, allSettings.Firewall.VPNInputPorts, vpnLooper := vpn.NewLoop(allSettings.VPN, ipv6SupportLevel, allSettings.Firewall.VPNInputPorts,
providers, storage, allSettings.Health, healthChecker, healthcheckServer, ovpnConf, netLinker, firewallConf, providers, storage, allSettings.Health, healthChecker, healthcheckServer, ovpnConf, netLinker, firewallConf,
routingConf, portForwardLooper, cmder, publicIPLooper, dnsLooper, vpnLogger, httpClient, routingConf, portForwardLooper, cmder, publicIPLooper, dnsLooper, vpnLogger, httpClient,
buildInfo, *allSettings.Version.Enabled) buildInfo, *allSettings.Version.Enabled)
@@ -469,12 +470,15 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
go shadowsocksLooper.Run(shadowsocksCtx, shadowsocksDone) go shadowsocksLooper.Run(shadowsocksCtx, shadowsocksDone)
otherGroupHandler.Add(shadowsocksHandler) otherGroupHandler.Add(shadowsocksHandler)
controlServerAddress := *allSettings.ControlServer.Address
controlServerLogging := *allSettings.ControlServer.Log
httpServerHandler, httpServerCtx, httpServerDone := goshutdown.NewGoRoutineHandler( httpServerHandler, httpServerCtx, httpServerDone := goshutdown.NewGoRoutineHandler(
"http server", goroutine.OptionTimeout(defaultShutdownTimeout)) "http server", goroutine.OptionTimeout(defaultShutdownTimeout))
httpServer, err := server.New(httpServerCtx, allSettings.ControlServer, httpServer, err := server.New(httpServerCtx, controlServerAddress, controlServerLogging,
logger.New(log.SetComponent("http server")), logger.New(log.SetComponent("http server")),
allSettings.ControlServer.AuthFilePath,
buildInfo, vpnLooper, portForwardLooper, dnsLooper, updaterLooper, publicIPLooper, buildInfo, vpnLooper, portForwardLooper, dnsLooper, updaterLooper, publicIPLooper,
storage, ipv6Supported) storage, ipv6SupportLevel.IsSupported())
if err != nil { if err != nil {
return fmt.Errorf("setting up control server: %w", err) return fmt.Errorf("setting up control server: %w", err)
} }
@@ -550,7 +554,9 @@ type netLinker interface {
Ruler Ruler
Linker Linker
IsWireguardSupported() (ok bool, err error) IsWireguardSupported() (ok bool, err error)
IsIPv6Supported() (ok bool, err error) FindIPv6SupportLevel(ctx context.Context,
checkAddress netip.AddrPort, firewall netlink.Firewall,
) (level netlink.IPv6SupportLevel, err error)
PatchLoggerLevel(level log.Level) PatchLoggerLevel(level log.Level)
} }
@@ -602,34 +608,3 @@ type RunStarter interface {
Start(cmd *exec.Cmd) (stdoutLines, stderrLines <-chan string, Start(cmd *exec.Cmd) (stdoutLines, stderrLines <-chan string,
waitError <-chan error, err error) waitError <-chan error, err error)
} }
const gluetunLogo = ` @@@
@@@@
@@@@@@
@@@@.@@ @@@@@@@@@@
@@@@.@@@ @@@@@@@@==@@@@
@@@.@..@@ @@@@@@@=@..==@@@@
@@@@ @@@.@@.@@ @@@@@@===@@@@.=@@@
@...-@@ @@@@.@@.@@@ @@@ @@@@@@=======@@@=@@@@
@@@@@@@@ @@@.-%@.+@@@@@@@@ @@@@@%============@@@@
@@@.--@..@@@@.-@@@@@@@==============@@@@
@@@@ @@@-@--@@.@@.---@@@@@==============#@@@@@
@@@ @@@.@@-@@.@@--@@@@@===============@@@@@@
@@@@.@--@@@@@@@@@@================@@@@@@@
@@@..--@@*@@@@@@================@@@@+*@@
@@@.---@@.@@@@=================@@@@--@@
@@@-.---@@@@@@================@@@@*--@@@
@@@.:-#@@@@@@===============*@@@@.---@@
@@@.-------.@@@============@@@@@@.--@@@
@@@..--------:@@@=========@@@@@@@@.--@@@
@@@.-@@@@@@@@@@@========@@@@@ @@@.--@@
@@.@@@@===============@@@@@ @@@@@@---@@@@@@
@@@@@@@==============@@@@@@@@@@@@*@---@@@@@@@@
@@@@@@=============@@@@@ @@@...------------.*@@@
@@@@%===========@@@@@@ @@@..------@@@@.-----.-@@@
@@@@@@.=======@@@@@@ @@@.-------@@@@@@-.------=@@
@@@@@@@@@===@@@@@@ @@.------@@@@ @@@@.-----@@@
@@@==@@@=@@@@@@@ @@@.-@@@@@@@ @@@@@@@--@@
@@@@@@@@@@@@@ @@@@@@@@ @@@@@@@
@@@@@@@@ @@@@ @@@@
`

23
go.mod
View File

@@ -3,14 +3,13 @@ module github.com/qdm12/gluetun
go 1.25.0 go 1.25.0
require ( require (
github.com/ProtonMail/go-srp v0.0.7
github.com/breml/rootcerts v0.3.3 github.com/breml/rootcerts v0.3.3
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/golang/mock v1.6.0 github.com/golang/mock v1.6.0
github.com/klauspost/compress v1.18.1 github.com/klauspost/compress v1.18.1
github.com/klauspost/pgzip v1.2.6 github.com/klauspost/pgzip v1.2.6
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20251123213823-54e987293e88 github.com/qdm12/dns/v2 v2.0.0-rc9
github.com/qdm12/gosettings v0.4.4 github.com/qdm12/gosettings v0.4.4
github.com/qdm12/goshutdown v0.3.0 github.com/qdm12/goshutdown v0.3.0
github.com/qdm12/gosplash v0.2.0 github.com/qdm12/gosplash v0.2.0
@@ -22,21 +21,17 @@ require (
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/net v0.47.0 golang.org/x/net v0.46.0
golang.org/x/sys v0.38.0 golang.org/x/sys v0.37.0
golang.org/x/text v0.31.0 golang.org/x/text v0.30.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
gopkg.in/ini.v1 v1.67.0 gopkg.in/ini.v1 v1.67.0
) )
require ( require (
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/go-crypto v1.3.0-proton // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect
github.com/josharian/native v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect
@@ -47,7 +42,6 @@ require (
github.com/mdlayher/socket v0.4.1 // indirect github.com/mdlayher/socket v0.4.1 // indirect
github.com/miekg/dns v1.1.62 // indirect github.com/miekg/dns v1.1.62 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
@@ -56,11 +50,10 @@ require (
github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 // indirect github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/crypto v0.44.0 // indirect golang.org/x/crypto v0.43.0 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.37.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.35.1 // indirect google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

76
go.sum
View File

@@ -1,23 +1,9 @@
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.3.0-proton h1:tAQKQRZX/73VmzK6yHSCaRUOvS/3OYSQzhXQsrR7yUM=
github.com/ProtonMail/go-crypto v1.3.0-proton/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/breml/rootcerts v0.3.3 h1://GnaRtQ/9BY2+GtMk2wtWxVdCRysiaPr5/xBwl7NKw= github.com/breml/rootcerts v0.3.3 h1://GnaRtQ/9BY2+GtMk2wtWxVdCRysiaPr5/xBwl7NKw=
github.com/breml/rootcerts v0.3.3/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= github.com/breml/rootcerts v0.3.3/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -57,8 +43,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
@@ -69,8 +53,8 @@ github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPA
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20251123213823-54e987293e88 h1:GJ5FALvJ3UmHjVaNYebrfV5zF5You4dq8HfRWZy2loM= github.com/qdm12/dns/v2 v2.0.0-rc9 h1:qDzRkHr6993jknNB/ZOCnZOyIG6bsZcl2MIfdeUd0kI=
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20251123213823-54e987293e88/go.mod h1:98foWgXJZ+g8gJIuO+fdO+oWpFei5WShMFTeN4Im2lE= github.com/qdm12/dns/v2 v2.0.0-rc9/go.mod h1:98foWgXJZ+g8gJIuO+fdO+oWpFei5WShMFTeN4Im2lE=
github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 h1:TRGpCU1l0lNwtogEUSs5U+RFceYxkAJUmrGabno7J5c= github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 h1:TRGpCU1l0lNwtogEUSs5U+RFceYxkAJUmrGabno7J5c=
github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978/go.mod h1:D1Po4CRQLYjccnAR2JsVlN1sBMgQrcNLONbvyuzcdTg= github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978/go.mod h1:D1Po4CRQLYjccnAR2JsVlN1sBMgQrcNLONbvyuzcdTg=
github.com/qdm12/gosettings v0.4.4 h1:SM6tOZDf6k8qbjWU8KWyBF4mWIixfsKCfh9DGRLHlj4= github.com/qdm12/gosettings v0.4.4 h1:SM6tOZDf6k8qbjWU8KWyBF4mWIixfsKCfh9DGRLHlj4=
@@ -100,72 +84,48 @@ github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZla
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk= github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk=
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4= github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -0,0 +1,14 @@
package cli
import (
"context"
"net/netip"
)
type noopFirewall struct{}
func (f *noopFirewall) AcceptOutput(_ context.Context, _, _ string, _ netip.Addr,
_ uint16, _ bool,
) (err error) {
return nil
}

View File

@@ -7,4 +7,3 @@ func newNoopLogger() *noopLogger {
} }
func (l *noopLogger) Info(string) {} func (l *noopLogger) Info(string) {}
func (l *noopLogger) Warn(string) {}

View File

@@ -11,6 +11,7 @@ import (
"github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/constants" "github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/netlink"
"github.com/qdm12/gluetun/internal/openvpn/extract" "github.com/qdm12/gluetun/internal/openvpn/extract"
"github.com/qdm12/gluetun/internal/provider" "github.com/qdm12/gluetun/internal/provider"
"github.com/qdm12/gluetun/internal/storage" "github.com/qdm12/gluetun/internal/storage"
@@ -40,7 +41,9 @@ type IPFetcher interface {
} }
type IPv6Checker interface { type IPv6Checker interface {
IsIPv6Supported() (supported bool, err error) FindIPv6SupportLevel(ctx context.Context,
checkAddress netip.AddrPort, firewall netlink.Firewall,
) (level netlink.IPv6SupportLevel, err error)
} }
func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader, func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
@@ -58,12 +61,14 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
} }
allSettings.SetDefaults() allSettings.SetDefaults()
ipv6Supported, err := ipv6Checker.IsIPv6Supported() ipv6SupportLevel, err := ipv6Checker.FindIPv6SupportLevel(context.Background(),
allSettings.IPv6.CheckAddress, &noopFirewall{})
if err != nil { if err != nil {
return fmt.Errorf("checking for IPv6 support: %w", err) return fmt.Errorf("checking for IPv6 support: %w", err)
} }
if err = allSettings.Validate(storage, ipv6Supported, logger); err != nil { err = allSettings.Validate(storage, ipv6SupportLevel.IsSupported(), logger)
if err != nil {
return fmt.Errorf("validating settings: %w", err) return fmt.Errorf("validating settings: %w", err)
} }
@@ -76,16 +81,16 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
openvpnFileExtractor := extract.New() openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, warner, client, providers := provider.NewProviders(storage, time.Now, warner, client,
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, allSettings.Updater) unzipper, parallelResolver, ipFetcher, openvpnFileExtractor)
providerConf := providers.Get(allSettings.VPN.Provider.Name) providerConf := providers.Get(allSettings.VPN.Provider.Name)
connection, err := providerConf.GetConnection( connection, err := providerConf.GetConnection(
allSettings.VPN.Provider.ServerSelection, ipv6Supported) allSettings.VPN.Provider.ServerSelection, ipv6SupportLevel == netlink.IPv6Internet)
if err != nil { if err != nil {
return err return err
} }
lines := providerConf.OpenVPNConfig(connection, lines := providerConf.OpenVPNConfig(connection,
allSettings.VPN.OpenVPN, ipv6Supported) allSettings.VPN.OpenVPN, ipv6SupportLevel.IsSupported())
fmt.Println(strings.Join(lines, "\n")) fmt.Println(strings.Join(lines, "\n"))
return nil return nil

View File

@@ -6,7 +6,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strings" "strings"
"time" "time"
@@ -25,8 +24,6 @@ import (
var ( var (
ErrModeUnspecified = errors.New("at least one of -enduser or -maintainer must be specified") ErrModeUnspecified = errors.New("at least one of -enduser or -maintainer must be specified")
ErrNoProviderSpecified = errors.New("no provider was specified") ErrNoProviderSpecified = errors.New("no provider was specified")
ErrUsernameMissing = errors.New("username is required for this provider")
ErrPasswordMissing = errors.New("password is required for this provider")
) )
type UpdaterLogger interface { type UpdaterLogger interface {
@@ -38,7 +35,7 @@ type UpdaterLogger interface {
func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) error { func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) error {
options := settings.Updater{} options := settings.Updater{}
var endUserMode, maintainerMode, updateAll bool var endUserMode, maintainerMode, updateAll bool
var csvProviders, ipToken, protonUsername, protonEmail, protonPassword string var csvProviders, ipToken string
flagSet := flag.NewFlagSet("update", flag.ExitOnError) flagSet := flag.NewFlagSet("update", flag.ExitOnError)
flagSet.BoolVar(&endUserMode, "enduser", false, "Write results to /gluetun/servers.json (for end users)") flagSet.BoolVar(&endUserMode, "enduser", false, "Write results to /gluetun/servers.json (for end users)")
flagSet.BoolVar(&maintainerMode, "maintainer", false, flagSet.BoolVar(&maintainerMode, "maintainer", false,
@@ -50,10 +47,6 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
flagSet.BoolVar(&updateAll, "all", false, "Update servers for all VPN providers") flagSet.BoolVar(&updateAll, "all", false, "Update servers for all VPN providers")
flagSet.StringVar(&csvProviders, "providers", "", "CSV string of VPN providers to update server data for") flagSet.StringVar(&csvProviders, "providers", "", "CSV string of VPN providers to update server data for")
flagSet.StringVar(&ipToken, "ip-token", "", "IP data service token (e.g. ipinfo.io) to use") flagSet.StringVar(&ipToken, "ip-token", "", "IP data service token (e.g. ipinfo.io) to use")
flagSet.StringVar(&protonUsername, "proton-username", "",
"(Retro-compatibility) Username to use to authenticate with Proton. Use -proton-email instead.") // v4 remove this
flagSet.StringVar(&protonEmail, "proton-email", "", "Email to use to authenticate with Proton")
flagSet.StringVar(&protonPassword, "proton-password", "", "Password to use to authenticate with Proton")
if err := flagSet.Parse(args); err != nil { if err := flagSet.Parse(args); err != nil {
return err return err
} }
@@ -71,16 +64,6 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
options.Providers = strings.Split(csvProviders, ",") options.Providers = strings.Split(csvProviders, ",")
} }
if slices.Contains(options.Providers, providers.Protonvpn) {
if protonEmail == "" && protonUsername != "" {
protonEmail = protonUsername + "@protonmail.com"
logger.Warn("use -proton-email instead of -proton-username in the future. " +
"This assumes the email is " + protonEmail + " and may not work.")
}
options.ProtonEmail = &protonEmail
options.ProtonPassword = &protonPassword
}
options.SetDefaults(options.Providers[0]) options.SetDefaults(options.Providers[0])
err := options.Validate() err := options.Validate()
@@ -88,11 +71,7 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
return fmt.Errorf("options validation failed: %w", err) return fmt.Errorf("options validation failed: %w", err)
} }
serversDataPath := constants.ServersData storage, err := storage.New(logger, constants.ServersData)
if maintainerMode {
serversDataPath = ""
}
storage, err := storage.New(logger, serversDataPath)
if err != nil { if err != nil {
return fmt.Errorf("creating servers storage: %w", err) return fmt.Errorf("creating servers storage: %w", err)
} }
@@ -115,7 +94,7 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
openvpnFileExtractor := extract.New() openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, logger, httpClient, providers := provider.NewProviders(storage, time.Now, logger, httpClient,
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, options) unzipper, parallelResolver, ipFetcher, openvpnFileExtractor)
updater := updater.New(httpClient, storage, providers, logger) updater := updater.New(httpClient, storage, providers, logger)
err = updater.UpdateServers(ctx, options.Providers, options.MinRatio) err = updater.UpdateServers(ctx, options.Providers, options.MinRatio)

View File

@@ -22,9 +22,6 @@ type DNSBlacklist struct {
AddBlockedHosts []string AddBlockedHosts []string
AddBlockedIPs []netip.Addr AddBlockedIPs []netip.Addr
AddBlockedIPPrefixes []netip.Prefix AddBlockedIPPrefixes []netip.Prefix
// RebindingProtectionExemptHostnames is a list of hostnames
// exempt from DNS rebinding protection.
RebindingProtectionExemptHostnames []string
} }
func (b *DNSBlacklist) setDefaults() { func (b *DNSBlacklist) setDefaults() {
@@ -38,7 +35,6 @@ var hostRegex = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9_][a-zA-Z0-9\-_]{0,6
var ( var (
ErrAllowedHostNotValid = errors.New("allowed host is not valid") ErrAllowedHostNotValid = errors.New("allowed host is not valid")
ErrBlockedHostNotValid = errors.New("blocked host is not valid") ErrBlockedHostNotValid = errors.New("blocked host is not valid")
ErrRebindingProtectionExemptHostNotValid = errors.New("rebinding protection exempt host is not valid")
) )
func (b DNSBlacklist) validate() (err error) { func (b DNSBlacklist) validate() (err error) {
@@ -54,12 +50,6 @@ func (b DNSBlacklist) validate() (err error) {
} }
} }
for _, host := range b.RebindingProtectionExemptHostnames {
if !hostRegex.MatchString(host) {
return fmt.Errorf("%w: %s", ErrRebindingProtectionExemptHostNotValid, host)
}
}
return nil return nil
} }
@@ -72,7 +62,6 @@ func (b DNSBlacklist) copy() (copied DNSBlacklist) {
AddBlockedHosts: gosettings.CopySlice(b.AddBlockedHosts), AddBlockedHosts: gosettings.CopySlice(b.AddBlockedHosts),
AddBlockedIPs: gosettings.CopySlice(b.AddBlockedIPs), AddBlockedIPs: gosettings.CopySlice(b.AddBlockedIPs),
AddBlockedIPPrefixes: gosettings.CopySlice(b.AddBlockedIPPrefixes), AddBlockedIPPrefixes: gosettings.CopySlice(b.AddBlockedIPPrefixes),
RebindingProtectionExemptHostnames: gosettings.CopySlice(b.RebindingProtectionExemptHostnames),
} }
} }
@@ -84,8 +73,6 @@ func (b *DNSBlacklist) overrideWith(other DNSBlacklist) {
b.AddBlockedHosts = gosettings.OverrideWithSlice(b.AddBlockedHosts, other.AddBlockedHosts) b.AddBlockedHosts = gosettings.OverrideWithSlice(b.AddBlockedHosts, other.AddBlockedHosts)
b.AddBlockedIPs = gosettings.OverrideWithSlice(b.AddBlockedIPs, other.AddBlockedIPs) b.AddBlockedIPs = gosettings.OverrideWithSlice(b.AddBlockedIPs, other.AddBlockedIPs)
b.AddBlockedIPPrefixes = gosettings.OverrideWithSlice(b.AddBlockedIPPrefixes, other.AddBlockedIPPrefixes) b.AddBlockedIPPrefixes = gosettings.OverrideWithSlice(b.AddBlockedIPPrefixes, other.AddBlockedIPPrefixes)
b.RebindingProtectionExemptHostnames = gosettings.OverrideWithSlice(b.RebindingProtectionExemptHostnames,
other.RebindingProtectionExemptHostnames)
} }
func (b DNSBlacklist) ToBlockBuilderSettings(client *http.Client) ( func (b DNSBlacklist) ToBlockBuilderSettings(client *http.Client) (
@@ -142,13 +129,6 @@ func (b DNSBlacklist) toLinesNode() (node *gotree.Node) {
} }
} }
if len(b.RebindingProtectionExemptHostnames) > 0 {
exemptHostsNode := node.Append("Rebinding protection exempt hostnames:")
for _, host := range b.RebindingProtectionExemptHostnames {
exemptHostsNode.Append(host)
}
}
return node return node
} }
@@ -176,8 +156,6 @@ func (b *DNSBlacklist) read(r *reader.Reader) (err error) {
b.AllowedHosts = r.CSV("DNS_UNBLOCK_HOSTNAMES", reader.RetroKeys("UNBLOCK")) b.AllowedHosts = r.CSV("DNS_UNBLOCK_HOSTNAMES", reader.RetroKeys("UNBLOCK"))
b.RebindingProtectionExemptHostnames = r.CSV("DNS_REBINDING_PROTECTION_EXEMPT_HOSTNAMES")
return nil return nil
} }

View File

@@ -36,8 +36,6 @@ var (
ErrSystemPUIDNotValid = errors.New("process user id is not valid") ErrSystemPUIDNotValid = errors.New("process user id is not valid")
ErrSystemTimezoneNotValid = errors.New("timezone is not valid") ErrSystemTimezoneNotValid = errors.New("timezone is not valid")
ErrUpdaterPeriodTooSmall = errors.New("VPN server data updater period is too small") ErrUpdaterPeriodTooSmall = errors.New("VPN server data updater period is too small")
ErrUpdaterProtonPasswordMissing = errors.New("proton password is missing")
ErrUpdaterProtonEmailMissing = errors.New("proton email is missing")
ErrVPNProviderNameNotValid = errors.New("VPN provider name is not valid") ErrVPNProviderNameNotValid = errors.New("VPN provider name is not valid")
ErrVPNTypeNotValid = errors.New("VPN type is not valid") ErrVPNTypeNotValid = errors.New("VPN type is not valid")
ErrWireguardAllowedIPNotSet = errors.New("allowed IP is not set") ErrWireguardAllowedIPNotSet = errors.New("allowed IP is not set")

View File

@@ -1,10 +1,10 @@
package settings package settings
import ( import (
"errors"
"fmt" "fmt"
"net/netip" "net/netip"
"os" "os"
"time"
"github.com/qdm12/gosettings" "github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader" "github.com/qdm12/gosettings/reader"
@@ -18,62 +18,41 @@ type Health struct {
// for the health check server. // for the health check server.
// It cannot be the empty string in the internal state. // It cannot be the empty string in the internal state.
ServerAddress string ServerAddress string
// TargetAddresses are the addresses (host or host:port) // ReadHeaderTimeout is the HTTP server header read timeout
// duration of the HTTP server. It defaults to 100 milliseconds.
ReadHeaderTimeout time.Duration
// ReadTimeout is the HTTP read timeout duration of the
// HTTP server. It defaults to 500 milliseconds.
ReadTimeout time.Duration
// TargetAddress is the address (host or host:port)
// to TCP TLS dial to periodically for the health check. // to TCP TLS dial to periodically for the health check.
// Addresses after the first one are used as fallbacks for retries. // It cannot be the empty string in the internal state.
// It cannot be empty in the internal state. TargetAddress string
TargetAddresses []string // ICMPTargetIP is the IP address to use for ICMP echo requests
// ICMPTargetIPs are the IP addresses to use for ICMP echo requests // in the health checker. It can be set to an unspecified address (0.0.0.0)
// in the health checker. The slice can be set to a single // such that the VPN server IP is used, which is also the default behavior.
// unspecified address (0.0.0.0) such that the VPN server IP is used, ICMPTargetIP netip.Addr
// although this can be less reliable. It defaults to [1.1.1.1,8.8.8.8],
// and cannot be left empty in the internal state.
ICMPTargetIPs []netip.Addr
// SmallCheckType is the type of small health check to perform.
// It can be "icmp" or "dns", and defaults to "icmp".
// Note it changes automatically to dns if icmp is not supported.
SmallCheckType string
// RestartVPN indicates whether to restart the VPN connection // RestartVPN indicates whether to restart the VPN connection
// when the healthcheck fails. // when the healthcheck fails.
RestartVPN *bool RestartVPN *bool
} }
var (
ErrICMPTargetIPNotValid = errors.New("ICMP target IP address is not valid")
ErrICMPTargetIPsNotCompatible = errors.New("ICMP target IP addresses are not compatible")
ErrSmallCheckTypeNotValid = errors.New("small check type is not valid")
)
func (h Health) Validate() (err error) { func (h Health) Validate() (err error) {
err = validate.ListeningAddress(h.ServerAddress, os.Getuid()) err = validate.ListeningAddress(h.ServerAddress, os.Getuid())
if err != nil { if err != nil {
return fmt.Errorf("server listening address is not valid: %w", err) return fmt.Errorf("server listening address is not valid: %w", err)
} }
for _, ip := range h.ICMPTargetIPs {
switch {
case !ip.IsValid():
return fmt.Errorf("%w: %s", ErrICMPTargetIPNotValid, ip)
case ip.IsUnspecified() && len(h.ICMPTargetIPs) > 1:
return fmt.Errorf("%w: only a single IP address must be set if it is to be unspecified",
ErrICMPTargetIPsNotCompatible)
}
}
err = validate.IsOneOf(h.SmallCheckType, "icmp", "dns")
if err != nil {
return fmt.Errorf("%w: %s", ErrSmallCheckTypeNotValid, err)
}
return nil return nil
} }
func (h *Health) copy() (copied Health) { func (h *Health) copy() (copied Health) {
return Health{ return Health{
ServerAddress: h.ServerAddress, ServerAddress: h.ServerAddress,
TargetAddresses: h.TargetAddresses, ReadHeaderTimeout: h.ReadHeaderTimeout,
ICMPTargetIPs: gosettings.CopySlice(h.ICMPTargetIPs), ReadTimeout: h.ReadTimeout,
SmallCheckType: h.SmallCheckType, TargetAddress: h.TargetAddress,
ICMPTargetIP: h.ICMPTargetIP,
RestartVPN: gosettings.CopyPointer(h.RestartVPN), RestartVPN: gosettings.CopyPointer(h.RestartVPN),
} }
} }
@@ -83,20 +62,21 @@ func (h *Health) copy() (copied Health) {
// settings. // settings.
func (h *Health) OverrideWith(other Health) { func (h *Health) OverrideWith(other Health) {
h.ServerAddress = gosettings.OverrideWithComparable(h.ServerAddress, other.ServerAddress) h.ServerAddress = gosettings.OverrideWithComparable(h.ServerAddress, other.ServerAddress)
h.TargetAddresses = gosettings.OverrideWithSlice(h.TargetAddresses, other.TargetAddresses) h.ReadHeaderTimeout = gosettings.OverrideWithComparable(h.ReadHeaderTimeout, other.ReadHeaderTimeout)
h.ICMPTargetIPs = gosettings.OverrideWithSlice(h.ICMPTargetIPs, other.ICMPTargetIPs) h.ReadTimeout = gosettings.OverrideWithComparable(h.ReadTimeout, other.ReadTimeout)
h.SmallCheckType = gosettings.OverrideWithComparable(h.SmallCheckType, other.SmallCheckType) h.TargetAddress = gosettings.OverrideWithComparable(h.TargetAddress, other.TargetAddress)
h.ICMPTargetIP = gosettings.OverrideWithComparable(h.ICMPTargetIP, other.ICMPTargetIP)
h.RestartVPN = gosettings.OverrideWithPointer(h.RestartVPN, other.RestartVPN) h.RestartVPN = gosettings.OverrideWithPointer(h.RestartVPN, other.RestartVPN)
} }
func (h *Health) SetDefaults() { func (h *Health) SetDefaults() {
h.ServerAddress = gosettings.DefaultComparable(h.ServerAddress, "127.0.0.1:9999") h.ServerAddress = gosettings.DefaultComparable(h.ServerAddress, "127.0.0.1:9999")
h.TargetAddresses = gosettings.DefaultSlice(h.TargetAddresses, []string{"cloudflare.com:443", "github.com:443"}) const defaultReadHeaderTimeout = 100 * time.Millisecond
h.ICMPTargetIPs = gosettings.DefaultSlice(h.ICMPTargetIPs, []netip.Addr{ h.ReadHeaderTimeout = gosettings.DefaultComparable(h.ReadHeaderTimeout, defaultReadHeaderTimeout)
netip.AddrFrom4([4]byte{1, 1, 1, 1}), const defaultReadTimeout = 500 * time.Millisecond
netip.AddrFrom4([4]byte{8, 8, 8, 8}), h.ReadTimeout = gosettings.DefaultComparable(h.ReadTimeout, defaultReadTimeout)
}) h.TargetAddress = gosettings.DefaultComparable(h.TargetAddress, "cloudflare.com:443")
h.SmallCheckType = gosettings.DefaultComparable(h.SmallCheckType, "icmp") h.ICMPTargetIP = gosettings.DefaultComparable(h.ICMPTargetIP, netip.IPv4Unspecified()) // use the VPN server IP
h.RestartVPN = gosettings.DefaultPointer(h.RestartVPN, true) h.RestartVPN = gosettings.DefaultPointer(h.RestartVPN, true)
} }
@@ -107,37 +87,24 @@ func (h Health) String() string {
func (h Health) toLinesNode() (node *gotree.Node) { func (h Health) toLinesNode() (node *gotree.Node) {
node = gotree.New("Health settings:") node = gotree.New("Health settings:")
node.Appendf("Server listening address: %s", h.ServerAddress) node.Appendf("Server listening address: %s", h.ServerAddress)
targetAddrs := node.Appendf("Target addresses:") node.Appendf("Target address: %s", h.TargetAddress)
for _, targetAddr := range h.TargetAddresses { icmpTarget := "VPN server IP"
targetAddrs.Append(targetAddr) if !h.ICMPTargetIP.IsUnspecified() {
} icmpTarget = h.ICMPTargetIP.String()
switch h.SmallCheckType {
case "icmp":
icmpNode := node.Appendf("Small health check type: ICMP echo request")
if len(h.ICMPTargetIPs) == 1 && h.ICMPTargetIPs[0].IsUnspecified() {
icmpNode.Appendf("ICMP target IP: VPN server IP address")
} else {
icmpIPs := icmpNode.Appendf("ICMP target IPs:")
for _, ip := range h.ICMPTargetIPs {
icmpIPs.Append(ip.String())
}
}
case "dns":
node.Appendf("Small health check type: Plain DNS lookup over UDP")
} }
node.Appendf("ICMP target IP: %s", icmpTarget)
node.Appendf("Restart VPN on healthcheck failure: %s", gosettings.BoolToYesNo(h.RestartVPN)) node.Appendf("Restart VPN on healthcheck failure: %s", gosettings.BoolToYesNo(h.RestartVPN))
return node return node
} }
func (h *Health) Read(r *reader.Reader) (err error) { func (h *Health) Read(r *reader.Reader) (err error) {
h.ServerAddress = r.String("HEALTH_SERVER_ADDRESS") h.ServerAddress = r.String("HEALTH_SERVER_ADDRESS")
h.TargetAddresses = r.CSV("HEALTH_TARGET_ADDRESSES", h.TargetAddress = r.String("HEALTH_TARGET_ADDRESS",
reader.RetroKeys("HEALTH_ADDRESS_TO_PING", "HEALTH_TARGET_ADDRESS")) reader.RetroKeys("HEALTH_ADDRESS_TO_PING"))
h.ICMPTargetIPs, err = r.CSVNetipAddresses("HEALTH_ICMP_TARGET_IPS", reader.RetroKeys("HEALTH_ICMP_TARGET_IP")) h.ICMPTargetIP, err = r.NetipAddr("HEALTH_ICMP_TARGET_IP")
if err != nil { if err != nil {
return err return err
} }
h.SmallCheckType = r.String("HEALTH_SMALL_CHECK_TYPE")
h.RestartVPN, err = r.BoolPtr("HEALTH_RESTART_VPN") h.RestartVPN, err = r.BoolPtr("HEALTH_RESTART_VPN")
if err != nil { if err != nil {
return err return err

View File

@@ -0,0 +1,51 @@
package settings
import (
"net/netip"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gotree"
)
// IPv6 contains settings regarding IPv6 configuration.
type IPv6 struct {
// CheckAddress is the TCP ip:port address to dial to check
// IPv6 is supported, in case a default IPv6 route is found.
// It defaults to cloudflare.com address [2606:4700::6810:84e5]:443
CheckAddress netip.AddrPort
}
func (i IPv6) validate() (err error) {
return nil
}
func (i *IPv6) copy() (copied IPv6) {
return IPv6{
CheckAddress: i.CheckAddress,
}
}
func (i *IPv6) overrideWith(other IPv6) {
i.CheckAddress = gosettings.OverrideWithValidator(i.CheckAddress, other.CheckAddress)
}
func (i *IPv6) setDefaults() {
defaultCheckAddress := netip.MustParseAddrPort("[2606:4700::6810:84e5]:443")
i.CheckAddress = gosettings.DefaultComparable(i.CheckAddress, defaultCheckAddress)
}
func (i IPv6) String() string {
return i.toLinesNode().String()
}
func (i IPv6) toLinesNode() (node *gotree.Node) {
node = gotree.New("IPv6 settings:")
node.Appendf("Check address: %s", i.CheckAddress)
return node
}
func (i *IPv6) read(r *reader.Reader) (err error) {
i.CheckAddress, err = r.NetipAddrPort("IPV6_CHECK_ADDRESS")
return err
}

View File

@@ -2,7 +2,6 @@ package settings
import ( import (
"fmt" "fmt"
"net/netip"
"strings" "strings"
"github.com/qdm12/gluetun/internal/configuration/settings/helpers" "github.com/qdm12/gluetun/internal/configuration/settings/helpers"
@@ -25,12 +24,6 @@ type OpenVPNSelection struct {
// and can be udp or tcp. It cannot be the empty string // and can be udp or tcp. It cannot be the empty string
// in the internal state. // in the internal state.
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
// EndpointIP is the server endpoint IP address.
// If set, it overrides any IP address from the picked
// built-in server connection. To indicate it should
// not be used, it should be set to [netip.IPv4Unspecified].
// It can never be the zero value in the internal state.
EndpointIP netip.Addr `json:"endpoint_ip"`
// CustomPort is the OpenVPN server endpoint port. // CustomPort is the OpenVPN server endpoint port.
// It can be set to 0 to indicate no custom port should // It can be set to 0 to indicate no custom port should
// be used. It cannot be nil in the internal state. // be used. It cannot be nil in the internal state.
@@ -149,7 +142,6 @@ func (o *OpenVPNSelection) copy() (copied OpenVPNSelection) {
return OpenVPNSelection{ return OpenVPNSelection{
ConfFile: gosettings.CopyPointer(o.ConfFile), ConfFile: gosettings.CopyPointer(o.ConfFile),
Protocol: o.Protocol, Protocol: o.Protocol,
EndpointIP: o.EndpointIP,
CustomPort: gosettings.CopyPointer(o.CustomPort), CustomPort: gosettings.CopyPointer(o.CustomPort),
PIAEncPreset: gosettings.CopyPointer(o.PIAEncPreset), PIAEncPreset: gosettings.CopyPointer(o.PIAEncPreset),
} }
@@ -159,14 +151,12 @@ func (o *OpenVPNSelection) overrideWith(other OpenVPNSelection) {
o.ConfFile = gosettings.OverrideWithPointer(o.ConfFile, other.ConfFile) o.ConfFile = gosettings.OverrideWithPointer(o.ConfFile, other.ConfFile)
o.Protocol = gosettings.OverrideWithComparable(o.Protocol, other.Protocol) o.Protocol = gosettings.OverrideWithComparable(o.Protocol, other.Protocol)
o.CustomPort = gosettings.OverrideWithPointer(o.CustomPort, other.CustomPort) o.CustomPort = gosettings.OverrideWithPointer(o.CustomPort, other.CustomPort)
o.EndpointIP = gosettings.OverrideWithValidator(o.EndpointIP, other.EndpointIP)
o.PIAEncPreset = gosettings.OverrideWithPointer(o.PIAEncPreset, other.PIAEncPreset) o.PIAEncPreset = gosettings.OverrideWithPointer(o.PIAEncPreset, other.PIAEncPreset)
} }
func (o *OpenVPNSelection) setDefaults(vpnProvider string) { func (o *OpenVPNSelection) setDefaults(vpnProvider string) {
o.ConfFile = gosettings.DefaultPointer(o.ConfFile, "") o.ConfFile = gosettings.DefaultPointer(o.ConfFile, "")
o.Protocol = gosettings.DefaultComparable(o.Protocol, constants.UDP) o.Protocol = gosettings.DefaultComparable(o.Protocol, constants.UDP)
o.EndpointIP = gosettings.DefaultValidator(o.EndpointIP, netip.IPv4Unspecified())
o.CustomPort = gosettings.DefaultPointer(o.CustomPort, 0) o.CustomPort = gosettings.DefaultPointer(o.CustomPort, 0)
var defaultEncPreset string var defaultEncPreset string
@@ -184,10 +174,6 @@ func (o OpenVPNSelection) toLinesNode() (node *gotree.Node) {
node = gotree.New("OpenVPN server selection settings:") node = gotree.New("OpenVPN server selection settings:")
node.Appendf("Protocol: %s", strings.ToUpper(o.Protocol)) node.Appendf("Protocol: %s", strings.ToUpper(o.Protocol))
if !o.EndpointIP.IsUnspecified() {
node.Appendf("Endpoint IP address: %s", o.EndpointIP)
}
if *o.CustomPort != 0 { if *o.CustomPort != 0 {
node.Appendf("Custom port: %d", *o.CustomPort) node.Appendf("Custom port: %d", *o.CustomPort)
} }
@@ -208,12 +194,6 @@ func (o *OpenVPNSelection) read(r *reader.Reader) (err error) {
o.Protocol = r.String("OPENVPN_PROTOCOL", reader.RetroKeys("PROTOCOL")) o.Protocol = r.String("OPENVPN_PROTOCOL", reader.RetroKeys("PROTOCOL"))
o.EndpointIP, err = r.NetipAddr("OPENVPN_ENDPOINT_IP",
reader.RetroKeys("OPENVPN_TARGET_IP", "VPN_ENDPOINT_IP"))
if err != nil {
return err
}
o.CustomPort, err = r.Uint16Ptr("OPENVPN_ENDPOINT_PORT", o.CustomPort, err = r.Uint16Ptr("OPENVPN_ENDPOINT_PORT",
reader.RetroKeys("PORT", "OPENVPN_PORT", "VPN_ENDPOINT_PORT")) reader.RetroKeys("PORT", "OPENVPN_PORT", "VPN_ENDPOINT_PORT"))
if err != nil { if err != nil {

View File

@@ -1,14 +1,11 @@
package settings package settings
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"net" "net"
"os" "os"
"strconv" "strconv"
"github.com/qdm12/gluetun/internal/server/middlewares/auth"
"github.com/qdm12/gosettings" "github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader" "github.com/qdm12/gosettings/reader"
"github.com/qdm12/gotree" "github.com/qdm12/gotree"
@@ -27,9 +24,6 @@ type ControlServer struct {
// It cannot be empty in the internal state and defaults to // It cannot be empty in the internal state and defaults to
// /gluetun/auth/config.toml. // /gluetun/auth/config.toml.
AuthFilePath string AuthFilePath string
// AuthDefaultRole is a JSON encoded object defining the default role
// that applies to all routes without a previously user-defined role assigned to.
AuthDefaultRole string
} }
func (c ControlServer) validate() (err error) { func (c ControlServer) validate() (err error) {
@@ -50,21 +44,6 @@ func (c ControlServer) validate() (err error) {
ErrControlServerPrivilegedPort, port, uid) ErrControlServerPrivilegedPort, port, uid)
} }
jsonDecoder := json.NewDecoder(bytes.NewBufferString(c.AuthDefaultRole))
jsonDecoder.DisallowUnknownFields()
var role auth.Role
err = jsonDecoder.Decode(&role)
if err != nil {
return fmt.Errorf("default authentication role is not valid JSON: %w", err)
}
if role.Auth != "" {
err = role.Validate()
if err != nil {
return fmt.Errorf("default authentication role is not valid: %w", err)
}
}
return nil return nil
} }
@@ -73,7 +52,6 @@ func (c *ControlServer) copy() (copied ControlServer) {
Address: gosettings.CopyPointer(c.Address), Address: gosettings.CopyPointer(c.Address),
Log: gosettings.CopyPointer(c.Log), Log: gosettings.CopyPointer(c.Log),
AuthFilePath: c.AuthFilePath, AuthFilePath: c.AuthFilePath,
AuthDefaultRole: c.AuthDefaultRole,
} }
} }
@@ -84,21 +62,12 @@ func (c *ControlServer) overrideWith(other ControlServer) {
c.Address = gosettings.OverrideWithPointer(c.Address, other.Address) c.Address = gosettings.OverrideWithPointer(c.Address, other.Address)
c.Log = gosettings.OverrideWithPointer(c.Log, other.Log) c.Log = gosettings.OverrideWithPointer(c.Log, other.Log)
c.AuthFilePath = gosettings.OverrideWithComparable(c.AuthFilePath, other.AuthFilePath) c.AuthFilePath = gosettings.OverrideWithComparable(c.AuthFilePath, other.AuthFilePath)
c.AuthDefaultRole = gosettings.OverrideWithComparable(c.AuthDefaultRole, other.AuthDefaultRole)
} }
func (c *ControlServer) setDefaults() { func (c *ControlServer) setDefaults() {
c.Address = gosettings.DefaultPointer(c.Address, ":8000") c.Address = gosettings.DefaultPointer(c.Address, ":8000")
c.Log = gosettings.DefaultPointer(c.Log, true) c.Log = gosettings.DefaultPointer(c.Log, true)
c.AuthFilePath = gosettings.DefaultComparable(c.AuthFilePath, "/gluetun/auth/config.toml") c.AuthFilePath = gosettings.DefaultComparable(c.AuthFilePath, "/gluetun/auth/config.toml")
c.AuthDefaultRole = gosettings.DefaultComparable(c.AuthDefaultRole, "{}")
if c.AuthDefaultRole != "{}" {
var role auth.Role
_ = json.Unmarshal([]byte(c.AuthDefaultRole), &role)
role.Name = "default"
roleBytes, _ := json.Marshal(role) //nolint:errchkjson
c.AuthDefaultRole = string(roleBytes)
}
} }
func (c ControlServer) String() string { func (c ControlServer) String() string {
@@ -110,11 +79,6 @@ func (c ControlServer) toLinesNode() (node *gotree.Node) {
node.Appendf("Listening address: %s", *c.Address) node.Appendf("Listening address: %s", *c.Address)
node.Appendf("Logging: %s", gosettings.BoolToYesNo(c.Log)) node.Appendf("Logging: %s", gosettings.BoolToYesNo(c.Log))
node.Appendf("Authentication file path: %s", c.AuthFilePath) node.Appendf("Authentication file path: %s", c.AuthFilePath)
if c.AuthDefaultRole != "{}" {
var role auth.Role
_ = json.Unmarshal([]byte(c.AuthDefaultRole), &role)
node.AppendNode(role.ToLinesNode())
}
return node return node
} }
@@ -127,7 +91,6 @@ func (c *ControlServer) read(r *reader.Reader) (err error) {
c.Address = r.Get("HTTP_CONTROL_SERVER_ADDRESS") c.Address = r.Get("HTTP_CONTROL_SERVER_ADDRESS")
c.AuthFilePath = r.String("HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH") c.AuthFilePath = r.String("HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH")
c.AuthDefaultRole = r.String("HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE")
return nil return nil
} }

View File

@@ -3,6 +3,7 @@ package settings
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/netip"
"strings" "strings"
"github.com/qdm12/gluetun/internal/configuration/settings/helpers" "github.com/qdm12/gluetun/internal/configuration/settings/helpers"
@@ -21,6 +22,12 @@ type ServerSelection struct {
// or 'wireguard'. It cannot be the empty string // or 'wireguard'. It cannot be the empty string
// in the internal state. // in the internal state.
VPN string `json:"vpn"` VPN string `json:"vpn"`
// TargetIP is the server endpoint IP address to use.
// It will override any IP address from the picked
// built-in server. It cannot be the empty value in the internal
// state, and can be set to the unspecified address to indicate
// there is not target IP address to use.
TargetIP netip.Addr `json:"target_ip"`
// Countries is the list of countries to filter VPN servers with. // Countries is the list of countries to filter VPN servers with.
Countries []string `json:"countries"` Countries []string `json:"countries"`
// Categories is the list of categories to filter VPN servers with. // Categories is the list of categories to filter VPN servers with.
@@ -292,6 +299,7 @@ func validateFeatureFilters(settings ServerSelection, vpnServiceProvider string)
func (ss *ServerSelection) copy() (copied ServerSelection) { func (ss *ServerSelection) copy() (copied ServerSelection) {
return ServerSelection{ return ServerSelection{
VPN: ss.VPN, VPN: ss.VPN,
TargetIP: ss.TargetIP,
Countries: gosettings.CopySlice(ss.Countries), Countries: gosettings.CopySlice(ss.Countries),
Categories: gosettings.CopySlice(ss.Categories), Categories: gosettings.CopySlice(ss.Categories),
Regions: gosettings.CopySlice(ss.Regions), Regions: gosettings.CopySlice(ss.Regions),
@@ -315,6 +323,7 @@ func (ss *ServerSelection) copy() (copied ServerSelection) {
func (ss *ServerSelection) overrideWith(other ServerSelection) { func (ss *ServerSelection) overrideWith(other ServerSelection) {
ss.VPN = gosettings.OverrideWithComparable(ss.VPN, other.VPN) ss.VPN = gosettings.OverrideWithComparable(ss.VPN, other.VPN)
ss.TargetIP = gosettings.OverrideWithValidator(ss.TargetIP, other.TargetIP)
ss.Countries = gosettings.OverrideWithSlice(ss.Countries, other.Countries) ss.Countries = gosettings.OverrideWithSlice(ss.Countries, other.Countries)
ss.Categories = gosettings.OverrideWithSlice(ss.Categories, other.Categories) ss.Categories = gosettings.OverrideWithSlice(ss.Categories, other.Categories)
ss.Regions = gosettings.OverrideWithSlice(ss.Regions, other.Regions) ss.Regions = gosettings.OverrideWithSlice(ss.Regions, other.Regions)
@@ -337,6 +346,7 @@ func (ss *ServerSelection) overrideWith(other ServerSelection) {
func (ss *ServerSelection) setDefaults(vpnProvider string, portForwardingEnabled bool) { func (ss *ServerSelection) setDefaults(vpnProvider string, portForwardingEnabled bool) {
ss.VPN = gosettings.DefaultComparable(ss.VPN, vpn.OpenVPN) ss.VPN = gosettings.DefaultComparable(ss.VPN, vpn.OpenVPN)
ss.TargetIP = gosettings.DefaultValidator(ss.TargetIP, netip.IPv4Unspecified())
ss.OwnedOnly = gosettings.DefaultPointer(ss.OwnedOnly, false) ss.OwnedOnly = gosettings.DefaultPointer(ss.OwnedOnly, false)
ss.FreeOnly = gosettings.DefaultPointer(ss.FreeOnly, false) ss.FreeOnly = gosettings.DefaultPointer(ss.FreeOnly, false)
ss.PremiumOnly = gosettings.DefaultPointer(ss.PremiumOnly, false) ss.PremiumOnly = gosettings.DefaultPointer(ss.PremiumOnly, false)
@@ -358,6 +368,9 @@ func (ss ServerSelection) String() string {
func (ss ServerSelection) toLinesNode() (node *gotree.Node) { func (ss ServerSelection) toLinesNode() (node *gotree.Node) {
node = gotree.New("Server selection settings:") node = gotree.New("Server selection settings:")
node.Appendf("VPN type: %s", ss.VPN) node.Appendf("VPN type: %s", ss.VPN)
if !ss.TargetIP.IsUnspecified() {
node.Appendf("Target IP address: %s", ss.TargetIP)
}
if len(ss.Countries) > 0 { if len(ss.Countries) > 0 {
node.Appendf("Countries: %s", strings.Join(ss.Countries, ", ")) node.Appendf("Countries: %s", strings.Join(ss.Countries, ", "))
@@ -448,6 +461,12 @@ func (ss *ServerSelection) read(r *reader.Reader,
) (err error) { ) (err error) {
ss.VPN = vpnType ss.VPN = vpnType
ss.TargetIP, err = r.NetipAddr("OPENVPN_ENDPOINT_IP",
reader.RetroKeys("OPENVPN_TARGET_IP", "VPN_ENDPOINT_IP"))
if err != nil {
return err
}
countriesRetroKeys := []string{"COUNTRY"} countriesRetroKeys := []string{"COUNTRY"}
if vpnProvider == providers.Cyberghost { if vpnProvider == providers.Cyberghost {
countriesRetroKeys = append(countriesRetroKeys, "REGION") countriesRetroKeys = append(countriesRetroKeys, "REGION")

View File

@@ -27,6 +27,7 @@ type Settings struct {
Updater Updater Updater Updater
Version Version Version Version
VPN VPN VPN VPN
IPv6 IPv6
Pprof pprof.Settings Pprof pprof.Settings
} }
@@ -53,6 +54,7 @@ func (s *Settings) Validate(filterChoicesGetter FilterChoicesGetter, ipv6Support
"system": s.System.validate, "system": s.System.validate,
"updater": s.Updater.Validate, "updater": s.Updater.Validate,
"version": s.Version.validate, "version": s.Version.validate,
"ipv6": s.IPv6.validate,
// Pprof validation done in pprof constructor // Pprof validation done in pprof constructor
"VPN": func() error { "VPN": func() error {
return s.VPN.Validate(filterChoicesGetter, ipv6Supported, warner) return s.VPN.Validate(filterChoicesGetter, ipv6Supported, warner)
@@ -85,6 +87,7 @@ func (s *Settings) copy() (copied Settings) {
Version: s.Version.copy(), Version: s.Version.copy(),
VPN: s.VPN.Copy(), VPN: s.VPN.Copy(),
Pprof: s.Pprof.Copy(), Pprof: s.Pprof.Copy(),
IPv6: s.IPv6.copy(),
} }
} }
@@ -106,6 +109,7 @@ func (s *Settings) OverrideWith(other Settings,
patchedSettings.Version.overrideWith(other.Version) patchedSettings.Version.overrideWith(other.Version)
patchedSettings.VPN.OverrideWith(other.VPN) patchedSettings.VPN.OverrideWith(other.VPN)
patchedSettings.Pprof.OverrideWith(other.Pprof) patchedSettings.Pprof.OverrideWith(other.Pprof)
patchedSettings.IPv6.overrideWith(other.IPv6)
err = patchedSettings.Validate(filterChoicesGetter, ipv6Supported, warner) err = patchedSettings.Validate(filterChoicesGetter, ipv6Supported, warner)
if err != nil { if err != nil {
return err return err
@@ -121,6 +125,7 @@ func (s *Settings) SetDefaults() {
s.Health.SetDefaults() s.Health.SetDefaults()
s.HTTPProxy.setDefaults() s.HTTPProxy.setDefaults()
s.Log.setDefaults() s.Log.setDefaults()
s.IPv6.setDefaults()
s.PublicIP.setDefaults() s.PublicIP.setDefaults()
s.Shadowsocks.setDefaults() s.Shadowsocks.setDefaults()
s.Storage.setDefaults() s.Storage.setDefaults()
@@ -142,6 +147,7 @@ func (s Settings) toLinesNode() (node *gotree.Node) {
node.AppendNode(s.DNS.toLinesNode()) node.AppendNode(s.DNS.toLinesNode())
node.AppendNode(s.Firewall.toLinesNode()) node.AppendNode(s.Firewall.toLinesNode())
node.AppendNode(s.Log.toLinesNode()) node.AppendNode(s.Log.toLinesNode())
node.AppendNode(s.IPv6.toLinesNode())
node.AppendNode(s.Health.toLinesNode()) node.AppendNode(s.Health.toLinesNode())
node.AppendNode(s.Shadowsocks.toLinesNode()) node.AppendNode(s.Shadowsocks.toLinesNode())
node.AppendNode(s.HTTPProxy.toLinesNode()) node.AppendNode(s.HTTPProxy.toLinesNode())
@@ -208,6 +214,7 @@ func (s *Settings) Read(r *reader.Reader, warner Warner) (err error) {
"updater": s.Updater.read, "updater": s.Updater.read,
"version": s.Version.read, "version": s.Version.read,
"VPN": s.VPN.read, "VPN": s.VPN.read,
"IPv6": s.IPv6.read,
"profiling": s.Pprof.Read, "profiling": s.Pprof.Read,
} }

View File

@@ -55,15 +55,12 @@ func Test_Settings_String(t *testing.T) {
| └── Enabled: yes | └── Enabled: yes
├── Log settings: ├── Log settings:
| └── Log level: INFO | └── Log level: INFO
├── IPv6 settings:
| └── Check address: [2606:4700::6810:84e5]:443
├── Health settings: ├── Health settings:
| ├── Server listening address: 127.0.0.1:9999 | ├── Server listening address: 127.0.0.1:9999
| ├── Target addresses: | ├── Target address: cloudflare.com:443
| | ├── cloudflare.com:443 | ├── ICMP target IP: VPN server IP
| | └── github.com:443
| ├── Small health check type: ICMP echo request
| | └── ICMP target IPs:
| | ├── 1.1.1.1
| | └── 8.8.8.8
| └── Restart VPN on healthcheck failure: yes | └── Restart VPN on healthcheck failure: yes
├── Shadowsocks server settings: ├── Shadowsocks server settings:
| └── Enabled: no | └── Enabled: no

View File

@@ -2,7 +2,6 @@ package settings
import ( import (
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
@@ -32,10 +31,6 @@ type Updater struct {
// Providers is the list of VPN service providers // Providers is the list of VPN service providers
// to update server information for. // to update server information for.
Providers []string Providers []string
// ProtonEmail is the email to authenticate with the Proton API.
ProtonEmail *string
// ProtonPassword is the password to authenticate with the Proton API.
ProtonPassword *string
} }
func (u Updater) Validate() (err error) { func (u Updater) Validate() (err error) {
@@ -56,18 +51,6 @@ func (u Updater) Validate() (err error) {
if err != nil { if err != nil {
return fmt.Errorf("%w: %w", ErrVPNProviderNameNotValid, err) return fmt.Errorf("%w: %w", ErrVPNProviderNameNotValid, err)
} }
if provider == providers.Protonvpn {
authenticatedAPI := *u.ProtonEmail != "" || *u.ProtonPassword != ""
if authenticatedAPI {
switch {
case *u.ProtonEmail == "":
return fmt.Errorf("%w", ErrUpdaterProtonEmailMissing)
case *u.ProtonPassword == "":
return fmt.Errorf("%w", ErrUpdaterProtonPasswordMissing)
}
}
}
} }
return nil return nil
@@ -79,8 +62,6 @@ func (u *Updater) copy() (copied Updater) {
DNSAddress: u.DNSAddress, DNSAddress: u.DNSAddress,
MinRatio: u.MinRatio, MinRatio: u.MinRatio,
Providers: gosettings.CopySlice(u.Providers), Providers: gosettings.CopySlice(u.Providers),
ProtonEmail: gosettings.CopyPointer(u.ProtonEmail),
ProtonPassword: gosettings.CopyPointer(u.ProtonPassword),
} }
} }
@@ -92,8 +73,6 @@ func (u *Updater) overrideWith(other Updater) {
u.DNSAddress = gosettings.OverrideWithComparable(u.DNSAddress, other.DNSAddress) u.DNSAddress = gosettings.OverrideWithComparable(u.DNSAddress, other.DNSAddress)
u.MinRatio = gosettings.OverrideWithComparable(u.MinRatio, other.MinRatio) u.MinRatio = gosettings.OverrideWithComparable(u.MinRatio, other.MinRatio)
u.Providers = gosettings.OverrideWithSlice(u.Providers, other.Providers) u.Providers = gosettings.OverrideWithSlice(u.Providers, other.Providers)
u.ProtonEmail = gosettings.OverrideWithPointer(u.ProtonEmail, other.ProtonEmail)
u.ProtonPassword = gosettings.OverrideWithPointer(u.ProtonPassword, other.ProtonPassword)
} }
func (u *Updater) SetDefaults(vpnProvider string) { func (u *Updater) SetDefaults(vpnProvider string) {
@@ -108,10 +87,6 @@ func (u *Updater) SetDefaults(vpnProvider string) {
if len(u.Providers) == 0 && vpnProvider != providers.Custom { if len(u.Providers) == 0 && vpnProvider != providers.Custom {
u.Providers = []string{vpnProvider} u.Providers = []string{vpnProvider}
} }
// Set these to empty strings to avoid nil pointer panics
u.ProtonEmail = gosettings.DefaultPointer(u.ProtonEmail, "")
u.ProtonPassword = gosettings.DefaultPointer(u.ProtonPassword, "")
} }
func (u Updater) String() string { func (u Updater) String() string {
@@ -128,10 +103,6 @@ func (u Updater) toLinesNode() (node *gotree.Node) {
node.Appendf("DNS address: %s", u.DNSAddress) node.Appendf("DNS address: %s", u.DNSAddress)
node.Appendf("Minimum ratio: %.1f", u.MinRatio) node.Appendf("Minimum ratio: %.1f", u.MinRatio)
node.Appendf("Providers to update: %s", strings.Join(u.Providers, ", ")) node.Appendf("Providers to update: %s", strings.Join(u.Providers, ", "))
if slices.Contains(u.Providers, providers.Protonvpn) {
node.Appendf("Proton API email: %s", *u.ProtonEmail)
node.Appendf("Proton API password: %s", gosettings.ObfuscateKey(*u.ProtonPassword))
}
return node return node
} }
@@ -154,16 +125,6 @@ func (u *Updater) read(r *reader.Reader) (err error) {
u.Providers = r.CSV("UPDATER_VPN_SERVICE_PROVIDERS") u.Providers = r.CSV("UPDATER_VPN_SERVICE_PROVIDERS")
u.ProtonEmail = r.Get("UPDATER_PROTONVPN_EMAIL")
if u.ProtonEmail == nil {
protonUsername := r.String("UPDATER_PROTONVPN_USERNAME", reader.IsRetro("UPDATER_PROTONVPN_EMAIL"))
if protonUsername != "" {
protonEmail := protonUsername + "@protonmail.com"
u.ProtonEmail = &protonEmail
}
}
u.ProtonPassword = r.Get("UPDATER_PROTONVPN_PASSWORD")
return nil return nil
} }

View File

@@ -14,11 +14,11 @@ import (
type WireguardSelection struct { type WireguardSelection struct {
// EndpointIP is the server endpoint IP address. // EndpointIP is the server endpoint IP address.
// It is notably required with the custom provider. // It is only used with VPN providers generating Wireguard
// Otherwise it overrides any IP address from the picked // configurations specific to each server and user.
// built-in server connection. To indicate it should // To indicate it should not be used, it should be set
// not be used, it should be set to [netip.IPv4Unspecified]. // to netip.IPv4Unspecified(). It can never be the zero value
// It can never be the zero value in the internal state. // in the internal state.
EndpointIP netip.Addr `json:"endpoint_ip"` EndpointIP netip.Addr `json:"endpoint_ip"`
// EndpointPort is a the server port to use for the VPN server. // EndpointPort is a the server port to use for the VPN server.
// It is optional for VPN providers IVPN, Mullvad, Surfshark // It is optional for VPN providers IVPN, Mullvad, Surfshark
@@ -155,8 +155,7 @@ func (w WireguardSelection) toLinesNode() (node *gotree.Node) {
func (w *WireguardSelection) read(r *reader.Reader) (err error) { func (w *WireguardSelection) read(r *reader.Reader) (err error) {
w.EndpointIP, err = r.NetipAddr("WIREGUARD_ENDPOINT_IP", reader.RetroKeys("VPN_ENDPOINT_IP")) w.EndpointIP, err = r.NetipAddr("WIREGUARD_ENDPOINT_IP", reader.RetroKeys("VPN_ENDPOINT_IP"))
if err != nil { if err != nil {
return fmt.Errorf("%w - note this MUST be an IP address, "+ return err
"see https://github.com/qdm12/gluetun/issues/788", err)
} }
w.EndpointPort, err = r.Uint16Ptr("WIREGUARD_ENDPOINT_PORT", reader.RetroKeys("VPN_ENDPOINT_PORT")) w.EndpointPort, err = r.Uint16Ptr("WIREGUARD_ENDPOINT_PORT", reader.RetroKeys("VPN_ENDPOINT_PORT"))

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"net/netip"
"time" "time"
"github.com/qdm12/dns/v2/pkg/middlewares/filter/mapfilter" "github.com/qdm12/dns/v2/pkg/middlewares/filter/mapfilter"
@@ -21,7 +20,6 @@ type Loop struct {
state *state.State state *state.State
server *server.Server server *server.Server
filter *mapfilter.Filter filter *mapfilter.Filter
localResolvers []netip.Addr
resolvConf string resolvConf string
client *http.Client client *http.Client
logger Logger logger Logger
@@ -50,9 +48,7 @@ func NewLoop(settings settings.DNS,
statusManager := loopstate.New(constants.Stopped, start, running, stop, stopped) statusManager := loopstate.New(constants.Stopped, start, running, stop, stopped)
state := state.New(statusManager, settings, updateTicker) state := state.New(statusManager, settings, updateTicker)
filter, err := mapfilter.New(mapfilter.Settings{ filter, err := mapfilter.New(mapfilter.Settings{})
Logger: buildFilterLogger(logger),
})
if err != nil { if err != nil {
return nil, fmt.Errorf("creating map filter: %w", err) return nil, fmt.Errorf("creating map filter: %w", err)
} }
@@ -104,15 +100,3 @@ func (l *Loop) signalOrSetStatus(status models.LoopStatus) {
l.statusManager.SetStatus(status) l.statusManager.SetStatus(status)
} }
} }
type filterLogger struct {
logger Logger
}
func (l *filterLogger) Log(msg string) {
l.logger.Info(msg)
}
func buildFilterLogger(logger Logger) *filterLogger {
return &filterLogger{logger: logger}
}

View File

@@ -4,20 +4,12 @@ import (
"context" "context"
"errors" "errors"
"github.com/qdm12/dns/v2/pkg/nameserver"
"github.com/qdm12/gluetun/internal/constants" "github.com/qdm12/gluetun/internal/constants"
) )
func (l *Loop) Run(ctx context.Context, done chan<- struct{}) { func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
defer close(done) defer close(done)
var err error
l.localResolvers, err = nameserver.GetPrivateDNSServers()
if err != nil {
l.logger.Error("getting private DNS servers: " + err.Error())
return
}
if *l.GetSettings().KeepNameserver { if *l.GetSettings().KeepNameserver {
l.logger.Warn("⚠️⚠️⚠️ keeping the default container nameservers, " + l.logger.Warn("⚠️⚠️⚠️ keeping the default container nameservers, " +
"this will likely leak DNS traffic outside the VPN " + "this will likely leak DNS traffic outside the VPN " +
@@ -45,6 +37,7 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
if err == nil { if err == nil {
l.backoffTime = defaultBackoffTime l.backoffTime = defaultBackoffTime
l.logger.Info("ready") l.logger.Info("ready")
l.signalOrSetStatus(constants.Running)
break break
} }
@@ -61,7 +54,6 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
l.logAndWait(ctx, err) l.logAndWait(ctx, err)
settings = l.GetSettings() settings = l.GetSettings()
} }
l.signalOrSetStatus(constants.Running)
settings = l.GetSettings() settings = l.GetSettings()
if !*settings.KeepNameserver && !*settings.ServerEnabled { if !*settings.KeepNameserver && !*settings.ServerEnabled {
@@ -82,19 +74,15 @@ func (l *Loop) runWait(ctx context.Context, runError <-chan error) (exitLoop boo
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
if !*l.GetSettings().KeepNameserver {
l.stopServer() l.stopServer()
// TODO revert OS and Go nameserver when exiting // TODO revert OS and Go nameserver when exiting
}
return true return true
case <-l.stop: case <-l.stop:
l.userTrigger = true l.userTrigger = true
l.logger.Info("stopping") l.logger.Info("stopping")
if !*l.GetSettings().KeepNameserver {
const fallback = false const fallback = false
l.useUnencryptedDNS(fallback) l.useUnencryptedDNS(fallback)
l.stopServer() l.stopServer()
}
l.stopped <- struct{}{} l.stopped <- struct{}{}
case <-l.start: case <-l.start:
l.userTrigger = true l.userTrigger = true

View File

@@ -3,7 +3,6 @@ package dns
import ( import (
"context" "context"
"fmt" "fmt"
"net/netip"
"github.com/qdm12/dns/v2/pkg/doh" "github.com/qdm12/dns/v2/pkg/doh"
"github.com/qdm12/dns/v2/pkg/dot" "github.com/qdm12/dns/v2/pkg/dot"
@@ -11,7 +10,6 @@ import (
"github.com/qdm12/dns/v2/pkg/middlewares/cache/lru" "github.com/qdm12/dns/v2/pkg/middlewares/cache/lru"
filtermiddleware "github.com/qdm12/dns/v2/pkg/middlewares/filter" filtermiddleware "github.com/qdm12/dns/v2/pkg/middlewares/filter"
"github.com/qdm12/dns/v2/pkg/middlewares/filter/mapfilter" "github.com/qdm12/dns/v2/pkg/middlewares/filter/mapfilter"
"github.com/qdm12/dns/v2/pkg/middlewares/localdns"
"github.com/qdm12/dns/v2/pkg/plain" "github.com/qdm12/dns/v2/pkg/plain"
"github.com/qdm12/dns/v2/pkg/provider" "github.com/qdm12/dns/v2/pkg/provider"
"github.com/qdm12/dns/v2/pkg/server" "github.com/qdm12/dns/v2/pkg/server"
@@ -27,8 +25,7 @@ func (l *Loop) SetSettings(ctx context.Context, settings settings.DNS) (
} }
func buildServerSettings(settings settings.DNS, func buildServerSettings(settings settings.DNS,
filter *mapfilter.Filter, localResolvers []netip.Addr, filter *mapfilter.Filter, logger Logger) (
logger Logger) (
serverSettings server.Settings, err error, serverSettings server.Settings, err error,
) { ) {
serverSettings.Logger = logger serverSettings.Logger = logger
@@ -104,22 +101,5 @@ func buildServerSettings(settings settings.DNS,
} }
serverSettings.Middlewares = append(serverSettings.Middlewares, filterMiddleware) serverSettings.Middlewares = append(serverSettings.Middlewares, filterMiddleware)
localResolversAddrPorts := make([]netip.AddrPort, len(localResolvers))
const defaultDNSPort = 53
for i, addr := range localResolvers {
localResolversAddrPorts[i] = netip.AddrPortFrom(addr, defaultDNSPort)
}
localDNSMiddleware, err := localdns.New(localdns.Settings{
Resolvers: localResolversAddrPorts, // auto-detected at container start only
Logger: logger,
})
if err != nil {
return server.Settings{}, fmt.Errorf("creating local DNS middleware: %w", err)
}
// Place after cache middleware, since we want to avoid caching for local
// hostnames that may change regularly.
// Place after filter middleware to avoid conflicts with the rebinding protection.
serverSettings.Middlewares = append(serverSettings.Middlewares, localDNSMiddleware)
return serverSettings, nil return serverSettings, nil
} }

View File

@@ -21,7 +21,7 @@ func (l *Loop) setupServer(ctx context.Context) (runError <-chan error, err erro
settings := l.GetSettings() settings := l.GetSettings()
serverSettings, err := buildServerSettings(settings, l.filter, l.localResolvers, l.logger) serverSettings, err := buildServerSettings(settings, l.filter, l.logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("building server settings: %w", err) return nil, fmt.Errorf("building server settings: %w", err)
} }

View File

@@ -37,7 +37,6 @@ func (l *Loop) updateFiles(ctx context.Context) (err error) {
IPPrefixes: result.BlockedIPPrefixes, IPPrefixes: result.BlockedIPPrefixes,
} }
updateSettings.BlockHostnames(result.BlockedHostnames) updateSettings.BlockHostnames(result.BlockedHostnames)
updateSettings.SetRebindingProtectionExempt(settings.Blacklist.RebindingProtectionExemptHostnames)
err = l.filter.Update(updateSettings) err = l.filter.Update(updateSettings)
if err != nil { if err != nil {
return fmt.Errorf("updating filter: %w", err) return fmt.Errorf("updating filter: %w", err)

View File

@@ -162,6 +162,24 @@ func (c *Config) acceptOutputTrafficToVPN(ctx context.Context,
return c.runIP6tablesInstruction(ctx, instruction) return c.runIP6tablesInstruction(ctx, instruction)
} }
func (c *Config) AcceptOutput(ctx context.Context,
protocol, intf string, ip netip.Addr, port uint16, remove bool,
) error {
interfaceFlag := "-o " + intf
if intf == "*" { // all interfaces
interfaceFlag = ""
}
instruction := fmt.Sprintf("%s OUTPUT -d %s %s -p %s -m %s --dport %d -j ACCEPT",
appendOrDelete(remove), ip, interfaceFlag, protocol, protocol, port)
if ip.Is4() {
return c.runIptablesInstruction(ctx, instruction)
} else if c.ip6Tables == "" {
return fmt.Errorf("accept output to VPN server: %w", ErrNeedIP6Tables)
}
return c.runIP6tablesInstruction(ctx, instruction)
}
// Thanks to @npawelek. // Thanks to @npawelek.
func (c *Config) acceptOutputFromIPToSubnet(ctx context.Context, func (c *Config) acceptOutputFromIPToSubnet(ctx context.Context,
intf string, sourceIP netip.Addr, destinationSubnet netip.Prefix, remove bool, intf string, sourceIP netip.Addr, destinationSubnet netip.Prefix, remove bool,

View File

@@ -16,16 +16,16 @@ import (
) )
type Checker struct { type Checker struct {
tlsDialAddrs []string tlsDialAddr string
dialer *net.Dialer dialer *net.Dialer
echoer *icmp.Echoer echoer *icmp.Echoer
dnsClient *dns.Client dnsClient *dns.Client
logger Logger logger Logger
icmpTargetIPs []netip.Addr icmpTarget netip.Addr
smallCheckType string
configMutex sync.Mutex configMutex sync.Mutex
icmpNotPermitted bool icmpNotPermitted bool
smallCheckName string
// Internal periodic service signals // Internal periodic service signals
stop context.CancelFunc stop context.CancelFunc
@@ -45,37 +45,35 @@ func NewChecker(logger Logger) *Checker {
} }
} }
// SetConfig sets the TCP+TLS dial addresses, the ICMP echo IP address // SetConfig sets the TCP+TLS dial address and the ICMP echo IP address
// to target and the desired small check type (dns or icmp). // to target by the [Checker].
// This function MUST be called before calling [Checker.Start]. // This function MUST be called before calling [Checker.Start].
func (c *Checker) SetConfig(tlsDialAddrs []string, icmpTargets []netip.Addr, func (c *Checker) SetConfig(tlsDialAddr string, icmpTarget netip.Addr) {
smallCheckType string,
) {
c.configMutex.Lock() c.configMutex.Lock()
defer c.configMutex.Unlock() defer c.configMutex.Unlock()
c.tlsDialAddrs = tlsDialAddrs c.tlsDialAddr = tlsDialAddr
c.icmpTargetIPs = icmpTargets c.icmpTarget = icmpTarget
c.smallCheckType = smallCheckType
} }
// Start starts the checker by first running a blocking 6s-timed TCP+TLS check, // Start starts the checker by first running a blocking 2s-timed TCP+TLS check,
// and, on success, starts the periodic checks in a separate goroutine: // and, on success, starts the periodic checks in a separate goroutine:
// - a "small" ICMP echo check every minute // - a "small" ICMP echo check every 15 seconds
// - a "full" TCP+TLS check every 5 minutes // - a "full" TCP+TLS check every 5 minutes
// It returns a channel `runError` that receives an error (nil or not) when a periodic check is performed. // It returns a channel `runError` that receives an error (nil or not) when a periodic check is performed.
// It returns an error if the initial TCP+TLS check fails. // It returns an error if the initial TCP+TLS check fails.
// The Checker has to be ultimately stopped by calling [Checker.Stop]. // The Checker has to be ultimately stopped by calling [Checker.Stop].
func (c *Checker) Start(ctx context.Context) (runError <-chan error, err error) { func (c *Checker) Start(ctx context.Context) (runError <-chan error, err error) {
if len(c.tlsDialAddrs) == 0 || len(c.icmpTargetIPs) == 0 || c.smallCheckType == "" { if c.tlsDialAddr == "" || c.icmpTarget.IsUnspecified() {
panic("call Checker.SetConfig with non empty values before Checker.Start") panic("call Checker.SetConfig with non empty values before Checker.Start")
} }
if c.icmpNotPermitted { // connection isn't under load yet when the checker starts, so a short
// restore forced check type to dns if icmp was found to be not permitted // 6 seconds timeout suffices and provides quick enough feedback that
c.smallCheckType = smallCheckDNS // the new connection is not working.
} const timeout = 6 * time.Second
tcpTLSCheckCtx, tcpTLSCheckCancel := context.WithTimeout(ctx, timeout)
err = c.startupCheck(ctx) err = tcpTLSCheck(tcpTLSCheckCtx, c.dialer, c.tlsDialAddr)
tcpTLSCheckCancel()
if err != nil { if err != nil {
return nil, fmt.Errorf("startup check: %w", err) return nil, fmt.Errorf("startup check: %w", err)
} }
@@ -85,6 +83,7 @@ func (c *Checker) Start(ctx context.Context) (runError <-chan error, err error)
c.stop = cancel c.stop = cancel
done := make(chan struct{}) done := make(chan struct{})
c.done = done c.done = done
c.smallCheckName = "ICMP echo"
const smallCheckPeriod = time.Minute const smallCheckPeriod = time.Minute
smallCheckTimer := time.NewTimer(smallCheckPeriod) smallCheckTimer := time.NewTimer(smallCheckPeriod)
const fullCheckPeriod = 5 * time.Minute const fullCheckPeriod = 5 * time.Minute
@@ -124,56 +123,43 @@ func (c *Checker) Start(ctx context.Context) (runError <-chan error, err error)
func (c *Checker) Stop() error { func (c *Checker) Stop() error {
c.stop() c.stop()
<-c.done <-c.done
c.tlsDialAddrs = nil c.icmpTarget = netip.Addr{}
c.icmpTargetIPs = nil
c.smallCheckType = ""
return nil return nil
} }
func (c *Checker) smallPeriodicCheck(ctx context.Context) error { func (c *Checker) smallPeriodicCheck(ctx context.Context) error {
c.configMutex.Lock() c.configMutex.Lock()
icmpTargetIPs := make([]netip.Addr, len(c.icmpTargetIPs)) ip := c.icmpTarget
copy(icmpTargetIPs, c.icmpTargetIPs)
c.configMutex.Unlock() c.configMutex.Unlock()
tryTimeouts := []time.Duration{ const maxTries = 3
5 * time.Second, const timeout = 10 * time.Second
5 * time.Second, const extraTryTime = 10 * time.Second // 10s added for each subsequent retry
5 * time.Second, check := func(ctx context.Context) error {
10 * time.Second, if c.icmpNotPermitted {
10 * time.Second,
10 * time.Second,
15 * time.Second,
15 * time.Second,
15 * time.Second,
30 * time.Second,
}
check := func(ctx context.Context, try int) error {
if c.smallCheckType == smallCheckDNS {
return c.dnsClient.Check(ctx) return c.dnsClient.Check(ctx)
} }
ip := icmpTargetIPs[try%len(icmpTargetIPs)]
err := c.echoer.Echo(ctx, ip) err := c.echoer.Echo(ctx, ip)
if errors.Is(err, icmp.ErrNotPermitted) { if errors.Is(err, icmp.ErrNotPermitted) {
c.icmpNotPermitted = true c.icmpNotPermitted = true
c.smallCheckType = smallCheckDNS c.smallCheckName = "plain DNS over UDP"
c.logger.Infof("%s; permanently falling back to %s checks", c.logger.Infof("%s; permanently falling back to %s checks.", c.smallCheckName, err)
smallCheckTypeToString(c.smallCheckType), err)
return c.dnsClient.Check(ctx) return c.dnsClient.Check(ctx)
} }
return err return err
} }
return withRetries(ctx, tryTimeouts, c.logger, smallCheckTypeToString(c.smallCheckType), check) return withRetries(ctx, maxTries, timeout, extraTryTime, c.logger, c.smallCheckName, check)
} }
func (c *Checker) fullPeriodicCheck(ctx context.Context) error { func (c *Checker) fullPeriodicCheck(ctx context.Context) error {
const maxTries = 2
// 20s timeout in case the connection is under stress // 20s timeout in case the connection is under stress
// See https://github.com/qdm12/gluetun/issues/2270 // See https://github.com/qdm12/gluetun/issues/2270
tryTimeouts := []time.Duration{10 * time.Second, 15 * time.Second, 30 * time.Second} const timeout = 20 * time.Second
check := func(ctx context.Context, try int) error { const extraTryTime = 10 * time.Second // 10s added for each subsequent retry
tlsDialAddr := c.tlsDialAddrs[try%len(c.tlsDialAddrs)] check := func(ctx context.Context) error {
return tcpTLSCheck(ctx, c.dialer, tlsDialAddr) return tcpTLSCheck(ctx, c.dialer, c.tlsDialAddr)
} }
return withRetries(ctx, tryTimeouts, c.logger, "TCP+TLS dial", check) return withRetries(ctx, maxTries, timeout, extraTryTime, c.logger, "TCP+TLS dial", check)
} }
func tcpTLSCheck(ctx context.Context, dialer *net.Dialer, targetAddress string) error { func tcpTLSCheck(ctx context.Context, dialer *net.Dialer, targetAddress string) error {
@@ -232,19 +218,15 @@ func makeAddressToDial(address string) (addressToDial string, err error) {
var ErrAllCheckTriesFailed = errors.New("all check tries failed") var ErrAllCheckTriesFailed = errors.New("all check tries failed")
func withRetries(ctx context.Context, tryTimeouts []time.Duration, func withRetries(ctx context.Context, maxTries uint, tryTimeout, extraTryTime time.Duration,
logger Logger, checkName string, check func(ctx context.Context, try int) error, logger Logger, checkName string, check func(ctx context.Context) error,
) error { ) error {
maxTries := len(tryTimeouts) try := uint(0)
type errData struct { var errs []error
err error for {
durationMS int64 timeout := tryTimeout + time.Duration(try)*extraTryTime //nolint:gosec
}
errs := make([]errData, maxTries)
for i, timeout := range tryTimeouts {
start := time.Now()
checkCtx, cancel := context.WithTimeout(ctx, timeout) checkCtx, cancel := context.WithTimeout(ctx, timeout)
err := check(checkCtx, i) err := check(checkCtx)
cancel() cancel()
switch { switch {
case err == nil: case err == nil:
@@ -252,75 +234,17 @@ func withRetries(ctx context.Context, tryTimeouts []time.Duration,
case ctx.Err() != nil: case ctx.Err() != nil:
return fmt.Errorf("%s: %w", checkName, ctx.Err()) return fmt.Errorf("%s: %w", checkName, ctx.Err())
} }
logger.Debugf("%s attempt %d/%d failed: %s", checkName, i+1, maxTries, err) logger.Debugf("%s attempt %d/%d failed: %s", checkName, try+1, maxTries, err)
errs[i].err = err
errs[i].durationMS = time.Since(start).Round(time.Millisecond).Milliseconds()
}
errStrings := make([]string, len(errs))
for i, err := range errs {
errStrings[i] = fmt.Sprintf("attempt %d (%dms): %s", i+1, err.durationMS, err.err)
}
return fmt.Errorf("%w: %s", ErrAllCheckTriesFailed, strings.Join(errStrings, ", "))
}
func (c *Checker) startupCheck(ctx context.Context) error {
// connection isn't under load yet when the checker starts, so a short
// 6 seconds timeout suffices and provides quick enough feedback that
// the new connection is not working. However, since the addresses to dial
// may be multiple, we run the check in parallel. If any succeeds, the check passes.
// This is to prevent false negatives at startup, if one of the addresses is down
// for external reasons.
const timeout = 6 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
errCh := make(chan error)
for _, address := range c.tlsDialAddrs {
go func(addr string) {
err := tcpTLSCheck(ctx, c.dialer, addr)
errCh <- err
}(address)
}
errs := make([]error, 0, len(c.tlsDialAddrs))
success := false
for range c.tlsDialAddrs {
err := <-errCh
if err == nil {
success = true
cancel()
continue
} else if success {
continue // ignore canceled errors after success
}
c.logger.Debugf("startup check parallel attempt failed: %s", err)
errs = append(errs, err) errs = append(errs, err)
try++
if try < maxTries {
continue
} }
if success {
return nil
}
errStrings := make([]string, len(errs)) errStrings := make([]string, len(errs))
for i, err := range errs { for i, err := range errs {
errStrings[i] = fmt.Sprintf("parallel attempt %d/%d failed: %s", i+1, len(errs), err) errStrings[i] = fmt.Sprintf("attempt %d: %s", i+1, err.Error())
} }
return fmt.Errorf("%w: %s", ErrAllCheckTriesFailed, strings.Join(errStrings, ", ")) return fmt.Errorf("%w: after %d %s attempts (%s)",
} ErrAllCheckTriesFailed, maxTries, checkName, strings.Join(errStrings, "; "))
const (
smallCheckDNS = "dns"
smallCheckICMP = "icmp"
)
func smallCheckTypeToString(smallCheckType string) string {
switch smallCheckType {
case smallCheckICMP:
return "ICMP echo"
case smallCheckDNS:
return "plain DNS over UDP"
default:
panic("unknown small check type: " + smallCheckType)
} }
} }

View File

@@ -18,11 +18,11 @@ func Test_Checker_fullcheck(t *testing.T) {
t.Parallel() t.Parallel()
dialer := &net.Dialer{} dialer := &net.Dialer{}
addresses := []string{"badaddress:9876", "cloudflare.com:443", "google.com:443"} const address = "cloudflare.com:443"
checker := &Checker{ checker := &Checker{
dialer: dialer, dialer: dialer,
tlsDialAddrs: addresses, tlsDialAddr: address,
} }
canceledCtx, cancel := context.WithCancel(context.Background()) canceledCtx, cancel := context.WithCancel(context.Background())
@@ -53,7 +53,7 @@ func Test_Checker_fullcheck(t *testing.T) {
dialer := &net.Dialer{} dialer := &net.Dialer{}
checker := &Checker{ checker := &Checker{
dialer: dialer, dialer: dialer,
tlsDialAddrs: []string{listeningAddress.String()}, tlsDialAddr: listeningAddress.String(),
} }
err = checker.fullPeriodicCheck(ctx) err = checker.fullPeriodicCheck(ctx)

View File

@@ -44,22 +44,22 @@ func concatAddrPorts(addrs [][]netip.AddrPort) []netip.AddrPort {
var ErrLookupNoIPs = errors.New("no IPs found from DNS lookup") var ErrLookupNoIPs = errors.New("no IPs found from DNS lookup")
func (c *Client) Check(ctx context.Context) error { func (c *Client) Check(ctx context.Context) error {
dnsAddr := c.serverAddrs[c.dnsIPIndex].String() dnsAddr := c.serverAddrs[c.dnsIPIndex].Addr()
resolver := &net.Resolver{ resolver := &net.Resolver{
PreferGo: true, PreferGo: true,
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{} dialer := net.Dialer{}
return dialer.DialContext(ctx, "udp", dnsAddr) return dialer.DialContext(ctx, "udp", dnsAddr.String())
}, },
} }
ips, err := resolver.LookupIP(ctx, "ip", "github.com") ips, err := resolver.LookupIP(ctx, "ip", "github.com")
switch { switch {
case err != nil: case err != nil:
c.dnsIPIndex = (c.dnsIPIndex + 1) % len(c.serverAddrs) c.dnsIPIndex = (c.dnsIPIndex + 1) % len(c.serverAddrs)
return fmt.Errorf("with DNS server %s: %w", dnsAddr, err) return err
case len(ips) == 0: case len(ips) == 0:
c.dnsIPIndex = (c.dnsIPIndex + 1) % len(c.serverAddrs) c.dnsIPIndex = (c.dnsIPIndex + 1) % len(c.serverAddrs)
return fmt.Errorf("with DNS server %s: %w", dnsAddr, ErrLookupNoIPs) return fmt.Errorf("%w", ErrLookupNoIPs)
default: default:
return nil return nil
} }

View File

@@ -82,20 +82,20 @@ func (i *Echoer) Echo(ctx context.Context, ip netip.Addr) (err error) {
if strings.HasSuffix(err.Error(), "sendto: operation not permitted") { if strings.HasSuffix(err.Error(), "sendto: operation not permitted") {
err = fmt.Errorf("%w", ErrNotPermitted) err = fmt.Errorf("%w", ErrNotPermitted)
} }
return fmt.Errorf("writing ICMP message to %s: %w", ip, err) return fmt.Errorf("writing ICMP message: %w", err)
} }
receivedData, err := receiveEchoReply(conn, id, i.buffer, ipVersion, i.logger) receivedData, err := receiveEchoReply(conn, id, i.buffer, ipVersion, i.logger)
if err != nil { if err != nil {
if errors.Is(err, net.ErrClosed) && ctx.Err() != nil { if errors.Is(err, net.ErrClosed) && ctx.Err() != nil {
return fmt.Errorf("%w from %s", ErrTimedOut, ip) return fmt.Errorf("%w", ErrTimedOut)
} }
return fmt.Errorf("receiving ICMP echo reply from %s: %w", ip, err) return fmt.Errorf("receiving ICMP echo reply: %w", err)
} }
sentData := message.Body.(*icmp.Echo).Data //nolint:forcetypeassert sentData := message.Body.(*icmp.Echo).Data //nolint:forcetypeassert
if !bytes.Equal(receivedData, sentData) { if !bytes.Equal(receivedData, sentData) {
return fmt.Errorf("%w: sent %x to %s and received %x", ErrICMPEchoDataMismatch, sentData, ip, receivedData) return fmt.Errorf("%w: sent %x and received %x", ErrICMPEchoDataMismatch, sentData, receivedData)
} }
return nil return nil

View File

@@ -10,13 +10,11 @@ import (
func (s *Server) Run(ctx context.Context, done chan<- struct{}) { func (s *Server) Run(ctx context.Context, done chan<- struct{}) {
defer close(done) defer close(done)
const readHeaderTimeout = 100 * time.Millisecond
const readTimeout = 500 * time.Millisecond
server := http.Server{ server := http.Server{
Addr: s.config.ServerAddress, Addr: s.config.ServerAddress,
Handler: s.handler, Handler: s.handler,
ReadHeaderTimeout: readHeaderTimeout, ReadHeaderTimeout: s.config.ReadHeaderTimeout,
ReadTimeout: readTimeout, ReadTimeout: s.config.ReadTimeout,
} }
serverDone := make(chan struct{}) serverDone := make(chan struct{})
go func() { go func() {

View File

@@ -1,9 +1,19 @@
package netlink package netlink
import "github.com/qdm12/log" import (
"context"
"net/netip"
"github.com/qdm12/log"
)
type DebugLogger interface { type DebugLogger interface {
Debug(message string) Debug(message string)
Debugf(format string, args ...any) Debugf(format string, args ...any)
Patch(options ...log.Option) Patch(options ...log.Option)
} }
type Firewall interface {
AcceptOutput(ctx context.Context, protocol, intf string, ip netip.Addr,
port uint16, remove bool) (err error)
}

View File

@@ -1,37 +1,106 @@
package netlink package netlink
import ( import (
"context"
"fmt" "fmt"
"net"
"net/netip"
"time"
) )
func (n *NetLink) IsIPv6Supported() (supported bool, err error) { type IPv6SupportLevel uint8
const (
IPv6Unsupported = iota
// IPv6Supported indicates the host supports IPv6 but has no access to the
// Internet via IPv6. It is true if one IPv6 route is found and no default
// IPv6 route is found.
IPv6Supported
// IPv6Internet indicates the host has access to the Internet via IPv6,
// which is detected when a default IPv6 route is found.
IPv6Internet
)
func (i IPv6SupportLevel) IsSupported() bool {
return i == IPv6Supported || i == IPv6Internet
}
func (n *NetLink) FindIPv6SupportLevel(ctx context.Context,
checkAddress netip.AddrPort, firewall Firewall,
) (level IPv6SupportLevel, err error) {
routes, err := n.RouteList(FamilyV6) routes, err := n.RouteList(FamilyV6)
if err != nil { if err != nil {
return false, fmt.Errorf("listing IPv6 routes: %w", err) return IPv6Unsupported, fmt.Errorf("listing IPv6 routes: %w", err)
} }
// Check each route for IPv6 due to Podman bug listing IPv4 routes // Check each route for IPv6 due to Podman bug listing IPv4 routes
// as IPv6 routes at container start, see: // as IPv6 routes at container start, see:
// https://github.com/qdm12/gluetun/issues/1241#issuecomment-1333405949 // https://github.com/qdm12/gluetun/issues/1241#issuecomment-1333405949
level = IPv6Unsupported
for _, route := range routes { for _, route := range routes {
link, err := n.LinkByIndex(route.LinkIndex) link, err := n.LinkByIndex(route.LinkIndex)
if err != nil { if err != nil {
return false, fmt.Errorf("finding link corresponding to route: %w", err) return IPv6Unsupported, fmt.Errorf("finding link corresponding to route: %w", err)
} }
sourceIsIPv6 := route.Src.IsValid() && route.Src.Is6() sourceIsIPv4 := route.Src.IsValid() && route.Src.Is4()
destinationIsIPv4 := route.Dst.IsValid() && route.Dst.Addr().Is4()
destinationIsIPv6 := route.Dst.IsValid() && route.Dst.Addr().Is6() destinationIsIPv6 := route.Dst.IsValid() && route.Dst.Addr().Is6()
switch { switch {
case !sourceIsIPv6 && !destinationIsIPv6, case sourceIsIPv4 && destinationIsIPv4,
destinationIsIPv6 && route.Dst.Addr().IsLoopback(): destinationIsIPv6 && route.Dst.Addr().IsLoopback():
case route.Dst.Addr().IsUnspecified(): // default ipv6 route
n.debugLogger.Debugf("IPv6 default route found on link %s", link.Name)
err = dialAddrThroughFirewall(ctx, link.Name, checkAddress, firewall)
if err != nil {
n.debugLogger.Debugf("IPv6 query failed on %s: %w", link.Name, err)
level = IPv6Supported
continue continue
} }
n.debugLogger.Debugf("IPv6 internet is accessible through link %s", link.Name)
return IPv6Internet, nil
default: // non-default ipv6 route found
n.debugLogger.Debugf("IPv6 is supported by link %s", link.Name) n.debugLogger.Debugf("IPv6 is supported by link %s", link.Name)
return true, nil level = IPv6Supported
}
} }
n.debugLogger.Debugf("IPv6 is not supported after searching %d routes", if level == IPv6Unsupported {
len(routes)) n.debugLogger.Debugf("no IPv6 route found in %d routes", len(routes))
return false, nil }
return level, nil
}
func dialAddrThroughFirewall(ctx context.Context, intf string,
checkAddress netip.AddrPort, firewall Firewall,
) (err error) {
const protocol = "tcp"
remove := false
err = firewall.AcceptOutput(ctx, protocol, intf,
checkAddress.Addr(), checkAddress.Port(), remove)
if err != nil {
return fmt.Errorf("accepting output traffic: %w", err)
}
defer func() {
remove = true
firewallErr := firewall.AcceptOutput(ctx, protocol, intf,
checkAddress.Addr(), checkAddress.Port(), remove)
if err == nil && firewallErr != nil {
err = fmt.Errorf("removing output traffic rule: %w", firewallErr)
}
}()
dialer := &net.Dialer{
Timeout: time.Second,
}
conn, err := dialer.DialContext(ctx, protocol, checkAddress.String())
if err != nil {
return fmt.Errorf("dialing: %w", err)
}
err = conn.Close()
if err != nil {
return fmt.Errorf("closing connection: %w", err)
}
return nil
} }

View File

@@ -0,0 +1,167 @@
package netlink
import (
"context"
"errors"
"net"
"net/netip"
"strings"
"testing"
"time"
gomock "github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func isIPv6LocallySupported() bool {
dialer := net.Dialer{Timeout: time.Millisecond}
_, err := dialer.Dial("tcp6", "[::1]:9999")
return !strings.HasSuffix(err.Error(), "connect: cannot assign requested address")
}
// Susceptible to TOCTOU but it should be fine for the use case.
func findAvailableTCPPort(t *testing.T) (port uint16) {
t.Helper()
config := &net.ListenConfig{}
listener, err := config.Listen(context.Background(), "tcp", "localhost:0")
require.NoError(t, err)
addr := listener.Addr().String()
err = listener.Close()
require.NoError(t, err)
addrPort, err := netip.ParseAddrPort(addr)
require.NoError(t, err)
return addrPort.Port()
}
func Test_dialAddrThroughFirewall(t *testing.T) {
t.Parallel()
errTest := errors.New("test error")
const ipv6InternetWorks = false
testCases := map[string]struct {
getIPv6CheckAddr func(t *testing.T) netip.AddrPort
firewallAddErr error
firewallRemoveErr error
errMessageRegex func() string
}{
"cloudflare.com": {
getIPv6CheckAddr: func(_ *testing.T) netip.AddrPort {
return netip.MustParseAddrPort("[2606:4700::6810:84e5]:443")
},
errMessageRegex: func() string {
if ipv6InternetWorks {
return ""
}
return "dialing: dial tcp \\[2606:4700::6810:84e5\\]:443: " +
"connect: (cannot assign requested address|network is unreachable)"
},
},
"local_server": {
getIPv6CheckAddr: func(t *testing.T) netip.AddrPort {
t.Helper()
network := "tcp6"
loopback := netip.MustParseAddr("::1")
if !isIPv6LocallySupported() {
network = "tcp4"
loopback = netip.MustParseAddr("127.0.0.1")
}
listener, err := net.ListenTCP(network, nil)
require.NoError(t, err)
t.Cleanup(func() {
err := listener.Close()
require.NoError(t, err)
})
addrPort := netip.MustParseAddrPort(listener.Addr().String())
return netip.AddrPortFrom(loopback, addrPort.Port())
},
},
"no_local_server": {
getIPv6CheckAddr: func(t *testing.T) netip.AddrPort {
t.Helper()
loopback := netip.MustParseAddr("::1")
if !ipv6InternetWorks {
loopback = netip.MustParseAddr("127.0.0.1")
}
availablePort := findAvailableTCPPort(t)
return netip.AddrPortFrom(loopback, availablePort)
},
errMessageRegex: func() string {
return "dialing: dial tcp (\\[::1\\]|127\\.0\\.0\\.1):[1-9][0-9]{1,4}: " +
"connect: connection refused"
},
},
"firewall_add_error": {
firewallAddErr: errTest,
errMessageRegex: func() string {
return "accepting output traffic: test error"
},
},
"firewall_remove_error": {
getIPv6CheckAddr: func(t *testing.T) netip.AddrPort {
t.Helper()
network := "tcp4"
loopback := netip.MustParseAddr("127.0.0.1")
listener, err := net.ListenTCP(network, nil)
require.NoError(t, err)
t.Cleanup(func() {
err := listener.Close()
require.NoError(t, err)
})
addrPort := netip.MustParseAddrPort(listener.Addr().String())
return netip.AddrPortFrom(loopback, addrPort.Port())
},
firewallRemoveErr: errTest,
errMessageRegex: func() string {
return "removing output traffic rule: test error"
},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
var checkAddr netip.AddrPort
if testCase.getIPv6CheckAddr != nil {
checkAddr = testCase.getIPv6CheckAddr(t)
}
ctx := context.Background()
const intf = "eth0"
firewall := NewMockFirewall(ctrl)
call := firewall.EXPECT().AcceptOutput(ctx, "tcp", intf,
checkAddr.Addr(), checkAddr.Port(), false).
Return(testCase.firewallAddErr)
if testCase.firewallAddErr == nil {
firewall.EXPECT().AcceptOutput(ctx, "tcp", intf,
checkAddr.Addr(), checkAddr.Port(), true).
Return(testCase.firewallRemoveErr).After(call)
}
err := dialAddrThroughFirewall(ctx, intf, checkAddr, firewall)
var errMessageRegex string
if testCase.errMessageRegex != nil {
errMessageRegex = testCase.errMessageRegex()
}
if errMessageRegex == "" {
assert.NoError(t, err)
} else {
require.Error(t, err)
assert.Regexp(t, errMessageRegex, err.Error())
}
})
}
}

View File

@@ -0,0 +1,3 @@
package netlink
//go:generate mockgen -destination=mocks_test.go -package=$GOPACKAGE . Firewall

View File

@@ -0,0 +1,50 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/qdm12/gluetun/internal/netlink (interfaces: Firewall)
// Package netlink is a generated GoMock package.
package netlink
import (
context "context"
netip "net/netip"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockFirewall is a mock of Firewall interface.
type MockFirewall struct {
ctrl *gomock.Controller
recorder *MockFirewallMockRecorder
}
// MockFirewallMockRecorder is the mock recorder for MockFirewall.
type MockFirewallMockRecorder struct {
mock *MockFirewall
}
// NewMockFirewall creates a new mock instance.
func NewMockFirewall(ctrl *gomock.Controller) *MockFirewall {
mock := &MockFirewall{ctrl: ctrl}
mock.recorder = &MockFirewallMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockFirewall) EXPECT() *MockFirewallMockRecorder {
return m.recorder
}
// AcceptOutput mocks base method.
func (m *MockFirewall) AcceptOutput(arg0 context.Context, arg1, arg2 string, arg3 netip.Addr, arg4 uint16, arg5 bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AcceptOutput", arg0, arg1, arg2, arg3, arg4, arg5)
ret0, _ := ret[0].(error)
return ret0
}
// AcceptOutput indicates an expected call of AcceptOutput.
func (mr *MockFirewallMockRecorder) AcceptOutput(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcceptOutput", reflect.TypeOf((*MockFirewall)(nil).AcceptOutput), arg0, arg1, arg2, arg3, arg4, arg5)
}

View File

@@ -10,7 +10,7 @@ import (
) )
func runCommand(ctx context.Context, cmder Cmder, logger Logger, func runCommand(ctx context.Context, cmder Cmder, logger Logger,
commandTemplate string, ports []uint16, vpnInterface string, commandTemplate string, ports []uint16,
) (err error) { ) (err error) {
portStrings := make([]string, len(ports)) portStrings := make([]string, len(ports))
for i, port := range ports { for i, port := range ports {
@@ -18,8 +18,6 @@ func runCommand(ctx context.Context, cmder Cmder, logger Logger,
} }
portsString := strings.Join(portStrings, ",") portsString := strings.Join(portStrings, ",")
commandString := strings.ReplaceAll(commandTemplate, "{{PORTS}}", portsString) commandString := strings.ReplaceAll(commandTemplate, "{{PORTS}}", portsString)
commandString = strings.ReplaceAll(commandString, "{{PORT}}", portStrings[0])
commandString = strings.ReplaceAll(commandString, "{{VPN_INTERFACE}}", vpnInterface)
args, err := command.Split(commandString) args, err := command.Split(commandString)
if err != nil { if err != nil {
return fmt.Errorf("parsing command: %w", err) return fmt.Errorf("parsing command: %w", err)

View File

@@ -17,13 +17,12 @@ func Test_Service_runCommand(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cmder := command.New() cmder := command.New()
const commandTemplate = `/bin/sh -c "echo {{PORTS}}-{{PORT}}-{{VPN_INTERFACE}}"` const commandTemplate = `/bin/sh -c "echo {{PORTS}}"`
ports := []uint16{1234, 5678} ports := []uint16{1234, 5678}
const vpnInterface = "tun0"
logger := NewMockLogger(ctrl) logger := NewMockLogger(ctrl)
logger.EXPECT().Info("1234,5678-1234-tun0") logger.EXPECT().Info("1234,5678")
err := runCommand(ctx, cmder, logger, commandTemplate, ports, vpnInterface) err := runCommand(ctx, cmder, logger, commandTemplate, ports)
require.NoError(t, err) require.NoError(t, err)
} }

View File

@@ -14,11 +14,7 @@ func (s *Service) writePortForwardedFile(ports []uint16) (err error) {
fileData := []byte(strings.Join(portStrings, "\n")) fileData := []byte(strings.Join(portStrings, "\n"))
filepath := s.settings.Filepath filepath := s.settings.Filepath
if len(ports) == 0 {
s.logger.Info("clearing port file " + filepath)
} else {
s.logger.Info("writing port file " + filepath) s.logger.Info("writing port file " + filepath)
}
const perms = os.FileMode(0o644) const perms = os.FileMode(0o644)
err = os.WriteFile(filepath, fileData, perms) err = os.WriteFile(filepath, fileData, perms)
if err != nil { if err != nil {

View File

@@ -74,7 +74,7 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error)
s.portMutex.Unlock() s.portMutex.Unlock()
if s.settings.UpCommand != "" { if s.settings.UpCommand != "" {
err = runCommand(ctx, s.cmder, s.logger, s.settings.UpCommand, ports, s.settings.Interface) err = runCommand(ctx, s.cmder, s.logger, s.settings.UpCommand, ports)
if err != nil { if err != nil {
err = fmt.Errorf("running up command: %w", err) err = fmt.Errorf("running up command: %w", err)
s.logger.Error(err.Error()) s.logger.Error(err.Error())

View File

@@ -34,7 +34,7 @@ func (s *Service) cleanup() (err error) {
const downTimeout = 60 * time.Second const downTimeout = 60 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), downTimeout) ctx, cancel := context.WithTimeout(context.Background(), downTimeout)
defer cancel() defer cancel()
err = runCommand(ctx, s.cmder, s.logger, s.settings.DownCommand, s.ports, s.settings.Interface) err = runCommand(ctx, s.cmder, s.logger, s.settings.DownCommand, s.ports)
if err != nil { if err != nil {
err = fmt.Errorf("running down command: %w", err) err = fmt.Errorf("running down command: %w", err)
s.logger.Error(err.Error()) s.logger.Error(err.Error())
@@ -59,6 +59,8 @@ func (s *Service) cleanup() (err error) {
s.ports = nil s.ports = nil
filepath := s.settings.Filepath
s.logger.Info("clearing port file " + filepath)
err = s.writePortForwardedFile(nil) err = s.writePortForwardedFile(nil)
if err != nil { if err != nil {
return fmt.Errorf("clearing port file: %w", err) return fmt.Errorf("clearing port file: %w", err)

View File

@@ -13,7 +13,6 @@ var (
ErrNotEnoughServers = errors.New("not enough servers found") ErrNotEnoughServers = errors.New("not enough servers found")
ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK") ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
ErrIPFetcherUnsupported = errors.New("IP fetcher not supported") ErrIPFetcherUnsupported = errors.New("IP fetcher not supported")
ErrCredentialsMissing = errors.New("credentials missing")
) )
type Fetcher interface { type Fetcher interface {

View File

@@ -18,12 +18,11 @@ type Provider struct {
func New(storage common.Storage, randSource rand.Source, func New(storage common.Storage, randSource rand.Source,
client *http.Client, updaterWarner common.Warner, client *http.Client, updaterWarner common.Warner,
email, password string,
) *Provider { ) *Provider {
return &Provider{ return &Provider{
storage: storage, storage: storage,
randSource: randSource, randSource: randSource,
Fetcher: updater.New(client, updaterWarner, email, password), Fetcher: updater.New(client, updaterWarner),
} }
} }

View File

@@ -1,562 +1,15 @@
package updater package updater
import ( import (
"bytes"
"context" "context"
crand "crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"math/rand/v2"
"net/http" "net/http"
"net/netip" "net/netip"
"slices"
"strings"
srp "github.com/ProtonMail/go-srp"
) )
// apiClient is a minimal Proton v4 API client which can handle all the var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
// oddities of Proton's authentication flow they want to keep hidden
// from the public.
type apiClient struct {
apiURLBase string
httpClient *http.Client
appVersion string
userAgent string
generator *rand.ChaCha8
}
// newAPIClient returns an [apiClient] with sane defaults matching Proton's
// insane expectations.
func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClient, err error) {
var seed [32]byte
_, _ = crand.Read(seed[:])
generator := rand.NewChaCha8(seed)
// Pick a random user agent from this list. Because I'm not going to tell
// Proton shit on where all these funny requests are coming from, given their
// unhelpfulness in figuring out their authentication flow.
userAgents := [...]string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
"Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
}
userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))]
appVersion, err := getMostRecentStableTag(ctx, httpClient)
if err != nil {
return nil, fmt.Errorf("getting most recent version for proton app: %w", err)
}
return &apiClient{
apiURLBase: "https://account.proton.me/api",
httpClient: httpClient,
appVersion: appVersion,
userAgent: userAgent,
generator: generator,
}, nil
}
var ErrCodeNotSuccess = errors.New("response code is not success")
// setHeaders sets the minimal necessary headers for Proton API requests
// to succeed without being blocked by their "security" measures.
// See for example [getMostRecentStableTag] on how the app version must
// be set to a recent version or they block your request. "SeCuRiTy"...
func (c *apiClient) setHeaders(request *http.Request, cookie cookie) {
request.Header.Set("Cookie", cookie.String())
request.Header.Set("User-Agent", c.userAgent)
request.Header.Set("x-pm-appversion", c.appVersion)
request.Header.Set("x-pm-locale", "en_US")
request.Header.Set("x-pm-uid", cookie.uid)
}
// authenticate performs the full Proton authentication flow
// to obtain an authenticated cookie (uid, token and session ID).
func (c *apiClient) authenticate(ctx context.Context, email, password string,
) (authCookie cookie, err error) {
sessionID, err := c.getSessionID(ctx)
if err != nil {
return cookie{}, fmt.Errorf("getting session ID: %w", err)
}
tokenType, accessToken, refreshToken, uid, err := c.getUnauthSession(ctx, sessionID)
if err != nil {
return cookie{}, fmt.Errorf("getting unauthenticated session data: %w", err)
}
cookieToken, err := c.cookieToken(ctx, sessionID, tokenType, accessToken, refreshToken, uid)
if err != nil {
return cookie{}, fmt.Errorf("getting cookie token: %w", err)
}
unauthCookie := cookie{
uid: uid,
token: cookieToken,
sessionID: sessionID,
}
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64,
srpSessionHex, version, err := c.authInfo(ctx, email, unauthCookie)
if err != nil {
return cookie{}, fmt.Errorf("getting auth information: %w", err)
}
// Prepare SRP proof generator using Proton's official SRP parameters and hashing.
srpAuth, err := srp.NewAuth(version, username, []byte(password),
saltBase64, modulusPGPClearSigned, serverEphemeralBase64)
if err != nil {
return cookie{}, fmt.Errorf("initializing SRP auth: %w", err)
}
// Generate SRP proofs (A, M1) with the usual 2048-bit modulus.
const modulusBits = 2048
proofs, err := srpAuth.GenerateProofs(modulusBits)
if err != nil {
return cookie{}, fmt.Errorf("generating SRP proofs: %w", err)
}
authCookie, err = c.auth(ctx, unauthCookie, email, srpSessionHex, proofs)
if err != nil {
return cookie{}, fmt.Errorf("authentifying: %w", err)
}
return authCookie, nil
}
var ErrSessionIDNotFound = errors.New("session ID not found in cookies")
func (c *apiClient) getSessionID(ctx context.Context) (sessionID string, err error) {
const url = "https://account.proton.me/vpn"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
response, err := c.httpClient.Do(request)
if err != nil {
return "", err
}
err = response.Body.Close()
if err != nil {
return "", fmt.Errorf("closing response body: %w", err)
}
for _, cookie := range response.Cookies() {
if cookie.Name == "Session-Id" {
return cookie.Value, nil
}
}
return "", fmt.Errorf("%w", ErrSessionIDNotFound)
}
var ErrDataFieldMissing = errors.New("data field missing in response")
func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) (
tokenType, accessToken, refreshToken, uid string, err error,
) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/auth/v4/sessions", nil)
if err != nil {
return "", "", "", "", fmt.Errorf("creating request: %w", err)
}
unauthCookie := cookie{
sessionID: sessionID,
}
c.setHeaders(request, unauthCookie)
response, err := c.httpClient.Do(request)
if err != nil {
return "", "", "", "", err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return "", "", "", "", fmt.Errorf("reading response body: %w", err)
} else if response.StatusCode != http.StatusOK {
return "", "", "", "", buildError(response.StatusCode, responseBody)
}
var data struct {
Code uint `json:"Code"` // 1000 on success
AccessToken string `json:"AccessToken"` // 32-chars lowercase and digits
RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits
TokenType string `json:"TokenType"` // "Bearer"
Scopes []string `json:"Scopes"` // should be [] for our usage
UID string `json:"UID"` // 32-chars lowercase and digits
LocalID uint `json:"LocalID"` // 0 in my case
}
err = json.Unmarshal(responseBody, &data)
if err != nil {
return "", "", "", "", fmt.Errorf("decoding response body: %w", err)
}
const successCode = 1000
switch {
case data.Code != successCode:
return "", "", "", "", fmt.Errorf("%w: expected %d got %d",
ErrCodeNotSuccess, successCode, data.Code)
case data.AccessToken == "":
return "", "", "", "", fmt.Errorf("%w: access token is empty", ErrDataFieldMissing)
case data.RefreshToken == "":
return "", "", "", "", fmt.Errorf("%w: refresh token is empty", ErrDataFieldMissing)
case data.TokenType == "":
return "", "", "", "", fmt.Errorf("%w: token type is empty", ErrDataFieldMissing)
case data.UID == "":
return "", "", "", "", fmt.Errorf("%w: UID is empty", ErrDataFieldMissing)
}
// Ignore Scopes and LocalID fields, we don't use them.
return data.TokenType, data.AccessToken, data.RefreshToken, data.UID, nil
}
var ErrUIDMismatch = errors.New("UID in response does not match request UID")
func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, accessToken,
refreshToken, uid string,
) (cookieToken string, err error) {
type requestBodySchema struct {
GrantType string `json:"GrantType"` // "refresh_token"
Persistent uint `json:"Persistent"` // 0
RedirectURI string `json:"RedirectURI"` // "https://protonmail.com"
RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits
ResponseType string `json:"ResponseType"` // "token"
State string `json:"State"` // 24-chars letters and digits
UID string `json:"UID"` // 32-chars lowercase and digits
}
requestBody := requestBodySchema{
GrantType: "refresh_token",
Persistent: 0,
RedirectURI: "https://protonmail.com",
RefreshToken: refreshToken,
ResponseType: "token",
State: generateLettersDigits(c.generator, 24), //nolint:mnd
UID: uid,
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
if err := encoder.Encode(requestBody); err != nil {
return "", fmt.Errorf("encoding request body: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/cookies", buffer)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
unauthCookie := cookie{
uid: uid,
sessionID: sessionID,
}
c.setHeaders(request, unauthCookie)
request.Header.Set("Authorization", tokenType+" "+accessToken)
response, err := c.httpClient.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("reading response body: %w", err)
} else if response.StatusCode != http.StatusOK {
return "", buildError(response.StatusCode, responseBody)
}
var cookies struct {
Code uint `json:"Code"` // 1000 on success
UID string `json:"UID"` // should match request UID
LocalID uint `json:"LocalID"` // 0
RefreshCounter uint `json:"RefreshCounter"` // 1
}
err = json.Unmarshal(responseBody, &cookies)
if err != nil {
return "", fmt.Errorf("decoding response body: %w", err)
}
const successCode = 1000
switch {
case cookies.Code != successCode:
return "", fmt.Errorf("%w: expected %d got %d",
ErrCodeNotSuccess, successCode, cookies.Code)
case cookies.UID != requestBody.UID:
return "", fmt.Errorf("%w: expected %s got %s",
ErrUIDMismatch, requestBody.UID, cookies.UID)
}
// Ignore LocalID and RefreshCounter fields, we don't use them.
for _, cookie := range response.Cookies() {
if cookie.Name == "AUTH-"+uid {
return cookie.Value, nil
}
}
return "", fmt.Errorf("%w", ErrAuthCookieNotFound)
}
var ErrUsernameDoesNotExist = errors.New("username does not exist")
// authInfo fetches SRP parameters for the account.
func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie cookie) (
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string,
version int, err error,
) {
type requestBodySchema struct {
Intent string `json:"Intent"` // "Proton"
Username string `json:"Username"`
}
requestBody := requestBodySchema{
Intent: "Proton",
Username: email,
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
if err := encoder.Encode(requestBody); err != nil {
return "", "", "", "", "", 0, fmt.Errorf("encoding request body: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/info", buffer)
if err != nil {
return "", "", "", "", "", 0, fmt.Errorf("creating request: %w", err)
}
c.setHeaders(request, unauthCookie)
response, err := c.httpClient.Do(request)
if err != nil {
return "", "", "", "", "", 0, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return "", "", "", "", "", 0, fmt.Errorf("reading response body: %w", err)
} else if response.StatusCode != http.StatusOK {
return "", "", "", "", "", 0, buildError(response.StatusCode, responseBody)
}
var info struct {
Code uint `json:"Code"` // 1000 on success
Modulus string `json:"Modulus"` // PGP clearsigned modulus string
ServerEphemeral string `json:"ServerEphemeral"` // base64
Version *uint `json:"Version,omitempty"` // 4 as of 2025-10-26
Salt string `json:"Salt"` // base64
SRPSession string `json:"SRPSession"` // hexadecimal
Username string `json:"Username"` // user without @domain.com. Mine has its first letter capitalized.
}
err = json.Unmarshal(responseBody, &info)
if err != nil {
return "", "", "", "", "", 0, fmt.Errorf("decoding response body: %w", err)
}
const successCode = 1000
switch {
case info.Code != successCode:
return "", "", "", "", "", 0, fmt.Errorf("%w: expected %d got %d",
ErrCodeNotSuccess, successCode, info.Code)
case info.Modulus == "":
return "", "", "", "", "", 0, fmt.Errorf("%w: modulus is empty", ErrDataFieldMissing)
case info.ServerEphemeral == "":
return "", "", "", "", "", 0, fmt.Errorf("%w: server ephemeral is empty", ErrDataFieldMissing)
case info.Salt == "":
return "", "", "", "", "", 0, fmt.Errorf("%w (salt data field is empty)", ErrUsernameDoesNotExist)
case info.SRPSession == "":
return "", "", "", "", "", 0, fmt.Errorf("%w: SRP session is empty", ErrDataFieldMissing)
case info.Username == "":
return "", "", "", "", "", 0, fmt.Errorf("%w: username is empty", ErrDataFieldMissing)
case info.Version == nil:
return "", "", "", "", "", 0, fmt.Errorf("%w: version is missing", ErrDataFieldMissing)
}
version = int(*info.Version) //nolint:gosec
return info.Username, info.Modulus, info.ServerEphemeral, info.Salt,
info.SRPSession, version, nil
}
type cookie struct {
uid string
token string
sessionID string
}
func (c *cookie) String() string {
s := ""
if c.token != "" {
s += fmt.Sprintf("AUTH-%s=%s; ", c.uid, c.token)
}
if c.sessionID != "" {
s += fmt.Sprintf("Session-Id=%s; ", c.sessionID)
}
if c.token != "" {
s += "Tag=default; iaas=W10; Domain=proton.me; Feature=VPNDashboard:A"
}
return s
}
var (
// ErrServerProofNotValid indicates the M2 from the server didn't match the expected proof.
ErrServerProofNotValid = errors.New("server proof from server is not valid")
ErrVPNScopeNotFound = errors.New("VPN scope not found in scopes")
ErrTwoFANotSupported = errors.New("two factor authentication not supported in this client")
ErrAuthCookieNotFound = errors.New("auth cookie not found")
)
// auth performs the SRP proof submission (and optionally TOTP) to obtain tokens.
func (c *apiClient) auth(ctx context.Context, unauthCookie cookie,
username, srpSession string, proofs *srp.Proofs,
) (authCookie cookie, err error) {
clientEphemeral := base64.StdEncoding.EncodeToString(proofs.ClientEphemeral)
clientProof := base64.StdEncoding.EncodeToString(proofs.ClientProof)
type requestBodySchema struct {
ClientEphemeral string `json:"ClientEphemeral"` // base64(A)
ClientProof string `json:"ClientProof"` // base64(M1)
Payload map[string]string `json:"Payload,omitempty"` // not sure
SRPSession string `json:"SRPSession"` // hexadecimal
Username string `json:"Username"` // user@protonmail.com
}
requestBody := requestBodySchema{
ClientEphemeral: clientEphemeral,
ClientProof: clientProof,
SRPSession: srpSession,
Username: username,
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
if err := encoder.Encode(requestBody); err != nil {
return cookie{}, fmt.Errorf("encoding request body: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth", buffer)
if err != nil {
return cookie{}, fmt.Errorf("creating request: %w", err)
}
c.setHeaders(request, unauthCookie)
response, err := c.httpClient.Do(request)
if err != nil {
return cookie{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return cookie{}, fmt.Errorf("reading response body: %w", err)
} else if response.StatusCode != http.StatusOK {
return cookie{}, buildError(response.StatusCode, responseBody)
}
type twoFAStatus uint
//nolint:unused
const (
twoFADisabled twoFAStatus = iota
twoFAHasTOTP
twoFAHasFIDO2
twoFAHasFIDO2AndTOTP
)
type twoFAInfo struct {
Enabled twoFAStatus `json:"Enabled"`
FIDO2 struct {
AuthenticationOptions any `json:"AuthenticationOptions"`
RegisteredKeys []any `json:"RegisteredKeys"`
} `json:"FIDO2"`
TOTP uint `json:"TOTP"`
}
var auth struct {
Code uint `json:"Code"` // 1000 on success
LocalID uint `json:"LocalID"` // 7 in my case
Scopes []string `json:"Scopes"` // this should contain "vpn". Same as `Scope` field value.
UID string `json:"UID"` // same as `Uid` field value
UserID string `json:"UserID"` // base64
EventID string `json:"EventID"` // base64
PasswordMode uint `json:"PasswordMode"` // 1 in my case
ServerProof string `json:"ServerProof"` // base64(M2)
TwoFactor uint `json:"TwoFactor"` // 0 if 2FA not required
TwoFA twoFAInfo `json:"2FA"`
TemporaryPassword uint `json:"TemporaryPassword"` // 0 in my case
}
err = json.Unmarshal(responseBody, &auth)
if err != nil {
return cookie{}, fmt.Errorf("decoding response body: %w", err)
}
m2, err := base64.StdEncoding.DecodeString(auth.ServerProof)
if err != nil {
return cookie{}, fmt.Errorf("decoding server proof: %w", err)
}
if !bytes.Equal(m2, proofs.ExpectedServerProof) {
return cookie{}, fmt.Errorf("%w: expected %x got %x",
ErrServerProofNotValid, proofs.ExpectedServerProof, m2)
}
const successCode = 1000
switch {
case auth.Code != successCode:
return cookie{}, fmt.Errorf("%w: expected %d got %d",
ErrCodeNotSuccess, successCode, auth.Code)
case auth.UID != unauthCookie.uid:
return cookie{}, fmt.Errorf("%w: expected %s got %s",
ErrUIDMismatch, unauthCookie.uid, auth.UID)
case auth.TwoFactor != 0:
return cookie{}, fmt.Errorf("%w", ErrTwoFANotSupported)
case !slices.Contains(auth.Scopes, "vpn"):
return cookie{}, fmt.Errorf("%w: in %v", ErrVPNScopeNotFound, auth.Scopes)
}
for _, setCookieHeader := range response.Header.Values("Set-Cookie") {
parts := strings.Split(setCookieHeader, ";")
for _, part := range parts {
if strings.HasPrefix(part, "AUTH-"+unauthCookie.uid+"=") {
authCookie = unauthCookie
authCookie.token = strings.TrimPrefix(part, "AUTH-"+unauthCookie.uid+"=")
return authCookie, nil
}
}
}
return cookie{}, fmt.Errorf("%w: in HTTP headers %s",
ErrAuthCookieNotFound, httpHeadersToString(response.Header))
}
// generateLettersDigits mimicing Proton's own random string generator:
// https://github.com/ProtonMail/WebClients/blob/e4d7e4ab9babe15b79a131960185f9f8275512cd/packages/utils/generateLettersDigits.ts
func generateLettersDigits(rng *rand.ChaCha8, length uint) string {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return generateFromCharset(rng, length, charset)
}
func generateFromCharset(rng *rand.ChaCha8, length uint, charset string) string {
result := make([]byte, length)
randomBytes := make([]byte, length)
_, _ = rng.Read(randomBytes)
for i := range length {
result[i] = charset[int(randomBytes[i])%len(charset)]
}
return string(result)
}
func httpHeadersToString(headers http.Header) string {
var builder strings.Builder
first := true
for key, values := range headers {
for _, value := range values {
if !first {
builder.WriteString(", ")
}
builder.WriteString(fmt.Sprintf("%s: %s", key, value))
first = false
}
}
return builder.String()
}
type apiData struct { type apiData struct {
LogicalServers []logicalServer `json:"LogicalServers"` LogicalServers []logicalServer `json:"LogicalServers"`
@@ -580,25 +33,25 @@ type physicalServer struct {
X25519PublicKey string `json:"X25519PublicKey"` X25519PublicKey string `json:"X25519PublicKey"`
} }
func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) ( func fetchAPI(ctx context.Context, client *http.Client) (
data apiData, err error, data apiData, err error,
) { ) {
const url = "https://account.proton.me/api/vpn/logicals" const url = "https://api.protonmail.ch/vpn/logicals"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return data, err return data, err
} }
c.setHeaders(request, cookie)
response, err := c.httpClient.Do(request) response, err := client.Do(request)
if err != nil { if err != nil {
return data, err return data, err
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode != http.StatusOK { if response.StatusCode != http.StatusOK {
b, _ := io.ReadAll(response.Body) return data, fmt.Errorf("%w: %d %s", ErrHTTPStatusCodeNotOK,
return data, buildError(response.StatusCode, b) response.StatusCode, response.Status)
} }
decoder := json.NewDecoder(response.Body) decoder := json.NewDecoder(response.Body)
@@ -606,31 +59,9 @@ func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) (
return data, fmt.Errorf("decoding response body: %w", err) return data, fmt.Errorf("decoding response body: %w", err)
} }
if err := response.Body.Close(); err != nil {
return data, err
}
return data, nil return data, nil
} }
var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
func buildError(httpCode int, body []byte) error {
prettyCode := http.StatusText(httpCode)
var protonError struct {
Code *int `json:"Code,omitempty"`
Error *string `json:"Error,omitempty"`
Details map[string]string `json:"Details"`
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
err := decoder.Decode(&protonError)
if err != nil || protonError.Error == nil || protonError.Code == nil {
return fmt.Errorf("%w: %s: %s",
ErrHTTPStatusCodeNotOK, prettyCode, body)
}
details := make([]string, 0, len(protonError.Details))
for key, value := range protonError.Details {
details = append(details, fmt.Sprintf("%s: %s", key, value))
}
return fmt.Errorf("%w: %s: %s (code %d with details: %s)",
ErrHTTPStatusCodeNotOK, prettyCode, *protonError.Error, *protonError.Code, strings.Join(details, ", "))
}

View File

@@ -13,26 +13,9 @@ import (
func (u *Updater) FetchServers(ctx context.Context, minServers int) ( func (u *Updater) FetchServers(ctx context.Context, minServers int) (
servers []models.Server, err error, servers []models.Server, err error,
) { ) {
switch { data, err := fetchAPI(ctx, u.client)
case u.email == "":
return nil, fmt.Errorf("%w: email is empty", common.ErrCredentialsMissing)
case u.password == "":
return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing)
}
apiClient, err := newAPIClient(ctx, u.client)
if err != nil { if err != nil {
return nil, fmt.Errorf("creating API client: %w", err) return nil, err
}
cookie, err := apiClient.authenticate(ctx, u.email, u.password)
if err != nil {
return nil, fmt.Errorf("authentifying with Proton: %w", err)
}
data, err := apiClient.fetchServers(ctx, cookie)
if err != nil {
return nil, fmt.Errorf("fetching logical servers: %w", err)
} }
countryCodes := constants.CountryCodes() countryCodes := constants.CountryCodes()

View File

@@ -8,16 +8,12 @@ import (
type Updater struct { type Updater struct {
client *http.Client client *http.Client
email string
password string
warner common.Warner warner common.Warner
} }
func New(client *http.Client, warner common.Warner, email, password string) *Updater { func New(client *http.Client, warner common.Warner) *Updater {
return &Updater{ return &Updater{
client: client, client: client,
email: email,
password: password,
warner: warner, warner: warner,
} }
} }

View File

@@ -1,64 +0,0 @@
package updater
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
// getMostRecentStableTag finds the most recent proton-account stable tag version,
// in order to use it in the x-pm-appversion http request header. Because if we do
// fall behind on versioning, Proton doesn't like it because they like to create
// complications where there is no need for it. Hence this function.
func getMostRecentStableTag(ctx context.Context, client *http.Client) (version string, err error) {
page := 1
regexVersion := regexp.MustCompile(`^proton-account@(\d+\.\d+\.\d+\.\d+)$`)
for ctx.Err() == nil {
url := "https://api.github.com/repos/ProtonMail/WebClients/tags?per_page=30&page=" + fmt.Sprint(page)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
request.Header.Set("Accept", "application/vnd.github.v3+json")
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("reading response body: %w", err)
}
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: %s: %s", ErrHTTPStatusCodeNotOK, response.Status, data)
}
var tags []struct {
Name string `json:"name"`
}
err = json.Unmarshal(data, &tags)
if err != nil {
return "", fmt.Errorf("decoding JSON response: %w", err)
}
for _, tag := range tags {
if !regexVersion.MatchString(tag.Name) {
continue
}
version := "web-account@" + strings.TrimPrefix(tag.Name, "proton-account@")
return version, nil
}
page++
}
return "", fmt.Errorf("%w (queried %d pages)", context.Canceled, page)
}

View File

@@ -54,7 +54,7 @@ type Extractor interface {
func NewProviders(storage Storage, timeNow func() time.Time, func NewProviders(storage Storage, timeNow func() time.Time,
updaterWarner common.Warner, client *http.Client, unzipper common.Unzipper, updaterWarner common.Warner, client *http.Client, unzipper common.Unzipper,
parallelResolver common.ParallelResolver, ipFetcher common.IPFetcher, parallelResolver common.ParallelResolver, ipFetcher common.IPFetcher,
extractor custom.Extractor, credentials settings.Updater, extractor custom.Extractor,
) *Providers { ) *Providers {
randSource := rand.NewSource(timeNow().UnixNano()) randSource := rand.NewSource(timeNow().UnixNano())
@@ -75,7 +75,7 @@ func NewProviders(storage Storage, timeNow func() time.Time,
providers.Privado: privado.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver), providers.Privado: privado.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver),
providers.PrivateInternetAccess: privateinternetaccess.New(storage, randSource, timeNow, client), providers.PrivateInternetAccess: privateinternetaccess.New(storage, randSource, timeNow, client),
providers.Privatevpn: privatevpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver), providers.Privatevpn: privatevpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver),
providers.Protonvpn: protonvpn.New(storage, randSource, client, updaterWarner, *credentials.ProtonEmail, *credentials.ProtonPassword), providers.Protonvpn: protonvpn.New(storage, randSource, client, updaterWarner),
providers.Purevpn: purevpn.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver), providers.Purevpn: purevpn.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver),
providers.SlickVPN: slickvpn.New(storage, randSource, client, updaterWarner, parallelResolver), providers.SlickVPN: slickvpn.New(storage, randSource, client, updaterWarner, parallelResolver),
providers.Surfshark: surfshark.New(storage, randSource, client, unzipper, updaterWarner, parallelResolver), providers.Surfshark: surfshark.New(storage, randSource, client, unzipper, updaterWarner, parallelResolver),

View File

@@ -26,25 +26,16 @@ func pickConnection(connections []models.Connection,
return connection, ErrNoConnectionToPickFrom return connection, ErrNoConnectionToPickFrom
} }
var targetIP netip.Addr targetIPSet := selection.TargetIP.IsValid() && !selection.TargetIP.IsUnspecified()
switch selection.VPN {
case vpn.OpenVPN:
targetIP = selection.OpenVPN.EndpointIP
case vpn.Wireguard:
targetIP = selection.Wireguard.EndpointIP
default:
panic("unknown VPN type: " + selection.VPN)
}
targetIPSet := targetIP.IsValid() && !targetIP.IsUnspecified()
if targetIPSet && selection.VPN == vpn.Wireguard { if targetIPSet && selection.VPN == vpn.Wireguard {
// we need the right public key // we need the right public key
return getTargetIPConnection(connections, targetIP) return getTargetIPConnection(connections, selection.TargetIP)
} }
connection = pickRandomConnection(connections, randSource) connection = pickRandomConnection(connections, randSource)
if targetIPSet { if targetIPSet {
connection.IP = targetIP connection.IP = selection.TargetIP
} }
return connection, nil return connection, nil

View File

@@ -25,14 +25,13 @@ func newHandler(ctx context.Context, logger Logger, logging bool,
handler := &handler{} handler := &handler{}
vpn := newVPNHandler(ctx, vpnLooper, storage, ipv6Supported, logger) vpn := newVPNHandler(ctx, vpnLooper, storage, ipv6Supported, logger)
openvpn := newOpenvpnHandler(ctx, vpnLooper, logger) openvpn := newOpenvpnHandler(ctx, vpnLooper, pfGetter, logger)
dns := newDNSHandler(ctx, dnsLooper, logger) dns := newDNSHandler(ctx, dnsLooper, logger)
updater := newUpdaterHandler(ctx, updaterLooper, logger) updater := newUpdaterHandler(ctx, updaterLooper, logger)
publicip := newPublicIPHandler(publicIPLooper, logger) publicip := newPublicIPHandler(publicIPLooper, logger)
portForward := newPortForwardHandler(ctx, pfGetter, logger)
handler.v0 = newHandlerV0(ctx, logger, vpnLooper, dnsLooper, updaterLooper) handler.v0 = newHandlerV0(ctx, logger, vpnLooper, dnsLooper, updaterLooper)
handler.v1 = newHandlerV1(logger, buildInfo, vpn, openvpn, dns, updater, publicip, portForward) handler.v1 = newHandlerV1(logger, buildInfo, vpn, openvpn, dns, updater, publicip)
authMiddleware, err := auth.New(authSettings, logger) authMiddleware, err := auth.New(authSettings, logger)
if err != nil { if err != nil {

View File

@@ -52,7 +52,7 @@ func (h *handlerV0) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.logger.Warn(err.Error()) h.logger.Warn(err.Error())
} }
case "/openvpn/portforwarded": case "/openvpn/portforwarded":
http.Redirect(w, r, "/v1/portforward", http.StatusPermanentRedirect) http.Redirect(w, r, "/v1/openvpn/portforwarded", http.StatusPermanentRedirect)
case "/openvpn/settings": case "/openvpn/settings":
http.Redirect(w, r, "/v1/openvpn/settings", http.StatusPermanentRedirect) http.Redirect(w, r, "/v1/openvpn/settings", http.StatusPermanentRedirect)
case "/updater/restart": case "/updater/restart":

View File

@@ -10,7 +10,7 @@ import (
) )
func newHandlerV1(w warner, buildInfo models.BuildInformation, func newHandlerV1(w warner, buildInfo models.BuildInformation,
vpn, openvpn, dns, updater, publicip, portForward http.Handler, vpn, openvpn, dns, updater, publicip http.Handler,
) http.Handler { ) http.Handler {
return &handlerV1{ return &handlerV1{
warner: w, warner: w,
@@ -20,7 +20,6 @@ func newHandlerV1(w warner, buildInfo models.BuildInformation,
dns: dns, dns: dns,
updater: updater, updater: updater,
publicip: publicip, publicip: publicip,
portForward: portForward,
} }
} }
@@ -32,7 +31,6 @@ type handlerV1 struct {
dns http.Handler dns http.Handler
updater http.Handler updater http.Handler
publicip http.Handler publicip http.Handler
portForward http.Handler
} }
func (h *handlerV1) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handlerV1) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -49,8 +47,6 @@ func (h *handlerV1) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.updater.ServeHTTP(w, r) h.updater.ServeHTTP(w, r)
case strings.HasPrefix(r.RequestURI, "/publicip"): case strings.HasPrefix(r.RequestURI, "/publicip"):
h.publicip.ServeHTTP(w, r) h.publicip.ServeHTTP(w, r)
case strings.HasPrefix(r.RequestURI, "/portforward"):
h.portForward.ServeHTTP(w, r)
default: default:
errString := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI) errString := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI)
http.Error(w, errString, http.StatusBadRequest) http.Error(w, errString, http.StatusBadRequest)

View File

@@ -20,7 +20,6 @@ func New(settings Settings, debugLogger DebugLogger) (
routeToRoles: routeToRoles, routeToRoles: routeToRoles,
unprotectedRoutes: map[string]struct{}{ unprotectedRoutes: map[string]struct{}{
http.MethodGet + " /openvpn/actions/restart": {}, http.MethodGet + " /openvpn/actions/restart": {},
http.MethodGet + " /openvpn/portforwarded": {},
http.MethodGet + " /unbound/actions/restart": {}, http.MethodGet + " /unbound/actions/restart": {},
http.MethodGet + " /updater/restart": {}, http.MethodGet + " /updater/restart": {},
http.MethodGet + " /v1/version": {}, http.MethodGet + " /v1/version": {},
@@ -37,7 +36,6 @@ func New(settings Settings, debugLogger DebugLogger) (
http.MethodGet + " /v1/updater/status": {}, http.MethodGet + " /v1/updater/status": {},
http.MethodPut + " /v1/updater/status": {}, http.MethodPut + " /v1/updater/status": {},
http.MethodGet + " /v1/publicip/ip": {}, http.MethodGet + " /v1/publicip/ip": {},
http.MethodGet + " /v1/portforward": {},
}, },
logger: debugLogger, logger: debugLogger,
} }

View File

@@ -1,16 +1,12 @@
package auth package auth
import ( import (
"bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"github.com/qdm12/gosettings" "github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/validate" "github.com/qdm12/gosettings/validate"
"github.com/qdm12/gotree"
) )
type Settings struct { type Settings struct {
@@ -19,50 +15,6 @@ type Settings struct {
Roles []Role Roles []Role
} }
// SetDefaultRole sets a default role to apply to all routes without a
// previously user-defined role assigned to. Note the role argument
// routes are ignored. This should be called BEFORE calling [Settings.SetDefaults].
func (s *Settings) SetDefaultRole(jsonRole string) error {
var role Role
decoder := json.NewDecoder(bytes.NewBufferString(jsonRole))
decoder.DisallowUnknownFields()
err := decoder.Decode(&role)
if err != nil {
return fmt.Errorf("decoding default role: %w", err)
}
if role.Auth == "" {
return nil // no default role to set
}
err = role.Validate()
if err != nil {
return fmt.Errorf("validating default role: %w", err)
}
authenticatedRoutes := make(map[string]struct{}, len(validRoutes))
for _, role := range s.Roles {
for _, route := range role.Routes {
authenticatedRoutes[route] = struct{}{}
}
}
if len(authenticatedRoutes) == len(validRoutes) {
return nil
}
unauthenticatedRoutes := make([]string, 0, len(validRoutes))
for route := range validRoutes {
_, authenticated := authenticatedRoutes[route]
if !authenticated {
unauthenticatedRoutes = append(unauthenticatedRoutes, route)
}
}
slices.Sort(unauthenticatedRoutes)
role.Routes = unauthenticatedRoutes
s.Roles = append(s.Roles, role)
return nil
}
func (s *Settings) SetDefaults() { func (s *Settings) SetDefaults() {
s.Roles = gosettings.DefaultSlice(s.Roles, []Role{{ // TODO v3.41.0 leave empty s.Roles = gosettings.DefaultSlice(s.Roles, []Role{{ // TODO v3.41.0 leave empty
Name: "public", Name: "public",
@@ -70,7 +22,6 @@ func (s *Settings) SetDefaults() {
Routes: []string{ Routes: []string{
http.MethodGet + " /openvpn/actions/restart", http.MethodGet + " /openvpn/actions/restart",
http.MethodGet + " /unbound/actions/restart", http.MethodGet + " /unbound/actions/restart",
http.MethodGet + " /openvpn/portforwarded",
http.MethodGet + " /updater/restart", http.MethodGet + " /updater/restart",
http.MethodGet + " /v1/version", http.MethodGet + " /v1/version",
http.MethodGet + " /v1/vpn/status", http.MethodGet + " /v1/vpn/status",
@@ -83,14 +34,13 @@ func (s *Settings) SetDefaults() {
http.MethodGet + " /v1/updater/status", http.MethodGet + " /v1/updater/status",
http.MethodPut + " /v1/updater/status", http.MethodPut + " /v1/updater/status",
http.MethodGet + " /v1/publicip/ip", http.MethodGet + " /v1/publicip/ip",
http.MethodGet + " /v1/portforward",
}, },
}}) }})
} }
func (s Settings) Validate() (err error) { func (s Settings) Validate() (err error) {
for i, role := range s.Roles { for i, role := range s.Roles {
err = role.Validate() err = role.validate()
if err != nil { if err != nil {
return fmt.Errorf("role %s (%d of %d): %w", return fmt.Errorf("role %s (%d of %d): %w",
role.Name, i+1, len(s.Roles), err) role.Name, i+1, len(s.Roles), err)
@@ -111,18 +61,18 @@ const (
type Role struct { type Role struct {
// Name is the role name and is only used for documentation // Name is the role name and is only used for documentation
// and in the authentication middleware debug logs. // and in the authentication middleware debug logs.
Name string `json:"name"` Name string
// Auth is the authentication method to use, which can be 'none', 'basic' or 'apikey'. // Auth is the authentication method to use, which can be 'none' or 'apikey'.
Auth string `json:"auth"` Auth string
// APIKey is the API key to use when using the 'apikey' authentication. // APIKey is the API key to use when using the 'apikey' authentication.
APIKey string `json:"apikey"` APIKey string
// Username for HTTP Basic authentication method. // Username for HTTP Basic authentication method.
Username string `json:"username"` Username string
// Password for HTTP Basic authentication method. // Password for HTTP Basic authentication method.
Password string `json:"password"` Password string
// Routes is a list of routes that the role can access in the format // Routes is a list of routes that the role can access in the format
// "HTTP_METHOD PATH", for example "GET /v1/vpn/status" // "HTTP_METHOD PATH", for example "GET /v1/vpn/status"
Routes []string `json:"-"` Routes []string
} }
var ( var (
@@ -133,7 +83,7 @@ var (
ErrRouteNotSupported = errors.New("route not supported by the control server") ErrRouteNotSupported = errors.New("route not supported by the control server")
) )
func (r Role) Validate() (err error) { func (r Role) validate() (err error) {
err = validate.IsOneOf(r.Auth, AuthNone, AuthAPIKey, AuthBasic) err = validate.IsOneOf(r.Auth, AuthNone, AuthAPIKey, AuthBasic)
if err != nil { if err != nil {
return fmt.Errorf("%w: %s", ErrMethodNotSupported, r.Auth) return fmt.Errorf("%w: %s", ErrMethodNotSupported, r.Auth)
@@ -162,8 +112,6 @@ func (r Role) Validate() (err error) {
// WARNING: do not mutate programmatically. // WARNING: do not mutate programmatically.
var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals
http.MethodGet + " /openvpn/actions/restart": {}, http.MethodGet + " /openvpn/actions/restart": {},
http.MethodGet + " /openvpn/portforwarded": {},
http.MethodGet + " /openvpn/settings": {},
http.MethodGet + " /unbound/actions/restart": {}, http.MethodGet + " /unbound/actions/restart": {},
http.MethodGet + " /updater/restart": {}, http.MethodGet + " /updater/restart": {},
http.MethodGet + " /v1/version": {}, http.MethodGet + " /v1/version": {},
@@ -180,22 +128,4 @@ var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals
http.MethodGet + " /v1/updater/status": {}, http.MethodGet + " /v1/updater/status": {},
http.MethodPut + " /v1/updater/status": {}, http.MethodPut + " /v1/updater/status": {},
http.MethodGet + " /v1/publicip/ip": {}, http.MethodGet + " /v1/publicip/ip": {},
http.MethodGet + " /v1/portforward": {},
}
func (r Role) ToLinesNode() (node *gotree.Node) {
node = gotree.New("Role " + r.Name)
node.Appendf("Authentication method: %s", r.Auth)
switch r.Auth {
case AuthNone:
case AuthBasic:
node.Appendf("Username: %s", r.Username)
node.Appendf("Password: %s", gosettings.ObfuscateKey(r.Password))
case AuthAPIKey:
node.Appendf("API key: %s", gosettings.ObfuscateKey(r.APIKey))
default:
panic("missing code for authentication method: " + r.Auth)
}
node.Appendf("Number of routes covered: %d", len(r.Routes))
return node
} }

View File

@@ -38,7 +38,7 @@ func (m *logMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.childHandler.ServeHTTP(statefulWriter, r) m.childHandler.ServeHTTP(statefulWriter, r)
duration := m.timeNow().Sub(tStart) duration := m.timeNow().Sub(tStart)
m.logger.Info(strconv.Itoa(statefulWriter.statusCode) + " " + m.logger.Info(strconv.Itoa(statefulWriter.statusCode) + " " +
r.Method + " " + r.URL.String() + r.Method + " " + r.RequestURI +
" wrote " + strconv.Itoa(statefulWriter.length) + "B to " + " wrote " + strconv.Itoa(statefulWriter.length) + "B to " +
r.RemoteAddr + " in " + duration.String()) r.RemoteAddr + " in " + duration.String())
} }

View File

@@ -10,10 +10,13 @@ import (
"github.com/qdm12/gluetun/internal/constants/vpn" "github.com/qdm12/gluetun/internal/constants/vpn"
) )
func newOpenvpnHandler(ctx context.Context, looper VPNLooper, w warner) http.Handler { func newOpenvpnHandler(ctx context.Context, looper VPNLooper,
pfGetter PortForwardedGetter, w warner,
) http.Handler {
return &openvpnHandler{ return &openvpnHandler{
ctx: ctx, ctx: ctx,
looper: looper, looper: looper,
pf: pfGetter,
warner: w, warner: w,
} }
} }
@@ -21,6 +24,7 @@ func newOpenvpnHandler(ctx context.Context, looper VPNLooper, w warner) http.Han
type openvpnHandler struct { type openvpnHandler struct {
ctx context.Context //nolint:containedctx ctx context.Context //nolint:containedctx
looper VPNLooper looper VPNLooper
pf PortForwardedGetter
warner warner warner warner
} }
@@ -43,10 +47,10 @@ func (h *openvpnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
default: default:
errMethodNotSupported(w, r.Method) errMethodNotSupported(w, r.Method)
} }
case "/portforwarded": // TODO v4 remove case "/portforwarded":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
http.Redirect(w, r, "/v1/portforward", http.StatusMovedPermanently) h.getPortForwarded(w)
default: default:
errMethodNotSupported(w, r.Method) errMethodNotSupported(w, r.Method)
} }
@@ -118,3 +122,23 @@ func (h *openvpnHandler) getSettings(w http.ResponseWriter) {
return return
} }
} }
func (h *openvpnHandler) getPortForwarded(w http.ResponseWriter) {
ports := h.pf.GetPortsForwarded()
encoder := json.NewEncoder(w)
var data any
switch len(ports) {
case 0:
data = portWrapper{Port: 0} // TODO v4 change to portsWrapper
case 1:
data = portWrapper{Port: ports[0]} // TODO v4 change to portsWrapper
default:
data = portsWrapper{Ports: ports}
}
err := encoder.Encode(data)
if err != nil {
h.warner.Warn(err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
}

View File

@@ -1,52 +0,0 @@
package server
import (
"context"
"encoding/json"
"net/http"
)
func newPortForwardHandler(ctx context.Context,
portForward PortForwardedGetter, warner warner,
) http.Handler {
return &portForwardHandler{
ctx: ctx,
portForward: portForward,
warner: warner,
}
}
type portForwardHandler struct {
ctx context.Context //nolint:containedctx
portForward PortForwardedGetter
warner warner
}
func (h *portForwardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.getPortForwarded(w)
default:
errMethodNotSupported(w, r.Method)
}
}
func (h *portForwardHandler) getPortForwarded(w http.ResponseWriter) {
ports := h.portForward.GetPortsForwarded()
encoder := json.NewEncoder(w)
var data any
switch len(ports) {
case 0:
data = portWrapper{Port: 0} // TODO v4 change to portsWrapper
case 1:
data = portWrapper{Port: ports[0]} // TODO v4 change to portsWrapper
default:
data = portsWrapper{Ports: ports}
}
err := encoder.Encode(data)
if err != nil {
h.warner.Warn(err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
}

View File

@@ -6,25 +6,33 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/httpserver" "github.com/qdm12/gluetun/internal/httpserver"
"github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/server/middlewares/auth" "github.com/qdm12/gluetun/internal/server/middlewares/auth"
) )
func New(ctx context.Context, settings settings.ControlServer, logger Logger, func New(ctx context.Context, address string, logEnabled bool, logger Logger,
buildInfo models.BuildInformation, openvpnLooper VPNLooper, authConfigPath string, buildInfo models.BuildInformation, openvpnLooper VPNLooper,
pfGetter PortForwardedGetter, dnsLooper DNSLoop, pfGetter PortForwardedGetter, dnsLooper DNSLoop,
updaterLooper UpdaterLooper, publicIPLooper PublicIPLoop, storage Storage, updaterLooper UpdaterLooper, publicIPLooper PublicIPLoop, storage Storage,
ipv6Supported bool) ( ipv6Supported bool) (
server *httpserver.Server, err error, server *httpserver.Server, err error,
) { ) {
authSettings, err := setupAuthMiddleware(settings.AuthFilePath, settings.AuthDefaultRole, logger) authSettings, err := auth.Read(authConfigPath)
switch {
case errors.Is(err, os.ErrNotExist): // no auth file present
case err != nil:
return nil, fmt.Errorf("reading auth settings: %w", err)
default:
logger.Infof("read %d roles from authentication file", len(authSettings.Roles))
}
authSettings.SetDefaults()
err = authSettings.Validate()
if err != nil { if err != nil {
return nil, fmt.Errorf("building authentication middleware settings: %w", err) return nil, fmt.Errorf("validating auth settings: %w", err)
} }
handler, err := newHandler(ctx, logger, *settings.Log, authSettings, buildInfo, handler, err := newHandler(ctx, logger, logEnabled, authSettings, buildInfo,
openvpnLooper, pfGetter, dnsLooper, updaterLooper, publicIPLooper, openvpnLooper, pfGetter, dnsLooper, updaterLooper, publicIPLooper,
storage, ipv6Supported) storage, ipv6Supported)
if err != nil { if err != nil {
@@ -32,7 +40,7 @@ func New(ctx context.Context, settings settings.ControlServer, logger Logger,
} }
httpServerSettings := httpserver.Settings{ httpServerSettings := httpserver.Settings{
Address: *settings.Address, Address: address,
Handler: handler, Handler: handler,
Logger: logger, Logger: logger,
} }
@@ -44,26 +52,3 @@ func New(ctx context.Context, settings settings.ControlServer, logger Logger,
return server, nil return server, nil
} }
func setupAuthMiddleware(authPath, jsonDefaultRole string, logger Logger) (
authSettings auth.Settings, err error,
) {
authSettings, err = auth.Read(authPath)
switch {
case errors.Is(err, os.ErrNotExist): // no auth file present
case err != nil:
return auth.Settings{}, fmt.Errorf("reading auth settings: %w", err)
default:
logger.Infof("read %d roles from authentication file", len(authSettings.Roles))
}
err = authSettings.SetDefaultRole(jsonDefaultRole)
if err != nil {
return auth.Settings{}, fmt.Errorf("setting default role: %w", err)
}
authSettings.SetDefaults()
err = authSettings.Validate()
if err != nil {
return auth.Settings{}, fmt.Errorf("validating auth settings: %w", err)
}
return authSettings, nil
}

View File

@@ -21,9 +21,6 @@ func (s *Storage) FlushToFile(path string) error {
// flushToFile flushes the merged servers data to the file // flushToFile flushes the merged servers data to the file
// specified by path, as indented JSON. It is not thread-safe. // specified by path, as indented JSON. It is not thread-safe.
func (s *Storage) flushToFile(path string) error { func (s *Storage) flushToFile(path string) error {
if path == "" {
return nil // no file to write to
}
const permission = 0o644 const permission = 0o644
dirPath := filepath.Dir(path) dirPath := filepath.Dir(path)
if err := os.MkdirAll(dirPath, permission); err != nil { if err := os.MkdirAll(dirPath, permission); err != nil {

View File

@@ -8,7 +8,6 @@ import (
"github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/constants" "github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/constants/vpn"
) )
func commaJoin(slice []string) string { func commaJoin(slice []string) string {
@@ -149,13 +148,9 @@ func noServerFoundError(selection settings.ServerSelection) (err error) {
messageParts = append(messageParts, "tor only") messageParts = append(messageParts, "tor only")
} }
targetIP := selection.OpenVPN.EndpointIP if selection.TargetIP.IsValid() {
if selection.VPN == vpn.Wireguard {
targetIP = selection.Wireguard.EndpointIP
}
if targetIP.IsValid() {
messageParts = append(messageParts, messageParts = append(messageParts,
"target ip address "+targetIP.String()) "target ip address "+selection.TargetIP.String())
} }
message := "for " + strings.Join(messageParts, "; ") message := "for " + strings.Join(messageParts, "; ")

View File

@@ -1,3 +1,3 @@
package storage package storage
//go:generate mockgen -destination=mocks_test.go -package $GOPACKAGE . Logger //go:generate mockgen -destination=mocks_test.go -package $GOPACKAGE . Infoer

View File

@@ -1,5 +1,5 @@
// Code generated by MockGen. DO NOT EDIT. // Code generated by MockGen. DO NOT EDIT.
// Source: github.com/qdm12/gluetun/internal/storage (interfaces: Logger) // Source: github.com/qdm12/gluetun/internal/storage (interfaces: Infoer)
// Package storage is a generated GoMock package. // Package storage is a generated GoMock package.
package storage package storage
@@ -10,49 +10,37 @@ import (
gomock "github.com/golang/mock/gomock" gomock "github.com/golang/mock/gomock"
) )
// MockLogger is a mock of Logger interface. // MockInfoer is a mock of Infoer interface.
type MockLogger struct { type MockInfoer struct {
ctrl *gomock.Controller ctrl *gomock.Controller
recorder *MockLoggerMockRecorder recorder *MockInfoerMockRecorder
} }
// MockLoggerMockRecorder is the mock recorder for MockLogger. // MockInfoerMockRecorder is the mock recorder for MockInfoer.
type MockLoggerMockRecorder struct { type MockInfoerMockRecorder struct {
mock *MockLogger mock *MockInfoer
} }
// NewMockLogger creates a new mock instance. // NewMockInfoer creates a new mock instance.
func NewMockLogger(ctrl *gomock.Controller) *MockLogger { func NewMockInfoer(ctrl *gomock.Controller) *MockInfoer {
mock := &MockLogger{ctrl: ctrl} mock := &MockInfoer{ctrl: ctrl}
mock.recorder = &MockLoggerMockRecorder{mock} mock.recorder = &MockInfoerMockRecorder{mock}
return mock return mock
} }
// EXPECT returns an object that allows the caller to indicate expected use. // EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { func (m *MockInfoer) EXPECT() *MockInfoerMockRecorder {
return m.recorder return m.recorder
} }
// Info mocks base method. // Info mocks base method.
func (m *MockLogger) Info(arg0 string) { func (m *MockInfoer) Info(arg0 string) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
m.ctrl.Call(m, "Info", arg0) m.ctrl.Call(m, "Info", arg0)
} }
// Info indicates an expected call of Info. // Info indicates an expected call of Info.
func (mr *MockLoggerMockRecorder) Info(arg0 interface{}) *gomock.Call { func (mr *MockInfoerMockRecorder) Info(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), arg0) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockInfoer)(nil).Info), arg0)
}
// Warn mocks base method.
func (m *MockLogger) Warn(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Warn", arg0)
}
// Warn indicates an expected call of Warn.
func (mr *MockLoggerMockRecorder) Warn(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), arg0)
} }

View File

@@ -95,7 +95,7 @@ func Test_extractServersFromBytes(t *testing.T) {
t.Parallel() t.Parallel()
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
logger := NewMockLogger(ctrl) logger := NewMockInfoer(ctrl)
var previousLogCall *gomock.Call var previousLogCall *gomock.Call
for _, logged := range testCase.logged { for _, logged := range testCase.logged {
call := logger.EXPECT().Info(logged) call := logger.EXPECT().Info(logged)

File diff suppressed because it is too large Load Diff

View File

@@ -13,36 +13,31 @@ type Storage struct {
// the embedded JSON file on every call to the // the embedded JSON file on every call to the
// SyncServers method. // SyncServers method.
hardcodedServers models.AllServers hardcodedServers models.AllServers
logger Logger logger Infoer
filepath string filepath string
} }
type Logger interface { type Infoer interface {
Info(s string) Info(s string)
Warn(s string)
} }
// New creates a new storage and reads the servers from the // New creates a new storage and reads the servers from the
// embedded servers file and the file on disk. // embedded servers file and the file on disk.
// Passing an empty filepath disables the reading and writing of // Passing an empty filepath disables writing servers to a file.
// servers. func New(logger Infoer, filepath string) (storage *Storage, err error) {
func New(logger Logger, filepath string) (storage *Storage, err error) {
// A unit test prevents any error from being returned // A unit test prevents any error from being returned
// and ensures all providers are part of the servers returned. // and ensures all providers are part of the servers returned.
hardcodedServers, _ := parseHardcodedServers() hardcodedServers, _ := parseHardcodedServers()
storage = &Storage{ storage = &Storage{
hardcodedServers: hardcodedServers, hardcodedServers: hardcodedServers,
mergedServers: hardcodedServers,
logger: logger, logger: logger,
filepath: filepath, filepath: filepath,
} }
if filepath != "" {
if err := storage.syncServers(); err != nil { if err := storage.syncServers(); err != nil {
return nil, err return nil, err
} }
}
return storage, nil return storage, nil
} }

View File

@@ -46,13 +46,13 @@ func (s *Storage) syncServers() (err error) {
} }
// Eventually write file // Eventually write file
if reflect.DeepEqual(serversOnFile, s.mergedServers) { if s.filepath == "" || reflect.DeepEqual(serversOnFile, s.mergedServers) {
return nil return nil
} }
err = s.flushToFile(s.filepath) err = s.flushToFile(s.filepath)
if err != nil { if err != nil {
s.logger.Warn("failed writing servers to file: " + err.Error()) return fmt.Errorf("writing servers to file: %w", err)
} }
return nil return nil
} }

View File

@@ -29,7 +29,7 @@ func (u *Updater) updateProvider(ctx context.Context, provider Provider,
u.logger.Warn("note: if running the update manually, you can use the flag " + u.logger.Warn("note: if running the update manually, you can use the flag " +
"-minratio to allow the update to succeed with less servers found") "-minratio to allow the update to succeed with less servers found")
} }
return fmt.Errorf("getting %s servers: %w", providerName, err) return fmt.Errorf("getting servers: %w", err)
} }
for _, server := range servers { for _, server := range servers {

View File

@@ -2,11 +2,9 @@ package updater
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"time" "time"
"github.com/qdm12/gluetun/internal/provider/common"
"github.com/qdm12/gluetun/internal/updater/unzip" "github.com/qdm12/gluetun/internal/updater/unzip"
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
@@ -50,23 +48,23 @@ func (u *Updater) UpdateServers(ctx context.Context, providers []string,
// TODO support servers offering only TCP or only UDP // TODO support servers offering only TCP or only UDP
// for NordVPN and PureVPN // for NordVPN and PureVPN
err := u.updateProvider(ctx, fetcher, minRatio) err := u.updateProvider(ctx, fetcher, minRatio)
switch { if err == nil {
case err == nil:
continue continue
case errors.Is(err, common.ErrCredentialsMissing): }
u.logger.Warn(err.Error() + " - skipping update for " + providerName)
continue
case len(providers) == 1:
// return the only error for the single provider. // return the only error for the single provider.
if len(providers) == 1 {
return err return err
case ctx.Err() != nil: }
// stop updating other providers if context is done
return ctx.Err() // stop updating the next providers if context is canceled.
default: // error encountered updating one of multiple providers if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
// Log the error and continue updating the next provider. // Log the error and continue updating the next provider.
u.logger.Error(err.Error()) u.logger.Error(err.Error())
} }
}
return nil return nil
} }

View File

@@ -101,7 +101,7 @@ type CmdStarter interface {
} }
type HealthChecker interface { type HealthChecker interface {
SetConfig(tlsDialAddrs []string, icmpTargetIPs []netip.Addr, smallCheckType string) SetConfig(tlsDialAddr string, icmpTarget netip.Addr)
Start(ctx context.Context) (runError <-chan error, err error) Start(ctx context.Context) (runError <-chan error, err error)
Stop() error Stop() error
} }

View File

@@ -8,6 +8,7 @@ import (
"github.com/qdm12/gluetun/internal/constants" "github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/loopstate" "github.com/qdm12/gluetun/internal/loopstate"
"github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/netlink"
"github.com/qdm12/gluetun/internal/vpn/state" "github.com/qdm12/gluetun/internal/vpn/state"
"github.com/qdm12/log" "github.com/qdm12/log"
) )
@@ -23,7 +24,7 @@ type Loop struct {
// Fixed parameters // Fixed parameters
buildInfo models.BuildInformation buildInfo models.BuildInformation
versionInfo bool versionInfo bool
ipv6Supported bool ipv6SupportLevel netlink.IPv6SupportLevel
vpnInputPorts []uint16 // TODO make changeable through stateful firewall vpnInputPorts []uint16 // TODO make changeable through stateful firewall
// Configurators // Configurators
openvpnConf OpenVPN openvpnConf OpenVPN
@@ -51,7 +52,7 @@ const (
defaultBackoffTime = 15 * time.Second defaultBackoffTime = 15 * time.Second
) )
func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint16, func NewLoop(vpnSettings settings.VPN, ipv6SupportLevel netlink.IPv6SupportLevel, vpnInputPorts []uint16,
providers Providers, storage Storage, healthSettings settings.Health, providers Providers, storage Storage, healthSettings settings.Health,
healthChecker HealthChecker, healthServer HealthServer, openvpnConf OpenVPN, healthChecker HealthChecker, healthServer HealthServer, openvpnConf OpenVPN,
netLinker NetLinker, fw Firewall, routing Routing, netLinker NetLinker, fw Firewall, routing Routing,
@@ -78,7 +79,7 @@ func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint1
healthServer: healthServer, healthServer: healthServer,
buildInfo: buildInfo, buildInfo: buildInfo,
versionInfo: versionInfo, versionInfo: versionInfo,
ipv6Supported: ipv6Supported, ipv6SupportLevel: ipv6SupportLevel,
vpnInputPorts: vpnInputPorts, vpnInputPorts: vpnInputPorts,
openvpnConf: openvpnConf, openvpnConf: openvpnConf,
netLinker: netLinker, netLinker: netLinker,

View File

@@ -6,6 +6,7 @@ import (
"github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/netlink"
"github.com/qdm12/gluetun/internal/openvpn" "github.com/qdm12/gluetun/internal/openvpn"
"github.com/qdm12/gluetun/internal/provider" "github.com/qdm12/gluetun/internal/provider"
) )
@@ -14,15 +15,16 @@ import (
// It returns a serverName for port forwarding (PIA) and an error if it fails. // It returns a serverName for port forwarding (PIA) and an error if it fails.
func setupOpenVPN(ctx context.Context, fw Firewall, func setupOpenVPN(ctx context.Context, fw Firewall,
openvpnConf OpenVPN, providerConf provider.Provider, openvpnConf OpenVPN, providerConf provider.Provider,
settings settings.VPN, ipv6Supported bool, starter CmdStarter, settings settings.VPN, ipv6SupportLevel netlink.IPv6SupportLevel, starter CmdStarter,
logger openvpn.Logger) (runner *openvpn.Runner, connection models.Connection, err error, logger openvpn.Logger) (runner *openvpn.Runner, connection models.Connection, err error,
) { ) {
connection, err = providerConf.GetConnection(settings.Provider.ServerSelection, ipv6Supported) ipv6Internet := ipv6SupportLevel == netlink.IPv6Internet
connection, err = providerConf.GetConnection(settings.Provider.ServerSelection, ipv6Internet)
if err != nil { if err != nil {
return nil, models.Connection{}, fmt.Errorf("finding a valid server connection: %w", err) return nil, models.Connection{}, fmt.Errorf("finding a valid server connection: %w", err)
} }
lines := providerConf.OpenVPNConfig(connection, settings.OpenVPN, ipv6Supported) lines := providerConf.OpenVPNConfig(connection, settings.OpenVPN, ipv6SupportLevel.IsSupported())
if err := openvpnConf.WriteConfig(lines); err != nil { if err := openvpnConf.WriteConfig(lines); err != nil {
return nil, models.Connection{}, fmt.Errorf("writing configuration to file: %w", err) return nil, models.Connection{}, fmt.Errorf("writing configuration to file: %w", err)

View File

@@ -36,11 +36,11 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
if settings.Type == vpn.OpenVPN { if settings.Type == vpn.OpenVPN {
vpnInterface = settings.OpenVPN.Interface vpnInterface = settings.OpenVPN.Interface
vpnRunner, connection, err = setupOpenVPN(ctx, l.fw, vpnRunner, connection, err = setupOpenVPN(ctx, l.fw,
l.openvpnConf, providerConf, settings, l.ipv6Supported, l.starter, subLogger) l.openvpnConf, providerConf, settings, l.ipv6SupportLevel, l.starter, subLogger)
} else { // Wireguard } else { // Wireguard
vpnInterface = settings.Wireguard.Interface vpnInterface = settings.Wireguard.Interface
vpnRunner, connection, err = setupWireguard(ctx, l.netLinker, l.fw, vpnRunner, connection, err = setupWireguard(ctx, l.netLinker, l.fw,
providerConf, settings, l.ipv6Supported, subLogger) providerConf, settings, l.ipv6SupportLevel, subLogger)
} }
if err != nil { if err != nil {
l.crashed(ctx, err) l.crashed(ctx, err)

View File

@@ -31,25 +31,20 @@ func (l *Loop) onTunnelUp(ctx, loopCtx context.Context, data tunnelUpData) {
} }
} }
icmpTargetIPs := l.healthSettings.ICMPTargetIPs icmpTarget := l.healthSettings.ICMPTargetIP
if len(icmpTargetIPs) == 1 && icmpTargetIPs[0].IsUnspecified() { if icmpTarget.IsUnspecified() {
icmpTargetIPs = []netip.Addr{data.serverIP} icmpTarget = data.serverIP
} }
l.healthChecker.SetConfig(l.healthSettings.TargetAddresses, icmpTargetIPs, l.healthChecker.SetConfig(l.healthSettings.TargetAddress, icmpTarget)
l.healthSettings.SmallCheckType)
healthErrCh, err := l.healthChecker.Start(ctx) healthErrCh, err := l.healthChecker.Start(ctx)
l.healthServer.SetError(err) l.healthServer.SetError(err)
if err != nil { if err != nil {
if *l.healthSettings.RestartVPN {
// Note this restart call must be done in a separate goroutine // Note this restart call must be done in a separate goroutine
// from the VPN loop goroutine. // from the VPN loop goroutine.
l.restartVPN(loopCtx, err) l.restartVPN(loopCtx, err)
return return
} }
l.logger.Warnf("(ignored) healthchecker start failed: %s", err)
l.logger.Info("👉 See https://github.com/qdm12/gluetun-wiki/blob/main/faq/healthcheck.md")
}
if *l.dnsLooper.GetSettings().ServerEnabled { if *l.dnsLooper.GetSettings().ServerEnabled {
_, _ = l.dnsLooper.ApplyStatus(ctx, constants.Running) _, _ = l.dnsLooper.ApplyStatus(ctx, constants.Running)
@@ -100,7 +95,7 @@ func (l *Loop) collectHealthErrors(ctx, loopCtx context.Context, healthErrCh <-c
l.restartVPN(loopCtx, healthErr) l.restartVPN(loopCtx, healthErr)
return return
} }
l.logger.Warnf("(ignored) healthcheck failed: %s", healthErr) l.logger.Warnf("healthcheck failed: %s", healthErr)
l.logger.Info("👉 See https://github.com/qdm12/gluetun-wiki/blob/main/faq/healthcheck.md") l.logger.Info("👉 See https://github.com/qdm12/gluetun-wiki/blob/main/faq/healthcheck.md")
} else if previousHealthErr != nil { } else if previousHealthErr != nil {
l.logger.Info("healthcheck passed successfully after previous failure(s)") l.logger.Info("healthcheck passed successfully after previous failure(s)")

View File

@@ -6,6 +6,7 @@ import (
"github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/netlink"
"github.com/qdm12/gluetun/internal/provider" "github.com/qdm12/gluetun/internal/provider"
"github.com/qdm12/gluetun/internal/provider/utils" "github.com/qdm12/gluetun/internal/provider/utils"
"github.com/qdm12/gluetun/internal/wireguard" "github.com/qdm12/gluetun/internal/wireguard"
@@ -16,15 +17,16 @@ import (
// It returns a serverName for port forwarding (PIA) and an error if it fails. // It returns a serverName for port forwarding (PIA) and an error if it fails.
func setupWireguard(ctx context.Context, netlinker NetLinker, func setupWireguard(ctx context.Context, netlinker NetLinker,
fw Firewall, providerConf provider.Provider, fw Firewall, providerConf provider.Provider,
settings settings.VPN, ipv6Supported bool, logger wireguard.Logger) ( settings settings.VPN, ipv6SupportLevel netlink.IPv6SupportLevel, logger wireguard.Logger) (
wireguarder *wireguard.Wireguard, connection models.Connection, err error, wireguarder *wireguard.Wireguard, connection models.Connection, err error,
) { ) {
connection, err = providerConf.GetConnection(settings.Provider.ServerSelection, ipv6Supported) ipv6Internet := ipv6SupportLevel == netlink.IPv6Internet
connection, err = providerConf.GetConnection(settings.Provider.ServerSelection, ipv6Internet)
if err != nil { if err != nil {
return nil, models.Connection{}, fmt.Errorf("finding a VPN server: %w", err) return nil, models.Connection{}, fmt.Errorf("finding a VPN server: %w", err)
} }
wireguardSettings := utils.BuildWireguardSettings(connection, settings.Wireguard, ipv6Supported) wireguardSettings := utils.BuildWireguardSettings(connection, settings.Wireguard, ipv6SupportLevel.IsSupported())
logger.Debug("Wireguard server public key: " + wireguardSettings.PublicKey) logger.Debug("Wireguard server public key: " + wireguardSettings.PublicKey)
logger.Debug("Wireguard client private key: " + gosettings.ObfuscateKey(wireguardSettings.PrivateKey)) logger.Debug("Wireguard client private key: " + gosettings.ObfuscateKey(wireguardSettings.PrivateKey))