Compare commits

..

18 Commits

Author SHA1 Message Date
Quentin McGaw
90b9e81129 Merge branch 'master' into pmtu 2025-11-07 21:55:58 +00:00
Quentin McGaw
2391c890b4 Run MTU discovery AFTER healthcheck is started 2025-10-17 00:39:44 +00:00
Quentin McGaw
51fd46b58e Merge branch 'master' into pmtu 2025-10-17 00:17:45 +00:00
Quentin McGaw
906e7b5ee1 Remove unneeded error context wrapping 2025-10-14 17:56:54 +00:00
Quentin McGaw
5428580b8f Handle ICMP not permitted errors 2025-10-14 17:56:04 +00:00
Quentin McGaw
6c25ee53f1 Fix unit test 2025-10-06 11:08:03 +00:00
Quentin McGaw
b9051b02bf Use the VPN local gateway IP address to run path MTU discovery 2025-10-06 10:03:15 +00:00
Quentin McGaw
f0f3193c1c Remove VPN_PMTUD option 2025-10-06 09:57:15 +00:00
Quentin McGaw
c0ebd180cb Revert to VPN original MTU (set by WIREGUARD_MTU for example) if ICMP fails 2025-10-06 09:57:15 +00:00
Quentin McGaw
b6e873cf25 Improve logging in case of ICMP blocked 2025-10-06 09:57:15 +00:00
Quentin McGaw
ccc2f306b9 Fallback on 1320 if ICMP is blocked 2025-10-06 09:57:15 +00:00
Quentin McGaw
5b1dc295fe Return an error if all MTUs failed to test 2025-10-06 09:57:15 +00:00
Quentin McGaw
00bc8bbbbb Handle administrative prohibition of ICMP 2025-10-06 09:57:15 +00:00
Quentin McGaw
8bef380d8c Fix unit test 2025-10-06 09:57:15 +00:00
Quentin McGaw
9ad1907574 Update log that PMTUD can take up to 4s 2025-10-06 09:57:15 +00:00
Quentin McGaw
d83999d954 Make binary search faster with 11 parallel queries 2025-10-06 09:57:15 +00:00
Quentin McGaw
162d244865 Use PMTUD to set the MTU to the VPN interface
- Add `VPN_PMTUD` option enabled by default
- One can revert to use `VPN_PMTUD=off` to disable the new PMTUD mechanism
2025-10-06 09:57:15 +00:00
Quentin McGaw
e21d798f57 pmtud package 2025-10-06 09:57:15 +00:00
86 changed files with 5148 additions and 8559 deletions

View File

