Compare commits

..

18 Commits

Author SHA1 Message Date
qdm12 35137cfba0 [create-pull-request] automated change 2026-05-01 04:37:23 +00:00
Quentin McGaw 66b9f71ecf hotfix(openvpn): fix support for tcp-client
- always use `proto tcp-client` when using TCP
- parses `tcp-client` (on top of `tcp`, `tcp4`, `tcp6`) as meaning TCP
- Fix #3302
2026-05-01 00:39:58 +00:00
Quentin McGaw 704a7fd7ef chore(dev): add AGENTS.md 2026-04-30 23:55:59 +00:00
Quentin McGaw f615e3c780 feat(openvpn): reduce handshake window to 10 seconds for faster failure detection 2026-04-30 23:55:59 +00:00
Quentin McGaw f1a8303db7 chore(dev): add markdownlint-cli2 (and nodejs) in dev container 2026-04-30 11:12:52 +00:00
Quentin McGaw 628b0a22e2 hotfix(pia): fix servers data updater and update servers data
- use v7 API endpoint to get correct list of servers
- skip offline regions
- do not skip *.pvt.site
2026-04-22 12:34:56 +00:00
Quentin McGaw ea3d138bd6 fix(pia): ignore *.pvt.site regions 2026-04-22 00:49:47 +00:00
Quentin McGaw c3a6809447 fix(pia): try x.y.128.1 and x.y.0.1 from the gateway IP to find the API IP address 2026-04-22 00:42:23 +00:00
Quentin McGaw 792a5ff5f3 hotfix(dns): fix pool panicing (again) 2026-04-21 17:31:36 +00:00
Quentin McGaw 7eef1c89a7 fix(portforward): no longer stuck after failed port forwarding 2026-04-20 15:27:47 +00:00
Quentin McGaw 8bc2fbd487 hotfix(dns): fix race condition with DoT pool 2026-04-20 14:31:35 +00:00
Quentin McGaw a4eb625fbe chore(settings/dns): remove unused code 2026-04-19 18:05:19 +00:00
Quentin McGaw 17a7bf6d54 fix(privateinternetaccess): use AES-GCM for all presets 2026-04-19 18:00:56 +00:00
Quentin McGaw b11de4f0c3 fix(privateinternetaccess): remove none encryption preset 2026-04-19 17:51:20 +00:00
Quentin McGaw e87a92efa0 hotfix(boringpoll): fix race condition on stop 2026-04-19 17:48:38 +00:00
Quentin McGaw 44977f4d9e fix(dns): DNS over TLS pool behavior fixed
- handle timed out connections the same as closed connections
- close connection on TLS handshake failure
- improve mutex handling during connection renewal and retrieval
2026-04-19 01:31:09 +00:00
Quentin McGaw c473579261 chore(provider/utils): remove unused code 2026-04-19 01:31:09 +00:00
Quentin McGaw d5eeec6fb3 feat(protonvpn): support up to 5 forwarded ports (#3208) 2026-04-18 02:36:06 +02:00
41 changed files with 157830 additions and 49006 deletions
+1
View File
@@ -1,2 +1,3 @@
FROM ghcr.io/qdm12/godevcontainer:v0.21-alpine
RUN apk add wireguard-tools htop openssl tcpdump iptables
RUN apk add nodejs npm && npm install markdownlint-cli2 --global && apk del npm
+195
View File
@@ -0,0 +1,195 @@
# AGENTS
Guidance for coding agents working in this repository.
## Scope and priorities
- Keep changes minimal and targeted. Feel free to do light refactors that are relevant to the modifications.
- Breaking changes:
- Do not introduce breaking usage behavior (cli flags, environment variables, etc.) unless explicitly agreed.
- Do not introduce breaking changes for the Go API in the `pkg/` directory.
- If a compatibility break seems beneficial, stop and ask for confirmation before implementing it.
- Update or add tests when behavior changes.
## Go coding conventions
### General guidelines
- Use explicit, descriptive variable names by default.
- Notable bad examples: `req`, `resp`, `cfg`, `v`
- Allowed short-name exceptions:
- indexes such as `i`, `j`
- `ctx` for `context.Context`
- `t` for `*testing.T` and `b` for `*testing.B`
- `ctrl` for `*gomock.Controller`
- `err` for `error`, `errs` for `[]error`
- `wg` for `*sync.WaitGroup`
- Avoid using global variables except for:
- exported sentinel errors that are used outside the package boundaries
- regular expressions defined with `regexp.MustCompile`
- variables set by the build pipeline, such as `Version` and `BuildDate`
- Constants
- Prefer defining them inline in a function if it's only used in that function, rather than at the package level.
- Each one should be defined right above where it's used, instead of having multiple defined at the same place in a `const ()` block
- If one is only used in a single production code function, define it right above it so it's more local for readability.
- Do not define constants when constants exist in other packages, for example `http.StatusBadRequest` or `log.LevelDebug`.
- Structs
- Prefer defining them inline in a function if it's only used in that function, rather than at the package level.
- Do not use the short if form, prefer the longer one
- Follow modern Go, according to the Go version defined in go.mod. Prefer modern constructs when equivalent:
- Example: use `for i := range 5` rather than `for i := 0; i < 5; i++`.
- Example: use `new("string")` rather than helper wrappers such as `stringPtr("string")`.
- Example: no need to pin variables in for loops when using them in goroutines or subtests.
- Use `New(...) *Item` constructor per package. Each package should ideally only have one constructor, although this is not a strict rule. The constructor should return a pointer to the struct, and not an interface.
- Always prefer using context-aware functions, for example:
- `exec.CommandContext` rather than `exec.Command`
- `http.NewRequestWithContext` rather than `http.NewRequest`
- Never export a symbol unless absolutely necessary.
- Always use the most restrictive builtin types. For example prefer `uint` over `int` if it's only zero or positive. Prefer `uint16` is the max value is 65535.
- Prefer using builtin types whenever possible AND do not define single field structs unless necessary
- Prefer splitting a code line only when it triggers the `lll` linter, do not split a command or arguments list for each element
- Use `netip` types instead of `net` types whenever possible
- Use constants instead of variables whenever possible, especially function-local inline constants.
- Do not use `time.Sleep`, prefer using a `time.Timer` with a `select` statement also listening on a context cancelation
- `panic`:
- should only be used when a programming error is encountered and you should NOT return errors for programming errors (such as passing nil objects)
- Its counterpart `recover` should not really be used, except for testing a panic in test code (or use `assert.PanicsWithValue`).
### Directory structure and file naming
- Executable main packages with a single `main()` function must be in the `cmd` directory.
Prefer having top level logic and have a longer `main()` function rather than having an `internal/app` package.
- Code lives by default in subpackages within the `internal` directory
- Code needing to be imported by external Go modules must be in subpackages within the `pkg` directory
- Example code especially using the `pkg` directory must be in `main` packages within the `examples` directory, each with a single `main.go` function.
- If AND only if the repository is a Go library and not a Go application, you may have Go files at the root of the project to simplify import paths. Most of the code should still be in subpackages in the `internal` directory.
- Interfaces should be defined in `interfaces.go` files for each package. If there are unexported interfaces which need to be mocked, which is rare, they should be defined in `interfaces_local.go` files.
- Mock files are
- `mocks_generate_test.go` which only contains `//go:generate` directives for generating mocks, and no actual code
- `mocks_test.go` which contains the generated mocks from exported interfaces and no other code, and is ignored in coverage reports
- `mocks_local_test.go` (rare) which contains the generated mocks from unexported interfaces and no other code, and is ignored in coverage reports
- NEVER generate an exported mock in a non test file, prefer re-generating files across packages.
- Package naming
- Your package name should be the same as the directory containing it, **except for the `main` package**
- Use single words for package names
- Do not use generic names for package names such as `utils` or `helpers`
- Package nesting
- Try to avoid nesting packages by default
- You can nest packages if you have different implementations for the same interface (e.g. a store interface)
- You can nest packages if you start having a lot of Go files (more than 10) and it really does make sense to make subpackages
### Linting
The linter is `golangci-lint` with the configuration defined in `.golangci.yml`.
To exclude code from linting, prefer using, when absolutely necessary, command comments `//nolint:<linter>`.
This allows the `nolintlint` linter to detect and report unnecessary `//nolint` comments later.
You can notably use `//nolint:lll` and, for good valid reasons, `//nolint:gosec`. Sometimes `//nolint:mnd` when it just doesn't make sense to extract a constant such as `n = n << 4`
Always prefer placing `//nolint` comments on the same code line where the error comes from, and not above a code block.
### Mocking
Mocking works with the `go.uber.org/mock` library, and the `mockgen` tool.
- Mocks from exported interfaces are generated using go generate commands in `mocks_generate_test.go` files, and stored in `mocks_test.go` files, using:
```go
//go:generate mockgen -destination=mocks_test.go -package=$GOPACKAGE . InterfaceA,InterfaceB
```
- Mocks from unexported interfaces are generated using go generate commands in `mocks_generate_test.go` files, and stored in `mocks_local_test.go` files. The source file for unexported interfaces is `interfaces_local.go`. The go generate command is similar to:
```go
//go:generate mockgen -destination=mocks_local_test.go -package $GOPACKAGE -source interfaces_local.go
```
- Mocks from external interfaces are generated using go generate commands in `mocks_generate_test.go` files, and stored in `mocks_<package-name>_test.go` files, using:
```go
//go:generate mockgen -destination=mocks_<package-name>_test.go -package $GOPACKAGE module-name InterfaceA,InterfaceB
```
- Generated mocks usage in tests:
- Define mocks in the subtest, not in the parent test. You can also have a function returning the mocks as a field of the test case struct, which takes in the subtest `*testing.T` as argument, and call it in the subtest to get the mocks.
- **Never** use `gomock.Any()` as argument. Always use concrete, precise arguments. You might need to define a custom GoMock matcher for your argument in some very niche and corner cases.
- **Never** use `.AnyTimes()` on mocks. Always define the number of times a certain mock call should be called, with `.Times(3)` for example.
- **Always** set the `.Return(...)` on the mock if the function returns something.
- Avoid using **mock helpers** functions, prefer a bit of repetition than tight coupling and dependency
### main.go
- Make the program OS signal aware, so it attempts a graceful shutdown when interruped. Force quit the program on a second interrupt signal.
### Formatting
The Go formatter used is gofumpt.
### Errors
- Always prefer wrapping errors with some context with `fmt.Errorf("doing this: %w", err)`
- In rare cases, you can just use `return err` notably:
- If the function is called **recursively**, since we don't wrap the wrapping multiple times for each recursion
- If the current function only statement is the call to another function, for example:
```go
func (s *Struct) Fetch() error {
return fetch() // do not wrap the error
}
```
- When wrapping errors, use verbs ending in "ing" and no "failed to" or "cannot" to avoid redundancy. For example, use `fmt.Errorf("resolving host: %w", err)` rather than `fmt.Errorf("failed to resolve host: %w", err)`.
- When wrapping an error, the context should NEVER contain variables injected as arguments in the function returning an error, to avoid repeating the same variable in multiple error messages.
- Testing errors:
- If the error does not wrap a sentinel error, use `assert.ErrorContains` to check for error messages, rather than `assert.EqualError`, to avoid having to update tests for minor changes in error messages. And use `assert.NoError` to check for no error.
- If the error wraps a sentinel error, use `assert.ErrorIs` to check both for the sentinel error or an expected nil error. You can also check the error message with `assert.ErrorContains`
### User program settings
- For configuration structs, each field Go zero value (i.e. `0` for `int`, `nil` for `*string`) should be an INVALID value in the user sense. This is used to detect when a field is not set, in order to default it, merge it or override it. For example if `""` is not a valid value, the field should be of type `string`. Conversely, if `""` is a valid value, the field should be of type `*string` to distinguish between "not set" and "set to empty string". Notably, boolean fields are ALWAYS of type `*bool` for this reason, since both `true` and `false` are valid values.
- Configuration reading and handling relies on the Go library github.com/qdm12/gosettings please use it whenever appropriate.
- Do not wrap errors coming from `reader.Reader` methods, since they already contain the necessary context.
- All keys passed to `reader.Reader` methods must be in environment variable format, i.e. uppercase with underscores. These get converted to lowercase and dashes for flags notably.
- For each settings structs, define the following methods, which are usually unexported, but can be exported especially for the top level Settings struct, in this order:
- `func (s *Settings) setDefaults()` whichs sets defaults (using `gosettings.Default*` functions) on unset fields
- If the settings need to be patched at runtime, which is rarely the case, define `func (s *Settings) overrideWith(other Settings)` which overrides the settings with another settings struct, only for fields that are set in the other struct (using `gosettings.OverrideWith` functions).
- `func (s Settings) validate() error` which validates the settings, and returns an error if anything is invalid
- `func (s *Settings) read(r *reader.Reader) error` which reads the settings from a gosettings/reader.Reader (which can be from multiple sources, such as environment variables, cli flags, config files etc.)
- `func (s Settings) String() string` which uses `toLinesNode().String()` to return a string representation of the settings
- `func (s Settings) toLinesNode() *gotree.Node` which a github.com/qdm12/gotree `*Node` representing the settings
### Testing
- Use the github.com/stretchr/testify library for assertions
- Most tests should be table tests with parallel subtests
- Prefer map-based table tests of the form `map[string]struct{ ... }`, with the key as the test name.
Use underscores in test names, not spaces, to keep `go test` output searchable.
- Use `testCases` for the table variable name, and `testCase` for each iterated case value.
- Run all tests in parallel:
- call `t.Parallel()` in the top-level test
- call `t.Parallel()` in each subtest
### Libraries to use
- Logging: `github.com/qdm12/log`
- Splash information at program start: `github.com/qdm12/gosplash`
- Long running services (i.e. health server, http prod server, backup loop etc.): `github.com/qdm12/goservices`
- String tree structures: `github.com/qdm12/gotree`
### Extra rules
- Do not use `http.DefaultClient`, use a custom `*http.Client` with a fixed timeout and share with dependency injections.
- Do not check for injected dependencies being `nil`, prefer to just panic on a nil pointer. By default it's fine to panic if a developer injects a dependency `nil`. `nil` does not mean use a default.
## Validation checklist
Run the following before finishing changes:
1. Go building `go build ./...`
1. Go linting `golangci-lint run`
1. Go unit tests `go test ./...`
1. If a module is added or modified, run `go mod tidy`
1. If an interface or mock command is modified, run `go generate -run mockgen ./...`
If a Markdown file is modified and `markdownlint-cli2` is available, run `markdownlint-cli2 "**/*.md"`
If a command is unavailable in the current environment, report it clearly and provide the exact command needed once available.
+2 -1
View File
@@ -164,7 +164,8 @@ ENV VPN_SERVICE_PROVIDER=pia \
VPN_PORT_FORWARDING_PROVIDER= \
VPN_PORT_FORWARDING_UP_COMMAND= \
VPN_PORT_FORWARDING_DOWN_COMMAND= \
VPN_PORT_FORWARDING_LISTENING_PORT=0 \
VPN_PORT_FORWARDING_LISTENING_PORTS=0 \
VPN_PORT_FORWARDING_PORTS_COUNT=1 \
VPN_PORT_FORWARDING_STATUS_FILE="/tmp/gluetun/forwarded_port" \
# PMTUD
PMTUD_ICMP_ADDRESSES=1.1.1.1,8.8.8.8 \
+2 -2
View File
@@ -14,7 +14,7 @@ require (
github.com/mdlayher/genetlink v1.3.2
github.com/mdlayher/netlink v1.9.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260310123525-76fabc2b3294
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260421173011-9de8e7fdbe3a
github.com/qdm12/gosettings v0.4.4
github.com/qdm12/goshutdown v0.3.0
github.com/qdm12/gosplash v0.2.1-0.20260305164749-b713de4fee6c
@@ -26,7 +26,6 @@ require (
github.com/ulikunitz/xz v0.5.15
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/mod v0.33.0
golang.org/x/net v0.51.0
golang.org/x/sys v0.42.0
golang.org/x/text v0.35.0
@@ -58,6 +57,7 @@ require (
github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+2 -2
View File
@@ -74,8 +74,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.20260310123525-76fabc2b3294 h1:adkCP7N9mEHpsKSR/5LToF27qJo0yOufhT5zBdKpyrE=
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260310123525-76fabc2b3294/go.mod h1:98foWgXJZ+g8gJIuO+fdO+oWpFei5WShMFTeN4Im2lE=
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260421173011-9de8e7fdbe3a h1:TE157yPQmAbVruH0MWCQzs0vTT/6t96DkoWUXd6PVuc=
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260421173011-9de8e7fdbe3a/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=
+3 -2
View File
@@ -22,7 +22,7 @@ type BoringPoll struct {
// Internal signals and channels
cancel context.CancelFunc
done <-chan struct{}
done *sync.WaitGroup
mutex sync.Mutex
}
@@ -53,6 +53,7 @@ func (b *BoringPoll) Start() (runError <-chan error, err error) {
const logEveryBytes = 100 * 1000 * 1000 // 100 IEC MB
var ready, done sync.WaitGroup
b.done = &done
ready.Add(len(b.urlToData))
done.Add(len(b.urlToData))
ctx, cancel := context.WithCancel(context.Background())
@@ -166,7 +167,7 @@ func (b *BoringPoll) Stop() error {
return nil
}
b.cancel()
<-b.done
b.done.Wait()
b.cancel = nil
b.done = nil
return nil
-40
View File
@@ -138,46 +138,6 @@ func defaultDNSProviders() []string {
}
}
func (d DNS) GetFirstPlaintextIPv4() (ipv4 netip.Addr) {
if d.UpstreamType == DNSUpstreamTypePlain {
for _, addrPort := range d.UpstreamPlainAddresses {
if addrPort.Addr().Is4() {
return addrPort.Addr()
}
}
}
ipv4 = findPlainIPv4InProviders(d.Providers)
if ipv4.IsValid() {
return ipv4
}
// Either:
// - all upstream plain addresses are IPv6 and no provider is set
// - all providers set do not have a plaintext IPv4 address
ipv4 = findPlainIPv4InProviders(defaultDNSProviders())
if !ipv4.IsValid() {
panic("no plaintext IPv4 address found in default DNS providers")
}
return ipv4
}
func findPlainIPv4InProviders(providerNames []string) netip.Addr {
providers := provider.NewProviders()
for _, name := range providerNames {
provider, err := providers.Get(name)
if err != nil {
// Settings should be validated before calling this function,
// so an error happening here is a programming error.
panic(err)
}
if len(provider.Plain.IPv4) > 0 {
return provider.Plain.IPv4[0].Addr()
}
}
return netip.Addr{}
}
func (d DNS) String() string {
return d.toLinesNode().String()
}
@@ -132,7 +132,6 @@ func (o OpenVPNSelection) validate(vpnProvider string) (err error) {
// Validate EncPreset
if vpnProvider == providers.PrivateInternetAccess {
validEncryptionPresets := []string{
presets.None,
presets.Normal,
presets.Strong,
}
+67 -20
View File
@@ -1,8 +1,10 @@
package settings
import (
"errors"
"fmt"
"path/filepath"
"slices"
"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gosettings"
@@ -37,16 +39,28 @@ type PortForwarding struct {
// It can be the empty string to indicate to NOT run a command.
// It cannot be nil in the internal state.
DownCommand *string `json:"down_command"`
// ListeningPort is the port traffic would be redirected to from the
// forwarded port. The redirection is disabled if it is set to 0, which
// is its default as well.
ListeningPort *uint16 `json:"listening_port"`
// ListeningPorts are the ports traffic would be redirected to from the
// forwarded ports. The redirection is disabled if it is the slice [0],
// which is its default as well. If set and not [0], its length must match
// the PortsCount value, such that each forwarded port is redirected to
// the corresponding listening port.
ListeningPorts []uint16 `json:"listening_port"`
// PortsCount is the number of ports to forward. It is optional for ProtonVPN
// and be between 1 and 5. For other providers, it must be set to 1 if port
// forwarding is enabled.
PortsCount uint16 `json:"ports_count"`
// Username is only used for Private Internet Access port forwarding.
Username string `json:"username"`
// Password is only used for Private Internet Access port forwarding.
Password string `json:"password"`
}
var (
ErrPortsCountTooHigh = errors.New("ports count too high")
ErrListeningPortsLen = errors.New("listening ports length must be equal to ports count")
ErrListeningPortZero = errors.New("listening port cannot be 0")
)
func (p PortForwarding) Validate(vpnProvider string) (err error) {
if !*p.Enabled {
return nil
@@ -75,13 +89,36 @@ func (p PortForwarding) Validate(vpnProvider string) (err error) {
}
}
if providerSelected == providers.PrivateInternetAccess {
switch providerSelected {
case providers.PrivateInternetAccess:
const maxPortsCount = 1
switch {
case p.PortsCount > maxPortsCount:
return fmt.Errorf("%w: %d > %d", ErrPortsCountTooHigh, p.PortsCount, maxPortsCount)
case p.Username == "":
return fmt.Errorf("%w", ErrPortForwardingUserEmpty)
case p.Password == "":
return fmt.Errorf("%w", ErrPortForwardingPasswordEmpty)
}
case providers.Protonvpn:
const maxPortsCount = 4
if p.PortsCount > maxPortsCount {
return fmt.Errorf("%w: %d > %d", ErrPortsCountTooHigh, p.PortsCount, maxPortsCount)
}
default:
const maxPortsCount = 1
if p.PortsCount > maxPortsCount {
return fmt.Errorf("%w: %d > %d", ErrPortsCountTooHigh, p.PortsCount, maxPortsCount)
}
}
if !slices.Equal(p.ListeningPorts, []uint16{0}) {
switch {
case len(p.ListeningPorts) != int(p.PortsCount):
return fmt.Errorf("%w: %d != %d", ErrListeningPortsLen, len(p.ListeningPorts), p.PortsCount)
case slices.Contains(p.ListeningPorts, 0):
return fmt.Errorf("%w: in %v", ErrListeningPortZero, p.ListeningPorts)
}
}
return nil
@@ -89,14 +126,14 @@ func (p PortForwarding) Validate(vpnProvider string) (err error) {
func (p *PortForwarding) Copy() (copied PortForwarding) {
return PortForwarding{
Enabled: gosettings.CopyPointer(p.Enabled),
Provider: gosettings.CopyPointer(p.Provider),
Filepath: gosettings.CopyPointer(p.Filepath),
UpCommand: gosettings.CopyPointer(p.UpCommand),
DownCommand: gosettings.CopyPointer(p.DownCommand),
ListeningPort: gosettings.CopyPointer(p.ListeningPort),
Username: p.Username,
Password: p.Password,
Enabled: gosettings.CopyPointer(p.Enabled),
Provider: gosettings.CopyPointer(p.Provider),
Filepath: gosettings.CopyPointer(p.Filepath),
UpCommand: gosettings.CopyPointer(p.UpCommand),
DownCommand: gosettings.CopyPointer(p.DownCommand),
ListeningPorts: gosettings.CopySlice(p.ListeningPorts),
Username: p.Username,
Password: p.Password,
}
}
@@ -106,7 +143,7 @@ func (p *PortForwarding) OverrideWith(other PortForwarding) {
p.Filepath = gosettings.OverrideWithPointer(p.Filepath, other.Filepath)
p.UpCommand = gosettings.OverrideWithPointer(p.UpCommand, other.UpCommand)
p.DownCommand = gosettings.OverrideWithPointer(p.DownCommand, other.DownCommand)
p.ListeningPort = gosettings.OverrideWithPointer(p.ListeningPort, other.ListeningPort)
p.ListeningPorts = gosettings.OverrideWithSlice(p.ListeningPorts, other.ListeningPorts)
p.Username = gosettings.OverrideWithComparable(p.Username, other.Username)
p.Password = gosettings.OverrideWithComparable(p.Password, other.Password)
}
@@ -117,7 +154,8 @@ func (p *PortForwarding) setDefaults() {
p.Filepath = gosettings.DefaultPointer(p.Filepath, "/tmp/gluetun/forwarded_port")
p.UpCommand = gosettings.DefaultPointer(p.UpCommand, "")
p.DownCommand = gosettings.DefaultPointer(p.DownCommand, "")
p.ListeningPort = gosettings.DefaultPointer(p.ListeningPort, 0)
p.ListeningPorts = gosettings.DefaultSlice(p.ListeningPorts, []uint16{0}) // disabled
p.PortsCount = gosettings.DefaultComparable(p.PortsCount, 1)
}
func (p PortForwarding) String() string {
@@ -131,11 +169,14 @@ func (p PortForwarding) toLinesNode() (node *gotree.Node) {
node = gotree.New("Automatic port forwarding settings:")
listeningPort := "disabled"
if *p.ListeningPort != 0 {
listeningPort = fmt.Sprintf("%d", *p.ListeningPort)
node.Appendf("Number of ports to be forwarded: %d", p.PortsCount)
if !slices.Equal(p.ListeningPorts, []uint16{0}) {
redirNode := node.Appendf("Redirection for listening ports:")
for i, port := range p.ListeningPorts {
redirNode.Appendf("Port #%d -> %d", i+1, port)
}
}
node.Appendf("Redirection listening port: %s", listeningPort)
if *p.Provider == "" {
node.Appendf("Use port forwarding code for current provider")
@@ -190,7 +231,13 @@ func (p *PortForwarding) read(r *reader.Reader) (err error) {
p.DownCommand = r.Get("VPN_PORT_FORWARDING_DOWN_COMMAND",
reader.ForceLowercase(false))
p.ListeningPort, err = r.Uint16Ptr("VPN_PORT_FORWARDING_LISTENING_PORT")
p.ListeningPorts, err = r.CSVUint16("VPN_PORT_FORWARDING_LISTENING_PORTS",
reader.RetroKeys("VPN_PORT_FORWARDING_LISTENING_PORT"))
if err != nil {
return err
}
p.PortsCount, err = r.Uint16("VPN_PORT_FORWARDING_PORTS_COUNT")
if err != nil {
return err
}
+1
View File
@@ -32,6 +32,7 @@ type Logger interface {
Info(s string)
Warn(s string)
Error(s string)
Errorf(format string, args ...any)
}
type Cmder interface {
+17 -7
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"sync"
"time"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/portforward/service"
@@ -42,11 +43,12 @@ func NewLoop(settings settings.PortForwarding, routing Routing,
settings: Settings{
VPNIsUp: ptrTo(false),
Service: service.Settings{
Enabled: settings.Enabled,
Filepath: *settings.Filepath,
UpCommand: *settings.UpCommand,
DownCommand: *settings.DownCommand,
ListeningPort: *settings.ListeningPort,
Enabled: settings.Enabled,
Filepath: *settings.Filepath,
UpCommand: *settings.UpCommand,
DownCommand: *settings.DownCommand,
ListeningPorts: settings.ListeningPorts,
PortsCount: settings.PortsCount,
},
},
routing: routing,
@@ -86,6 +88,8 @@ func (l *Loop) run(runCtx context.Context, runDone chan<- struct{},
defer close(runDone)
var serviceRunError <-chan error
var retryAfter <-chan time.Time
const retryDelay = 5 * time.Second
for {
updateReceived := false
select {
@@ -104,10 +108,12 @@ func (l *Loop) run(runCtx context.Context, runDone chan<- struct{},
l.settingsMutex.Unlock()
case err := <-serviceRunError:
l.logger.Error(err.Error())
case <-retryAfter:
// Retry starting the service after a delay
retryAfter = nil
}
firstRun := serviceRunError == nil
if !firstRun {
if l.service != nil {
err := l.service.Stop()
if err != nil {
runErrorCh <- fmt.Errorf("stopping previous service: %w", err)
@@ -131,6 +137,10 @@ func (l *Loop) run(runCtx context.Context, runDone chan<- struct{},
err = fmt.Errorf("starting port forwarding service: %w", err)
}
updateResult <- err
} else if err != nil {
// Log the error and schedule a retry
l.logger.Errorf("starting port forwarding service: %s - retrying in %s", err, retryDelay)
retryAfter = time.After(retryDelay)
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ type Logger interface {
type PortForwarder interface {
Name() string
PortForward(ctx context.Context, objects utils.PortForwardObjects) (
ports []uint16, err error)
internalToExternalPorts map[uint16]uint16, err error)
KeepPortForward(ctx context.Context, objects utils.PortForwardObjects) (err error)
}
+5 -1
View File
@@ -69,7 +69,11 @@ func (s *Service) SetPortsForwarded(ctx context.Context, ports []uint16) (err er
return fmt.Errorf("cleaning up: %w", err)
}
err = s.onNewPorts(ctx, ports)
internalToExternalPorts := make(map[uint16]uint16, len(ports))
for _, port := range ports {
internalToExternalPorts[port] = port
}
err = s.onNewPorts(ctx, internalToExternalPorts)
if err != nil {
return fmt.Errorf("handling new ports: %w", err)
}
+37 -4
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"slices"
"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gosettings"
@@ -17,7 +18,8 @@ type Settings struct {
Interface string // needed for PIA, PrivateVPN and ProtonVPN, tun0 for example
ServerName string // needed for PIA
CanPortForward bool // needed for PIA
ListeningPort uint16
ListeningPorts []uint16
PortsCount uint16
Username string // needed for PIA
Password string // needed for PIA
}
@@ -31,7 +33,8 @@ func (s Settings) Copy() (copied Settings) {
copied.Interface = s.Interface
copied.ServerName = s.ServerName
copied.CanPortForward = s.CanPortForward
copied.ListeningPort = s.ListeningPort
copied.ListeningPorts = gosettings.CopySlice(s.ListeningPorts)
copied.PortsCount = s.PortsCount
copied.Username = s.Username
copied.Password = s.Password
return copied
@@ -46,7 +49,8 @@ func (s *Settings) OverrideWith(update Settings) {
s.Interface = gosettings.OverrideWithComparable(s.Interface, update.Interface)
s.ServerName = gosettings.OverrideWithComparable(s.ServerName, update.ServerName)
s.CanPortForward = gosettings.OverrideWithComparable(s.CanPortForward, update.CanPortForward)
s.ListeningPort = gosettings.OverrideWithComparable(s.ListeningPort, update.ListeningPort)
s.ListeningPorts = gosettings.OverrideWithSlice(s.ListeningPorts, update.ListeningPorts)
s.PortsCount = gosettings.OverrideWithComparable(s.PortsCount, update.PortsCount)
s.Username = gosettings.OverrideWithComparable(s.Username, update.Username)
s.Password = gosettings.OverrideWithComparable(s.Password, update.Password)
}
@@ -58,6 +62,10 @@ var (
ErrPasswordNotSet = errors.New("password not set")
ErrFilepathNotSet = errors.New("file path not set")
ErrInterfaceNotSet = errors.New("interface not set")
ErrPortsCountZero = errors.New("ports count cannot be zero")
ErrPortsCountTooHigh = errors.New("ports count too high")
ErrListeningPortsLen = errors.New("listening ports length must be equal to ports count")
ErrListeningPortZero = errors.New("listening port cannot be 0")
)
func (s *Settings) Validate(forStartup bool) (err error) {
@@ -78,7 +86,12 @@ func (s *Settings) Validate(forStartup bool) (err error) {
return fmt.Errorf("%w", ErrPortForwarderNotSet)
case s.Interface == "":
return fmt.Errorf("%w", ErrInterfaceNotSet)
case s.PortForwarder.Name() == providers.PrivateInternetAccess:
case s.PortsCount == 0:
return fmt.Errorf("%w", ErrPortsCountZero)
}
switch s.PortForwarder.Name() {
case providers.PrivateInternetAccess:
switch {
case s.ServerName == "":
return fmt.Errorf("%w", ErrServerNameNotSet)
@@ -87,6 +100,26 @@ func (s *Settings) Validate(forStartup bool) (err error) {
case s.Password == "":
return fmt.Errorf("%w", ErrPasswordNotSet)
}
case providers.Protonvpn:
const maxPortsCount = 4
if s.PortsCount > maxPortsCount {
return fmt.Errorf("%w: %d > %d", ErrPortsCountTooHigh, s.PortsCount, maxPortsCount)
}
default:
const maxPortsCount = 1
if s.PortsCount > maxPortsCount {
return fmt.Errorf("%w: %d > %d", ErrPortsCountTooHigh, s.PortsCount, maxPortsCount)
}
}
if !slices.Equal(s.ListeningPorts, []uint16{0}) {
switch {
case len(s.ListeningPorts) != int(s.PortsCount):
return fmt.Errorf("%w: %d != %d", ErrListeningPortsLen, len(s.ListeningPorts), s.PortsCount)
case slices.Contains(s.ListeningPorts, 0):
return fmt.Errorf("%w: in %v", ErrListeningPortZero, s.ListeningPorts)
}
}
return nil
}
+42 -16
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"maps"
"slices"
"github.com/qdm12/gluetun/internal/netlink"
@@ -42,8 +43,9 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error)
CanPortForward: s.settings.CanPortForward,
Username: s.settings.Username,
Password: s.settings.Password,
PortsCount: s.settings.PortsCount,
}
ports, err := s.settings.PortForwarder.PortForward(ctx, obj)
internalToExternalPorts, err := s.settings.PortForwarder.PortForward(ctx, obj)
if err != nil {
return nil, fmt.Errorf("port forwarding for the first time: %w", err)
}
@@ -51,7 +53,7 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error)
s.portMutex.Lock()
defer s.portMutex.Unlock()
err = s.onNewPorts(ctx, ports)
err = s.onNewPorts(ctx, internalToExternalPorts)
if err != nil {
return nil, err
}
@@ -86,36 +88,60 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error)
return runErrorCh, nil
}
func (s *Service) onNewPorts(ctx context.Context, ports []uint16) (err error) {
slices.Sort(ports)
func (s *Service) onNewPorts(ctx context.Context, internalToExternalPorts map[uint16]uint16) (err error) {
autoRedirectionNeeded := false
externalToInternalPorts := make(map[uint16]uint16, len(internalToExternalPorts))
for internal, external := range internalToExternalPorts {
externalToInternalPorts[external] = internal
if internal != external {
autoRedirectionNeeded = true
}
}
s.logger.Info(portsToString(ports))
externalPorts := slices.Collect(maps.Keys(externalToInternalPorts))
slices.Sort(externalPorts)
for _, port := range ports {
err = s.portAllower.SetAllowedPort(ctx, port, s.settings.Interface)
s.logger.Info(portsToString(externalPorts))
userRedirectionEnabled := !slices.Equal(s.settings.ListeningPorts, []uint16{0})
for i, port := range externalPorts {
internalPort := externalToInternalPorts[port]
err = s.portAllower.SetAllowedPort(ctx, internalPort, s.settings.Interface)
if err != nil {
return fmt.Errorf("allowing port in firewall: %w", err)
}
if s.settings.ListeningPort != 0 {
err = s.portAllower.RedirectPort(ctx, s.settings.Interface, port, s.settings.ListeningPort)
if err != nil {
return fmt.Errorf("redirecting port in firewall: %w", err)
}
var sourcePort, destinationPort uint16
switch {
case userRedirectionEnabled: // precedence over auto redirection
sourcePort = externalToInternalPorts[port]
destinationPort = s.settings.ListeningPorts[i]
case autoRedirectionNeeded:
sourcePort = externalToInternalPorts[port]
destinationPort = port
default:
// No redirection needed, source and destination ports are the same.
continue
}
err = s.portAllower.RedirectPort(ctx, s.settings.Interface, sourcePort, destinationPort)
if err != nil {
return fmt.Errorf("redirecting port %d to %d in firewall: %w",
sourcePort, destinationPort, err)
}
}
err = s.writePortForwardedFile(ports)
err = s.writePortForwardedFile(externalPorts)
if err != nil {
_ = s.cleanup()
return fmt.Errorf("writing port file: %w", err)
}
s.ports = make([]uint16, len(ports))
copy(s.ports, ports)
s.ports = make([]uint16, len(internalToExternalPorts))
copy(s.ports, externalPorts)
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, externalPorts, s.settings.Interface)
if err != nil {
err = fmt.Errorf("running up command: %w", err)
s.logger.Error(err.Error())
+3 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"slices"
"time"
)
@@ -38,13 +39,14 @@ func (s *Service) cleanup() (err error) {
}
}
redirectionWasEnabled := !slices.Equal(s.settings.ListeningPorts, []uint16{0})
for _, port := range s.ports {
err = s.portAllower.RemoveAllowedPort(context.Background(), port)
if err != nil {
return fmt.Errorf("blocking previous port in firewall: %w", err)
}
if s.settings.ListeningPort != 0 {
if redirectionWasEnabled {
ctx := context.Background()
const listeningPort = 0 // 0 to clear the redirection
err = s.portAllower.RedirectPort(ctx, s.settings.Interface, port, listeningPort)
+1
View File
@@ -76,6 +76,7 @@ func modifyConfig(lines []string, connection models.Connection,
modified = append(modified, "pull-filter ignore \"auth-token\"") // prevent auth failed loop
modified = append(modified, "auth-retry nointeract")
modified = append(modified, "suppress-timestamps")
modified = append(modified, "handshake-window 10") // default is 60 seconds which is too long
if *settings.User != "" {
modified = append(modified, "auth-user-pass "+openvpn.AuthConf)
}
@@ -62,6 +62,7 @@ func Test_modifyConfig(t *testing.T) {
"pull-filter ignore \"auth-token\"",
"auth-retry nointeract",
"suppress-timestamps",
"handshake-window 10",
"auth-user-pass /etc/openvpn/auth.conf",
"verb 0",
"data-ciphers-fallback cipher",
@@ -10,12 +10,17 @@ import (
// PortForward calculates and returns the VPN server side ports forwarded.
func (p *Provider) PortForward(_ context.Context,
objects utils.PortForwardObjects,
) (ports []uint16, err error) {
) (internalToExternalPorts map[uint16]uint16, err error) {
if !objects.InternalIP.IsValid() {
panic("internal ip is not set")
}
return internalIPToPorts(objects.InternalIP), nil
ports := internalIPToPorts(objects.InternalIP)
internalToExternalPorts = make(map[uint16]uint16, len(ports))
for _, port := range ports {
internalToExternalPorts[port] = port
}
return internalToExternalPorts, nil
}
func (p *Provider) KeepPortForward(ctx context.Context,
@@ -13,7 +13,7 @@ func (p *Provider) GetConnection(selection settings.ServerSelection, ipv6Support
// Set port defaults depending on encryption preset.
var defaults utils.ConnectionDefaults
switch *selection.OpenVPN.PIAEncPreset {
case presets.None, presets.Normal:
case presets.Normal:
defaults.OpenVPNTCPPort = 502
defaults.OpenVPNUDPPort = 1198
case presets.Strong:
@@ -24,16 +24,10 @@ func (p *Provider) OpenVPNConfig(connection models.Connection,
)
switch *settings.PIAEncPreset {
case presets.Normal:
providerSettings.Ciphers = []string{openvpn.AES128cbc}
providerSettings.Auth = openvpn.SHA1
providerSettings.CAs = []string{caNormalPreset}
case presets.None:
providerSettings.Ciphers = []string{"none"}
providerSettings.Auth = "none"
providerSettings.Ciphers = []string{openvpn.AES128gcm}
providerSettings.CAs = []string{caNormalPreset}
default: // strong
providerSettings.Ciphers = []string{openvpn.AES256cbc}
providerSettings.Auth = openvpn.SHA256
providerSettings.Ciphers = []string{openvpn.AES256gcm}
providerSettings.CAs = []string{"MIIHqzCCBZOgAwIBAgIJAJ0u+vODZJntMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzQwMzNaFw0zNDA0MTIxNzQwMzNaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALVkhjumaqBbL8aSgj6xbX1QPTfTd1qHsAZd2B97m8Vw31c/2yQgZNf5qZY0+jOIHULNDe4R9TIvyBEbvnAg/OkPw8n/+ScgYOeH876VUXzjLDBnDb8DLr/+w9oVsuDeFJ9KV2UFM1OYX0SnkHnrYAN2QLF98ESK4NCSU01h5zkcgmQ+qKSfA9Ny0/UpsKPBFqsQ25NvjDWFhCpeqCHKUJ4Be27CDbSl7lAkBuHMPHJs8f8xPgAbHRXZOxVCpayZ2SNDfCwsnGWpWFoMGvdMbygngCn6jA/W1VSFOlRlfLuuGe7QFfDwA0jaLCxuWt/BgZylp7tAzYKR8lnWmtUCPm4+BtjyVDYtDCiGBD9Z4P13RFWvJHw5aapx/5W/CuvVyI7pKwvc2IT+KPxCUhH1XI8ca5RN3C9NoPJJf6qpg4g0rJH3aaWkoMRrYvQ+5PXXYUzjtRHImghRGd/ydERYoAZXuGSbPkm9Y/p2X8unLcW+F0xpJD98+ZI+tzSsI99Zs5wijSUGYr9/j18KHFTMQ8n+1jauc5bCCegN27dPeKXNSZ5riXFL2XX6BkY68y58UaNzmeGMiUL9BOV1iV+PMb7B7PYs7oFLjAhh0EdyvfHkrh/ZV9BEhtFa7yXp8XR0J6vz1YV9R6DYJmLjOEbhU8N0gc3tZm4Qz39lIIG6w3FDAgMBAAGjggFUMIIBUDAdBgNVHQ4EFgQUrsRtyWJftjpdRM0+925Y6Cl08SUwggEfBgNVHSMEggEWMIIBEoAUrsRtyWJftjpdRM0+925Y6Cl08SWhge6kgeswgegxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTETMBEGA1UEBxMKTG9zQW5nZWxlczEgMB4GA1UEChMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBAsTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQDExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEKRMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxLzAtBgkqhkiG9w0BCQEWIHNlY3VyZUBwcml2YXRlaW50ZXJuZXRhY2Nlc3MuY29tggkAnS7684Nkme0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOCAgEAJsfhsPk3r8kLXLxY+v+vHzbr4ufNtqnL9/1Uuf8NrsCtpXAoyZ0YqfbkWx3NHTZ7OE9ZRhdMP/RqHQE1p4N4Sa1nZKhTKasV6KhHDqSCt/dvEm89xWm2MVA7nyzQxVlHa9AkcBaemcXEiyT19XdpiXOP4Vhs+J1R5m8zQOxZlV1GtF9vsXmJqWZpOVPmZ8f35BCsYPvv4yMewnrtAC8PFEK/bOPeYcKN50bol22QYaZuLfpkHfNiFTnfMh8sl/ablPyNY7DUNiP5DRcMdIwmfGQxR5WEQoHL3yPJ42LkB5zs6jIm26DGNXfwura/mi105+ENH1CaROtRYwkiHb08U6qLXXJz80mWJkT90nr8Asj35xN2cUppg74nG3YVav/38P48T56hG1NHbYF5uOCske19F6wi9maUoto/3vEr0rnXJUp2KODmKdvBI7co245lHBABWikk8VfejQSlCtDBXn644ZMtAdoxKNfR2WTFVEwJiyd1Fzx0yujuiXDROLhISLQDRjVVAvawrAtLZWYK31bY7KlezPlQnl/D9Asxe85l8jO5+0LdJ6VyOs/Hd4w52alDW/MFySDZSfQHMTIc30hLBJ8OnCEIvluVQQ2UQvoW+no177N9L2Y+M9TcTA62ZyMXShHQGeh20rb4kK8f+iFX8NxtdHVSkxMEFSfDDyQ="} //nolint:lll
}
@@ -26,7 +26,7 @@ var ErrServerNameNotFound = errors.New("server name not found in servers")
// PortForward obtains a VPN server side port forwarded from PIA.
func (p *Provider) PortForward(ctx context.Context,
objects utils.PortForwardObjects,
) (ports []uint16, err error) {
) (internalToExternalPorts map[uint16]uint16, err error) {
switch {
case objects.ServerName == "":
panic("server name cannot be empty")
@@ -39,7 +39,6 @@ func (p *Provider) PortForward(ctx context.Context,
}
serverName := objects.ServerName
apiIP := buildAPIIPAddress(objects.Gateway)
logger := objects.Logger
if !objects.CanPortForward {
@@ -68,9 +67,14 @@ func (p *Provider) PortForward(ctx context.Context,
}
}
p.apiIP, err = findAPIIP(ctx, privateIPClient, objects.Gateway)
if err != nil {
return nil, fmt.Errorf("finding API IP address: %w", err)
}
if !dataFound || expired {
client := objects.Client
data, err = refreshPIAPortForwardData(ctx, client, privateIPClient, apiIP,
data, err = refreshPIAPortForwardData(ctx, client, privateIPClient, p.apiIP,
p.portForwardPath, objects.Username, objects.Password)
if err != nil {
return nil, fmt.Errorf("refreshing port forward data: %w", err)
@@ -80,11 +84,11 @@ func (p *Provider) PortForward(ctx context.Context,
logger.Info("Port forwarded data expires in " + format.FriendlyDuration(durationToExpiration))
// First time binding
if err := bindPort(ctx, privateIPClient, apiIP, data); err != nil {
if err := bindPort(ctx, privateIPClient, p.apiIP, data); err != nil {
return nil, fmt.Errorf("binding port: %w", err)
}
return []uint16{data.Port}, nil
return map[uint16]uint16{data.Port: data.Port}, nil
}
var ErrPortForwardedExpired = errors.New("port forwarded data expired")
@@ -99,8 +103,6 @@ func (p *Provider) KeepPortForward(ctx context.Context,
panic("gateway is not set")
}
apiIP := buildAPIIPAddress(objects.Gateway)
privateIPClient, err := newHTTPClient(objects.ServerName)
if err != nil {
return fmt.Errorf("creating custom HTTP client: %w", err)
@@ -128,7 +130,7 @@ func (p *Provider) KeepPortForward(ctx context.Context,
}
return ctx.Err()
case <-keepAliveTimer.C:
err = bindPort(ctx, privateIPClient, apiIP, data)
err = bindPort(ctx, privateIPClient, p.apiIP, data)
if err != nil {
return fmt.Errorf("binding port: %w", err)
}
@@ -140,15 +142,53 @@ func (p *Provider) KeepPortForward(ctx context.Context,
}
}
func buildAPIIPAddress(gateway netip.Addr) (api netip.Addr) {
var errAPIIPNotFound = errors.New("API IP address not found")
func findAPIIP(ctx context.Context, client *http.Client, gateway netip.Addr) (
apiIP netip.Addr, err error,
) {
if gateway.Is6() {
panic("IPv6 gateway not supported")
}
gatewayBytes := gateway.As4()
gatewayBytes[2] = 128
gatewayBytes[3] = 1
return netip.AddrFrom4(gatewayBytes)
gatewayBytes[3] = 1 // x.y.z.1
gatewayBytes[2] = 128 // x.y.128.1
oldAPIIP := netip.AddrFrom4(gatewayBytes)
gatewayBytes[2] = 0 // x.y.0.1 - new API IP reported by some users
newAPIIP := netip.AddrFrom4(gatewayBytes)
possibleIPs := []netip.Addr{oldAPIIP, newAPIIP}
errs := make([]error, 0, len(possibleIPs))
for _, ip := range possibleIPs {
const timeout = 5 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := url.URL{
Scheme: "https",
Host: net.JoinHostPort(ip.String(), "19999"),
Path: "/ping",
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
if err != nil {
errs = append(errs, fmt.Errorf("trying IP %s: %w", ip, err))
continue
}
response, err := client.Do(request)
if err != nil {
errs = append(errs, fmt.Errorf("trying IP %s: %w", ip, err))
continue
}
_ = response.Body.Close()
return ip, nil
}
return netip.Addr{}, fmt.Errorf("%w: %w", errAPIIPNotFound, errors.Join(errs...))
}
func refreshPIAPortForwardData(ctx context.Context, client, privateIPClient *http.Client,
@@ -1,7 +1,6 @@
package presets
const (
None = "none"
Normal = "normal"
Strong = "strong"
)
@@ -3,6 +3,7 @@ package privateinternetaccess
import (
"math/rand"
"net/http"
"net/netip"
"time"
"github.com/qdm12/gluetun/internal/constants/providers"
@@ -17,6 +18,7 @@ type Provider struct {
common.Fetcher
// Port forwarding
portForwardPath string
apiIP netip.Addr
}
func New(storage common.Storage, randSource rand.Source,
@@ -36,7 +36,7 @@ type serverData struct {
func fetchAPI(ctx context.Context, client *http.Client) (
data apiData, err error,
) {
const url = "https://serverlist.piaservers.net/vpninfo/servers/v6"
const url = "https://serverlist.piaservers.net/vpninfo/servers/v7"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
@@ -77,6 +77,9 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
func addData(regions []regionData, nts nameToServer) (change bool) {
for _, region := range regions {
if region.Offline {
continue
}
for _, server := range region.Servers.UDP {
const tcp, udp = false, true
if nts.add(server.CN, region.DNS, region.Name, tcp, udp, region.PortForward, server.IP) {
+3 -2
View File
@@ -22,7 +22,7 @@ var ErrPortForwardedNotFound = errors.New("port forwarded not found")
// PortForward obtains a VPN server side port forwarded from the PrivateVPN API.
// It returns 0 if all ports are to forwarded on a dedicated server IP.
func (p *Provider) PortForward(ctx context.Context, objects utils.PortForwardObjects) (
ports []uint16, err error,
internalToExternalPorts map[uint16]uint16, err error,
) {
// Define a timeout since the default client has a large timeout and we don't
// want to wait too long.
@@ -75,7 +75,8 @@ func (p *Provider) PortForward(ctx context.Context, objects utils.PortForwardObj
if err != nil {
return nil, fmt.Errorf("parsing port: %w", err)
}
return []uint16{uint16(portUint64)}, nil
port := uint16(portUint64)
return map[uint16]uint16{port: port}, nil
}
func (p *Provider) KeepPortForward(ctx context.Context,
@@ -28,10 +28,10 @@ func Test_Provider_PortForward(t *testing.T) {
cancel()
testCases := map[string]struct {
ctx context.Context
objects utils.PortForwardObjects
ports []uint16
errMessage string
ctx context.Context
objects utils.PortForwardObjects
internalToExternalPorts map[uint16]uint16
errMessage string
}{
"canceled context": {
ctx: canceledCtx,
@@ -192,7 +192,7 @@ func Test_Provider_PortForward(t *testing.T) {
}),
},
},
ports: []uint16{61527},
internalToExternalPorts: map[uint16]uint16{61527: 61527},
},
}
for name, testCase := range testCases {
@@ -203,7 +203,7 @@ func Test_Provider_PortForward(t *testing.T) {
ports, err := provider.PortForward(testCase.ctx,
testCase.objects)
assert.Equal(t, testCase.ports, ports)
assert.Equal(t, testCase.internalToExternalPorts, ports)
if testCase.errMessage != "" {
assert.EqualError(t, err, testCase.errMessage)
} else {
+68 -45
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"maps"
"strings"
"time"
@@ -13,17 +14,18 @@ import (
var ErrServerPortForwardNotSupported = errors.New("server does not support port forwarding")
const nonSymmetricPortStart uint16 = 56789
// PortForward obtains a VPN server side port forwarded from ProtonVPN gateway.
func (p *Provider) PortForward(ctx context.Context, objects utils.PortForwardObjects) (
ports []uint16, err error,
internalToExternalPorts map[uint16]uint16, err error,
) {
if !objects.CanPortForward {
return nil, fmt.Errorf("%w", ErrServerPortForwardNotSupported)
}
client := natpmp.New()
_, externalIPv4Address, err := client.ExternalAddress(ctx,
objects.Gateway)
_, externalIPv4Address, err := client.ExternalAddress(ctx, objects.Gateway)
if err != nil {
switch {
case strings.HasSuffix(err.Error(), "connection refused"):
@@ -38,29 +40,37 @@ func (p *Provider) PortForward(ctx context.Context, objects utils.PortForwardObj
logger := objects.Logger
logger.Info("gateway external IPv4 address is " + externalIPv4Address.String())
const internalPort, externalPort = 0, 1
logger.Debug("gateway external IPv4 address is " + externalIPv4Address.String())
const externalPort = 0
const lifetime = 60 * time.Second
_, _, assignedUDPExternalPort, assignedLifetime, err := client.AddPortMapping(ctx, objects.Gateway, "udp",
internalPort, externalPort, lifetime)
if err != nil {
return nil, fmt.Errorf("adding UDP port mapping: %w", err)
p.internalToExternalPorts = make(map[uint16]uint16, objects.PortsCount)
for i := range objects.PortsCount {
internalPort := nonSymmetricPortStart + i
protoToInternalPort := map[string]uint16{
"udp": 0,
"tcp": 0,
}
protoToExternalPort := maps.Clone(protoToInternalPort)
for protocol := range protoToExternalPort {
_, assignedInternalPort, assignedExternalPort, assignedLifetime, err := client.AddPortMapping(
ctx, objects.Gateway, protocol, internalPort, externalPort, lifetime)
if err != nil {
return nil, fmt.Errorf("adding %d/%d %s port mapping: %w",
i+1, objects.PortsCount, strings.ToUpper(protocol), err)
}
checkLifetime(logger, strings.ToUpper(protocol), lifetime, assignedLifetime)
checkInternalPort(logger, internalPort, assignedInternalPort)
protoToInternalPort[protocol] = assignedInternalPort
protoToExternalPort[protocol] = assignedExternalPort
}
checkInternalPorts(logger, protoToInternalPort["udp"], protoToInternalPort["tcp"])
checkExternalPorts(logger, protoToExternalPort["udp"], protoToExternalPort["tcp"])
p.internalToExternalPorts[protoToInternalPort["tcp"]] = protoToExternalPort["tcp"]
}
checkLifetime(logger, "UDP", lifetime, assignedLifetime)
_, _, assignedTCPExternalPort, assignedLifetime, err := client.AddPortMapping(ctx, objects.Gateway, "tcp",
internalPort, externalPort, lifetime)
if err != nil {
return nil, fmt.Errorf("adding TCP port mapping: %w", err)
}
checkLifetime(logger, "TCP", lifetime, assignedLifetime)
checkExternalPorts(logger, assignedUDPExternalPort, assignedTCPExternalPort)
p.portForwarded = assignedTCPExternalPort
return []uint16{assignedTCPExternalPort}, nil
return maps.Clone(p.internalToExternalPorts), nil
}
func checkLifetime(logger utils.Logger, protocol string,
@@ -73,6 +83,20 @@ func checkLifetime(logger utils.Logger, protocol string,
}
}
func checkInternalPort(logger utils.Logger, sent, received uint16) {
if sent != received {
logger.Warn(fmt.Sprintf("internal port assigned %d differs from requested internal port %d",
sent, received))
}
}
func checkInternalPorts(logger utils.Logger, udpPort, tcpPort uint16) {
if udpPort != tcpPort {
logger.Warn(fmt.Sprintf("UDP internal port %d differs from TCP internal port %d",
udpPort, tcpPort))
}
}
func checkExternalPorts(logger utils.Logger, udpPort, tcpPort uint16) {
if udpPort != tcpPort {
logger.Warn(fmt.Sprintf("UDP external port %d differs from TCP external port %d",
@@ -80,7 +104,10 @@ func checkExternalPorts(logger utils.Logger, udpPort, tcpPort uint16) {
}
}
var ErrExternalPortChanged = errors.New("external port changed")
var (
ErrInternalPortChanged = errors.New("internal port changed")
ErrExternalPortChanged = errors.New("external port changed")
)
func (p *Provider) KeepPortForward(ctx context.Context,
objects utils.PortForwardObjects,
@@ -96,32 +123,28 @@ func (p *Provider) KeepPortForward(ctx context.Context,
case <-timer.C:
}
objects.Logger.Debug("refreshing port forward since 45 seconds have elapsed")
networkProtocols := []string{"udp", "tcp"}
const internalPort = 0
objects.Logger.Debug("refreshing forwarded ports since 45 seconds have elapsed")
networkProtocols := [...]string{"udp", "tcp"}
const lifetime = 60 * time.Second
for _, networkProtocol := range networkProtocols {
_, _, assignedExternalPort, assignedLiftetime, err := client.AddPortMapping(ctx, objects.Gateway, networkProtocol,
internalPort, p.portForwarded, lifetime)
if err != nil {
return fmt.Errorf("adding port mapping: %w", err)
}
if assignedLiftetime != lifetime {
logger.Warn(fmt.Sprintf("assigned lifetime %s differs"+
" from requested lifetime %s",
assignedLiftetime, lifetime))
}
if p.portForwarded != assignedExternalPort {
return fmt.Errorf("%w: %d changed to %d",
ErrExternalPortChanged, p.portForwarded, assignedExternalPort)
for internalPort, externalPort := range p.internalToExternalPorts {
for _, networkProtocol := range networkProtocols {
_, assignedInternalPort, assignedExternalPort, assignedLiftetime, err := client.AddPortMapping(
ctx, objects.Gateway, networkProtocol, internalPort, externalPort, lifetime)
if err != nil {
return fmt.Errorf("adding port mapping: %w", err)
}
checkLifetime(logger, networkProtocol, lifetime, assignedLiftetime)
if externalPort != assignedExternalPort {
return fmt.Errorf("%w: %d changed to %d",
ErrExternalPortChanged, externalPort, assignedExternalPort)
} else if internalPort != assignedInternalPort {
return fmt.Errorf("%w: %d (for external port %d) changed to %d",
ErrInternalPortChanged, internalPort, externalPort, assignedInternalPort)
}
}
objects.Logger.Debug(fmt.Sprintf("port forwarded %d maintained", externalPort))
}
objects.Logger.Debug(fmt.Sprintf("port forwarded %d maintained", p.portForwarded))
timer.Reset(refreshTimeout)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ type Provider struct {
storage common.Storage
randSource rand.Source
common.Fetcher
portForwarded uint16
internalToExternalPorts map[uint16]uint16
}
func New(storage common.Storage, randSource rand.Source,
+19 -27
View File
@@ -22,12 +22,11 @@ import (
// oddities of Proton's authentication flow they want to keep hidden
// from the public.
type apiClient struct {
apiURLBase string
httpClient *http.Client
appVersion string
vpnGtkAppVersion string
userAgent string
generator *rand.ChaCha8
apiURLBase string
httpClient *http.Client
appVersion string
userAgent string
generator *rand.ChaCha8
}
// newAPIClient returns an [apiClient] with sane defaults matching Proton's
@@ -47,22 +46,17 @@ func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClie
}
userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))]
appVersion, err := getMostRecentStableWebAccountTag(ctx, httpClient)
appVersion, err := getMostRecentStableTag(ctx, httpClient)
if err != nil {
return nil, fmt.Errorf("getting most recent version for web-account: %w", err)
}
vpnGtkAppVersion, err := getMostRecentStableVPNGtkAppTag(ctx, httpClient)
if err != nil {
return nil, fmt.Errorf("getting most recent version for linux VPN GTK app: %w", err)
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,
vpnGtkAppVersion: vpnGtkAppVersion,
userAgent: userAgent,
generator: generator,
apiURLBase: "https://account.proton.me/api",
httpClient: httpClient,
appVersion: appVersion,
userAgent: userAgent,
generator: generator,
}, nil
}
@@ -72,10 +66,10 @@ var ErrCodeNotSuccess = errors.New("response code is not success")
// 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, appVersion string) {
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", appVersion)
request.Header.Set("x-pm-appversion", c.appVersion)
request.Header.Set("x-pm-locale", "en_US")
request.Header.Set("x-pm-uid", cookie.uid)
}
@@ -171,7 +165,7 @@ func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) (
unauthCookie := cookie{
sessionID: sessionID,
}
c.setHeaders(request, unauthCookie, c.appVersion)
c.setHeaders(request, unauthCookie)
response, err := c.httpClient.Do(request)
if err != nil {
@@ -258,7 +252,7 @@ func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, acces
uid: uid,
sessionID: sessionID,
}
c.setHeaders(request, unauthCookie, c.appVersion)
c.setHeaders(request, unauthCookie)
request.Header.Set("Authorization", tokenType+" "+accessToken)
response, err := c.httpClient.Do(request)
@@ -331,7 +325,7 @@ func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie coo
if err != nil {
return "", "", "", "", "", 0, fmt.Errorf("creating request: %w", err)
}
c.setHeaders(request, unauthCookie, c.appVersion)
c.setHeaders(request, unauthCookie)
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
@@ -444,7 +438,7 @@ func (c *apiClient) auth(ctx context.Context, unauthCookie cookie,
if err != nil {
return cookie{}, fmt.Errorf("creating request: %w", err)
}
c.setHeaders(request, unauthCookie, c.appVersion)
c.setHeaders(request, unauthCookie)
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
@@ -596,9 +590,7 @@ func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) (
if err != nil {
return data, err
}
// Note we use the vpnGtkAppVersion field given it produces an output of more servers
c.setHeaders(request, cookie, c.vpnGtkAppVersion)
request.Header.Set("x-pm-appversion", "linux-vpn@4.15.2")
c.setHeaders(request, cookie)
response, err := c.httpClient.Do(request)
if err != nil {
+2 -47
View File
@@ -7,18 +7,15 @@ import (
"io"
"net/http"
"regexp"
"sort"
"strings"
"time"
"golang.org/x/mod/semver"
)
// getMostRecentStableWebAccountTag finds the most recent proton-account stable tag version,
// 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 getMostRecentStableWebAccountTag(ctx context.Context, client *http.Client) (version string, err error) {
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 {
@@ -72,45 +69,3 @@ func getMostRecentStableWebAccountTag(ctx context.Context, client *http.Client)
return "", fmt.Errorf("%w (queried %d pages)", context.Canceled, page)
}
// getMostRecentStableVPNGtkAppTag finds the latest proton-vpn-gtk-app semver tag,
// in order to use it in the x-pm-appversion http request header ONLY to fetch servers
// data. 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 getMostRecentStableVPNGtkAppTag(ctx context.Context, client *http.Client) (version string, err error) {
const url = "https://api.github.com/repos/ProtonVPN/proton-vpn-gtk-app/tags?per_page=30"
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()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: %s", ErrHTTPStatusCodeNotOK, response.Status)
}
decoder := json.NewDecoder(response.Body)
var data []struct {
Name string `json:"name"`
}
err = decoder.Decode(&data)
if err != nil {
return "", fmt.Errorf("decoding JSON response: %w", err)
}
// Sort tags by semver. Invalid tags are placed at the end and we ignore them.
// Yes, proton does push invalid semver tag names sometimes. Good job yet again.
sort.Slice(data, func(i, j int) bool {
return semver.Compare(data[i].Name, data[j].Name) > 0
})
version = "linux-vpn@" + data[0].Name[1:] // remove leading v
return version, nil
}
-131
View File
@@ -1,131 +0,0 @@
package utils
import (
"fmt"
"strings"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/models"
)
func filterServers(servers []models.Server,
selection settings.ServerSelection,
) (filtered []models.Server) {
for _, server := range servers {
if filterServer(server, selection) {
continue
}
filtered = append(filtered, server)
}
return filtered
}
func filterServer(server models.Server,
selection settings.ServerSelection,
) (filtered bool) {
// Note each condition is split to make sure
// we have full testing coverage.
if server.VPN != selection.VPN {
return true
}
if filterByProtocol(selection, server.TCP, server.UDP) {
return true
}
if *selection.MultiHopOnly && !server.MultiHop {
return true
}
if *selection.FreeOnly && !server.Free {
return true
}
if *selection.PremiumOnly && !server.Premium {
return true
}
if *selection.StreamOnly && !server.Stream {
return true
}
if *selection.OwnedOnly && !server.Owned {
return true
}
if *selection.PortForwardOnly && !server.PortForward {
return true
}
if *selection.SecureCoreOnly && !server.SecureCore {
return true
}
if *selection.TorOnly && !server.Tor {
return true
}
if filterByPossibilities(server.Country, selection.Countries) {
return true
}
if filterAnyByPossibilities(server.Categories, selection.Categories) {
return true
}
if filterByPossibilities(server.Region, selection.Regions) {
return true
}
if filterByPossibilities(server.City, selection.Cities) {
return true
}
if filterByPossibilities(server.ISP, selection.ISPs) {
return true
}
if filterByPossibilities(server.Number, selection.Numbers) {
return true
}
if filterByPossibilities(server.ServerName, selection.Names) {
return true
}
if filterByPossibilities(server.Hostname, selection.Hostnames) {
return true
}
// TODO filter port forward server for PIA
return false
}
func filterByPossibilities[T string | uint16](value T, possibilities []T) (filtered bool) {
if len(possibilities) == 0 {
return false
}
for _, possibility := range possibilities {
if strings.EqualFold(fmt.Sprint(value), fmt.Sprint(possibility)) {
return false
}
}
return true
}
func filterAnyByPossibilities(values, possibilities []string) (filtered bool) {
if len(possibilities) == 0 {
return false
}
for _, value := range values {
if !filterByPossibilities(value, possibilities) {
return false // found a valid value
}
}
return true
}
-313
View File
@@ -1,313 +0,0 @@
package utils
import (
"testing"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gluetun/internal/models"
"github.com/stretchr/testify/assert"
)
func Test_FilterServers(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
servers []models.Server
selection settings.ServerSelection
filtered []models.Server
}{
"no server available": {
selection: settings.ServerSelection{}.WithDefaults(providers.Mullvad),
},
"no filter": {
servers: []models.Server{
{VPN: vpn.OpenVPN, Hostname: "a", UDP: true},
{VPN: vpn.OpenVPN, Hostname: "b", UDP: true},
{VPN: vpn.OpenVPN, Hostname: "c", UDP: true},
},
selection: settings.ServerSelection{}.WithDefaults(providers.Mullvad),
filtered: []models.Server{
{VPN: vpn.OpenVPN, Hostname: "a", UDP: true},
{VPN: vpn.OpenVPN, Hostname: "b", UDP: true},
{VPN: vpn.OpenVPN, Hostname: "c", UDP: true},
},
},
"filter by VPN protocol": {
selection: settings.ServerSelection{
VPN: vpn.Wireguard,
}.WithDefaults(providers.Mullvad),
servers: []models.Server{
{VPN: vpn.OpenVPN, Hostname: "a", UDP: true},
{VPN: vpn.Wireguard, Hostname: "b", UDP: true},
{VPN: vpn.OpenVPN, Hostname: "c", UDP: true},
},
filtered: []models.Server{
{VPN: vpn.Wireguard, Hostname: "b", UDP: true},
},
},
"filter by network protocol": {
selection: settings.ServerSelection{
OpenVPN: settings.OpenVPNSelection{
Protocol: constants.TCP,
},
}.WithDefaults(providers.Ivpn),
servers: []models.Server{
{UDP: true, Hostname: "a", VPN: vpn.OpenVPN},
{UDP: true, TCP: true, Hostname: "b", VPN: vpn.OpenVPN},
{UDP: true, Hostname: "c", VPN: vpn.OpenVPN},
},
filtered: []models.Server{
{UDP: true, TCP: true, Hostname: "b", VPN: vpn.OpenVPN},
},
},
"filter by multihop only": {
selection: settings.ServerSelection{
MultiHopOnly: boolPtr(true),
}.WithDefaults(providers.Surfshark),
servers: []models.Server{
{MultiHop: false, VPN: vpn.OpenVPN, UDP: true},
{MultiHop: true, VPN: vpn.OpenVPN, UDP: true},
{MultiHop: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{MultiHop: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by free only": {
selection: settings.ServerSelection{
FreeOnly: boolPtr(true),
}.WithDefaults(providers.Surfshark),
servers: []models.Server{
{Free: false, VPN: vpn.OpenVPN, UDP: true},
{Free: true, VPN: vpn.OpenVPN, UDP: true},
{Free: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Free: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by premium only": {
selection: settings.ServerSelection{
PremiumOnly: boolPtr(true),
}.WithDefaults(providers.Surfshark),
servers: []models.Server{
{Premium: false, VPN: vpn.OpenVPN, UDP: true},
{Premium: true, VPN: vpn.OpenVPN, UDP: true},
{Premium: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Premium: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by stream only": {
selection: settings.ServerSelection{
StreamOnly: boolPtr(true),
}.WithDefaults(providers.Surfshark),
servers: []models.Server{
{Stream: false, VPN: vpn.OpenVPN, UDP: true},
{Stream: true, VPN: vpn.OpenVPN, UDP: true},
{Stream: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Stream: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by secure core only": {
selection: settings.ServerSelection{
SecureCoreOnly: boolPtr(true),
}.WithDefaults(providers.Protonvpn),
servers: []models.Server{
{SecureCore: false, VPN: vpn.OpenVPN, UDP: true},
{SecureCore: true, VPN: vpn.OpenVPN, UDP: true},
{SecureCore: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{SecureCore: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by tor only": {
selection: settings.ServerSelection{
TorOnly: boolPtr(true),
}.WithDefaults(providers.Protonvpn),
servers: []models.Server{
{Tor: false, VPN: vpn.OpenVPN, UDP: true},
{Tor: true, VPN: vpn.OpenVPN, UDP: true},
{Tor: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Tor: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by owned": {
selection: settings.ServerSelection{
OwnedOnly: boolPtr(true),
}.WithDefaults(providers.Mullvad),
servers: []models.Server{
{Owned: false, VPN: vpn.OpenVPN, UDP: true},
{Owned: true, VPN: vpn.OpenVPN, UDP: true},
{Owned: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Owned: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by port forwarding only": {
selection: settings.ServerSelection{
PortForwardOnly: boolPtr(true),
}.WithDefaults(providers.PrivateInternetAccess),
servers: []models.Server{
{PortForward: false, VPN: vpn.OpenVPN, UDP: true},
{PortForward: true, VPN: vpn.OpenVPN, UDP: true},
{PortForward: false, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{PortForward: true, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by country": {
selection: settings.ServerSelection{
Countries: []string{"b"},
}.WithDefaults(providers.Mullvad),
servers: []models.Server{
{Country: "a", VPN: vpn.OpenVPN, UDP: true},
{Country: "b", VPN: vpn.OpenVPN, UDP: true},
{Country: "c", VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Country: "b", VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by region": {
selection: settings.ServerSelection{
Regions: []string{"b"},
}.WithDefaults(providers.Surfshark),
servers: []models.Server{
{Region: "a", VPN: vpn.OpenVPN, UDP: true},
{Region: "b", VPN: vpn.OpenVPN, UDP: true},
{Region: "c", VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Region: "b", VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by city": {
selection: settings.ServerSelection{
Cities: []string{"b"},
}.WithDefaults(providers.Mullvad),
servers: []models.Server{
{City: "a", VPN: vpn.OpenVPN, UDP: true},
{City: "b", VPN: vpn.OpenVPN, UDP: true},
{City: "c", VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{City: "b", VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by category": {
selection: settings.ServerSelection{
Categories: []string{"legacy_p2p"},
}.WithDefaults(providers.Nordvpn),
servers: []models.Server{
{Categories: []string{"legacy_p2p"}, VPN: vpn.OpenVPN, UDP: true},
{Categories: []string{"legacy_standard"}, VPN: vpn.OpenVPN, UDP: true},
{VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Categories: []string{"legacy_p2p"}, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by ISP": {
selection: settings.ServerSelection{
ISPs: []string{"b"},
}.WithDefaults(providers.Mullvad),
servers: []models.Server{
{ISP: "a", VPN: vpn.OpenVPN, UDP: true},
{ISP: "b", VPN: vpn.OpenVPN, UDP: true},
{ISP: "c", VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{ISP: "b", VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by number": {
selection: settings.ServerSelection{
Numbers: []uint16{1},
}.WithDefaults(providers.Mullvad),
servers: []models.Server{
{Number: 0, VPN: vpn.OpenVPN, UDP: true},
{Number: 1, VPN: vpn.OpenVPN, UDP: true},
{Number: 2, VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Number: 1, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by server name": {
selection: settings.ServerSelection{
Names: []string{"b"},
}.WithDefaults(providers.Mullvad),
servers: []models.Server{
{ServerName: "a", VPN: vpn.OpenVPN, UDP: true},
{ServerName: "b", VPN: vpn.OpenVPN, UDP: true},
{ServerName: "c", VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{ServerName: "b", VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by hostname": {
selection: settings.ServerSelection{
Hostnames: []string{"b"},
}.WithDefaults(providers.Mullvad),
servers: []models.Server{
{Hostname: "a", VPN: vpn.OpenVPN, UDP: true},
{Hostname: "b", VPN: vpn.OpenVPN, UDP: true},
{Hostname: "c", VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Hostname: "b", VPN: vpn.OpenVPN, UDP: true},
},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
filtered := filterServers(testCase.servers, testCase.selection)
assert.Equal(t, testCase.filtered, filtered)
})
}
}
func Test_filterByPossibilities(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
value string
possibilities []string
filtered bool
}{
"no possibilities": {},
"value not in possibilities": {
value: "c",
possibilities: []string{"a", "b"},
filtered: true,
},
"value in possibilities": {
value: "c",
possibilities: []string{"a", "b", "c"},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
filtered := filterByPossibilities(testCase.value, testCase.possibilities)
assert.Equal(t, testCase.filtered, filtered)
})
}
}
+6 -1
View File
@@ -62,9 +62,14 @@ func OpenVPNConfig(provider OpenVPNProviderSettings,
lines.add("mute-replay-warnings") // these are often ignored by some VPN providers
lines.add("auth-retry", "nointeract") // retry authenticating without interaction
lines.add("suppress-timestamps") // do not log timestamps, the Gluetun logger takes care of it
lines.add("handshake-window", "10") // default is 60 seconds which is too long
lines.add("dev", settings.Interface)
lines.add("verb", fmt.Sprint(*settings.Verbosity))
lines.add("proto", connection.Protocol)
protocol := connection.Protocol
if protocol == constants.TCP {
protocol = "tcp-client"
}
lines.add("proto", protocol)
lines.add("remote", connection.IP.String(), fmt.Sprint(connection.Port))
if *settings.User != "" {
-1
View File
@@ -9,7 +9,6 @@ import (
"github.com/stretchr/testify/assert"
)
func boolPtr(b bool) *bool { return &b }
func uint16Ptr(n uint16) *uint16 { return &n }
func Test_GetPort(t *testing.T) {
+2
View File
@@ -25,6 +25,8 @@ type PortForwardObjects struct {
Username string
// Password is used by Private Internet Access for port forwarding.
Password string
// PortsCount is used by ProtonVPN for port forwarding.
PortsCount uint16
}
type Routing interface {
+157272 -48306
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ func ExtractProto(b []byte) (tcp, udp bool, err error) {
s = strings.TrimSpace(s)
s = strings.ToLower(s)
switch s {
case "tcp", "tcp4", "tcp6":
case "tcp", "tcp4", "tcp6", "tcp-client":
return true, false, nil
case "udp", "udp4", "udp6":
return false, true, nil
+1 -1
View File
@@ -52,7 +52,7 @@ type Provider interface {
type PortForwarder interface {
Name() string
PortForward(ctx context.Context, objects utils.PortForwardObjects) (
ports []uint16, err error)
internalToExternalPorts map[uint16]uint16, err error)
KeepPortForward(ctx context.Context, objects utils.PortForwardObjects) (err error)
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (n *noPortForwarder) Name() string {
}
func (n *noPortForwarder) PortForward(context.Context, pfutils.PortForwardObjects) (
ports []uint16, err error,
internalToExternalPorts map[uint16]uint16, err error,
) {
return nil, fmt.Errorf("%w: for %s", ErrPortForwardingNotSupported, n.providerName)
}