@@ -93,9 +93,6 @@ jobs:
- name: Run Gluetun container with Mullvad configuration
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:
runs-on: ubuntu-latest
permissions:
@@ -121,7 +118,7 @@ jobs:
github.event_name == 'release' ||
(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:
actions: read
contents: read

View File

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

View File

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

View File

@@ -163,23 +163,21 @@ ENV VPN_SERVICE_PROVIDER=pia \
LOG_LEVEL=info \
# Health
HEALTH_SERVER_ADDRESS=127.0.0.1:9999 \
HEALTH_TARGET_ADDRESSES=cloudflare.com:443,github.com:443 \
HEALTH_ICMP_TARGET_IPS=1.1.1.1,8.8.8.8 \
HEALTH_SMALL_CHECK_TYPE=icmp \
HEALTH_TARGET_ADDRESS=cloudflare.com:443 \
HEALTH_ICMP_TARGET_IP=1.1.1.1 \
HEALTH_RESTART_VPN=on \
# DNS
DNS_SERVER=on \
DNS_UPSTREAM_RESOLVER_TYPE=DoT \
DNS_UPSTREAM_RESOLVERS=cloudflare \
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_UPSTREAM_IPV6=off \
BLOCK_MALICIOUS=on \
BLOCK_SURVEILLANCE=off \
BLOCK_ADS=off \
DNS_UNBLOCK_HOSTNAMES= \
DNS_REBINDING_PROTECTION_EXEMPT_HOSTNAMES= \
DNS_UPDATE_PERIOD=24h \
DNS_ADDRESS=127.0.0.1 \
DNS_KEEP_NAMESERVER=off \
@@ -203,13 +201,10 @@ ENV VPN_SERVICE_PROVIDER=pia \
HTTP_CONTROL_SERVER_LOG=on \
HTTP_CONTROL_SERVER_ADDRESS=":8000" \
HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH=/gluetun/auth/config.toml \
HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE="{}" \
# Server data updater
UPDATER_PERIOD=0 \
UPDATER_MIN_RATIO=0.8 \
UPDATER_VPN_SERVICE_PROVIDERS= \
UPDATER_PROTONVPN_EMAIL= \
UPDATER_PROTONVPN_PASSWORD= \
# Public IP
PUBLICIP_FILE="/tmp/gluetun/ip" \
PUBLICIP_ENABLED=on \
@@ -225,8 +220,8 @@ ENV VPN_SERVICE_PROVIDER=pia \
# Extras
VERSION_INFORMATION=on \
TZ= \
PUID=1000 \
PGID=1000
PUID= \
PGID=
ENTRYPOINT ["/gluetun-entrypoint"]
EXPOSE 8000/tcp 8888/tcp 8388/tcp 8388/udp
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=3 CMD /gluetun-entrypoint healthcheck

View File

@@ -1,7 +1,5 @@
# 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
![Title image](https://raw.githubusercontent.com/qdm12/gluetun/master/title.svg)

View File

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

View File

@@ -1,27 +1,193 @@
package internal
import (
"bufio"
"context"
"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 {
expectedSecrets := []string{
"Wireguard private key",
"Wireguard address",
}
secrets, err := readSecrets(ctx, expectedSecrets)
secrets, err := readSecrets(ctx)
if err != nil {
return fmt.Errorf("reading secrets: %w", err)
}
env := []string{
"VPN_SERVICE_PROVIDER=mullvad",
"VPN_TYPE=wireguard",
"LOG_LEVEL=debug",
"SERVER_COUNTRIES=USA",
"WIREGUARD_PRIVATE_KEY=" + secrets[0],
"WIREGUARD_ADDRESSES=" + secrets[1],
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_TYPE=wireguard",
"LOG_LEVEL=debug",
"SERVER_COUNTRIES=USA",
"WIREGUARD_PRIVATE_KEY=" + secrets.mullvadWireguardPrivateKey,
"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

@@ -164,8 +164,6 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
}
}
defer fmt.Println(gluetunLogo)
announcementExp, err := time.Parse(time.RFC3339, "2024-12-01T00:00:00Z")
if err != nil {
return err
@@ -429,8 +427,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
parallelResolver := resolver.NewParallelResolver(allSettings.Updater.DNSAddress)
openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, updaterLogger,
httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(),
openvpnFileExtractor, allSettings.Updater)
httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(), openvpnFileExtractor)
vpnLogger := logger.New(log.SetComponent("vpn"))
vpnLooper := vpn.NewLoop(allSettings.VPN, ipv6Supported, allSettings.Firewall.VPNInputPorts,
@@ -469,10 +466,13 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
go shadowsocksLooper.Run(shadowsocksCtx, shadowsocksDone)
otherGroupHandler.Add(shadowsocksHandler)
controlServerAddress := *allSettings.ControlServer.Address
controlServerLogging := *allSettings.ControlServer.Log
httpServerHandler, httpServerCtx, httpServerDone := goshutdown.NewGoRoutineHandler(
"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")),
allSettings.ControlServer.AuthFilePath,
buildInfo, vpnLooper, portForwardLooper, dnsLooper, updaterLooper, publicIPLooper,
storage, ipv6Supported)
if err != nil {
@@ -581,6 +581,7 @@ type Linker interface {
LinkDel(link netlink.Link) (err error)
LinkSetUp(link netlink.Link) (linkIndex int, err error)
LinkSetDown(link netlink.Link) (err error)
LinkSetMTU(link netlink.Link, mtu int) error
}
type clier interface {
@@ -602,34 +603,3 @@ type RunStarter interface {
Start(cmd *exec.Cmd) (stdoutLines, stderrLines <-chan string,
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
require (
github.com/ProtonMail/go-srp v0.0.7
github.com/breml/rootcerts v0.3.3
github.com/fatih/color v1.18.0
github.com/golang/mock v1.6.0
github.com/klauspost/compress v1.18.1
github.com/klauspost/pgzip v1.2.6
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/goshutdown v0.3.0
github.com/qdm12/gosplash v0.2.0
@@ -22,21 +21,17 @@ require (
github.com/vishvananda/netlink v1.3.1
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/net v0.47.0
golang.org/x/sys v0.38.0
golang.org/x/text v0.31.0
golang.org/x/net v0.46.0
golang.org/x/sys v0.37.0
golang.org/x/text v0.30.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
gopkg.in/ini.v1 v1.67.0
)
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/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/google/go-cmp v0.6.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/miekg/dns v1.1.62 // 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/prometheus/client_golang v1.20.5 // 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/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.35.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/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/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/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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
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.0.20251123213823-54e987293e88/go.mod h1:98foWgXJZ+g8gJIuO+fdO+oWpFei5WShMFTeN4Im2lE=
github.com/qdm12/dns/v2 v2.0.0-rc9 h1:qDzRkHr6993jknNB/ZOCnZOyIG6bsZcl2MIfdeUd0kI=
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/go.mod h1:D1Po4CRQLYjccnAR2JsVlN1sBMgQrcNLONbvyuzcdTg=
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/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4=
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-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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
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/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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
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-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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.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-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-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-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.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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
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-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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
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/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/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-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.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

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

View File

@@ -76,7 +76,7 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
openvpnFileExtractor := extract.New()
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)
connection, err := providerConf.GetConnection(
allSettings.VPN.Provider.ServerSelection, ipv6Supported)

View File

@@ -6,7 +6,6 @@ import (
"flag"
"fmt"
"net/http"
"slices"
"strings"
"time"
@@ -25,8 +24,6 @@ import (
var (
ErrModeUnspecified = errors.New("at least one of -enduser or -maintainer must be 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 {
@@ -38,7 +35,7 @@ type UpdaterLogger interface {
func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) error {
options := settings.Updater{}
var endUserMode, maintainerMode, updateAll bool
var csvProviders, ipToken, protonUsername, protonEmail, protonPassword string
var csvProviders, ipToken string
flagSet := flag.NewFlagSet("update", flag.ExitOnError)
flagSet.BoolVar(&endUserMode, "enduser", false, "Write results to /gluetun/servers.json (for end users)")
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.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(&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 {
return err
}
@@ -71,16 +64,6 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
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])
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)
}
serversDataPath := constants.ServersData
if maintainerMode {
serversDataPath = ""
}
storage, err := storage.New(logger, serversDataPath)
storage, err := storage.New(logger, constants.ServersData)
if err != nil {
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()
providers := provider.NewProviders(storage, time.Now, logger, httpClient,
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, options)
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor)
updater := updater.New(httpClient, storage, providers, logger)
err = updater.UpdateServers(ctx, options.Providers, options.MinRatio)

View File

@@ -22,9 +22,6 @@ type DNSBlacklist struct {
AddBlockedHosts []string
AddBlockedIPs []netip.Addr
AddBlockedIPPrefixes []netip.Prefix
// RebindingProtectionExemptHostnames is a list of hostnames
// exempt from DNS rebinding protection.
RebindingProtectionExemptHostnames []string
}
func (b *DNSBlacklist) setDefaults() {
@@ -36,9 +33,8 @@ func (b *DNSBlacklist) setDefaults() {
var hostRegex = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9_][a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])(\.([a-zA-Z0-9]|[a-zA-Z0-9_][a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9]))*$`) //nolint:lll
var (
ErrAllowedHostNotValid = errors.New("allowed host is not valid")
ErrBlockedHostNotValid = errors.New("blocked host is not valid")
ErrRebindingProtectionExemptHostNotValid = errors.New("rebinding protection exempt host is not valid")
ErrAllowedHostNotValid = errors.New("allowed host is not valid")
ErrBlockedHostNotValid = errors.New("blocked host is not valid")
)
func (b DNSBlacklist) validate() (err error) {
@@ -54,25 +50,18 @@ func (b DNSBlacklist) validate() (err error) {
}
}
for _, host := range b.RebindingProtectionExemptHostnames {
if !hostRegex.MatchString(host) {
return fmt.Errorf("%w: %s", ErrRebindingProtectionExemptHostNotValid, host)
}
}
return nil
}
func (b DNSBlacklist) copy() (copied DNSBlacklist) {
return DNSBlacklist{
BlockMalicious: gosettings.CopyPointer(b.BlockMalicious),
BlockAds: gosettings.CopyPointer(b.BlockAds),
BlockSurveillance: gosettings.CopyPointer(b.BlockSurveillance),
AllowedHosts: gosettings.CopySlice(b.AllowedHosts),
AddBlockedHosts: gosettings.CopySlice(b.AddBlockedHosts),
AddBlockedIPs: gosettings.CopySlice(b.AddBlockedIPs),
AddBlockedIPPrefixes: gosettings.CopySlice(b.AddBlockedIPPrefixes),
RebindingProtectionExemptHostnames: gosettings.CopySlice(b.RebindingProtectionExemptHostnames),
BlockMalicious: gosettings.CopyPointer(b.BlockMalicious),
BlockAds: gosettings.CopyPointer(b.BlockAds),
BlockSurveillance: gosettings.CopyPointer(b.BlockSurveillance),
AllowedHosts: gosettings.CopySlice(b.AllowedHosts),
AddBlockedHosts: gosettings.CopySlice(b.AddBlockedHosts),
AddBlockedIPs: gosettings.CopySlice(b.AddBlockedIPs),
AddBlockedIPPrefixes: gosettings.CopySlice(b.AddBlockedIPPrefixes),
}
}
@@ -84,8 +73,6 @@ func (b *DNSBlacklist) overrideWith(other DNSBlacklist) {
b.AddBlockedHosts = gosettings.OverrideWithSlice(b.AddBlockedHosts, other.AddBlockedHosts)
b.AddBlockedIPs = gosettings.OverrideWithSlice(b.AddBlockedIPs, other.AddBlockedIPs)
b.AddBlockedIPPrefixes = gosettings.OverrideWithSlice(b.AddBlockedIPPrefixes, other.AddBlockedIPPrefixes)
b.RebindingProtectionExemptHostnames = gosettings.OverrideWithSlice(b.RebindingProtectionExemptHostnames,
other.RebindingProtectionExemptHostnames)
}
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
}
@@ -176,8 +156,6 @@ func (b *DNSBlacklist) read(r *reader.Reader) (err error) {
b.AllowedHosts = r.CSV("DNS_UNBLOCK_HOSTNAMES", reader.RetroKeys("UNBLOCK"))
b.RebindingProtectionExemptHostnames = r.CSV("DNS_REBINDING_PROTECTION_EXEMPT_HOSTNAMES")
return nil
}

View File

@@ -36,8 +36,6 @@ var (
ErrSystemPUIDNotValid = errors.New("process user id is not valid")
ErrSystemTimezoneNotValid = errors.New("timezone is not valid")
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")
ErrVPNTypeNotValid = errors.New("VPN type is not valid")
ErrWireguardAllowedIPNotSet = errors.New("allowed IP is not set")

View File

@@ -1,10 +1,10 @@
package settings
import (
"errors"
"fmt"
"net/netip"
"os"
"time"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
@@ -18,63 +18,42 @@ type Health struct {
// for the health check server.
// It cannot be the empty string in the internal state.
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.
// Addresses after the first one are used as fallbacks for retries.
// It cannot be empty in the internal state.
TargetAddresses []string
// ICMPTargetIPs are the IP addresses to use for ICMP echo requests
// in the health checker. The slice can be set to a single
// unspecified address (0.0.0.0) such that the VPN server IP is used,
// 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
// It cannot be the empty string in the internal state.
TargetAddress string
// ICMPTargetIP is the IP address to use for ICMP echo requests
// in the health checker. It can be set to an unspecified address (0.0.0.0)
// such that the VPN server IP is used, which is also the default behavior.
ICMPTargetIP netip.Addr
// RestartVPN indicates whether to restart the VPN connection
// when the healthcheck fails.
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) {
err = validate.ListeningAddress(h.ServerAddress, os.Getuid())
if err != nil {
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
}
func (h *Health) copy() (copied Health) {
return Health{
ServerAddress: h.ServerAddress,
TargetAddresses: h.TargetAddresses,
ICMPTargetIPs: gosettings.CopySlice(h.ICMPTargetIPs),
SmallCheckType: h.SmallCheckType,
RestartVPN: gosettings.CopyPointer(h.RestartVPN),
ServerAddress: h.ServerAddress,
ReadHeaderTimeout: h.ReadHeaderTimeout,
ReadTimeout: h.ReadTimeout,
TargetAddress: h.TargetAddress,
ICMPTargetIP: h.ICMPTargetIP,
RestartVPN: gosettings.CopyPointer(h.RestartVPN),
}
}
@@ -83,20 +62,21 @@ func (h *Health) copy() (copied Health) {
// settings.
func (h *Health) OverrideWith(other Health) {
h.ServerAddress = gosettings.OverrideWithComparable(h.ServerAddress, other.ServerAddress)
h.TargetAddresses = gosettings.OverrideWithSlice(h.TargetAddresses, other.TargetAddresses)
h.ICMPTargetIPs = gosettings.OverrideWithSlice(h.ICMPTargetIPs, other.ICMPTargetIPs)
h.SmallCheckType = gosettings.OverrideWithComparable(h.SmallCheckType, other.SmallCheckType)
h.ReadHeaderTimeout = gosettings.OverrideWithComparable(h.ReadHeaderTimeout, other.ReadHeaderTimeout)
h.ReadTimeout = gosettings.OverrideWithComparable(h.ReadTimeout, other.ReadTimeout)
h.TargetAddress = gosettings.OverrideWithComparable(h.TargetAddress, other.TargetAddress)
h.ICMPTargetIP = gosettings.OverrideWithComparable(h.ICMPTargetIP, other.ICMPTargetIP)
h.RestartVPN = gosettings.OverrideWithPointer(h.RestartVPN, other.RestartVPN)
}
func (h *Health) SetDefaults() {
h.ServerAddress = gosettings.DefaultComparable(h.ServerAddress, "127.0.0.1:9999")
h.TargetAddresses = gosettings.DefaultSlice(h.TargetAddresses, []string{"cloudflare.com:443", "github.com:443"})
h.ICMPTargetIPs = gosettings.DefaultSlice(h.ICMPTargetIPs, []netip.Addr{
netip.AddrFrom4([4]byte{1, 1, 1, 1}),
netip.AddrFrom4([4]byte{8, 8, 8, 8}),
})
h.SmallCheckType = gosettings.DefaultComparable(h.SmallCheckType, "icmp")
const defaultReadHeaderTimeout = 100 * time.Millisecond
h.ReadHeaderTimeout = gosettings.DefaultComparable(h.ReadHeaderTimeout, defaultReadHeaderTimeout)
const defaultReadTimeout = 500 * time.Millisecond
h.ReadTimeout = gosettings.DefaultComparable(h.ReadTimeout, defaultReadTimeout)
h.TargetAddress = gosettings.DefaultComparable(h.TargetAddress, "cloudflare.com:443")
h.ICMPTargetIP = gosettings.DefaultComparable(h.ICMPTargetIP, netip.IPv4Unspecified()) // use the VPN server IP
h.RestartVPN = gosettings.DefaultPointer(h.RestartVPN, true)
}
@@ -107,37 +87,24 @@ func (h Health) String() string {
func (h Health) toLinesNode() (node *gotree.Node) {
node = gotree.New("Health settings:")
node.Appendf("Server listening address: %s", h.ServerAddress)
targetAddrs := node.Appendf("Target addresses:")
for _, targetAddr := range h.TargetAddresses {
targetAddrs.Append(targetAddr)
}
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("Target address: %s", h.TargetAddress)
icmpTarget := "VPN server IP"
if !h.ICMPTargetIP.IsUnspecified() {
icmpTarget = h.ICMPTargetIP.String()
}
node.Appendf("ICMP target IP: %s", icmpTarget)
node.Appendf("Restart VPN on healthcheck failure: %s", gosettings.BoolToYesNo(h.RestartVPN))
return node
}
func (h *Health) Read(r *reader.Reader) (err error) {
h.ServerAddress = r.String("HEALTH_SERVER_ADDRESS")
h.TargetAddresses = r.CSV("HEALTH_TARGET_ADDRESSES",
reader.RetroKeys("HEALTH_ADDRESS_TO_PING", "HEALTH_TARGET_ADDRESS"))
h.ICMPTargetIPs, err = r.CSVNetipAddresses("HEALTH_ICMP_TARGET_IPS", reader.RetroKeys("HEALTH_ICMP_TARGET_IP"))
h.TargetAddress = r.String("HEALTH_TARGET_ADDRESS",
reader.RetroKeys("HEALTH_ADDRESS_TO_PING"))
h.ICMPTargetIP, err = r.NetipAddr("HEALTH_ICMP_TARGET_IP")
if err != nil {
return err
}
h.SmallCheckType = r.String("HEALTH_SMALL_CHECK_TYPE")
h.RestartVPN, err = r.BoolPtr("HEALTH_RESTART_VPN")
if err != nil {
return err

View File

@@ -2,7 +2,6 @@ package settings
import (
"fmt"
"net/netip"
"strings"
"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
// in the internal state.
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.
// It can be set to 0 to indicate no custom port should
// be used. It cannot be nil in the internal state.
@@ -149,7 +142,6 @@ func (o *OpenVPNSelection) copy() (copied OpenVPNSelection) {
return OpenVPNSelection{
ConfFile: gosettings.CopyPointer(o.ConfFile),
Protocol: o.Protocol,
EndpointIP: o.EndpointIP,
CustomPort: gosettings.CopyPointer(o.CustomPort),
PIAEncPreset: gosettings.CopyPointer(o.PIAEncPreset),
}
@@ -159,14 +151,12 @@ func (o *OpenVPNSelection) overrideWith(other OpenVPNSelection) {
o.ConfFile = gosettings.OverrideWithPointer(o.ConfFile, other.ConfFile)
o.Protocol = gosettings.OverrideWithComparable(o.Protocol, other.Protocol)
o.CustomPort = gosettings.OverrideWithPointer(o.CustomPort, other.CustomPort)
o.EndpointIP = gosettings.OverrideWithValidator(o.EndpointIP, other.EndpointIP)
o.PIAEncPreset = gosettings.OverrideWithPointer(o.PIAEncPreset, other.PIAEncPreset)
}
func (o *OpenVPNSelection) setDefaults(vpnProvider string) {
o.ConfFile = gosettings.DefaultPointer(o.ConfFile, "")
o.Protocol = gosettings.DefaultComparable(o.Protocol, constants.UDP)
o.EndpointIP = gosettings.DefaultValidator(o.EndpointIP, netip.IPv4Unspecified())
o.CustomPort = gosettings.DefaultPointer(o.CustomPort, 0)
var defaultEncPreset string
@@ -184,10 +174,6 @@ func (o OpenVPNSelection) toLinesNode() (node *gotree.Node) {
node = gotree.New("OpenVPN server selection settings:")
node.Appendf("Protocol: %s", strings.ToUpper(o.Protocol))
if !o.EndpointIP.IsUnspecified() {
node.Appendf("Endpoint IP address: %s", o.EndpointIP)
}
if *o.CustomPort != 0 {
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.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",
reader.RetroKeys("PORT", "OPENVPN_PORT", "VPN_ENDPOINT_PORT"))
if err != nil {

View File

@@ -1,14 +1,11 @@
package settings
import (
"bytes"
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"github.com/qdm12/gluetun/internal/server/middlewares/auth"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gotree"
@@ -27,9 +24,6 @@ type ControlServer struct {
// It cannot be empty in the internal state and defaults to
// /gluetun/auth/config.toml.
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) {
@@ -50,30 +44,14 @@ func (c ControlServer) validate() (err error) {
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
}
func (c *ControlServer) copy() (copied ControlServer) {
return ControlServer{
Address: gosettings.CopyPointer(c.Address),
Log: gosettings.CopyPointer(c.Log),
AuthFilePath: c.AuthFilePath,
AuthDefaultRole: c.AuthDefaultRole,
Address: gosettings.CopyPointer(c.Address),
Log: gosettings.CopyPointer(c.Log),
AuthFilePath: c.AuthFilePath,
}
}
@@ -84,21 +62,12 @@ func (c *ControlServer) overrideWith(other ControlServer) {
c.Address = gosettings.OverrideWithPointer(c.Address, other.Address)
c.Log = gosettings.OverrideWithPointer(c.Log, other.Log)
c.AuthFilePath = gosettings.OverrideWithComparable(c.AuthFilePath, other.AuthFilePath)
c.AuthDefaultRole = gosettings.OverrideWithComparable(c.AuthDefaultRole, other.AuthDefaultRole)
}
func (c *ControlServer) setDefaults() {
c.Address = gosettings.DefaultPointer(c.Address, ":8000")
c.Log = gosettings.DefaultPointer(c.Log, true)
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 {
@@ -110,11 +79,6 @@ func (c ControlServer) toLinesNode() (node *gotree.Node) {
node.Appendf("Listening address: %s", *c.Address)
node.Appendf("Logging: %s", gosettings.BoolToYesNo(c.Log))
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
}
@@ -127,7 +91,6 @@ func (c *ControlServer) read(r *reader.Reader) (err error) {
c.Address = r.Get("HTTP_CONTROL_SERVER_ADDRESS")
c.AuthFilePath = r.String("HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH")
c.AuthDefaultRole = r.String("HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE")
return nil
}

View File

@@ -3,6 +3,7 @@ package settings
import (
"errors"
"fmt"
"net/netip"
"strings"
"github.com/qdm12/gluetun/internal/configuration/settings/helpers"
@@ -21,6 +22,12 @@ type ServerSelection struct {
// or 'wireguard'. It cannot be the empty string
// in the internal state.
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 []string `json:"countries"`
// 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) {
return ServerSelection{
VPN: ss.VPN,
TargetIP: ss.TargetIP,
Countries: gosettings.CopySlice(ss.Countries),
Categories: gosettings.CopySlice(ss.Categories),
Regions: gosettings.CopySlice(ss.Regions),
@@ -315,6 +323,7 @@ func (ss *ServerSelection) copy() (copied ServerSelection) {
func (ss *ServerSelection) overrideWith(other ServerSelection) {
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.Categories = gosettings.OverrideWithSlice(ss.Categories, other.Categories)
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) {
ss.VPN = gosettings.DefaultComparable(ss.VPN, vpn.OpenVPN)
ss.TargetIP = gosettings.DefaultValidator(ss.TargetIP, netip.IPv4Unspecified())
ss.OwnedOnly = gosettings.DefaultPointer(ss.OwnedOnly, false)
ss.FreeOnly = gosettings.DefaultPointer(ss.FreeOnly, false)
ss.PremiumOnly = gosettings.DefaultPointer(ss.PremiumOnly, false)
@@ -358,6 +368,9 @@ func (ss ServerSelection) String() string {
func (ss ServerSelection) toLinesNode() (node *gotree.Node) {
node = gotree.New("Server selection settings:")
node.Appendf("VPN type: %s", ss.VPN)
if !ss.TargetIP.IsUnspecified() {
node.Appendf("Target IP address: %s", ss.TargetIP)
}
if len(ss.Countries) > 0 {
node.Appendf("Countries: %s", strings.Join(ss.Countries, ", "))
@@ -448,6 +461,12 @@ func (ss *ServerSelection) read(r *reader.Reader,
) (err error) {
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"}
if vpnProvider == providers.Cyberghost {
countriesRetroKeys = append(countriesRetroKeys, "REGION")

View File

@@ -57,13 +57,8 @@ func Test_Settings_String(t *testing.T) {
| └── Log level: INFO
├── Health settings:
| ├── Server listening address: 127.0.0.1:9999
| ├── Target addresses:
| | ├── cloudflare.com:443
| | └── github.com:443
| ├── Small health check type: ICMP echo request
| | └── ICMP target IPs:
| | ├── 1.1.1.1
| | └── 8.8.8.8
| ├── Target address: cloudflare.com:443
| ├── ICMP target IP: VPN server IP
| └── Restart VPN on healthcheck failure: yes
├── Shadowsocks server settings:
| └── Enabled: no

View File

@@ -2,7 +2,6 @@ package settings
import (
"fmt"
"slices"
"strings"
"time"
@@ -32,10 +31,6 @@ type Updater struct {
// Providers is the list of VPN service providers
// to update server information for.
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) {
@@ -56,18 +51,6 @@ func (u Updater) Validate() (err error) {
if err != nil {
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
@@ -75,12 +58,10 @@ func (u Updater) Validate() (err error) {
func (u *Updater) copy() (copied Updater) {
return Updater{
Period: gosettings.CopyPointer(u.Period),
DNSAddress: u.DNSAddress,
MinRatio: u.MinRatio,
Providers: gosettings.CopySlice(u.Providers),
ProtonEmail: gosettings.CopyPointer(u.ProtonEmail),
ProtonPassword: gosettings.CopyPointer(u.ProtonPassword),
Period: gosettings.CopyPointer(u.Period),
DNSAddress: u.DNSAddress,
MinRatio: u.MinRatio,
Providers: gosettings.CopySlice(u.Providers),
}
}
@@ -92,8 +73,6 @@ func (u *Updater) overrideWith(other Updater) {
u.DNSAddress = gosettings.OverrideWithComparable(u.DNSAddress, other.DNSAddress)
u.MinRatio = gosettings.OverrideWithComparable(u.MinRatio, other.MinRatio)
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) {
@@ -108,10 +87,6 @@ func (u *Updater) SetDefaults(vpnProvider string) {
if len(u.Providers) == 0 && vpnProvider != providers.Custom {
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 {
@@ -128,10 +103,6 @@ func (u Updater) toLinesNode() (node *gotree.Node) {
node.Appendf("DNS address: %s", u.DNSAddress)
node.Appendf("Minimum ratio: %.1f", u.MinRatio)
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
}
@@ -154,16 +125,6 @@ func (u *Updater) read(r *reader.Reader) (err error) {
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
}

View File

@@ -45,6 +45,7 @@ type Wireguard struct {
// It has been lowered to 1320 following quite a bit of
// investigation in the issue:
// https://github.com/qdm12/gluetun/issues/2533.
// Note this should now be replaced with the PMTUD feature.
MTU uint16 `json:"mtu"`
// Implementation is the Wireguard implementation to use.
// It can be "auto", "userspace" or "kernelspace".

View File

@@ -14,11 +14,11 @@ import (
type WireguardSelection struct {
// EndpointIP is the server endpoint IP address.
// It is notably required with the custom provider.
// Otherwise 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.
// It is only used with VPN providers generating Wireguard
// configurations specific to each server and user.
// 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"`
// EndpointPort is a the server port to use for the VPN server.
// 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) {
w.EndpointIP, err = r.NetipAddr("WIREGUARD_ENDPOINT_IP", reader.RetroKeys("VPN_ENDPOINT_IP"))
if err != nil {
return fmt.Errorf("%w - note this MUST be an IP address, "+
"see https://github.com/qdm12/gluetun/issues/788", err)
return err
}
w.EndpointPort, err = r.Uint16Ptr("WIREGUARD_ENDPOINT_PORT", reader.RetroKeys("VPN_ENDPOINT_PORT"))

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
"net/netip"
"time"
"github.com/qdm12/dns/v2/pkg/middlewares/filter/mapfilter"
@@ -17,23 +16,22 @@ import (
)
type Loop struct {
statusManager *loopstate.State
state *state.State
server *server.Server
filter *mapfilter.Filter
localResolvers []netip.Addr
resolvConf string
client *http.Client
logger Logger
userTrigger bool
start <-chan struct{}
running chan<- models.LoopStatus
stop <-chan struct{}
stopped chan<- struct{}
updateTicker <-chan struct{}
backoffTime time.Duration
timeNow func() time.Time
timeSince func(time.Time) time.Duration
statusManager *loopstate.State
state *state.State
server *server.Server
filter *mapfilter.Filter
resolvConf string
client *http.Client
logger Logger
userTrigger bool
start <-chan struct{}
running chan<- models.LoopStatus
stop <-chan struct{}
stopped chan<- struct{}
updateTicker <-chan struct{}
backoffTime time.Duration
timeNow func() time.Time
timeSince func(time.Time) time.Duration
}
const defaultBackoffTime = 10 * time.Second
@@ -50,9 +48,7 @@ func NewLoop(settings settings.DNS,
statusManager := loopstate.New(constants.Stopped, start, running, stop, stopped)
state := state.New(statusManager, settings, updateTicker)
filter, err := mapfilter.New(mapfilter.Settings{
Logger: buildFilterLogger(logger),
})
filter, err := mapfilter.New(mapfilter.Settings{})
if err != nil {
return nil, fmt.Errorf("creating map filter: %w", err)
}
@@ -104,15 +100,3 @@ func (l *Loop) signalOrSetStatus(status models.LoopStatus) {
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"
"errors"
"github.com/qdm12/dns/v2/pkg/nameserver"
"github.com/qdm12/gluetun/internal/constants"
)
func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
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 {
l.logger.Warn("⚠️⚠️⚠️ keeping the default container nameservers, " +
"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 {
l.backoffTime = defaultBackoffTime
l.logger.Info("ready")
l.signalOrSetStatus(constants.Running)
break
}
@@ -61,7 +54,6 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
l.logAndWait(ctx, err)
settings = l.GetSettings()
}
l.signalOrSetStatus(constants.Running)
settings = l.GetSettings()
if !*settings.KeepNameserver && !*settings.ServerEnabled {
@@ -82,19 +74,15 @@ func (l *Loop) runWait(ctx context.Context, runError <-chan error) (exitLoop boo
for {
select {
case <-ctx.Done():
if !*l.GetSettings().KeepNameserver {
l.stopServer()
// TODO revert OS and Go nameserver when exiting
}
l.stopServer()
// TODO revert OS and Go nameserver when exiting
return true
case <-l.stop:
l.userTrigger = true
l.logger.Info("stopping")
if !*l.GetSettings().KeepNameserver {
const fallback = false
l.useUnencryptedDNS(fallback)
l.stopServer()
}
const fallback = false
l.useUnencryptedDNS(fallback)
l.stopServer()
l.stopped <- struct{}{}
case <-l.start:
l.userTrigger = true

View File

@@ -3,7 +3,6 @@ package dns
import (
"context"
"fmt"
"net/netip"
"github.com/qdm12/dns/v2/pkg/doh"
"github.com/qdm12/dns/v2/pkg/dot"
@@ -11,7 +10,6 @@ import (
"github.com/qdm12/dns/v2/pkg/middlewares/cache/lru"
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/localdns"
"github.com/qdm12/dns/v2/pkg/plain"
"github.com/qdm12/dns/v2/pkg/provider"
"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,
filter *mapfilter.Filter, localResolvers []netip.Addr,
logger Logger) (
filter *mapfilter.Filter, logger Logger) (
serverSettings server.Settings, err error,
) {
serverSettings.Logger = logger
@@ -104,22 +101,5 @@ func buildServerSettings(settings settings.DNS,
}
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
}

View File

@@ -21,7 +21,7 @@ func (l *Loop) setupServer(ctx context.Context) (runError <-chan error, err erro
settings := l.GetSettings()
serverSettings, err := buildServerSettings(settings, l.filter, l.localResolvers, l.logger)
serverSettings, err := buildServerSettings(settings, l.filter, l.logger)
if err != nil {
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,
}
updateSettings.BlockHostnames(result.BlockedHostnames)
updateSettings.SetRebindingProtectionExempt(settings.Blacklist.RebindingProtectionExemptHostnames)
err = l.filter.Update(updateSettings)
if err != nil {
return fmt.Errorf("updating filter: %w", err)

View File

@@ -16,16 +16,16 @@ import (
)
type Checker struct {
tlsDialAddrs []string
dialer *net.Dialer
echoer *icmp.Echoer
dnsClient *dns.Client
logger Logger
icmpTargetIPs []netip.Addr
smallCheckType string
configMutex sync.Mutex
tlsDialAddr string
dialer *net.Dialer
echoer *icmp.Echoer
dnsClient *dns.Client
logger Logger
icmpTarget netip.Addr
configMutex sync.Mutex
icmpNotPermitted bool
smallCheckName string
// Internal periodic service signals
stop context.CancelFunc
@@ -45,37 +45,35 @@ func NewChecker(logger Logger) *Checker {
}
}
// SetConfig sets the TCP+TLS dial addresses, the ICMP echo IP address
// to target and the desired small check type (dns or icmp).
// SetConfig sets the TCP+TLS dial address and the ICMP echo IP address
// to target by the [Checker].
// This function MUST be called before calling [Checker.Start].
func (c *Checker) SetConfig(tlsDialAddrs []string, icmpTargets []netip.Addr,
smallCheckType string,
) {
func (c *Checker) SetConfig(tlsDialAddr string, icmpTarget netip.Addr) {
c.configMutex.Lock()
defer c.configMutex.Unlock()
c.tlsDialAddrs = tlsDialAddrs
c.icmpTargetIPs = icmpTargets
c.smallCheckType = smallCheckType
c.tlsDialAddr = tlsDialAddr
c.icmpTarget = icmpTarget
}
// 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:
// - a "small" ICMP echo check every minute
// - a "small" ICMP echo check every 15 seconds
// - 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 an error if the initial TCP+TLS check fails.
// The Checker has to be ultimately stopped by calling [Checker.Stop].
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")
}
if c.icmpNotPermitted {
// restore forced check type to dns if icmp was found to be not permitted
c.smallCheckType = smallCheckDNS
}
err = c.startupCheck(ctx)
// 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.
const timeout = 6 * time.Second
tcpTLSCheckCtx, tcpTLSCheckCancel := context.WithTimeout(ctx, timeout)
err = tcpTLSCheck(tcpTLSCheckCtx, c.dialer, c.tlsDialAddr)
tcpTLSCheckCancel()
if err != nil {
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
done := make(chan struct{})
c.done = done
c.smallCheckName = "ICMP echo"
const smallCheckPeriod = time.Minute
smallCheckTimer := time.NewTimer(smallCheckPeriod)
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 {
c.stop()
<-c.done
c.tlsDialAddrs = nil
c.icmpTargetIPs = nil
c.smallCheckType = ""
c.icmpTarget = netip.Addr{}
return nil
}
func (c *Checker) smallPeriodicCheck(ctx context.Context) error {
c.configMutex.Lock()
icmpTargetIPs := make([]netip.Addr, len(c.icmpTargetIPs))
copy(icmpTargetIPs, c.icmpTargetIPs)
ip := c.icmpTarget
c.configMutex.Unlock()
tryTimeouts := []time.Duration{
5 * time.Second,
5 * time.Second,
5 * time.Second,
10 * time.Second,
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 {
const maxTries = 3
const timeout = 10 * time.Second
const extraTryTime = 10 * time.Second // 10s added for each subsequent retry
check := func(ctx context.Context) error {
if c.icmpNotPermitted {
return c.dnsClient.Check(ctx)
}
ip := icmpTargetIPs[try%len(icmpTargetIPs)]
err := c.echoer.Echo(ctx, ip)
if errors.Is(err, icmp.ErrNotPermitted) {
c.icmpNotPermitted = true
c.smallCheckType = smallCheckDNS
c.logger.Infof("%s; permanently falling back to %s checks",
smallCheckTypeToString(c.smallCheckType), err)
c.smallCheckName = "plain DNS over UDP"
c.logger.Infof("%s; permanently falling back to %s checks.", c.smallCheckName, err)
return c.dnsClient.Check(ctx)
}
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 {
const maxTries = 2
// 20s timeout in case the connection is under stress
// See https://github.com/qdm12/gluetun/issues/2270
tryTimeouts := []time.Duration{10 * time.Second, 15 * time.Second, 30 * time.Second}
check := func(ctx context.Context, try int) error {
tlsDialAddr := c.tlsDialAddrs[try%len(c.tlsDialAddrs)]
return tcpTLSCheck(ctx, c.dialer, tlsDialAddr)
const timeout = 20 * time.Second
const extraTryTime = 10 * time.Second // 10s added for each subsequent retry
check := func(ctx context.Context) error {
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 {
@@ -232,19 +218,15 @@ func makeAddressToDial(address string) (addressToDial string, err error) {
var ErrAllCheckTriesFailed = errors.New("all check tries failed")
func withRetries(ctx context.Context, tryTimeouts []time.Duration,
logger Logger, checkName string, check func(ctx context.Context, try int) error,
func withRetries(ctx context.Context, maxTries uint, tryTimeout, extraTryTime time.Duration,
logger Logger, checkName string, check func(ctx context.Context) error,
) error {
maxTries := len(tryTimeouts)
type errData struct {
err error
durationMS int64
}
errs := make([]errData, maxTries)
for i, timeout := range tryTimeouts {
start := time.Now()
try := uint(0)
var errs []error
for {
timeout := tryTimeout + time.Duration(try)*extraTryTime //nolint:gosec
checkCtx, cancel := context.WithTimeout(ctx, timeout)
err := check(checkCtx, i)
err := check(checkCtx)
cancel()
switch {
case err == nil:
@@ -252,75 +234,17 @@ func withRetries(ctx context.Context, tryTimeouts []time.Duration,
case ctx.Err() != nil:
return fmt.Errorf("%s: %w", checkName, ctx.Err())
}
logger.Debugf("%s attempt %d/%d failed: %s", checkName, i+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)
logger.Debugf("%s attempt %d/%d failed: %s", checkName, try+1, maxTries, err)
errs = append(errs, err)
}
if success {
return nil
}
errStrings := make([]string, len(errs))
for i, err := range errs {
errStrings[i] = fmt.Sprintf("parallel attempt %d/%d failed: %s", i+1, len(errs), err)
}
return fmt.Errorf("%w: %s", ErrAllCheckTriesFailed, 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)
try++
if try < maxTries {
continue
}
errStrings := make([]string, len(errs))
for i, err := range errs {
errStrings[i] = fmt.Sprintf("attempt %d: %s", i+1, err.Error())
}
return fmt.Errorf("%w: after %d %s attempts (%s)",
ErrAllCheckTriesFailed, maxTries, checkName, strings.Join(errStrings, "; "))
}
}

View File

@@ -18,11 +18,11 @@ func Test_Checker_fullcheck(t *testing.T) {
t.Parallel()
dialer := &net.Dialer{}
addresses := []string{"badaddress:9876", "cloudflare.com:443", "google.com:443"}
const address = "cloudflare.com:443"
checker := &Checker{
dialer: dialer,
tlsDialAddrs: addresses,
dialer: dialer,
tlsDialAddr: address,
}
canceledCtx, cancel := context.WithCancel(context.Background())
@@ -52,8 +52,8 @@ func Test_Checker_fullcheck(t *testing.T) {
dialer := &net.Dialer{}
checker := &Checker{
dialer: dialer,
tlsDialAddrs: []string{listeningAddress.String()},
dialer: dialer,
tlsDialAddr: listeningAddress.String(),
}
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")
func (c *Client) Check(ctx context.Context) error {
dnsAddr := c.serverAddrs[c.dnsIPIndex].String()
dnsAddr := c.serverAddrs[c.dnsIPIndex].Addr()
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "udp", dnsAddr)
return dialer.DialContext(ctx, "udp", dnsAddr.String())
},
}
ips, err := resolver.LookupIP(ctx, "ip", "github.com")
switch {
case err != nil:
c.dnsIPIndex = (c.dnsIPIndex + 1) % len(c.serverAddrs)
return fmt.Errorf("with DNS server %s: %w", dnsAddr, err)
return err
case len(ips) == 0:
c.dnsIPIndex = (c.dnsIPIndex + 1) % len(c.serverAddrs)
return fmt.Errorf("with DNS server %s: %w", dnsAddr, ErrLookupNoIPs)
return fmt.Errorf("%w", ErrLookupNoIPs)
default:
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") {
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)
if 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
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

View File

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

View File

@@ -62,6 +62,10 @@ func (n *NetLink) LinkSetDown(link Link) (err error) {
return netlink.LinkSetDown(linkToNetlinkLink(&link))
}
func (n *NetLink) LinkSetMTU(link Link, mtu int) error {
return netlink.LinkSetMTU(linkToNetlinkLink(&link), mtu)
}
type netlinkLinkImpl struct {
attrs *netlink.LinkAttrs
linkType string

View File

@@ -0,0 +1,49 @@
package pmtud
import (
"net"
"time"
"golang.org/x/net/ipv4"
)
var _ net.PacketConn = &ipv4Wrapper{}
// ipv4Wrapper is a wrapper around ipv4.PacketConn to implement
// the net.PacketConn interface. It's only used for Darwin or iOS.
type ipv4Wrapper struct {
ipv4Conn *ipv4.PacketConn
}
func ipv4ToNetPacketConn(ipv4 *ipv4.PacketConn) *ipv4Wrapper {
return &ipv4Wrapper{ipv4Conn: ipv4}
}
func (i *ipv4Wrapper) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
n, _, addr, err = i.ipv4Conn.ReadFrom(p)
return n, addr, err
}
func (i *ipv4Wrapper) WriteTo(p []byte, addr net.Addr) (n int, err error) {
return i.ipv4Conn.WriteTo(p, nil, addr)
}
func (i *ipv4Wrapper) Close() error {
return i.ipv4Conn.Close()
}
func (i *ipv4Wrapper) LocalAddr() net.Addr {
return i.ipv4Conn.LocalAddr()
}
func (i *ipv4Wrapper) SetDeadline(t time.Time) error {
return i.ipv4Conn.SetDeadline(t)
}
func (i *ipv4Wrapper) SetReadDeadline(t time.Time) error {
return i.ipv4Conn.SetReadDeadline(t)
}
func (i *ipv4Wrapper) SetWriteDeadline(t time.Time) error {
return i.ipv4Conn.SetWriteDeadline(t)
}

83
internal/pmtud/check.go Normal file
View File

@@ -0,0 +1,83 @@
package pmtud
import (
"bytes"
"errors"
"fmt"
"golang.org/x/net/icmp"
)
var (
ErrICMPNextHopMTUTooLow = errors.New("ICMP Next Hop MTU is too low")
ErrICMPNextHopMTUTooHigh = errors.New("ICMP Next Hop MTU is too high")
)
func checkMTU(mtu, minMTU, physicalLinkMTU int) (err error) {
switch {
case mtu < minMTU:
return fmt.Errorf("%w: %d", ErrICMPNextHopMTUTooLow, mtu)
case mtu > physicalLinkMTU:
return fmt.Errorf("%w: %d is larger than physical link MTU %d",
ErrICMPNextHopMTUTooHigh, mtu, physicalLinkMTU)
default:
return nil
}
}
func checkInvokingReplyIDMatch(icmpProtocol int, received []byte,
outboundMessage *icmp.Message,
) (match bool, err error) {
inboundMessage, err := icmp.ParseMessage(icmpProtocol, received)
if err != nil {
return false, fmt.Errorf("parsing invoking packet: %w", err)
}
inboundBody, ok := inboundMessage.Body.(*icmp.Echo)
if !ok {
return false, fmt.Errorf("%w: %T", ErrICMPBodyUnsupported, inboundMessage.Body)
}
outboundBody := outboundMessage.Body.(*icmp.Echo) //nolint:forcetypeassert
return inboundBody.ID == outboundBody.ID, nil
}
var ErrICMPIDMismatch = errors.New("ICMP id mismatch")
func checkEchoReply(icmpProtocol int, received []byte,
outboundMessage *icmp.Message, truncatedBody bool,
) (err error) {
inboundMessage, err := icmp.ParseMessage(icmpProtocol, received)
if err != nil {
return fmt.Errorf("parsing invoking packet: %w", err)
}
inboundBody, ok := inboundMessage.Body.(*icmp.Echo)
if !ok {
return fmt.Errorf("%w: %T", ErrICMPBodyUnsupported, inboundMessage.Body)
}
outboundBody := outboundMessage.Body.(*icmp.Echo) //nolint:forcetypeassert
if inboundBody.ID != outboundBody.ID {
return fmt.Errorf("%w: sent id %d and received id %d",
ErrICMPIDMismatch, outboundBody.ID, inboundBody.ID)
}
err = checkEchoBodies(outboundBody.Data, inboundBody.Data, truncatedBody)
if err != nil {
return fmt.Errorf("checking sent and received bodies: %w", err)
}
return nil
}
var ErrICMPEchoDataMismatch = errors.New("ICMP data mismatch")
func checkEchoBodies(sent, received []byte, receivedTruncated bool) (err error) {
if len(received) > len(sent) {
return fmt.Errorf("%w: sent %d bytes and received %d bytes",
ErrICMPEchoDataMismatch, len(sent), len(received))
}
if receivedTruncated {
sent = sent[:len(received)]
}
if !bytes.Equal(received, sent) {
return fmt.Errorf("%w: sent %x and received %x",
ErrICMPEchoDataMismatch, sent, received)
}
return nil
}

10
internal/pmtud/df.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build !linux && !windows
package pmtud
// setDontFragment for platforms other than Linux and Windows
// is not implemented, so we just return assuming the don't
// fragment flag is set on IP packets.
func setDontFragment(fd uintptr) (err error) {
return nil
}

View File

@@ -0,0 +1,12 @@
//go:build linux
package pmtud
import (
"syscall"
)
func setDontFragment(fd uintptr) (err error) {
return syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP,
syscall.IP_MTU_DISCOVER, syscall.IP_PMTUDISC_PROBE)
}

View File

@@ -0,0 +1,13 @@
//go:build windows
package pmtud
import (
"syscall"
)
func setDontFragment(fd uintptr) (err error) {
// https://docs.microsoft.com/en-us/troubleshoot/windows/win32/header-library-requirement-socket-ipproto-ip
// #define IP_DONTFRAGMENT 14 /* don't fragment IP datagrams */
return syscall.SetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IP, 14, 1)
}

29
internal/pmtud/errors.go Normal file
View File

@@ -0,0 +1,29 @@
package pmtud
import (
"context"
"errors"
"fmt"
"net"
"strings"
"time"
)
var (
ErrICMPNotPermitted = errors.New("ICMP not permitted")
ErrICMPDestinationUnreachable = errors.New("ICMP destination unreachable")
ErrICMPCommunicationAdministrativelyProhibited = errors.New("communication administratively prohibited")
ErrICMPBodyUnsupported = errors.New("ICMP body type is not supported")
)
func wrapConnErr(err error, timedCtx context.Context, pingTimeout time.Duration) error { //nolint:revive
switch {
case strings.HasSuffix(err.Error(), "sendto: operation not permitted"):
err = fmt.Errorf("%w", ErrICMPNotPermitted)
case errors.Is(timedCtx.Err(), context.DeadlineExceeded):
err = fmt.Errorf("%w (timed out after %s)", net.ErrClosed, pingTimeout)
case timedCtx.Err() != nil:
err = timedCtx.Err()
}
return err
}

View File

@@ -0,0 +1,7 @@
package pmtud
type Logger interface {
Debug(msg string)
Debugf(msg string, args ...any)
Warnf(msg string, args ...any)
}

159
internal/pmtud/ipv4.go Normal file
View File

@@ -0,0 +1,159 @@
package pmtud
import (
"context"
"encoding/binary"
"fmt"
"net"
"net/netip"
"runtime"
"strings"
"syscall"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
)
const (
// see https://en.wikipedia.org/wiki/Maximum_transmission_unit#MTUs_for_common_media
minIPv4MTU = 68
icmpv4Protocol = 1
)
func listenICMPv4(ctx context.Context) (conn net.PacketConn, err error) {
var listenConfig net.ListenConfig
listenConfig.Control = func(_, _ string, rawConn syscall.RawConn) error {
var setDFErr error
err := rawConn.Control(func(fd uintptr) {
setDFErr = setDontFragment(fd) // runs when calling ListenPacket
})
if err == nil {
err = setDFErr
}
return err
}
const listenAddress = ""
packetConn, err := listenConfig.ListenPacket(ctx, "ip4:icmp", listenAddress)
if err != nil {
if strings.HasSuffix(err.Error(), "socket: operation not permitted") {
err = fmt.Errorf("%w: you can try adding NET_RAW capability to resolve this", ErrICMPNotPermitted)
}
return nil, err
}
if runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
packetConn = ipv4ToNetPacketConn(ipv4.NewPacketConn(packetConn))
}
return packetConn, nil
}
func findIPv4NextHopMTU(ctx context.Context, ip netip.Addr,
physicalLinkMTU int, pingTimeout time.Duration, logger Logger,
) (mtu int, err error) {
if ip.Is6() {
panic("IP address is not v4")
}
conn, err := listenICMPv4(ctx)
if err != nil {
return 0, fmt.Errorf("listening for ICMP packets: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
go func() {
<-ctx.Done()
conn.Close()
}()
// First try to send a packet which is too big to get the maximum MTU
// directly.
outboundID, outboundMessage := buildMessageToSend("v4", physicalLinkMTU)
encodedMessage, err := outboundMessage.Marshal(nil)
if err != nil {
return 0, fmt.Errorf("encoding ICMP message: %w", err)
}
_, err = conn.WriteTo(encodedMessage, &net.IPAddr{IP: ip.AsSlice()})
if err != nil {
err = wrapConnErr(err, ctx, pingTimeout)
return 0, fmt.Errorf("writing ICMP message: %w", err)
}
buffer := make([]byte, physicalLinkMTU)
for { // for loop in case we read an echo reply for another ICMP request
// Note we need to read the whole packet in one call to ReadFrom, so the buffer
// must be large enough to read the entire reply packet. See:
// https://groups.google.com/g/golang-nuts/c/5dy2Q4nPs08/m/KmuSQAGEtG4J
bytesRead, _, err := conn.ReadFrom(buffer)
if err != nil {
err = wrapConnErr(err, ctx, pingTimeout)
return 0, fmt.Errorf("reading from ICMP connection: %w", err)
}
packetBytes := buffer[:bytesRead]
// Side note: echo reply should be at most the number of bytes
// sent, and can be lower, more precisely 576-ipHeader bytes,
// in case the next hop we are reaching replies with a destination
// unreachable and wants to ensure the response makes it way back
// by keeping a low packet size, see:
// https://datatracker.ietf.org/doc/html/rfc1122#page-59
inboundMessage, err := icmp.ParseMessage(icmpv4Protocol, packetBytes)
if err != nil {
return 0, fmt.Errorf("parsing message: %w", err)
}
switch typedBody := inboundMessage.Body.(type) {
case *icmp.DstUnreach:
const fragmentationRequiredAndDFFlagSetCode = 4
const communicationAdministrativelyProhibitedCode = 13
switch inboundMessage.Code {
case fragmentationRequiredAndDFFlagSetCode:
case communicationAdministrativelyProhibitedCode:
return 0, fmt.Errorf("%w: %w (code %d)",
ErrICMPDestinationUnreachable,
ErrICMPCommunicationAdministrativelyProhibited,
inboundMessage.Code)
default:
return 0, fmt.Errorf("%w: code %d",
ErrICMPDestinationUnreachable, inboundMessage.Code)
}
// See https://datatracker.ietf.org/doc/html/rfc1191#section-4
// Note: the go library does not handle this NextHopMTU section.
nextHopMTU := packetBytes[6:8]
mtu = int(binary.BigEndian.Uint16(nextHopMTU))
err = checkMTU(mtu, minIPv4MTU, physicalLinkMTU)
if err != nil {
return 0, fmt.Errorf("checking next-hop-mtu found: %w", err)
}
// The code below is really for sanity checks
packetBytes = packetBytes[8:]
header, err := ipv4.ParseHeader(packetBytes)
if err != nil {
return 0, fmt.Errorf("parsing IPv4 header: %w", err)
}
packetBytes = packetBytes[header.Len:] // truncated original datagram
const truncated = true
err = checkEchoReply(icmpv4Protocol, packetBytes, outboundMessage, truncated)
if err != nil {
return 0, fmt.Errorf("checking echo reply: %w", err)
}
return mtu, nil
case *icmp.Echo:
inboundID := uint16(typedBody.ID) //nolint:gosec
if inboundID == outboundID {
return physicalLinkMTU, nil
}
logger.Debugf("discarding received ICMP echo reply with id %d mismatching sent id %d",
inboundID, outboundID)
continue
default:
return 0, fmt.Errorf("%w: %T", ErrICMPBodyUnsupported, typedBody)
}
}
}

122
internal/pmtud/ipv6.go Normal file
View File

@@ -0,0 +1,122 @@
package pmtud
import (
"context"
"fmt"
"net"
"net/netip"
"strings"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv6"
)
const (
minIPv6MTU = 1280
icmpv6Protocol = 58
)
func listenICMPv6(ctx context.Context) (conn net.PacketConn, err error) {
var listenConfig net.ListenConfig
const listenAddress = ""
packetConn, err := listenConfig.ListenPacket(ctx, "ip6:ipv6-icmp", listenAddress)
if err != nil {
if strings.HasSuffix(err.Error(), "socket: operation not permitted") {
err = fmt.Errorf("%w: you can try adding NET_RAW capability to resolve this", ErrICMPNotPermitted)
}
return nil, err
}
return packetConn, nil
}
func getIPv6PacketTooBig(ctx context.Context, ip netip.Addr,
physicalLinkMTU int, pingTimeout time.Duration, logger Logger,
) (mtu int, err error) {
if ip.Is4() {
panic("IP address is not v6")
}
conn, err := listenICMPv6(ctx)
if err != nil {
return 0, fmt.Errorf("listening for ICMP packets: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
go func() {
<-ctx.Done()
conn.Close()
}()
// First try to send a packet which is too big to get the maximum MTU
// directly.
outboundID, outboundMessage := buildMessageToSend("v6", physicalLinkMTU)
encodedMessage, err := outboundMessage.Marshal(nil)
if err != nil {
return 0, fmt.Errorf("encoding ICMP message: %w", err)
}
_, err = conn.WriteTo(encodedMessage, &net.IPAddr{IP: ip.AsSlice(), Zone: ip.Zone()})
if err != nil {
err = wrapConnErr(err, ctx, pingTimeout)
return 0, fmt.Errorf("writing ICMP message: %w", err)
}
buffer := make([]byte, physicalLinkMTU)
for { // for loop if we encounter another ICMP packet with an unknown id.
// Note we need to read the whole packet in one call to ReadFrom, so the buffer
// must be large enough to read the entire reply packet. See:
// https://groups.google.com/g/golang-nuts/c/5dy2Q4nPs08/m/KmuSQAGEtG4J
bytesRead, _, err := conn.ReadFrom(buffer)
if err != nil {
err = wrapConnErr(err, ctx, pingTimeout)
return 0, fmt.Errorf("reading from ICMP connection: %w", err)
}
packetBytes := buffer[:bytesRead]
packetBytes = packetBytes[ipv6.HeaderLen:]
inboundMessage, err := icmp.ParseMessage(icmpv6Protocol, packetBytes)
if err != nil {
return 0, fmt.Errorf("parsing message: %w", err)
}
switch typedBody := inboundMessage.Body.(type) {
case *icmp.PacketTooBig:
// https://datatracker.ietf.org/doc/html/rfc1885#section-3.2
mtu = typedBody.MTU
err = checkMTU(mtu, minIPv6MTU, physicalLinkMTU)
if err != nil {
return 0, fmt.Errorf("checking MTU: %w", err)
}
// Sanity checks
const truncatedBody = true
err = checkEchoReply(icmpv6Protocol, typedBody.Data, outboundMessage, truncatedBody)
if err != nil {
return 0, fmt.Errorf("checking invoking message: %w", err)
}
return typedBody.MTU, nil
case *icmp.DstUnreach:
// https://datatracker.ietf.org/doc/html/rfc1885#section-3.1
idMatch, err := checkInvokingReplyIDMatch(icmpv6Protocol, packetBytes, outboundMessage)
if err != nil {
return 0, fmt.Errorf("checking invoking message id: %w", err)
} else if idMatch {
return 0, fmt.Errorf("%w", ErrICMPDestinationUnreachable)
}
logger.Debug("discarding received ICMP destination unreachable reply with an unknown id")
continue
case *icmp.Echo:
inboundID := uint16(typedBody.ID) //nolint:gosec
if inboundID == outboundID {
return physicalLinkMTU, nil
}
logger.Debugf("discarding received ICMP echo reply with id %d mismatching sent id %d",
inboundID, outboundID)
continue
default:
return 0, fmt.Errorf("%w: %T", ErrICMPBodyUnsupported, typedBody)
}
}
}

58
internal/pmtud/message.go Normal file
View File

@@ -0,0 +1,58 @@
package pmtud
import (
cryptorand "crypto/rand"
"encoding/binary"
"fmt"
"math/rand/v2"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
func buildMessageToSend(ipVersion string, mtu int) (id uint16, message *icmp.Message) {
var seed [32]byte
_, _ = cryptorand.Read(seed[:])
randomSource := rand.NewChaCha8(seed)
const uint16Bytes = 2
idBytes := make([]byte, uint16Bytes)
_, _ = randomSource.Read(idBytes)
id = binary.BigEndian.Uint16(idBytes)
var ipHeaderLength int
var icmpType icmp.Type
switch ipVersion {
case "v4":
ipHeaderLength = ipv4.HeaderLen
icmpType = ipv4.ICMPTypeEcho
case "v6":
ipHeaderLength = ipv6.HeaderLen
icmpType = ipv6.ICMPTypeEchoRequest
default:
panic(fmt.Sprintf("IP version %q not supported", ipVersion))
}
const pingHeaderLength = 0 +
1 + // type
1 + // code
2 + // checksum
2 + // identifier
2 // sequence number
pingBodyDataSize := mtu - ipHeaderLength - pingHeaderLength
messageBodyData := make([]byte, pingBodyDataSize)
_, _ = randomSource.Read(messageBodyData)
// See https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml#icmp-parameters-types
message = &icmp.Message{
Type: icmpType, // echo request
Code: 0, // no code
Checksum: 0, // calculated at encoding (ipv4) or sending (ipv6)
Body: &icmp.Echo{
ID: int(id),
Seq: 0, // only one packet
Data: messageBodyData,
},
}
return id, message
}

View File

@@ -0,0 +1,7 @@
package pmtud
type noopLogger struct{}
func (noopLogger) Debug(_ string) {}
func (noopLogger) Debugf(_ string, _ ...any) {}
func (noopLogger) Warnf(_ string, _ ...any) {}

271
internal/pmtud/pmtud.go Normal file
View File

@@ -0,0 +1,271 @@
package pmtud
import (
"context"
"errors"
"fmt"
"math"
"net"
"net/netip"
"strings"
"time"
"golang.org/x/net/icmp"
)
var ErrMTUNotFound = errors.New("path MTU discovery failed to find MTU")
// PathMTUDiscover discovers the maximum MTU for the path to the given ip address.
// If the physicalLinkMTU is zero, it defaults to 1500 which is the ethernet standard MTU.
// If the pingTimeout is zero, it defaults to 1 second.
// If the logger is nil, a no-op logger is used.
// It returns [ErrMTUNotFound] if the MTU could not be determined.
func PathMTUDiscover(ctx context.Context, ip netip.Addr,
physicalLinkMTU int, pingTimeout time.Duration, logger Logger) (
mtu int, err error,
) {
if physicalLinkMTU == 0 {
const ethernetStandardMTU = 1500
physicalLinkMTU = ethernetStandardMTU
}
if pingTimeout == 0 {
pingTimeout = time.Second
}
if logger == nil {
logger = &noopLogger{}
}
if ip.Is4() {
logger.Debug("finding IPv4 next hop MTU")
mtu, err = findIPv4NextHopMTU(ctx, ip, physicalLinkMTU, pingTimeout, logger)
switch {
case err == nil:
return mtu, nil
case errors.Is(err, net.ErrClosed) || errors.Is(err, ErrICMPCommunicationAdministrativelyProhibited): // blackhole
default:
return 0, fmt.Errorf("finding IPv4 next hop MTU: %w", err)
}
} else {
logger.Debug("requesting IPv6 ICMP packet-too-big reply")
mtu, err = getIPv6PacketTooBig(ctx, ip, physicalLinkMTU, pingTimeout, logger)
switch {
case err == nil:
return mtu, nil
case errors.Is(err, net.ErrClosed): // blackhole
default:
return 0, fmt.Errorf("getting IPv6 packet-too-big message: %w", err)
}
}
// Fall back method: send echo requests with different packet
// sizes and check which ones succeed to find the maximum MTU.
logger.Debug("falling back to sending different sized echo packets")
minMTU := minIPv4MTU
if ip.Is6() {
minMTU = minIPv6MTU
}
return pmtudMultiSizes(ctx, ip, minMTU, physicalLinkMTU, pingTimeout, logger)
}
type pmtudTestUnit struct {
mtu int
echoID uint16
sentBytes int
ok bool
}
func pmtudMultiSizes(ctx context.Context, ip netip.Addr,
minMTU, maxPossibleMTU int, pingTimeout time.Duration,
logger Logger,
) (maxMTU int, err error) {
var ipVersion string
var conn net.PacketConn
if ip.Is4() {
ipVersion = "v4"
conn, err = listenICMPv4(ctx)
} else {
ipVersion = "v6"
conn, err = listenICMPv6(ctx)
}
if err != nil {
if strings.HasSuffix(err.Error(), "socket: operation not permitted") {
err = fmt.Errorf("%w: you can try adding NET_RAW capability to resolve this", ErrICMPNotPermitted)
}
return 0, fmt.Errorf("listening for ICMP packets: %w", err)
}
mtusToTest := makeMTUsToTest(minMTU, maxPossibleMTU)
if len(mtusToTest) == 1 { // only minMTU because minMTU == maxPossibleMTU
return minMTU, nil
}
logger.Debugf("testing the following MTUs: %v", mtusToTest)
tests := make([]pmtudTestUnit, len(mtusToTest))
for i := range mtusToTest {
tests[i] = pmtudTestUnit{mtu: mtusToTest[i]}
}
timedCtx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
go func() {
<-timedCtx.Done()
conn.Close()
}()
for i := range tests {
id, message := buildMessageToSend(ipVersion, tests[i].mtu)
tests[i].echoID = id
encodedMessage, err := message.Marshal(nil)
if err != nil {
return 0, fmt.Errorf("encoding ICMP message: %w", err)
}
tests[i].sentBytes = len(encodedMessage)
_, err = conn.WriteTo(encodedMessage, &net.IPAddr{IP: ip.AsSlice()})
if err != nil {
if strings.HasSuffix(err.Error(), "sendto: operation not permitted") {
err = fmt.Errorf("%w", ErrICMPNotPermitted)
}
return 0, fmt.Errorf("writing ICMP message: %w", err)
}
}
err = collectReplies(conn, ipVersion, tests, logger)
switch {
case err == nil: // max possible MTU is working
return tests[len(tests)-1].mtu, nil
case err != nil && errors.Is(err, net.ErrClosed):
// we have timeouts (IPv4 testing or IPv6 PMTUD blackholes)
// so find the highest MTU which worked.
// Note we start from index len(tests) - 2 since the max MTU
// cannot be working if we had a timeout.
for i := len(tests) - 2; i >= 0; i-- { //nolint:mnd
if tests[i].ok {
return pmtudMultiSizes(ctx, ip, tests[i].mtu, tests[i+1].mtu-1,
pingTimeout, logger)
}
}
// All MTUs failed.
return 0, fmt.Errorf("%w: ICMP might be blocked", ErrMTUNotFound)
case err != nil:
return 0, fmt.Errorf("collecting ICMP echo replies: %w", err)
default:
panic("unreachable")
}
}
// Create the MTU slice of length 11 such that:
// - the first element is the minMTU
// - the last element is the maxMTU
// - elements in-between are separated as close to each other
// The number 11 is chosen to find the final MTU in 3 searches,
// with a total search space of 1728 MTUs which is enough;
// to find it in 2 searches requires 37 parallel queries which
// could be blocked by firewalls.
func makeMTUsToTest(minMTU, maxMTU int) (mtus []int) {
const mtusLength = 11 // find the final MTU in 3 searches
diff := maxMTU - minMTU
switch {
case minMTU > maxMTU:
panic("minMTU > maxMTU")
case diff <= mtusLength:
mtus = make([]int, 0, diff)
for mtu := minMTU; mtu <= maxMTU; mtu++ {
mtus = append(mtus, mtu)
}
default:
step := float64(diff) / float64(mtusLength-1)
mtus = make([]int, 0, mtusLength)
for mtu := float64(minMTU); len(mtus) < mtusLength-1; mtu += step {
mtus = append(mtus, int(math.Round(mtu)))
}
mtus = append(mtus, maxMTU) // last element is the maxMTU
}
return mtus
}
func collectReplies(conn net.PacketConn, ipVersion string,
tests []pmtudTestUnit, logger Logger,
) (err error) {
echoIDToTestIndex := make(map[uint16]int, len(tests))
for i, test := range tests {
echoIDToTestIndex[test.echoID] = i
}
// The theoretical limit is 4GiB for IPv6 MTU path discovery jumbograms, but that would
// create huge buffers which we don't really want to support anyway.
// The standard frame maximum MTU is 1500 bytes, and there are Jumbo frames with
// a conventional maximum of 9000 bytes. However, some manufacturers support up
// 9216-20 = 9196 bytes for the maximum MTU. We thus use buffers of size 9196 to
// match eventual Jumbo frames. More information at:
// https://en.wikipedia.org/wiki/Maximum_transmission_unit#MTUs_for_common_media
const maxPossibleMTU = 9196
buffer := make([]byte, maxPossibleMTU)
idsFound := 0
for idsFound < len(tests) {
// Note we need to read the whole packet in one call to ReadFrom, so the buffer
// must be large enough to read the entire reply packet. See:
// https://groups.google.com/g/golang-nuts/c/5dy2Q4nPs08/m/KmuSQAGEtG4J
bytesRead, _, err := conn.ReadFrom(buffer)
if err != nil {
return fmt.Errorf("reading from ICMP connection: %w", err)
}
packetBytes := buffer[:bytesRead]
ipPacketLength := len(packetBytes)
var icmpProtocol int
switch ipVersion {
case "v4":
icmpProtocol = icmpv4Protocol
case "v6":
icmpProtocol = icmpv6Protocol
default:
panic(fmt.Sprintf("unknown IP version: %s", ipVersion))
}
// Parse the ICMP message
// Note: this parsing works for a truncated 556 bytes ICMP reply packet.
message, err := icmp.ParseMessage(icmpProtocol, packetBytes)
if err != nil {
return fmt.Errorf("parsing message: %w", err)
}
echoBody, ok := message.Body.(*icmp.Echo)
if !ok {
return fmt.Errorf("%w: %T", ErrICMPBodyUnsupported, message.Body)
}
id := uint16(echoBody.ID) //nolint:gosec
testIndex, testing := echoIDToTestIndex[id]
if !testing { // not an id we expected so ignore it
logger.Warnf("ignoring ICMP reply with unexpected ID %d (type: %d, code: %d, length: %d)",
echoBody.ID, message.Type, message.Code, ipPacketLength)
continue
}
idsFound++
sentBytes := tests[testIndex].sentBytes
// echo reply should be at most the number of bytes sent,
// and can be lower, more precisely 556 bytes, in case
// the host we are reaching wants to stay out of trouble
// and ensure its echo reply goes through without
// fragmentation, see the following page:
// https://datatracker.ietf.org/doc/html/rfc1122#page-59
const conservativeReplyLength = 556
truncated := ipPacketLength < sentBytes &&
ipPacketLength == conservativeReplyLength
// Check the packet size is the same if the reply is not truncated
if !truncated && sentBytes != ipPacketLength {
return fmt.Errorf("%w: sent %dB and received %dB",
ErrICMPEchoDataMismatch, sentBytes, ipPacketLength)
}
// Truncated reply or matching reply size
tests[testIndex].ok = true
}
return nil
}

View File

@@ -0,0 +1,22 @@
//go:build integration
package pmtud
import (
"context"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func Test_PathMTUDiscover(t *testing.T) {
t.Parallel()
const physicalLinkMTU = 1500
const timeout = time.Second
mtu, err := PathMTUDiscover(context.Background(), netip.MustParseAddr("1.1.1.1"),
physicalLinkMTU, timeout, nil)
require.NoError(t, err)
t.Log("MTU found:", mtu)
}

View File

@@ -0,0 +1,55 @@
package pmtud
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_makeMTUsToTest(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
minMTU int
maxMTU int
mtus []int
}{
"0_0": {
mtus: []int{0},
},
"0_1": {
maxMTU: 1,
mtus: []int{0, 1},
},
"0_8": {
maxMTU: 8,
mtus: []int{0, 1, 2, 3, 4, 5, 6, 7, 8},
},
"0_12": {
maxMTU: 12,
mtus: []int{0, 1, 2, 4, 5, 6, 7, 8, 10, 11, 12},
},
"0_80": {
maxMTU: 80,
mtus: []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80},
},
"0_100": {
maxMTU: 100,
mtus: []int{0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100},
},
"1280_1500": {
minMTU: 1280,
maxMTU: 1500,
mtus: []int{1280, 1302, 1324, 1346, 1368, 1390, 1412, 1434, 1456, 1478, 1500},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
mtus := makeMTUsToTest(testCase.minMTU, testCase.maxMTU)
assert.Equal(t, testCase.mtus, mtus)
})
}
}

View File

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

View File

@@ -17,13 +17,12 @@ func Test_Service_runCommand(t *testing.T) {
ctx := context.Background()
cmder := command.New()
const commandTemplate = `/bin/sh -c "echo {{PORTS}}-{{PORT}}-{{VPN_INTERFACE}}"`
const commandTemplate = `/bin/sh -c "echo {{PORTS}}"`
ports := []uint16{1234, 5678}
const vpnInterface = "tun0"
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)
}

View File

@@ -14,11 +14,7 @@ func (s *Service) writePortForwardedFile(ports []uint16) (err error) {
fileData := []byte(strings.Join(portStrings, "\n"))
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)
err = os.WriteFile(filepath, fileData, perms)
if err != nil {

View File

@@ -74,7 +74,7 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error)
s.portMutex.Unlock()
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 {
err = fmt.Errorf("running up command: %w", err)
s.logger.Error(err.Error())

View File

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

View File

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

View File

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

View File

@@ -1,562 +1,15 @@
package updater
import (
"bytes"
"context"
crand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"net/netip"
"slices"
"strings"
srp "github.com/ProtonMail/go-srp"
)
// apiClient is a minimal Proton v4 API client which can handle all the
// 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()
}
var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
type apiData struct {
LogicalServers []logicalServer `json:"LogicalServers"`
@@ -580,25 +33,25 @@ type physicalServer struct {
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,
) {
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)
if err != nil {
return data, err
}
c.setHeaders(request, cookie)
response, err := c.httpClient.Do(request)
response, err := client.Do(request)
if err != nil {
return data, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
b, _ := io.ReadAll(response.Body)
return data, buildError(response.StatusCode, b)
return data, fmt.Errorf("%w: %d %s", ErrHTTPStatusCodeNotOK,
response.StatusCode, response.Status)
}
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)
}
if err := response.Body.Close(); err != nil {
return data, err
}
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) (
servers []models.Server, err error,
) {
switch {
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)
data, err := fetchAPI(ctx, u.client)
if err != nil {
return nil, fmt.Errorf("creating API client: %w", 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)
return nil, err
}
countryCodes := constants.CountryCodes()

View File

@@ -7,17 +7,13 @@ import (
)
type Updater struct {
client *http.Client
email string
password string
warner common.Warner
client *http.Client
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{
client: client,
email: email,
password: password,
warner: warner,
client: client,
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,
updaterWarner common.Warner, client *http.Client, unzipper common.Unzipper,
parallelResolver common.ParallelResolver, ipFetcher common.IPFetcher,
extractor custom.Extractor, credentials settings.Updater,
extractor custom.Extractor,
) *Providers {
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.PrivateInternetAccess: privateinternetaccess.New(storage, randSource, timeNow, client),
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.SlickVPN: slickvpn.New(storage, randSource, client, 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
}
var targetIP netip.Addr
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()
targetIPSet := selection.TargetIP.IsValid() && !selection.TargetIP.IsUnspecified()
if targetIPSet && selection.VPN == vpn.Wireguard {
// we need the right public key
return getTargetIPConnection(connections, targetIP)
return getTargetIPConnection(connections, selection.TargetIP)
}
connection = pickRandomConnection(connections, randSource)
if targetIPSet {
connection.IP = targetIP
connection.IP = selection.TargetIP
}
return connection, nil

View File

@@ -25,14 +25,13 @@ func newHandler(ctx context.Context, logger Logger, logging bool,
handler := &handler{}
vpn := newVPNHandler(ctx, vpnLooper, storage, ipv6Supported, logger)
openvpn := newOpenvpnHandler(ctx, vpnLooper, logger)
openvpn := newOpenvpnHandler(ctx, vpnLooper, pfGetter, logger)
dns := newDNSHandler(ctx, dnsLooper, logger)
updater := newUpdaterHandler(ctx, updaterLooper, logger)
publicip := newPublicIPHandler(publicIPLooper, logger)
portForward := newPortForwardHandler(ctx, pfGetter, logger)
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)
if err != nil {

View File

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

View File

@@ -10,29 +10,27 @@ import (
)
func newHandlerV1(w warner, buildInfo models.BuildInformation,
vpn, openvpn, dns, updater, publicip, portForward http.Handler,
vpn, openvpn, dns, updater, publicip http.Handler,
) http.Handler {
return &handlerV1{
warner: w,
buildInfo: buildInfo,
vpn: vpn,
openvpn: openvpn,
dns: dns,
updater: updater,
publicip: publicip,
portForward: portForward,
warner: w,
buildInfo: buildInfo,
vpn: vpn,
openvpn: openvpn,
dns: dns,
updater: updater,
publicip: publicip,
}
}
type handlerV1 struct {
warner warner
buildInfo models.BuildInformation
vpn http.Handler
openvpn http.Handler
dns http.Handler
updater http.Handler
publicip http.Handler
portForward http.Handler
warner warner
buildInfo models.BuildInformation
vpn http.Handler
openvpn http.Handler
dns http.Handler
updater http.Handler
publicip http.Handler
}
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)
case strings.HasPrefix(r.RequestURI, "/publicip"):
h.publicip.ServeHTTP(w, r)
case strings.HasPrefix(r.RequestURI, "/portforward"):
h.portForward.ServeHTTP(w, r)
default:
errString := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI)
http.Error(w, errString, http.StatusBadRequest)

View File

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

View File

@@ -1,16 +1,12 @@
package auth
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/validate"
"github.com/qdm12/gotree"
)
type Settings struct {
@@ -19,50 +15,6 @@ type Settings struct {
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() {
s.Roles = gosettings.DefaultSlice(s.Roles, []Role{{ // TODO v3.41.0 leave empty
Name: "public",
@@ -70,7 +22,6 @@ func (s *Settings) SetDefaults() {
Routes: []string{
http.MethodGet + " /openvpn/actions/restart",
http.MethodGet + " /unbound/actions/restart",
http.MethodGet + " /openvpn/portforwarded",
http.MethodGet + " /updater/restart",
http.MethodGet + " /v1/version",
http.MethodGet + " /v1/vpn/status",
@@ -83,14 +34,13 @@ func (s *Settings) SetDefaults() {
http.MethodGet + " /v1/updater/status",
http.MethodPut + " /v1/updater/status",
http.MethodGet + " /v1/publicip/ip",
http.MethodGet + " /v1/portforward",
},
}})
}
func (s Settings) Validate() (err error) {
for i, role := range s.Roles {
err = role.Validate()
err = role.validate()
if err != nil {
return fmt.Errorf("role %s (%d of %d): %w",
role.Name, i+1, len(s.Roles), err)
@@ -111,18 +61,18 @@ const (
type Role struct {
// Name is the role name and is only used for documentation
// and in the authentication middleware debug logs.
Name string `json:"name"`
// Auth is the authentication method to use, which can be 'none', 'basic' or 'apikey'.
Auth string `json:"auth"`
Name string
// Auth is the authentication method to use, which can be 'none' or 'apikey'.
Auth string
// 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 string `json:"username"`
Username string
// 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
// "HTTP_METHOD PATH", for example "GET /v1/vpn/status"
Routes []string `json:"-"`
Routes []string
}
var (
@@ -133,7 +83,7 @@ var (
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)
if err != nil {
return fmt.Errorf("%w: %s", ErrMethodNotSupported, r.Auth)
@@ -162,8 +112,6 @@ func (r Role) Validate() (err error) {
// WARNING: do not mutate programmatically.
var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals
http.MethodGet + " /openvpn/actions/restart": {},
http.MethodGet + " /openvpn/portforwarded": {},
http.MethodGet + " /openvpn/settings": {},
http.MethodGet + " /unbound/actions/restart": {},
http.MethodGet + " /updater/restart": {},
http.MethodGet + " /v1/version": {},
@@ -180,22 +128,4 @@ var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals
http.MethodGet + " /v1/updater/status": {},
http.MethodPut + " /v1/updater/status": {},
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)
duration := m.timeNow().Sub(tStart)
m.logger.Info(strconv.Itoa(statefulWriter.statusCode) + " " +
r.Method + " " + r.URL.String() +
r.Method + " " + r.RequestURI +
" wrote " + strconv.Itoa(statefulWriter.length) + "B to " +
r.RemoteAddr + " in " + duration.String())
}

View File

@@ -10,10 +10,13 @@ import (
"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{
ctx: ctx,
looper: looper,
pf: pfGetter,
warner: w,
}
}
@@ -21,6 +24,7 @@ func newOpenvpnHandler(ctx context.Context, looper VPNLooper, w warner) http.Han
type openvpnHandler struct {
ctx context.Context //nolint:containedctx
looper VPNLooper
pf PortForwardedGetter
warner warner
}
@@ -43,10 +47,10 @@ func (h *openvpnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
default:
errMethodNotSupported(w, r.Method)
}
case "/portforwarded": // TODO v4 remove
case "/portforwarded":
switch r.Method {
case http.MethodGet:
http.Redirect(w, r, "/v1/portforward", http.StatusMovedPermanently)
h.getPortForwarded(w)
default:
errMethodNotSupported(w, r.Method)
}
@@ -118,3 +122,23 @@ func (h *openvpnHandler) getSettings(w http.ResponseWriter) {
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"
"os"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/httpserver"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/server/middlewares/auth"
)
func New(ctx context.Context, settings settings.ControlServer, logger Logger,
buildInfo models.BuildInformation, openvpnLooper VPNLooper,
func New(ctx context.Context, address string, logEnabled bool, logger Logger,
authConfigPath string, buildInfo models.BuildInformation, openvpnLooper VPNLooper,
pfGetter PortForwardedGetter, dnsLooper DNSLoop,
updaterLooper UpdaterLooper, publicIPLooper PublicIPLoop, storage Storage,
ipv6Supported bool) (
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 {
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,
storage, ipv6Supported)
if err != nil {
@@ -32,7 +40,7 @@ func New(ctx context.Context, settings settings.ControlServer, logger Logger,
}
httpServerSettings := httpserver.Settings{
Address: *settings.Address,
Address: address,
Handler: handler,
Logger: logger,
}
@@ -44,26 +52,3 @@ func New(ctx context.Context, settings settings.ControlServer, logger Logger,
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
// specified by path, as indented JSON. It is not thread-safe.
func (s *Storage) flushToFile(path string) error {
if path == "" {
return nil // no file to write to
}
const permission = 0o644
dirPath := filepath.Dir(path)
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/constants"
"github.com/qdm12/gluetun/internal/constants/vpn"
)
func commaJoin(slice []string) string {
@@ -149,13 +148,9 @@ func noServerFoundError(selection settings.ServerSelection) (err error) {
messageParts = append(messageParts, "tor only")
}
targetIP := selection.OpenVPN.EndpointIP
if selection.VPN == vpn.Wireguard {
targetIP = selection.Wireguard.EndpointIP
}
if targetIP.IsValid() {
if selection.TargetIP.IsValid() {
messageParts = append(messageParts,
"target ip address "+targetIP.String())
"target ip address "+selection.TargetIP.String())
}
message := "for " + strings.Join(messageParts, "; ")

View File

@@ -1,3 +1,3 @@
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.
// 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
@@ -10,49 +10,37 @@ import (
gomock "github.com/golang/mock/gomock"
)
// MockLogger is a mock of Logger interface.
type MockLogger struct {
// MockInfoer is a mock of Infoer interface.
type MockInfoer struct {
ctrl *gomock.Controller
recorder *MockLoggerMockRecorder
recorder *MockInfoerMockRecorder
}
// MockLoggerMockRecorder is the mock recorder for MockLogger.
type MockLoggerMockRecorder struct {
mock *MockLogger
// MockInfoerMockRecorder is the mock recorder for MockInfoer.
type MockInfoerMockRecorder struct {
mock *MockInfoer
}
// NewMockLogger creates a new mock instance.
func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
mock := &MockLogger{ctrl: ctrl}
mock.recorder = &MockLoggerMockRecorder{mock}
// NewMockInfoer creates a new mock instance.
func NewMockInfoer(ctrl *gomock.Controller) *MockInfoer {
mock := &MockInfoer{ctrl: ctrl}
mock.recorder = &MockInfoerMockRecorder{mock}
return mock
}
// 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
}
// Info mocks base method.
func (m *MockLogger) Info(arg0 string) {
func (m *MockInfoer) Info(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Info", arg0)
}
// 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()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(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)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockInfoer)(nil).Info), arg0)
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,7 @@ type Linker interface {
LinkDel(link netlink.Link) (err error)
LinkSetUp(link netlink.Link) (linkIndex int, err error)
LinkSetDown(link netlink.Link) (err error)
LinkSetMTU(link netlink.Link, mtu int) (err error)
}
type DNSLoop interface {
@@ -101,7 +102,7 @@ type CmdStarter 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)
Stop() error
}

View File

@@ -47,6 +47,7 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
continue
}
tunnelUpData := tunnelUpData{
vpnType: settings.Type,
serverIP: connection.IP,
serverName: connection.ServerName,
canPortForward: connection.PortForward,

View File

@@ -2,16 +2,24 @@ package vpn
import (
"context"
"errors"
"fmt"
"net/netip"
"time"
"github.com/qdm12/dns/v2/pkg/check"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/pmtud"
"github.com/qdm12/gluetun/internal/version"
"github.com/qdm12/log"
)
type tunnelUpData struct {
// Healthcheck
serverIP netip.Addr
// vpnType is used for path MTU discovery to find the protocol overhead.
// It can be "wireguard" or "openvpn".
vpnType string
// Port forwarding
vpnIntf string
serverName string // used for PIA
@@ -31,24 +39,26 @@ func (l *Loop) onTunnelUp(ctx, loopCtx context.Context, data tunnelUpData) {
}
}
icmpTargetIPs := l.healthSettings.ICMPTargetIPs
if len(icmpTargetIPs) == 1 && icmpTargetIPs[0].IsUnspecified() {
icmpTargetIPs = []netip.Addr{data.serverIP}
icmpTarget := l.healthSettings.ICMPTargetIP
if icmpTarget.IsUnspecified() {
icmpTarget = data.serverIP
}
l.healthChecker.SetConfig(l.healthSettings.TargetAddresses, icmpTargetIPs,
l.healthSettings.SmallCheckType)
l.healthChecker.SetConfig(l.healthSettings.TargetAddress, icmpTarget)
healthErrCh, err := l.healthChecker.Start(ctx)
l.healthServer.SetError(err)
if err != nil {
if *l.healthSettings.RestartVPN {
// Note this restart call must be done in a separate goroutine
// from the VPN loop goroutine.
l.restartVPN(loopCtx, err)
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")
// Note this restart call must be done in a separate goroutine
// from the VPN loop goroutine.
l.restartVPN(loopCtx, err)
return
}
mtuLogger := l.logger.New(log.SetComponent("MTU discovery"))
err = updateToMaxMTU(ctx, data.vpnIntf, data.vpnType,
l.netLinker, l.routing, mtuLogger)
if err != nil {
mtuLogger.Error(err.Error())
}
if *l.dnsLooper.GetSettings().ServerEnabled {
@@ -100,7 +110,7 @@ func (l *Loop) collectHealthErrors(ctx, loopCtx context.Context, healthErrCh <-c
l.restartVPN(loopCtx, healthErr)
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")
} else if previousHealthErr != nil {
l.logger.Info("healthcheck passed successfully after previous failure(s)")
@@ -117,3 +127,65 @@ func (l *Loop) restartVPN(ctx context.Context, healthErr error) {
_, _ = l.ApplyStatus(ctx, constants.Stopped)
_, _ = l.ApplyStatus(ctx, constants.Running)
}
var errVPNTypeUnknown = errors.New("unknown VPN type")
func updateToMaxMTU(ctx context.Context, vpnInterface string,
vpnType string, netlinker NetLinker, routing Routing, logger *log.Logger,
) error {
logger.Info("finding maximum MTU, this can take up to 4 seconds")
vpnGatewayIP, err := routing.VPNLocalGatewayIP(vpnInterface)
if err != nil {
return fmt.Errorf("getting VPN gateway IP address: %w", err)
}
link, err := netlinker.LinkByName(vpnInterface)
if err != nil {
return fmt.Errorf("getting VPN interface by name: %w", err)
}
originalMTU := link.MTU
// Note: no point testing for an MTU of 1500, it will never work due to the VPN
// protocol overhead, so start lower than 1500 according to the protocol used.
const physicalLinkMTU = 1500
vpnLinkMTU := physicalLinkMTU
switch vpnType {
case "wireguard":
vpnLinkMTU -= 60 // Wireguard overhead
case "openvpn":
vpnLinkMTU -= 41 // OpenVPN overhead
default:
return fmt.Errorf("%w: %q", errVPNTypeUnknown, vpnType)
}
// Setting the VPN link MTU to 1500 might interrupt the connection until
// the new MTU is set again, but this is necessary to find the highest valid MTU.
logger.Debugf("VPN interface %s MTU temporarily set to %d", vpnInterface, vpnLinkMTU)
err = netlinker.LinkSetMTU(link, vpnLinkMTU)
if err != nil {
return fmt.Errorf("setting VPN interface %s MTU to %d: %w", vpnInterface, vpnLinkMTU, err)
}
const pingTimeout = time.Second
vpnLinkMTU, err = pmtud.PathMTUDiscover(ctx, vpnGatewayIP, vpnLinkMTU, pingTimeout, logger)
switch {
case err == nil:
logger.Infof("setting VPN interface %s MTU to maximum valid MTU %d", vpnInterface, vpnLinkMTU)
case errors.Is(err, pmtud.ErrMTUNotFound) || errors.Is(err, pmtud.ErrICMPNotPermitted):
vpnLinkMTU = int(originalMTU)
logger.Infof("reverting VPN interface %s MTU to %d (due to: %s)",
vpnInterface, originalMTU, err)
default:
return fmt.Errorf("path MTU discovering: %w", err)
}
err = netlinker.LinkSetMTU(link, vpnLinkMTU)
if err != nil {
return fmt.Errorf("setting VPN interface %s MTU to %d: %w", vpnInterface, vpnLinkMTU, err)
}
return nil
}