Compare commits

..

52 Commits

Author SHA1 Message Date
dependabot[bot]
f4dafaecac Chore(deps): Bump peter-evans/dockerhub-description from 4 to 5
Bumps [peter-evans/dockerhub-description](https://github.com/peter-evans/dockerhub-description) from 4 to 5.
- [Release notes](https://github.com/peter-evans/dockerhub-description/releases)
- [Commits](https://github.com/peter-evans/dockerhub-description/compare/v4...v5)

---
updated-dependencies:
- dependency-name: peter-evans/dockerhub-description
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-31 20:04:12 +00:00
Quentin McGaw
b0b769d2c1 ci(markdown): fix config file path 2025-10-31 20:02:55 +00:00
Quentin McGaw
d3c7d3c7bc docs(readme): update Alpine version and image size 2025-10-30 16:15:44 +00:00
Quentin McGaw
65f49ea012 fix(wireguard): specify IP family for new route (#2629) 2025-10-30 17:14:45 +01:00
Quentin McGaw
5687555921 chore(container): bump Alpine from 3.20 to 3.22 2025-10-30 16:08:40 +00:00
Quentin McGaw
0fb75036a0 chore(build): bump Go from 1.24 to 1.25 2025-10-30 16:04:10 +00:00
dependabot[bot]
2b513dd43d Chore(deps): Bump github.com/vishvananda/netlink from 1.2.1 to 1.3.1 (#2932) 2025-10-30 17:02:32 +01:00
Quentin McGaw
687d9b4736 hotfix(tests): fix unit test for healthcheck 2025-10-30 16:01:25 +00:00
dependabot[bot]
c70c2ef932 Chore(deps): Bump golang.org/x/net from 0.34.0 to 0.46.0 (#2937) 2025-10-30 17:00:30 +01:00
dependabot[bot]
af3ada109b Chore(deps): Bump actions/setup-go from 5 to 6 (#2929) 2025-10-30 17:00:15 +01:00
Quentin McGaw
9d40564734 chore(deps): bump breml/rootcerts from v0.2.20 to v0.3.2 2025-10-30 15:59:20 +00:00
Quentin McGaw
3734815ada hotfix(health): debug log failed attempts and warn log all attempt errors if all failed
- Reduce "worrying" noise of icmp attempt failing
- Only log when an action (restart the VPN) is taken
2025-10-30 15:57:40 +00:00
Quentin McGaw
b9cc5c1fdc fix(port-forward): clear port file instead of removing it
- Prevent port forwarding loop crash when trying to delete a directly bind mounted file
- See https://github.com/qdm12/gluetun/issues/2942#issuecomment-3468510402
2025-10-30 15:45:01 +00:00
dependabot[bot]
c646ca5766 Chore(deps): Bump peter-evans/create-or-update-comment from 4 to 5 (#2931) 2025-10-30 03:45:31 +01:00
dependabot[bot]
1394be5143 Chore(deps): Bump golang.org/x/sys from 0.29.0 to 0.37.0 (#2939) 2025-10-30 03:45:16 +01:00
Quentin McGaw
93442526f8 chore(ci): run container and wait for it to connect (#2956)
- Added safety to prevent panics/errors when skipping CI checks (shame on me, sometimes)
- Opens new possibilities for end to end integration tests. PRs accepted!
2025-10-30 03:44:31 +01:00
dependabot[bot]
d85402050b Chore(deps): Bump github.com/ulikunitz/xz from 0.5.11 to 0.5.15 (#2955) 2025-10-30 01:57:18 +01:00
dependabot[bot]
b1c62cb525 Chore(deps): Bump golang.org/x/text from 0.21.0 to 0.30.0 (#2938) 2025-10-30 01:56:53 +01:00
dependabot[bot]
fae64a297a Chore(deps): Bump github/codeql-action from 3 to 4 (#2935) 2025-10-30 01:56:41 +01:00
Quentin McGaw
6e2682a9ce docs(readme): remove no longer valid LoC badge 2025-10-30 00:55:39 +00:00
Quentin McGaw
555049f09c feat(privado): update servers data 2025-10-29 12:30:48 +00:00
Quentin McGaw
712f7c3d35 chore(build): bump Go from 1.23 to 1.24 2025-10-29 02:34:22 +00:00
Quentin McGaw
7a51c211cd fix(publicip): respect PUBLICIP_ENABLED 2025-10-23 19:49:21 +00:00
Quentin McGaw
c48189c1c4 feat(health/icmp): log out return address on errors 2025-10-23 19:22:31 +00:00
Quentin McGaw
9803fa1cfd hotfix(health): info log on healthcheck passing after failure 2025-10-23 18:58:19 +00:00
Quentin McGaw
cf756f561a feat(health): info log when healthcheck passes after failure for the case of HEALTH_VPN_RESTART=off 2025-10-21 18:42:33 +00:00
Quentin McGaw
a4021fedc3 feat(health): HEALTH_RESTART_VPN option
- You should really leave it to `on` ⚠️
- Turn it to `off` if you have trust issues with the healthcheck. Don't then report issues if the connection is dead though.
2025-10-21 15:36:15 +00:00
Quentin McGaw
31a36a9250 hotfix(health): increase timeout values and periods
- run small check every 60s, from 15s
- small check (icmp/dns) initial timeout from 3s to 10s
- small check (icmp/dns) timeout increase from 1s to 10s
- full check initial timeout increased from 10s to 20s
- full check extra timeout increase from 3s to 10s
2025-10-19 23:27:02 +00:00
Quentin McGaw
36fe349b70 chore(ci): ignore .github/pull_request_template.md with markdown linter 2025-10-19 23:23:41 +00:00
shwoop
3ef1cfd97c docs(github): add pull request template (#2918) 2025-10-17 20:34:05 +02:00
Quentin McGaw
669feb45f1 hotfix(healthcheck): correct error string for DNS plain lookup fallback 2025-10-17 18:08:24 +00:00
Quentin McGaw
85890520ab feat(healthcheck): combination of ICMP and TCP+TLS checks (#2923)
- New option: `HEALTH_ICMP_TARGET_IP` defaults to `0.0.0.0` meaning use the VPN server public IP address.
- Options removed: `HEALTH_VPN_INITIAL_DURATION` and `HEALTH_VPN_ADDITIONAL_DURATION` - times and retries are handpicked and hardcoded.
- Less aggressive checks and less false positive detection
2025-10-17 01:45:50 +02:00
dependabot[bot]
340016521e Chore(deps): Bump github.com/breml/rootcerts from 0.2.19 to 0.2.20 (#2683) 2025-10-06 13:36:00 +02:00
Matthew Bennett
ef523df42c feat(expressvpn): update hardcoded servers data (#2888) 2025-10-06 13:33:36 +02:00
Quentin McGaw
5306e3bab1 feat(mullvad): update servers data 2025-10-03 15:25:12 +00:00
Vahin M
72a49afd2b docs(healthcheck): fix grammar issue in log (#2773) 2025-09-26 18:58:08 +02:00
Quentin McGaw
9b8edbb81e hotfix(vpnunlimited): fix formatting of certificates 2025-09-24 12:55:45 +00:00
Quentin McGaw
a1554feb3f chore(dev): add vscode git remote add task 2025-09-24 12:54:16 +00:00
Quentin McGaw
490410bf09 chore(dev): convert .vscode/launch.json to tasks.json 2025-09-24 12:54:16 +00:00
mutschler
8c113f5268 fix(vpnunlimited): update certificate values (#2835) 2025-09-11 21:15:20 +02:00
shwoop
075cbd5a0f chore(ci): bump github actions and use go.mod Go version (#2880)
- actions/checkout from v4 to v5
- actions/setup-go uses go-version from go.mod file
- DavidAnson/markdownlint-cli2-action from v19 to v20
2025-09-11 21:14:19 +02:00
Quentin McGaw
d82df2b431 hotfix(build): bump xcputranslate so it's available on ghcr.io
- v0.7.0 is a broken build
- v0.9.0 is the version available on ghcr.io
2025-08-16 20:34:07 +00:00
Quentin McGaw
a09f8214d9 hotfix(build): bump xcputranslate so it's available on ghcr.io 2025-08-16 20:29:40 +00:00
Quentin McGaw
396e9c003e chore(ci): pull container images at build time from ghcr.io when possible
- Reduce silly image pull rate limiting from docker hub registry
- still rely on docker hub registry to pull golang and alpine images since these are not on ghcr.io
2025-08-16 20:12:21 +00:00
Quentin McGaw
b0c4a28be6 chore(lint): upgrade linter to v2.4.0
- migrate configuration file
- fix existing code issues
- add exclusion rules
- update linter names
2025-08-16 20:10:19 +00:00
Quentin McGaw
85325e4a31 chore(dev): upgrade dev container to v0.21
See [release notes](https://github.com/qdm12/godevcontainer/releases/tag/v0.21.0)

Notably:
- Go upgraded from 1.23 to 1.25
- golangci-lint upgraded to v2.4.0
- Alpine upgraded from 3.20 to 3.22
- Disable package comment requirement by gopls' staticcheck
- Pull container image from ghcr.io
2025-08-16 20:10:14 +00:00
dependabot[bot]
9933dd3ec5 Chore(deps): Bump DavidAnson/markdownlint-cli2-action from 18 to 19 (#2632) 2025-01-22 09:27:10 +01:00
dependabot[bot]
13532c8b4b Chore(deps): Bump golang.org/x/net from 0.31.0 to 0.34.0 (#2648) 2025-01-22 09:26:57 +01:00
Leroy
3926797295 docs(readme): remove docker-compose example version field (#2663) 2025-01-22 09:26:39 +01:00
K1
febd3f784f docs(readme): "swiss-knife-like" -> "swiss-army-knife-like" (#2652) 2025-01-22 09:25:46 +01:00
dependabot[bot]
61b053f0e1 Chore(deps): Bump golang.org/x/crypto from 0.29.0 to 0.31.0 (#2619) 2024-12-27 21:15:31 +01:00
Quentin McGaw
8dae352ccc fix(cli): fix openvpnconfig command panic due to missing SetDefaults call 2024-12-27 09:31:04 +00:00
79 changed files with 15537 additions and 15189 deletions

View File

@@ -1,2 +1,2 @@
FROM qmcgaw/godevcontainer:v0.20-alpine FROM ghcr.io/qdm12/godevcontainer:v0.21-alpine
RUN apk add wireguard-tools htop openssl RUN apk add wireguard-tools htop openssl

View File

@@ -19,16 +19,16 @@ It works on Linux, Windows (WSL2) and OSX.
mkdir -p ~/.ssh mkdir -p ~/.ssh
``` ```
1. **For Docker on OSX**: ensure the project directory and your home directory `~` are accessible by Docker. 1. **For OSX hosts**: ensure the project directory and your home directory `~` are accessible by Docker.
1. Open the command palette in Visual Studio Code (CTRL+SHIFT+P). 1. Open the command palette in Visual Studio Code (CTRL+SHIFT+P).
1. Select `Dev Containers: Open Folder in Container...` and choose the project directory. 1. Select `Dev-Containers: Open Folder in Container...` and choose the project directory.
## Customization ## Customization
For any customization to take effect, you should "rebuild and reopen": For any customization to take effect, you should "rebuild and reopen":
1. Open the command palette in Visual Studio Code (CTRL+SHIFT+P) 1. Open the command palette in Visual Studio Code (CTRL+SHIFT+P)
2. Select `Dev Containers: Rebuild Container` 2. Select `Dev-Containers: Rebuild Container`
Changes you can make are notably: Changes you can make are notably:

View File

@@ -82,6 +82,9 @@
"gopls": { "gopls": {
"usePlaceholders": false, "usePlaceholders": false,
"staticcheck": true, "staticcheck": true,
"ui.diagnostic.analyses": {
"ST1000": false
},
"formatting.gofumpt": true, "formatting.gofumpt": true,
}, },
"go.lintTool": "golangci-lint", "go.lintTool": "golangci-lint",

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
# Description
<!-- Please describe the reason for the changes being proposed. -->
# Issue
<!-- Please link to the issue(s) this change relates to. -->
# Assertions
* [ ] I am aware that we do not accept manual changes to the servers.json file <!-- If this is your goal, please consult https://github.com/qdm12/gluetun-wiki/blob/main/setup/servers.md#update-using-the-command-line -->
* [ ] I am aware that any changes to settings should be reflected in the [wiki](https://github.com/qdm12/gluetun-wiki/)

View File

@@ -37,7 +37,7 @@ jobs:
env: env:
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: reviewdog/action-misspell@v1 - uses: reviewdog/action-misspell@v1
with: with:
@@ -66,6 +66,33 @@ jobs:
- name: Build final image - name: Build final image
run: docker build -t final-image . run: docker build -t final-image .
verify-private:
if: |
github.repository == 'qdm12/gluetun' &&
(
github.event_name == 'push' ||
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]
runs-on: ubuntu-latest
environment: secrets
steps:
- uses: actions/checkout@v5
- run: docker build -t qmcgaw/gluetun .
- name: Setup Go for CI utility
uses: actions/setup-go@v6
with:
go-version-file: ci/go.mod
- name: Build utility
run: go build -C ./ci -o runner ./cmd/main.go
- name: Run Gluetun container with Mullvad configuration
run: echo -e "${{ secrets.MULLVAD_WIREGUARD_PRIVATE_KEY }}\n${{ secrets.MULLVAD_WIREGUARD_ADDRESS }}" | ./ci/runner mullvad
codeql: codeql:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -73,15 +100,15 @@ jobs:
contents: read contents: read
security-events: write security-events: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version: "^1.23" go-version-file: go.mod
- uses: github/codeql-action/init@v3 - uses: github/codeql-action/init@v4
with: with:
languages: go languages: go
- uses: github/codeql-action/autobuild@v3 - uses: github/codeql-action/autobuild@v4
- uses: github/codeql-action/analyze@v3 - uses: github/codeql-action/analyze@v4
publish: publish:
if: | if: |
@@ -98,7 +125,7 @@ jobs:
packages: write packages: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
# extract metadata (tags, labels) for Docker # extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action # https://github.com/docker/metadata-action

View File

@@ -9,7 +9,7 @@ jobs:
issues: write issues: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: peter-evans/create-or-update-comment@v4 - uses: peter-evans/create-or-update-comment@v5
with: with:
token: ${{ github.token }} token: ${{ github.token }}
issue-number: ${{ github.event.issue.number }} issue-number: ${{ github.event.issue.number }}

View File

@@ -11,7 +11,7 @@ jobs:
issues: write issues: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: crazy-max/ghaction-github-labeler@v5 - uses: crazy-max/ghaction-github-labeler@v5
with: with:
yaml-file: .github/labels.yml yaml-file: .github/labels.yml

View File

@@ -18,12 +18,12 @@ jobs:
actions: read actions: read
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: DavidAnson/markdownlint-cli2-action@v18 - uses: DavidAnson/markdownlint-cli2-action@v20
with: with:
globs: "**.md" globs: "**.md"
config: .markdownlint.json config: .markdownlint-cli2.jsonc
- uses: reviewdog/action-misspell@v1 - uses: reviewdog/action-misspell@v1
with: with:
@@ -37,7 +37,7 @@ jobs:
use-quiet-mode: yes use-quiet-mode: yes
config-file: .github/workflows/configs/mlc-config.json config-file: .github/workflows/configs/mlc-config.json
- uses: peter-evans/dockerhub-description@v4 - uses: peter-evans/dockerhub-description@v5
if: github.repository == 'qdm12/gluetun' && github.event_name == 'push' if: github.repository == 'qdm12/gluetun' && github.event_name == 'push'
with: with:
username: qmcgaw username: qmcgaw

View File

@@ -9,7 +9,7 @@ jobs:
issues: write issues: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: peter-evans/create-or-update-comment@v4 - uses: peter-evans/create-or-update-comment@v5
with: with:
token: ${{ github.token }} token: ${{ github.token }}
issue-number: ${{ github.event.issue.number }} issue-number: ${{ github.event.issue.number }}

View File

@@ -1,30 +1,70 @@
linters-settings: version: "2"
misspell:
locale: US
issues: formatters:
exclude-rules: enable:
- path: _test\.go - gci
linters: - gofumpt
- dupl - goimports
- err113 exclusions:
- containedctx generated: lax
- maintidx paths:
- path: "internal\\/server\\/.+\\.go" - third_party$
linters: - builtin$
- dupl - examples$
- text: "returns interface \\(github\\.com\\/vishvananda\\/netlink\\.Link\\)"
linters:
- ireturn
- path: "internal\\/openvpn\\/pkcs8\\/descbc\\.go"
text: "newCipherDESCBCBlock returns interface \\(github\\.com\\/youmark\\/pkcs8\\.Cipher\\)"
linters:
- ireturn
- source: "^\\/\\/ https\\:\\/\\/.+$"
linters:
- lll
linters: linters:
settings:
misspell:
locale: US
goconst:
ignore-string-values:
# commonly used settings strings
- "^disabled$"
# Firewall and routing strings
- "^(ACCEPT|DROP)$"
- "^--delete$"
- "^all$"
- "^(tcp|udp)$"
# Server route strings
- "^/status$"
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- containedctx
- dupl
- err113
- maintidx
path: _test\.go
- linters:
- dupl
path: internal\/server\/.+\.go
- linters:
- ireturn
text: returns interface \(github\.com\/vishvananda\/netlink\.Link\)
- linters:
- ireturn
path: internal\/openvpn\/pkcs8\/descbc\.go
text: newCipherDESCBCBlock returns interface \(github\.com\/youmark\/pkcs8\.Cipher\)
- linters:
- revive
path: internal\/provider\/(common|utils)\/.+\.go
text: "var-naming: avoid (bad|meaningless) package names"
- linters:
- err113
- mnd
path: ci\/.+\.go
paths:
- third_party$
- builtin$
- examples$
enable: enable:
# - cyclop # - cyclop
# - errorlint # - errorlint
@@ -45,7 +85,6 @@ linters:
- exhaustive - exhaustive
- fatcontext - fatcontext
- forcetypeassert - forcetypeassert
- gci
- gocheckcompilerdirectives - gocheckcompilerdirectives
- gochecknoglobals - gochecknoglobals
- gochecknoinits - gochecknoinits
@@ -54,9 +93,7 @@ linters:
- gocritic - gocritic
- gocyclo - gocyclo
- godot - godot
- gofumpt
- goheader - goheader
- goimports
- gomoddirectives - gomoddirectives
- goprintffuncname - goprintffuncname
- gosec - gosec
@@ -89,7 +126,6 @@ linters:
- rowserrcheck - rowserrcheck
- sqlclosecheck - sqlclosecheck
- tagalign - tagalign
- tenv
- thelper - thelper
- tparallel - tparallel
- unconvert - unconvert

9
.markdownlint-cli2.jsonc Normal file
View File

@@ -0,0 +1,9 @@
{
"config": {
"default": true,
"MD013": false,
},
"ignores": [
".github/pull_request_template.md"
]
}

View File

@@ -1,3 +0,0 @@
{
"MD013": false
}

35
.vscode/launch.json vendored
View File

@@ -1,35 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Update a VPN provider servers data",
"type": "go",
"request": "launch",
"cwd": "${workspaceFolder}",
"program": "cmd/gluetun/main.go",
"args": [
"update",
"${input:updateMode}",
"-providers",
"${input:provider}"
],
}
],
"inputs": [
{
"id": "provider",
"type": "promptString",
"description": "Please enter a provider (or comma separated list of providers)",
},
{
"id": "updateMode",
"type": "pickString",
"description": "Update mode to use",
"options": [
"-maintainer",
"-enduser"
],
"default": "-maintainer"
},
]
}

51
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,51 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Update a VPN provider servers data",
"type": "shell",
"command": "go",
"args": [
"run",
"./cmd/gluetun/main.go",
"update",
"${input:updateMode}",
"-providers",
"${input:provider}"
],
},
{
"label": "Add a Gluetun Github Git remote",
"type": "shell",
"command": "git",
"args": [
"remote",
"add",
"${input:githubRemoteUsername}",
"git@github.com:${input:githubRemoteUsername}/gluetun.git"
],
}
],
"inputs": [
{
"id": "provider",
"type": "promptString",
"description": "Please enter a provider (or comma separated list of providers)",
},
{
"id": "updateMode",
"type": "pickString",
"description": "Update mode to use",
"options": [
"-maintainer",
"-enduser"
],
"default": "-maintainer"
},
{
"id": "githubRemoteUsername",
"type": "promptString",
"description": "Please enter a Github username",
},
]
}

View File

@@ -1,14 +1,14 @@
ARG ALPINE_VERSION=3.20 ARG ALPINE_VERSION=3.22
ARG GO_ALPINE_VERSION=3.20 ARG GO_ALPINE_VERSION=3.22
ARG GO_VERSION=1.23 ARG GO_VERSION=1.25
ARG XCPUTRANSLATE_VERSION=v0.6.0 ARG XCPUTRANSLATE_VERSION=v0.9.0
ARG GOLANGCI_LINT_VERSION=v1.61.0 ARG GOLANGCI_LINT_VERSION=v2.4.0
ARG MOCKGEN_VERSION=v1.6.0 ARG MOCKGEN_VERSION=v1.6.0
ARG BUILDPLATFORM=linux/amd64 ARG BUILDPLATFORM=linux/amd64
FROM --platform=${BUILDPLATFORM} qmcgaw/xcputranslate:${XCPUTRANSLATE_VERSION} AS xcputranslate FROM --platform=${BUILDPLATFORM} ghcr.io/qdm12/xcputranslate:${XCPUTRANSLATE_VERSION} AS xcputranslate
FROM --platform=${BUILDPLATFORM} qmcgaw/binpot:golangci-lint-${GOLANGCI_LINT_VERSION} AS golangci-lint FROM --platform=${BUILDPLATFORM} ghcr.io/qdm12/binpot:golangci-lint-${GOLANGCI_LINT_VERSION} AS golangci-lint
FROM --platform=${BUILDPLATFORM} qmcgaw/binpot:mockgen-${MOCKGEN_VERSION} AS mockgen FROM --platform=${BUILDPLATFORM} ghcr.io/qdm12/binpot:mockgen-${MOCKGEN_VERSION} AS mockgen
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine${GO_ALPINE_VERSION} AS base FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine${GO_ALPINE_VERSION} AS base
COPY --from=xcputranslate /xcputranslate /usr/local/bin/xcputranslate COPY --from=xcputranslate /xcputranslate /usr/local/bin/xcputranslate
@@ -32,7 +32,7 @@ ENTRYPOINT go test -race -coverpkg=./... -coverprofile=coverage.txt -covermode=a
FROM --platform=${BUILDPLATFORM} base AS lint FROM --platform=${BUILDPLATFORM} base AS lint
COPY .golangci.yml ./ COPY .golangci.yml ./
RUN golangci-lint run --timeout=10m RUN golangci-lint run
FROM --platform=${BUILDPLATFORM} base AS mocks FROM --platform=${BUILDPLATFORM} base AS mocks
RUN git init && \ RUN git init && \
@@ -164,9 +164,8 @@ ENV VPN_SERVICE_PROVIDER=pia \
# Health # Health
HEALTH_SERVER_ADDRESS=127.0.0.1:9999 \ HEALTH_SERVER_ADDRESS=127.0.0.1:9999 \
HEALTH_TARGET_ADDRESS=cloudflare.com:443 \ HEALTH_TARGET_ADDRESS=cloudflare.com:443 \
HEALTH_SUCCESS_WAIT_DURATION=5s \ HEALTH_ICMP_TARGET_IP=0.0.0.0 \
HEALTH_VPN_DURATION_INITIAL=6s \ HEALTH_RESTART_VPN=on \
HEALTH_VPN_DURATION_ADDITION=5s \
# DNS over TLS # DNS over TLS
DOT=on \ DOT=on \
DOT_PROVIDERS=cloudflare \ DOT_PROVIDERS=cloudflare \
@@ -204,8 +203,6 @@ ENV VPN_SERVICE_PROVIDER=pia \
UPDATER_PERIOD=0 \ UPDATER_PERIOD=0 \
UPDATER_MIN_RATIO=0.8 \ UPDATER_MIN_RATIO=0.8 \
UPDATER_VPN_SERVICE_PROVIDERS= \ UPDATER_VPN_SERVICE_PROVIDERS= \
UPDATER_PROTONVPN_USERNAME= \
UPDATER_PROTONVPN_PASSWORD= \
# Public IP # Public IP
PUBLICIP_FILE="/tmp/gluetun/ip" \ PUBLICIP_FILE="/tmp/gluetun/ip" \
PUBLICIP_ENABLED=on \ PUBLICIP_ENABLED=on \

View File

@@ -1,6 +1,6 @@
# Gluetun VPN client # Gluetun VPN client
Lightweight swiss-knife-like VPN client to multiple VPN service providers Lightweight swiss-army-knife-like VPN client to multiple VPN service providers
![Title image](https://raw.githubusercontent.com/qdm12/gluetun/master/title.svg) ![Title image](https://raw.githubusercontent.com/qdm12/gluetun/master/title.svg)
@@ -26,7 +26,6 @@ Lightweight swiss-knife-like VPN client to multiple VPN service providers
[![GitHub issues](https://img.shields.io/github/issues/qdm12/gluetun.svg)](https://github.com/qdm12/gluetun/issues) [![GitHub issues](https://img.shields.io/github/issues/qdm12/gluetun.svg)](https://github.com/qdm12/gluetun/issues)
[![GitHub closed issues](https://img.shields.io/github/issues-closed/qdm12/gluetun.svg)](https://github.com/qdm12/gluetun/issues?q=is%3Aissue+is%3Aclosed) [![GitHub closed issues](https://img.shields.io/github/issues-closed/qdm12/gluetun.svg)](https://github.com/qdm12/gluetun/issues?q=is%3Aissue+is%3Aclosed)
[![Lines of code](https://img.shields.io/tokei/lines/github/qdm12/gluetun)](https://github.com/qdm12/gluetun)
![Code size](https://img.shields.io/github/languages/code-size/qdm12/gluetun) ![Code size](https://img.shields.io/github/languages/code-size/qdm12/gluetun)
![GitHub repo size](https://img.shields.io/github/repo-size/qdm12/gluetun) ![GitHub repo size](https://img.shields.io/github/repo-size/qdm12/gluetun)
![Go version](https://img.shields.io/github/go-mod/go-version/qdm12/gluetun) ![Go version](https://img.shields.io/github/go-mod/go-version/qdm12/gluetun)
@@ -56,7 +55,7 @@ Lightweight swiss-knife-like VPN client to multiple VPN service providers
## Features ## Features
- Based on Alpine 3.20 for a small Docker image of 35.6MB - Based on Alpine 3.22 for a small Docker image of 41.1MB
- Supports: **AirVPN**, **Cyberghost**, **ExpressVPN**, **FastestVPN**, **Giganews**, **HideMyAss**, **IPVanish**, **IVPN**, **Mullvad**, **NordVPN**, **Perfect Privacy**, **Privado**, **Private Internet Access**, **PrivateVPN**, **ProtonVPN**, **PureVPN**, **SlickVPN**, **Surfshark**, **TorGuard**, **VPNSecure.me**, **VPNUnlimited**, **Vyprvpn**, **WeVPN**, **Windscribe** servers - Supports: **AirVPN**, **Cyberghost**, **ExpressVPN**, **FastestVPN**, **Giganews**, **HideMyAss**, **IPVanish**, **IVPN**, **Mullvad**, **NordVPN**, **Perfect Privacy**, **Privado**, **Private Internet Access**, **PrivateVPN**, **ProtonVPN**, **PureVPN**, **SlickVPN**, **Surfshark**, **TorGuard**, **VPNSecure.me**, **VPNUnlimited**, **Vyprvpn**, **WeVPN**, **Windscribe** servers
- Supports OpenVPN for all providers listed - Supports OpenVPN for all providers listed
- Supports Wireguard both kernelspace and userspace - Supports Wireguard both kernelspace and userspace
@@ -88,7 +87,7 @@ Go to the [Wiki](https://github.com/qdm12/gluetun-wiki)!
Here's a docker-compose.yml for the laziest: Here's a docker-compose.yml for the laziest:
```yml ```yml
version: "3" ---
services: services:
gluetun: gluetun:
image: qmcgaw/gluetun image: qmcgaw/gluetun

33
ci/cmd/main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"github.com/qdm12/gluetun/ci/internal"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: " + os.Args[0] + " <command>")
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
var err error
switch os.Args[1] {
case "mullvad":
err = internal.MullvadTest(ctx)
default:
err = fmt.Errorf("unknown command: %s", os.Args[1])
}
stop()
if err != nil {
fmt.Println("❌", err)
os.Exit(1)
}
fmt.Println("✅ Test completed successfully.")
}

36
ci/go.mod Normal file
View File

@@ -0,0 +1,36 @@
module github.com/qdm12/gluetun/ci
go 1.25.0
require (
github.com/docker/docker v28.5.1+incompatible
github.com/opencontainers/image-spec v1.1.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

97
ci/go.sum Normal file
View File

@@ -0,0 +1,97 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

193
ci/internal/mullvad.go Normal file
View File

@@ -0,0 +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 {
secrets, err := readSecrets(ctx)
if err != nil {
return fmt.Errorf("reading secrets: %w", err)
}
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)
}
}

View File

@@ -414,19 +414,25 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
return fmt.Errorf("starting public ip loop: %w", err) return fmt.Errorf("starting public ip loop: %w", err)
} }
healthLogger := logger.New(log.SetComponent("healthcheck"))
healthcheckServer := healthcheck.NewServer(allSettings.Health, healthLogger)
healthServerHandler, healthServerCtx, healthServerDone := goshutdown.NewGoRoutineHandler(
"HTTP health server", goroutine.OptionTimeout(defaultShutdownTimeout))
go healthcheckServer.Run(healthServerCtx, healthServerDone)
healthChecker := healthcheck.NewChecker(healthLogger)
updaterLogger := logger.New(log.SetComponent("updater")) updaterLogger := logger.New(log.SetComponent("updater"))
unzipper := unzip.New(httpClient) unzipper := unzip.New(httpClient)
parallelResolver := resolver.NewParallelResolver(allSettings.Updater.DNSAddress) parallelResolver := resolver.NewParallelResolver(allSettings.Updater.DNSAddress)
openvpnFileExtractor := extract.New() openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, updaterLogger, providers := provider.NewProviders(storage, time.Now, updaterLogger,
httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(), httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(), openvpnFileExtractor)
openvpnFileExtractor, allSettings.Updater)
vpnLogger := logger.New(log.SetComponent("vpn")) vpnLogger := logger.New(log.SetComponent("vpn"))
vpnLooper := vpn.NewLoop(allSettings.VPN, ipv6Supported, allSettings.Firewall.VPNInputPorts, vpnLooper := vpn.NewLoop(allSettings.VPN, ipv6Supported, allSettings.Firewall.VPNInputPorts,
providers, storage, ovpnConf, netLinker, firewallConf, routingConf, portForwardLooper, providers, storage, allSettings.Health, healthChecker, healthcheckServer, ovpnConf, netLinker, firewallConf,
cmder, publicIPLooper, dnsLooper, vpnLogger, httpClient, routingConf, portForwardLooper, cmder, publicIPLooper, dnsLooper, vpnLogger, httpClient,
buildInfo, *allSettings.Version.Enabled) buildInfo, *allSettings.Version.Enabled)
vpnHandler, vpnCtx, vpnDone := goshutdown.NewGoRoutineHandler( vpnHandler, vpnCtx, vpnDone := goshutdown.NewGoRoutineHandler(
"vpn", goroutine.OptionTimeout(time.Second)) "vpn", goroutine.OptionTimeout(time.Second))
@@ -477,12 +483,6 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
<-httpServerReady <-httpServerReady
controlGroupHandler.Add(httpServerHandler) controlGroupHandler.Add(httpServerHandler)
healthLogger := logger.New(log.SetComponent("healthcheck"))
healthcheckServer := healthcheck.NewServer(allSettings.Health, healthLogger, vpnLooper)
healthServerHandler, healthServerCtx, healthServerDone := goshutdown.NewGoRoutineHandler(
"HTTP health server", goroutine.OptionTimeout(defaultShutdownTimeout))
go healthcheckServer.Run(healthServerCtx, healthServerDone)
orderHandler := goshutdown.NewOrderHandler("gluetun", orderHandler := goshutdown.NewOrderHandler("gluetun",
order.OptionTimeout(totalShutdownTimeout), order.OptionTimeout(totalShutdownTimeout),
order.OptionOnSuccess(defaultShutdownOnSuccess), order.OptionOnSuccess(defaultShutdownOnSuccess),

30
go.mod
View File

@@ -1,10 +1,9 @@
module github.com/qdm12/gluetun module github.com/qdm12/gluetun
go 1.23 go 1.25.0
require ( require (
github.com/ProtonMail/go-srp v0.0.7 github.com/breml/rootcerts v0.3.2
github.com/breml/rootcerts v0.2.19
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/golang/mock v1.6.0 github.com/golang/mock v1.6.0
github.com/klauspost/compress v1.17.11 github.com/klauspost/compress v1.17.11
@@ -18,25 +17,21 @@ require (
github.com/qdm12/log v0.1.0 github.com/qdm12/log v0.1.0
github.com/qdm12/ss-server v0.6.0 github.com/qdm12/ss-server v0.6.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/ulikunitz/xz v0.5.11 github.com/ulikunitz/xz v0.5.15
github.com/vishvananda/netlink v1.2.1 github.com/vishvananda/netlink v1.3.1
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/net v0.31.0 golang.org/x/net v0.46.0
golang.org/x/sys v0.30.0 golang.org/x/sys v0.37.0
golang.org/x/text v0.22.0 golang.org/x/text v0.30.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
gopkg.in/ini.v1 v1.67.0 gopkg.in/ini.v1 v1.67.0
) )
require ( require (
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/go-crypto v1.3.0-proton // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect
github.com/josharian/native v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect
@@ -47,7 +42,6 @@ require (
github.com/mdlayher/socket v0.4.1 // indirect github.com/mdlayher/socket v0.4.1 // indirect
github.com/miekg/dns v1.1.62 // indirect github.com/miekg/dns v1.1.62 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
@@ -55,11 +49,11 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/qdm12/goservices v0.1.0 // indirect github.com/qdm12/goservices v0.1.0 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/vishvananda/netns v0.0.4 // indirect github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/crypto v0.33.0 // indirect golang.org/x/crypto v0.43.0 // indirect
golang.org/x/mod v0.21.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/tools v0.26.0 // indirect golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.35.1 // indirect google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

84
go.sum
View File

@@ -1,23 +1,9 @@
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.3.0-proton h1:tAQKQRZX/73VmzK6yHSCaRUOvS/3OYSQzhXQsrR7yUM=
github.com/ProtonMail/go-crypto v1.3.0-proton/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/breml/rootcerts v0.2.19 h1:3D/qwAC1xoh82GmZ21mYzQ1NaLOICUVntIo+MRZYr4U= github.com/breml/rootcerts v0.3.2 h1:gm11iClhK8wFn/GDdINoDaqPkiaGXyVvSwoXINZN+z4=
github.com/breml/rootcerts v0.2.19/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= github.com/breml/rootcerts v0.3.2/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -57,8 +43,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
@@ -91,81 +75,57 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vishvananda/netlink v1.2.1 h1:pfLv/qlJUwOTPvtWREA7c3PI4u81YkqZw1DYhI2HmLA= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.2.1/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk= github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk=
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4= github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= 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/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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -76,7 +76,7 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
openvpnFileExtractor := extract.New() openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, warner, client, providers := provider.NewProviders(storage, time.Now, warner, client,
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, allSettings.Updater) unzipper, parallelResolver, ipFetcher, openvpnFileExtractor)
providerConf := providers.Get(allSettings.VPN.Provider.Name) providerConf := providers.Get(allSettings.VPN.Provider.Name)
connection, err := providerConf.GetConnection( connection, err := providerConf.GetConnection(
allSettings.VPN.Provider.ServerSelection, ipv6Supported) allSettings.VPN.Provider.ServerSelection, ipv6Supported)

View File

@@ -6,7 +6,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strings" "strings"
"time" "time"
@@ -25,8 +24,6 @@ import (
var ( var (
ErrModeUnspecified = errors.New("at least one of -enduser or -maintainer must be specified") ErrModeUnspecified = errors.New("at least one of -enduser or -maintainer must be specified")
ErrNoProviderSpecified = errors.New("no provider was specified") ErrNoProviderSpecified = errors.New("no provider was specified")
ErrUsernameMissing = errors.New("username is required for this provider")
ErrPasswordMissing = errors.New("password is required for this provider")
) )
type UpdaterLogger interface { type UpdaterLogger interface {
@@ -38,7 +35,7 @@ type UpdaterLogger interface {
func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) error { func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) error {
options := settings.Updater{} options := settings.Updater{}
var endUserMode, maintainerMode, updateAll bool var endUserMode, maintainerMode, updateAll bool
var csvProviders, ipToken, protonUsername, protonPassword string var csvProviders, ipToken string
flagSet := flag.NewFlagSet("update", flag.ExitOnError) flagSet := flag.NewFlagSet("update", flag.ExitOnError)
flagSet.BoolVar(&endUserMode, "enduser", false, "Write results to /gluetun/servers.json (for end users)") flagSet.BoolVar(&endUserMode, "enduser", false, "Write results to /gluetun/servers.json (for end users)")
flagSet.BoolVar(&maintainerMode, "maintainer", false, flagSet.BoolVar(&maintainerMode, "maintainer", false,
@@ -50,8 +47,6 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
flagSet.BoolVar(&updateAll, "all", false, "Update servers for all VPN providers") flagSet.BoolVar(&updateAll, "all", false, "Update servers for all VPN providers")
flagSet.StringVar(&csvProviders, "providers", "", "CSV string of VPN providers to update server data for") flagSet.StringVar(&csvProviders, "providers", "", "CSV string of VPN providers to update server data for")
flagSet.StringVar(&ipToken, "ip-token", "", "IP data service token (e.g. ipinfo.io) to use") flagSet.StringVar(&ipToken, "ip-token", "", "IP data service token (e.g. ipinfo.io) to use")
flagSet.StringVar(&protonUsername, "proton-username", "", "Username to use to authenticate with Proton")
flagSet.StringVar(&protonPassword, "proton-password", "", "Password to use to authenticate with Proton")
if err := flagSet.Parse(args); err != nil { if err := flagSet.Parse(args); err != nil {
return err return err
} }
@@ -69,11 +64,6 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
options.Providers = strings.Split(csvProviders, ",") options.Providers = strings.Split(csvProviders, ",")
} }
if slices.Contains(options.Providers, providers.Protonvpn) {
options.ProtonUsername = &protonUsername
options.ProtonPassword = &protonPassword
}
options.SetDefaults(options.Providers[0]) options.SetDefaults(options.Providers[0])
err := options.Validate() err := options.Validate()
@@ -104,7 +94,7 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
openvpnFileExtractor := extract.New() openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, logger, httpClient, providers := provider.NewProviders(storage, time.Now, logger, httpClient,
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, options) unzipper, parallelResolver, ipFetcher, openvpnFileExtractor)
updater := updater.New(httpClient, storage, providers, logger) updater := updater.New(httpClient, storage, providers, logger)
err = updater.UpdateServers(ctx, options.Providers, options.MinRatio) err = updater.UpdateServers(ctx, options.Providers, options.MinRatio)

View File

@@ -9,9 +9,11 @@ import (
func readObsolete(r *reader.Reader) (warnings []string) { func readObsolete(r *reader.Reader) (warnings []string) {
keyToMessage := map[string]string{ keyToMessage := map[string]string{
"DOT_VERBOSITY": "DOT_VERBOSITY is obsolete, use LOG_LEVEL instead.", "DOT_VERBOSITY": "DOT_VERBOSITY is obsolete, use LOG_LEVEL instead.",
"DOT_VERBOSITY_DETAILS": "DOT_VERBOSITY_DETAILS is obsolete because it was specific to Unbound.", "DOT_VERBOSITY_DETAILS": "DOT_VERBOSITY_DETAILS is obsolete because it was specific to Unbound.",
"DOT_VALIDATION_LOGLEVEL": "DOT_VALIDATION_LOGLEVEL is obsolete because DNSSEC validation is not implemented.", "DOT_VALIDATION_LOGLEVEL": "DOT_VALIDATION_LOGLEVEL is obsolete because DNSSEC validation is not implemented.",
"HEALTH_VPN_DURATION_INITIAL": "HEALTH_VPN_DURATION_INITIAL is obsolete",
"HEALTH_VPN_DURATION_ADDITION": "HEALTH_VPN_DURATION_ADDITION is obsolete",
} }
sortedKeys := maps.Keys(keyToMessage) sortedKeys := maps.Keys(keyToMessage)
slices.Sort(sortedKeys) slices.Sort(sortedKeys)

View File

@@ -119,7 +119,7 @@ func (d DoT) toLinesNode() (node *gotree.Node) {
return node return node
} }
update := "disabled" //nolint:goconst update := "disabled"
if *d.UpdatePeriod > 0 { if *d.UpdatePeriod > 0 {
update = "every " + d.UpdatePeriod.String() update = "every " + d.UpdatePeriod.String()
} }

View File

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

View File

@@ -2,6 +2,7 @@ package settings
import ( import (
"fmt" "fmt"
"net/netip"
"os" "os"
"time" "time"
@@ -24,16 +25,16 @@ type Health struct {
// HTTP server. It defaults to 500 milliseconds. // HTTP server. It defaults to 500 milliseconds.
ReadTimeout time.Duration ReadTimeout time.Duration
// TargetAddress is the address (host or host:port) // TargetAddress is the address (host or host:port)
// to TCP dial to periodically for the health check. // to TCP TLS dial to periodically for the health check.
// It cannot be the empty string in the internal state. // It cannot be the empty string in the internal state.
TargetAddress string TargetAddress string
// SuccessWait is the duration to wait to re-run the // ICMPTargetIP is the IP address to use for ICMP echo requests
// healthcheck after a successful healthcheck. // in the health checker. It can be set to an unspecified address
// It defaults to 5 seconds and cannot be zero in // such that the VPN server IP is used, which is also the default behavior.
// the internal state. ICMPTargetIP netip.Addr
SuccessWait time.Duration // RestartVPN indicates whether to restart the VPN connection
// VPN has health settings specific to the VPN loop. // when the healthcheck fails.
VPN HealthyWait RestartVPN *bool
} }
func (h Health) Validate() (err error) { func (h Health) Validate() (err error) {
@@ -42,11 +43,6 @@ func (h Health) Validate() (err error) {
return fmt.Errorf("server listening address is not valid: %w", err) return fmt.Errorf("server listening address is not valid: %w", err)
} }
err = h.VPN.validate()
if err != nil {
return fmt.Errorf("health VPN settings: %w", err)
}
return nil return nil
} }
@@ -56,8 +52,8 @@ func (h *Health) copy() (copied Health) {
ReadHeaderTimeout: h.ReadHeaderTimeout, ReadHeaderTimeout: h.ReadHeaderTimeout,
ReadTimeout: h.ReadTimeout, ReadTimeout: h.ReadTimeout,
TargetAddress: h.TargetAddress, TargetAddress: h.TargetAddress,
SuccessWait: h.SuccessWait, ICMPTargetIP: h.ICMPTargetIP,
VPN: h.VPN.copy(), RestartVPN: gosettings.CopyPointer(h.RestartVPN),
} }
} }
@@ -69,8 +65,8 @@ func (h *Health) OverrideWith(other Health) {
h.ReadHeaderTimeout = gosettings.OverrideWithComparable(h.ReadHeaderTimeout, other.ReadHeaderTimeout) h.ReadHeaderTimeout = gosettings.OverrideWithComparable(h.ReadHeaderTimeout, other.ReadHeaderTimeout)
h.ReadTimeout = gosettings.OverrideWithComparable(h.ReadTimeout, other.ReadTimeout) h.ReadTimeout = gosettings.OverrideWithComparable(h.ReadTimeout, other.ReadTimeout)
h.TargetAddress = gosettings.OverrideWithComparable(h.TargetAddress, other.TargetAddress) h.TargetAddress = gosettings.OverrideWithComparable(h.TargetAddress, other.TargetAddress)
h.SuccessWait = gosettings.OverrideWithComparable(h.SuccessWait, other.SuccessWait) h.ICMPTargetIP = gosettings.OverrideWithComparable(h.ICMPTargetIP, other.ICMPTargetIP)
h.VPN.overrideWith(other.VPN) h.RestartVPN = gosettings.OverrideWithPointer(h.RestartVPN, other.RestartVPN)
} }
func (h *Health) SetDefaults() { func (h *Health) SetDefaults() {
@@ -80,9 +76,8 @@ func (h *Health) SetDefaults() {
const defaultReadTimeout = 500 * time.Millisecond const defaultReadTimeout = 500 * time.Millisecond
h.ReadTimeout = gosettings.DefaultComparable(h.ReadTimeout, defaultReadTimeout) h.ReadTimeout = gosettings.DefaultComparable(h.ReadTimeout, defaultReadTimeout)
h.TargetAddress = gosettings.DefaultComparable(h.TargetAddress, "cloudflare.com:443") h.TargetAddress = gosettings.DefaultComparable(h.TargetAddress, "cloudflare.com:443")
const defaultSuccessWait = 5 * time.Second h.ICMPTargetIP = gosettings.DefaultComparable(h.ICMPTargetIP, netip.IPv4Unspecified()) // use the VPN server IP
h.SuccessWait = gosettings.DefaultComparable(h.SuccessWait, defaultSuccessWait) h.RestartVPN = gosettings.DefaultPointer(h.RestartVPN, true)
h.VPN.setDefaults()
} }
func (h Health) String() string { func (h Health) String() string {
@@ -93,10 +88,12 @@ func (h Health) toLinesNode() (node *gotree.Node) {
node = gotree.New("Health settings:") node = gotree.New("Health settings:")
node.Appendf("Server listening address: %s", h.ServerAddress) node.Appendf("Server listening address: %s", h.ServerAddress)
node.Appendf("Target address: %s", h.TargetAddress) node.Appendf("Target address: %s", h.TargetAddress)
node.Appendf("Duration to wait after success: %s", h.SuccessWait) icmpTarget := "VPN server IP"
node.Appendf("Read header timeout: %s", h.ReadHeaderTimeout) if !h.ICMPTargetIP.IsUnspecified() {
node.Appendf("Read timeout: %s", h.ReadTimeout) icmpTarget = h.ICMPTargetIP.String()
node.AppendNode(h.VPN.toLinesNode("VPN")) }
node.Appendf("ICMP target IP: %s", icmpTarget)
node.Appendf("Restart VPN on healthcheck failure: %s", gosettings.BoolToYesNo(h.RestartVPN))
return node return node
} }
@@ -104,16 +101,13 @@ func (h *Health) Read(r *reader.Reader) (err error) {
h.ServerAddress = r.String("HEALTH_SERVER_ADDRESS") h.ServerAddress = r.String("HEALTH_SERVER_ADDRESS")
h.TargetAddress = r.String("HEALTH_TARGET_ADDRESS", h.TargetAddress = r.String("HEALTH_TARGET_ADDRESS",
reader.RetroKeys("HEALTH_ADDRESS_TO_PING")) reader.RetroKeys("HEALTH_ADDRESS_TO_PING"))
h.ICMPTargetIP, err = r.NetipAddr("HEALTH_ICMP_TARGET_IP")
h.SuccessWait, err = r.Duration("HEALTH_SUCCESS_WAIT_DURATION")
if err != nil { if err != nil {
return err return err
} }
h.RestartVPN, err = r.BoolPtr("HEALTH_RESTART_VPN")
err = h.VPN.read(r)
if err != nil { if err != nil {
return fmt.Errorf("VPN health settings: %w", err) return err
} }
return nil return nil
} }

View File

@@ -1,76 +0,0 @@
package settings
import (
"time"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gotree"
)
type HealthyWait struct {
// Initial is the initial duration to wait for the program
// to be healthy before taking action.
// It cannot be nil in the internal state.
Initial *time.Duration
// Addition is the duration to add to the Initial duration
// after Initial has expired to wait longer for the program
// to be healthy.
// It cannot be nil in the internal state.
Addition *time.Duration
}
func (h HealthyWait) validate() (err error) {
return nil
}
func (h *HealthyWait) copy() (copied HealthyWait) {
return HealthyWait{
Initial: gosettings.CopyPointer(h.Initial),
Addition: gosettings.CopyPointer(h.Addition),
}
}
// overrideWith overrides fields of the receiver
// settings object with any field set in the other
// settings.
func (h *HealthyWait) overrideWith(other HealthyWait) {
h.Initial = gosettings.OverrideWithPointer(h.Initial, other.Initial)
h.Addition = gosettings.OverrideWithPointer(h.Addition, other.Addition)
}
func (h *HealthyWait) setDefaults() {
const initialDurationDefault = 6 * time.Second
const additionDurationDefault = 5 * time.Second
h.Initial = gosettings.DefaultPointer(h.Initial, initialDurationDefault)
h.Addition = gosettings.DefaultPointer(h.Addition, additionDurationDefault)
}
func (h HealthyWait) String() string {
return h.toLinesNode("Health").String()
}
func (h HealthyWait) toLinesNode(kind string) (node *gotree.Node) {
node = gotree.New(kind + " wait durations:")
node.Appendf("Initial duration: %s", *h.Initial)
node.Appendf("Additional duration: %s", *h.Addition)
return node
}
func (h *HealthyWait) read(r *reader.Reader) (err error) {
h.Initial, err = r.DurationPtr(
"HEALTH_VPN_DURATION_INITIAL",
reader.RetroKeys("HEALTH_OPENVPN_DURATION_INITIAL"))
if err != nil {
return err
}
h.Addition, err = r.DurationPtr(
"HEALTH_VPN_DURATION_ADDITION",
reader.RetroKeys("HEALTH_OPENVPN_DURATION_ADDITION"))
if err != nil {
return err
}
return nil
}

View File

@@ -17,7 +17,7 @@ import (
"github.com/qdm12/gotree" "github.com/qdm12/gotree"
) )
type ServerSelection struct { //nolint:maligned type ServerSelection struct {
// VPN is the VPN type which can be 'openvpn' // VPN is the VPN type which can be 'openvpn'
// or 'wireguard'. It cannot be the empty string // or 'wireguard'. It cannot be the empty string
// in the internal state. // in the internal state.
@@ -354,11 +354,8 @@ func (ss *ServerSelection) setDefaults(vpnProvider string, portForwardingEnabled
ss.SecureCoreOnly = gosettings.DefaultPointer(ss.SecureCoreOnly, false) ss.SecureCoreOnly = gosettings.DefaultPointer(ss.SecureCoreOnly, false)
ss.TorOnly = gosettings.DefaultPointer(ss.TorOnly, false) ss.TorOnly = gosettings.DefaultPointer(ss.TorOnly, false)
ss.MultiHopOnly = gosettings.DefaultPointer(ss.MultiHopOnly, false) ss.MultiHopOnly = gosettings.DefaultPointer(ss.MultiHopOnly, false)
defaultPortForwardOnly := false defaultPortForwardOnly := portForwardingEnabled &&
if portForwardingEnabled && helpers.IsOneOf(vpnProvider, helpers.IsOneOf(vpnProvider, providers.PrivateInternetAccess, providers.Protonvpn)
providers.PrivateInternetAccess, providers.Protonvpn) {
defaultPortForwardOnly = true
}
ss.PortForwardOnly = gosettings.DefaultPointer(ss.PortForwardOnly, defaultPortForwardOnly) ss.PortForwardOnly = gosettings.DefaultPointer(ss.PortForwardOnly, defaultPortForwardOnly)
ss.OpenVPN.setDefaults(vpnProvider) ss.OpenVPN.setDefaults(vpnProvider)
ss.Wireguard.setDefaults() ss.Wireguard.setDefaults()

View File

@@ -58,12 +58,8 @@ func Test_Settings_String(t *testing.T) {
├── Health settings: ├── Health settings:
| ├── Server listening address: 127.0.0.1:9999 | ├── Server listening address: 127.0.0.1:9999
| ├── Target address: cloudflare.com:443 | ├── Target address: cloudflare.com:443
| ├── Duration to wait after success: 5s | ├── ICMP target IP: VPN server IP
| ── Read header timeout: 100ms | ── Restart VPN on healthcheck failure: yes
| ├── Read timeout: 500ms
| └── VPN wait durations:
| ├── Initial duration: 6s
| └── Additional duration: 5s
├── Shadowsocks server settings: ├── Shadowsocks server settings:
| └── Enabled: no | └── Enabled: no
├── HTTP proxy settings: ├── HTTP proxy settings:

View File

@@ -15,7 +15,7 @@ type Shadowsocks struct {
// It defaults to false, and cannot be nil in the internal state. // It defaults to false, and cannot be nil in the internal state.
Enabled *bool Enabled *bool
// Settings are settings for the TCP+UDP server. // Settings are settings for the TCP+UDP server.
tcpudp.Settings Settings tcpudp.Settings
} }
func (s Shadowsocks) validate() (err error) { func (s Shadowsocks) validate() (err error) {

View File

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

View File

@@ -16,7 +16,7 @@ func isDeleteMatchInstruction(instruction string) bool {
fields := strings.Fields(instruction) fields := strings.Fields(instruction)
for i, field := range fields { for i, field := range fields {
switch { switch {
case field != "-D" && field != "--delete": //nolint:goconst case field != "-D" && field != "--delete":
continue continue
case i == len(fields)-1: // malformed: missing chain name case i == len(fields)-1: // malformed: missing chain name
return false return false

View File

@@ -9,7 +9,7 @@ import (
"github.com/qdm12/gluetun/internal/routing" "github.com/qdm12/gluetun/internal/routing"
) )
type Config struct { //nolint:maligned type Config struct {
runner CmdRunner runner CmdRunner
logger Logger logger Logger
iptablesMutex sync.Mutex iptablesMutex sync.Mutex

View File

@@ -58,7 +58,7 @@ var ErrPolicyNotValid = errors.New("policy is not valid")
func (c *Config) setIPv6AllPolicies(ctx context.Context, policy string) error { func (c *Config) setIPv6AllPolicies(ctx context.Context, policy string) error {
switch policy { switch policy {
case "ACCEPT", "DROP": //nolint:goconst case "ACCEPT", "DROP":
default: default:
return fmt.Errorf("%w: %s", ErrPolicyNotValid, policy) return fmt.Errorf("%w: %s", ErrPolicyNotValid, policy)
} }

View File

@@ -149,7 +149,7 @@ func (c *Config) acceptOutputTrafficToVPN(ctx context.Context,
) error { ) error {
protocol := connection.Protocol protocol := connection.Protocol
if protocol == "tcp-client" { if protocol == "tcp-client" {
protocol = "tcp" //nolint:goconst protocol = "tcp"
} }
instruction := fmt.Sprintf("%s OUTPUT -d %s -o %s -p %s -m %s --dport %d -j ACCEPT", instruction := fmt.Sprintf("%s OUTPUT -d %s -o %s -p %s -m %s --dport %d -j ACCEPT",
appendOrDelete(remove), connection.IP, defaultInterface, protocol, appendOrDelete(remove), connection.IP, defaultInterface, protocol,

View File

@@ -0,0 +1,250 @@
package healthcheck
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/netip"
"strings"
"sync"
"time"
"github.com/qdm12/gluetun/internal/healthcheck/dns"
"github.com/qdm12/gluetun/internal/healthcheck/icmp"
)
type Checker struct {
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
done <-chan struct{}
}
func NewChecker(logger Logger) *Checker {
return &Checker{
dialer: &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
},
},
echoer: icmp.NewEchoer(logger),
dnsClient: dns.New(),
logger: logger,
}
}
// 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(tlsDialAddr string, icmpTarget netip.Addr) {
c.configMutex.Lock()
defer c.configMutex.Unlock()
c.tlsDialAddr = tlsDialAddr
c.icmpTarget = icmpTarget
}
// 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 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 c.tlsDialAddr == "" || c.icmpTarget.IsUnspecified() {
panic("call Checker.SetConfig with non empty values before Checker.Start")
}
// 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)
}
ready := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
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
fullCheckTimer := time.NewTimer(fullCheckPeriod)
runErrorCh := make(chan error)
runError = runErrorCh
go func() {
defer close(done)
close(ready)
for {
select {
case <-ctx.Done():
fullCheckTimer.Stop()
smallCheckTimer.Stop()
return
case <-smallCheckTimer.C:
err := c.smallPeriodicCheck(ctx)
if err != nil {
err = fmt.Errorf("small periodic check: %w", err)
}
runErrorCh <- err
smallCheckTimer.Reset(smallCheckPeriod)
case <-fullCheckTimer.C:
err := c.fullPeriodicCheck(ctx)
if err != nil {
err = fmt.Errorf("full periodic check: %w", err)
}
runErrorCh <- err
fullCheckTimer.Reset(fullCheckPeriod)
}
}
}()
<-ready
return runError, nil
}
func (c *Checker) Stop() error {
c.stop()
<-c.done
c.icmpTarget = netip.Addr{}
return nil
}
func (c *Checker) smallPeriodicCheck(ctx context.Context) error {
c.configMutex.Lock()
ip := c.icmpTarget
c.configMutex.Unlock()
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)
}
err := c.echoer.Echo(ctx, ip)
if errors.Is(err, icmp.ErrNotPermitted) {
c.icmpNotPermitted = true
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, 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
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, maxTries, timeout, extraTryTime, c.logger, "TCP+TLS dial", check)
}
func tcpTLSCheck(ctx context.Context, dialer *net.Dialer, targetAddress string) error {
// TODO use mullvad API if current provider is Mullvad
address, err := makeAddressToDial(targetAddress)
if err != nil {
return err
}
const dialNetwork = "tcp4"
connection, err := dialer.DialContext(ctx, dialNetwork, address)
if err != nil {
return fmt.Errorf("dialing: %w", err)
}
if strings.HasSuffix(address, ":443") {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("splitting host and port: %w", err)
}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: host,
}
tlsConnection := tls.Client(connection, tlsConfig)
err = tlsConnection.HandshakeContext(ctx)
if err != nil {
return fmt.Errorf("running TLS handshake: %w", err)
}
}
err = connection.Close()
if err != nil {
return fmt.Errorf("closing connection: %w", err)
}
return nil
}
func makeAddressToDial(address string) (addressToDial string, err error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
addrErr := new(net.AddrError)
ok := errors.As(err, &addrErr)
if !ok || addrErr.Err != "missing port in address" {
return "", fmt.Errorf("splitting host and port from address: %w", err)
}
host = address
const defaultPort = "443"
port = defaultPort
}
address = net.JoinHostPort(host, port)
return address, nil
}
var ErrAllCheckTriesFailed = errors.New("all check tries failed")
func withRetries(ctx context.Context, maxTries uint, tryTimeout, extraTryTime time.Duration,
logger Logger, checkName string, check func(ctx context.Context) error,
) error {
try := uint(0)
var errs []error
for {
timeout := tryTimeout + time.Duration(try)*extraTryTime //nolint:gosec
checkCtx, cancel := context.WithTimeout(ctx, timeout)
err := check(checkCtx)
cancel()
switch {
case err == nil:
return nil
case ctx.Err() != nil:
return fmt.Errorf("%s: %w", checkName, ctx.Err())
}
logger.Debugf("%s attempt %d/%d failed: %s", checkName, try+1, maxTries, err)
errs = append(errs, err)
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

@@ -7,12 +7,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func Test_Server_healthCheck(t *testing.T) { func Test_Checker_fullcheck(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("canceled real dialer", func(t *testing.T) { t.Run("canceled real dialer", func(t *testing.T) {
@@ -21,26 +20,28 @@ func Test_Server_healthCheck(t *testing.T) {
dialer := &net.Dialer{} dialer := &net.Dialer{}
const address = "cloudflare.com:443" const address = "cloudflare.com:443"
server := &Server{ checker := &Checker{
dialer: dialer, dialer: dialer,
config: settings.Health{ tlsDialAddr: address,
TargetAddress: address,
},
} }
canceledCtx, cancel := context.WithCancel(context.Background()) canceledCtx, cancel := context.WithCancel(context.Background())
cancel() cancel()
err := server.healthCheck(canceledCtx) err := checker.fullPeriodicCheck(canceledCtx)
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "operation was canceled") assert.EqualError(t, err, "TCP+TLS dial: context canceled")
}) })
t.Run("dial localhost:0", func(t *testing.T) { t.Run("dial localhost:0", func(t *testing.T) {
t.Parallel() t.Parallel()
listener, err := net.Listen("tcp4", "localhost:0") const timeout = 100 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
listenConfig := &net.ListenConfig{}
listener, err := listenConfig.Listen(ctx, "tcp4", "localhost:0")
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
err = listener.Close() err = listener.Close()
@@ -50,18 +51,12 @@ func Test_Server_healthCheck(t *testing.T) {
listeningAddress := listener.Addr() listeningAddress := listener.Addr()
dialer := &net.Dialer{} dialer := &net.Dialer{}
server := &Server{ checker := &Checker{
dialer: dialer, dialer: dialer,
config: settings.Health{ tlsDialAddr: listeningAddress.String(),
TargetAddress: listeningAddress.String(),
},
} }
const timeout = 100 * time.Millisecond err = checker.fullPeriodicCheck(ctx)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
err = server.healthCheck(ctx)
assert.NoError(t, err) assert.NoError(t, err)
}) })

View File

@@ -0,0 +1,39 @@
package dns
import (
"context"
"errors"
"fmt"
"net"
)
// Client is a simple plaintext UDP DNS client, to be used for healthchecks.
// Note the client connects to a DNS server only over UDP on port 53,
// because we don't want to use DoT or DoH and impact the TCP connections
// when running a healthcheck.
type Client struct{}
func New() *Client {
return &Client{}
}
var ErrLookupNoIPs = errors.New("no IPs found from DNS lookup")
func (c *Client) Check(ctx context.Context) error {
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "udp", "1.1.1.1:53")
},
}
ips, err := resolver.LookupIP(ctx, "ip", "github.com")
switch {
case err != nil:
return err
case len(ips) == 0:
return fmt.Errorf("%w", ErrLookupNoIPs)
default:
return nil
}
}

View File

@@ -9,13 +9,15 @@ import (
type handler struct { type handler struct {
healthErr error healthErr error
healthErrMu sync.RWMutex healthErrMu sync.RWMutex
logger Logger
} }
var errHealthcheckNotRunYet = errors.New("healthcheck did not run yet") var errHealthcheckNotRunYet = errors.New("healthcheck did not run yet")
func newHandler() *handler { func newHandler(logger Logger) *handler {
return &handler{ return &handler{
healthErr: errHealthcheckNotRunYet, healthErr: errHealthcheckNotRunYet,
logger: logger,
} }
} }

View File

@@ -1,122 +0,0 @@
package healthcheck
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"strings"
"time"
)
func (s *Server) runHealthcheckLoop(ctx context.Context, done chan<- struct{}) {
defer close(done)
timeoutIndex := 0
healthcheckTimeouts := []time.Duration{
2 * time.Second,
4 * time.Second,
6 * time.Second,
8 * time.Second,
// This can be useful when the connection is under stress
// See https://github.com/qdm12/gluetun/issues/2270
10 * time.Second,
}
s.vpn.healthyTimer = time.NewTimer(s.vpn.healthyWait)
for {
previousErr := s.handler.getErr()
timeout := healthcheckTimeouts[timeoutIndex]
healthcheckCtx, healthcheckCancel := context.WithTimeout(
ctx, timeout)
err := s.healthCheck(healthcheckCtx)
healthcheckCancel()
s.handler.setErr(err)
switch {
case previousErr != nil && err == nil: // First success
s.logger.Info("healthy!")
timeoutIndex = 0
s.vpn.healthyTimer.Stop()
s.vpn.healthyWait = *s.config.VPN.Initial
case previousErr == nil && err != nil: // First failure
s.logger.Debug("unhealthy: " + err.Error())
s.vpn.healthyTimer.Stop()
s.vpn.healthyTimer = time.NewTimer(s.vpn.healthyWait)
case previousErr != nil && err != nil: // Nth failure
if timeoutIndex < len(healthcheckTimeouts)-1 {
timeoutIndex++
}
select {
case <-s.vpn.healthyTimer.C:
timeoutIndex = 0 // retry next with the smallest timeout
s.onUnhealthyVPN(ctx, err.Error())
default:
}
case previousErr == nil && err == nil: // Nth success
timer := time.NewTimer(s.config.SuccessWait)
select {
case <-ctx.Done():
return
case <-timer.C:
}
}
}
}
func (s *Server) healthCheck(ctx context.Context) (err error) {
// TODO use mullvad API if current provider is Mullvad
address, err := makeAddressToDial(s.config.TargetAddress)
if err != nil {
return err
}
const dialNetwork = "tcp4"
connection, err := s.dialer.DialContext(ctx, dialNetwork, address)
if err != nil {
return fmt.Errorf("dialing: %w", err)
}
if strings.HasSuffix(address, ":443") {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("splitting host and port: %w", err)
}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: host,
}
tlsConnection := tls.Client(connection, tlsConfig)
err = tlsConnection.HandshakeContext(ctx)
if err != nil {
return fmt.Errorf("running TLS handshake: %w", err)
}
}
err = connection.Close()
if err != nil {
return fmt.Errorf("closing connection: %w", err)
}
return nil
}
func makeAddressToDial(address string) (addressToDial string, err error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
addrErr := new(net.AddrError)
ok := errors.As(err, &addrErr)
if !ok || addrErr.Err != "missing port in address" {
return "", fmt.Errorf("splitting host and port from address: %w", err)
}
host = address
const defaultPort = "443"
port = defaultPort
}
address = net.JoinHostPort(host, port)
return address, nil
}

View File

@@ -0,0 +1,49 @@
package icmp
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)
}

View File

@@ -0,0 +1,193 @@
package icmp
import (
"bytes"
"context"
cryptorand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"math/rand/v2"
"net"
"net/netip"
"strings"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
var (
ErrICMPBodyUnsupported = errors.New("ICMP body type is not supported")
ErrICMPEchoDataMismatch = errors.New("ICMP data mismatch")
)
type Echoer struct {
buffer []byte
randomSource io.Reader
logger Logger
}
func NewEchoer(logger Logger) *Echoer {
const maxICMPEchoSize = 1500
buffer := make([]byte, maxICMPEchoSize)
var seed [32]byte
_, _ = cryptorand.Read(seed[:])
randomSource := rand.NewChaCha8(seed)
return &Echoer{
buffer: buffer,
randomSource: randomSource,
logger: logger,
}
}
var (
ErrTimedOut = errors.New("timed out waiting for ICMP echo reply")
ErrNotPermitted = errors.New("not permitted")
)
func (i *Echoer) Echo(ctx context.Context, ip netip.Addr) (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", ErrNotPermitted)
}
return fmt.Errorf("listening for ICMP packets: %w", err)
}
go func() {
<-ctx.Done()
conn.Close()
}()
const echoDataSize = 32
id, message := buildMessageToSend(ipVersion, echoDataSize, i.randomSource)
encodedMessage, err := message.Marshal(nil)
if err != nil {
return fmt.Errorf("encoding ICMP message: %w", err)
}
_, 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", ErrNotPermitted)
}
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", ErrTimedOut)
}
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 and received %x", ErrICMPEchoDataMismatch, sentData, receivedData)
}
return nil
}
func buildMessageToSend(ipVersion string, size uint, randomSource io.Reader) (id int, message *icmp.Message) {
const uint16Bytes = 2
idBytes := make([]byte, uint16Bytes)
_, _ = randomSource.Read(idBytes)
id = int(binary.BigEndian.Uint16(idBytes))
var icmpType icmp.Type
switch ipVersion {
case "v4":
icmpType = ipv4.ICMPTypeEcho
case "v6":
icmpType = ipv6.ICMPTypeEchoRequest
default:
panic(fmt.Sprintf("IP version %q not supported", ipVersion))
}
messageBodyData := make([]byte, size)
_, _ = 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: id,
Seq: 0, // only one packet
Data: messageBodyData,
},
}
return id, message
}
func receiveEchoReply(conn net.PacketConn, id int, buffer []byte, ipVersion string, logger Logger,
) (data []byte, err error) {
var icmpProtocol int
const (
icmpv4Protocol = 1
icmpv6Protocol = 58
)
switch ipVersion {
case "v4":
icmpProtocol = icmpv4Protocol
case "v6":
icmpProtocol = icmpv6Protocol
default:
panic(fmt.Sprintf("unknown IP version: %s", ipVersion))
}
for {
// 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, returnAddr, err := conn.ReadFrom(buffer)
if err != nil {
return nil, fmt.Errorf("reading from ICMP connection: %w", err)
}
packetBytes := buffer[:bytesRead]
// Parse the ICMP message
message, err := icmp.ParseMessage(icmpProtocol, packetBytes)
if err != nil {
return nil, fmt.Errorf("parsing message: %w", err)
}
switch body := message.Body.(type) {
case *icmp.Echo:
if id != body.ID {
logger.Warnf("ignoring ICMP echo reply mismatching expected id %d "+
"(id: %d, type: %d, code: %d, length: %d, return address %s)",
id, body.ID, message.Type, message.Code, len(packetBytes), returnAddr)
continue // not the ID we are looking for
}
return body.Data, nil
case *icmp.DstUnreach:
logger.Debugf("ignoring ICMP destination unreachable message (type: 3, code: %d, return address %s, expected-id %d)",
message.Code, returnAddr, id)
// See https://github.com/qdm12/gluetun/pull/2923#issuecomment-3377532249
// on why we ignore this message. If it is actually unreachable, the timeout on waiting for
// the echo reply will do instead of returning an error error.
continue
case *icmp.TimeExceeded:
logger.Debugf("ignoring ICMP time exceeded message (type: 11, code: %d, return address %s, expected-id %d)",
message.Code, returnAddr, id)
continue
default:
return nil, fmt.Errorf("%w: %T (type %d, code %d, return address %s, expected-id %d)",
ErrICMPBodyUnsupported, body, message.Type, message.Code, returnAddr, id)
}
}
}

View File

@@ -0,0 +1,6 @@
package icmp
type Logger interface {
Debugf(format string, args ...any)
Warnf(format string, args ...any)
}

View File

@@ -0,0 +1,35 @@
package icmp
import (
"context"
"fmt"
"net"
"runtime"
"golang.org/x/net/ipv4"
)
func listenICMPv4(ctx context.Context) (conn net.PacketConn, err error) {
var listenConfig net.ListenConfig
const listenAddress = ""
packetConn, err := listenConfig.ListenPacket(ctx, "ip4:icmp", listenAddress)
if err != nil {
return nil, fmt.Errorf("listening for ICMP packets: %w", err)
}
if runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
packetConn = ipv4ToNetPacketConn(ipv4.NewPacketConn(packetConn))
}
return packetConn, nil
}
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 {
return nil, fmt.Errorf("listening for ICMPv6 packets: %w", err)
}
return packetConn, nil
}

View File

@@ -0,0 +1,9 @@
package healthcheck
type Logger interface {
Debugf(format string, args ...any)
Info(s string)
Infof(format string, args ...any)
Warnf(format string, args ...any)
Error(s string)
}

View File

@@ -1,7 +0,0 @@
package healthcheck
type Logger interface {
Debug(s string)
Info(s string)
Error(s string)
}

View File

@@ -1,25 +0,0 @@
package healthcheck
import (
"context"
"time"
"github.com/qdm12/gluetun/internal/constants"
)
type vpnHealth struct {
loop StatusApplier
healthyWait time.Duration
healthyTimer *time.Timer
}
func (s *Server) onUnhealthyVPN(ctx context.Context, lastErrMessage string) {
s.logger.Info("program has been unhealthy for " +
s.vpn.healthyWait.String() + ": restarting VPN (healthcheck error: " + lastErrMessage + ")")
s.logger.Info("👉 See https://github.com/qdm12/gluetun-wiki/blob/main/faq/healthcheck.md")
s.logger.Info("DO NOT OPEN AN ISSUE UNLESS YOU READ AND TRIED EACH POSSIBLE SOLUTION")
_, _ = s.vpn.loop.ApplyStatus(ctx, constants.Stopped)
_, _ = s.vpn.loop.ApplyStatus(ctx, constants.Running)
s.vpn.healthyWait += *s.config.VPN.Addition
s.vpn.healthyTimer = time.NewTimer(s.vpn.healthyWait)
}

View File

@@ -10,9 +10,6 @@ import (
func (s *Server) Run(ctx context.Context, done chan<- struct{}) { func (s *Server) Run(ctx context.Context, done chan<- struct{}) {
defer close(done) defer close(done)
loopDone := make(chan struct{})
go s.runHealthcheckLoop(ctx, loopDone)
server := http.Server{ server := http.Server{
Addr: s.config.ServerAddress, Addr: s.config.ServerAddress,
Handler: s.handler, Handler: s.handler,
@@ -37,6 +34,5 @@ func (s *Server) Run(ctx context.Context, done chan<- struct{}) {
s.logger.Error(err.Error()) s.logger.Error(err.Error())
} }
<-loopDone
<-serverDone <-serverDone
} }

View File

@@ -2,7 +2,6 @@ package healthcheck
import ( import (
"context" "context"
"net"
"github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/models"
@@ -11,30 +10,21 @@ import (
type Server struct { type Server struct {
logger Logger logger Logger
handler *handler handler *handler
dialer *net.Dialer
config settings.Health config settings.Health
vpn vpnHealth
} }
func NewServer(config settings.Health, func NewServer(config settings.Health, logger Logger) *Server {
logger Logger, vpnLoop StatusApplier,
) *Server {
return &Server{ return &Server{
logger: logger, logger: logger,
handler: newHandler(), handler: newHandler(logger),
dialer: &net.Dialer{ config: config,
Resolver: &net.Resolver{
PreferGo: true,
},
},
config: config,
vpn: vpnHealth{
loop: vpnLoop,
healthyWait: *config.VPN.Initial,
},
} }
} }
func (s *Server) SetError(err error) {
s.handler.setErr(err)
}
type StatusApplier interface { type StatusApplier interface {
ApplyStatus(ctx context.Context, status models.LoopStatus) ( ApplyStatus(ctx context.Context, status models.LoopStatus) (
outcome string, err error) outcome string, err error)

View File

@@ -20,8 +20,10 @@ func (s *Server) Run(ctx context.Context, ready chan<- struct{}, done chan<- str
crashed := make(chan struct{}) crashed := make(chan struct{})
shutdownDone := make(chan struct{}) shutdownDone := make(chan struct{})
listenCtx, listenCancel := context.WithCancel(ctx)
go func() { go func() {
defer close(shutdownDone) defer close(shutdownDone)
defer listenCancel()
select { select {
case <-ctx.Done(): case <-ctx.Done():
case <-crashed: case <-crashed:
@@ -37,7 +39,8 @@ func (s *Server) Run(ctx context.Context, ready chan<- struct{}, done chan<- str
} }
}() }()
listener, err := net.Listen("tcp", s.address) listenConfig := &net.ListenConfig{}
listener, err := listenConfig.Listen(listenCtx, "tcp", s.address)
if err != nil { if err != nil {
close(s.addressSet) close(s.addressSet)
close(crashed) // stop shutdown goroutine close(crashed) // stop shutdown goroutine

View File

@@ -76,9 +76,9 @@ func initModule(path string) (err error) {
const flags = 0 const flags = 0
err = unix.FinitModule(int(file.Fd()), moduleParams, flags) err = unix.FinitModule(int(file.Fd()), moduleParams, flags)
switch { switch {
case err == nil, err == unix.EEXIST: //nolint:goerr113 case err == nil, err == unix.EEXIST: //nolint:err113
return nil return nil
case err != unix.ENOSYS: //nolint:goerr113 case err != unix.ENOSYS: //nolint:err113
if strings.HasSuffix(err.Error(), "operation not permitted") { if strings.HasSuffix(err.Error(), "operation not permitted") {
err = fmt.Errorf("%w; did you set the SYS_MODULE capability to your container?", err) err = fmt.Errorf("%w; did you set the SYS_MODULE capability to your container?", err)
} }

View File

@@ -13,7 +13,7 @@ const (
func FamilyToString(family int) string { func FamilyToString(family int) string {
switch family { switch family {
case FamilyAll: case FamilyAll:
return "all" //nolint:goconst return "all"
case FamilyV4: case FamilyV4:
return "v4" return "v4"
case FamilyV6: case FamilyV6:

View File

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

View File

@@ -15,12 +15,12 @@ type Provider struct {
} }
func New(storage common.Storage, randSource rand.Source, func New(storage common.Storage, randSource rand.Source,
updaterWarner common.Warner, parallelResolver common.ParallelResolver, parallelResolver common.ParallelResolver,
) *Provider { ) *Provider {
return &Provider{ return &Provider{
storage: storage, storage: storage,
randSource: randSource, randSource: randSource,
Fetcher: updater.New(parallelResolver, updaterWarner), Fetcher: updater.New(parallelResolver),
} }
} }

View File

@@ -16,10 +16,7 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
possibleHosts := possibleServers.hostsSlice() possibleHosts := possibleServers.hostsSlice()
resolveSettings := parallelResolverSettings(possibleHosts) resolveSettings := parallelResolverSettings(possibleHosts)
hostToIPs, warnings, err := u.parallelResolver.Resolve(ctx, resolveSettings) hostToIPs, _, err := u.parallelResolver.Resolve(ctx, resolveSettings)
for _, warning := range warnings {
u.warner.Warn(warning)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -6,12 +6,10 @@ import (
type Updater struct { type Updater struct {
parallelResolver common.ParallelResolver parallelResolver common.ParallelResolver
warner common.Warner
} }
func New(parallelResolver common.ParallelResolver, warner common.Warner) *Updater { func New(parallelResolver common.ParallelResolver) *Updater {
return &Updater{ return &Updater{
parallelResolver: parallelResolver, parallelResolver: parallelResolver,
warner: warner,
} }
} }

View File

@@ -4,7 +4,6 @@ import (
"github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/models"
) )
//nolint:lll
func hardcodedServers() (servers []models.Server) { func hardcodedServers() (servers []models.Server) {
return []models.Server{ return []models.Server{
{Country: "Albania", Hostname: "albania-ca-version-2.expressnetw.com"}, {Country: "Albania", Hostname: "albania-ca-version-2.expressnetw.com"},
@@ -12,69 +11,83 @@ func hardcodedServers() (servers []models.Server) {
{Country: "Andorra", Hostname: "andorra-ca-version-2.expressnetw.com"}, {Country: "Andorra", Hostname: "andorra-ca-version-2.expressnetw.com"},
{Country: "Argentina", Hostname: "argentina-ca-version-2.expressnetw.com"}, {Country: "Argentina", Hostname: "argentina-ca-version-2.expressnetw.com"},
{Country: "Armenia", Hostname: "armenia-ca-version-2.expressnetw.com"}, {Country: "Armenia", Hostname: "armenia-ca-version-2.expressnetw.com"},
{Country: "Australia", City: "Adelaide", Hostname: "australia-adelaide--ca-version-2.expressnetw.com"},
{Country: "Australia", City: "Brisbane", Hostname: "australia-brisbane-ca-version-2.expressnetw.com"}, {Country: "Australia", City: "Brisbane", Hostname: "australia-brisbane-ca-version-2.expressnetw.com"},
{Country: "Australia", City: "Melbourne", Hostname: "australia-melbourne-ca-version-2.expressnetw.com"}, {Country: "Australia", City: "Melbourne", Hostname: "australia-melbourne-ca-version-2.expressnetw.com"},
{Country: "Australia", City: "Perth", Hostname: "australia-perth-ca-version-2.expressnetw.com"}, {Country: "Australia", City: "Perth", Hostname: "australia-perth-ca-version-2.expressnetw.com"},
{Country: "Australia", City: "Sydney", Hostname: "australia-sydney-2-ca-version-2.expressnetw.com"}, {Country: "Australia", City: "Sydney", Hostname: "australia-sydney-2-ca-version-2.expressnetw.com"},
{Country: "Australia", City: "Sydney", Hostname: "australia-sydney-ca-version-2.expressnetw.com"}, {Country: "Australia", City: "Sydney", Hostname: "australia-sydney-ca-version-2.expressnetw.com"},
{Country: "Australia", City: "Woolloomooloo", Hostname: "australia-woolloomooloo-2-ca-version-2.expressnetw.com"},
{Country: "Austria", Hostname: "austria-ca-version-2.expressnetw.com"}, {Country: "Austria", Hostname: "austria-ca-version-2.expressnetw.com"},
{Country: "Azerbaijan", Hostname: "azerbaijan-ca-version-2.expressnetw.com"},
{Country: "Bahamas", Hostname: "bahamas-ca-version-2.expressnetw.com"}, {Country: "Bahamas", Hostname: "bahamas-ca-version-2.expressnetw.com"},
{Country: "Bangladesh", Hostname: "bangladesh-ca-version-2.expressnetw.com"}, {Country: "Bangladesh", Hostname: "bangladesh-ca-version-2.expressnetw.com"},
{Country: "Belarus", Hostname: "belarus-ca-version-2.expressnetw.com"}, {Country: "Belarus", Hostname: "belarus-ca-version-2.expressnetw.com"},
{Country: "Belgium", Hostname: "belgium-ca-version-2.expressnetw.com"}, {Country: "Belgium", Hostname: "belgium-ca-version-2.expressnetw.com"},
{Country: "Bermuda", Hostname: "bermuda-ca-version-2.expressnetw.com"},
{Country: "Bhutan", Hostname: "bhutan-ca-version-2.expressnetw.com"}, {Country: "Bhutan", Hostname: "bhutan-ca-version-2.expressnetw.com"},
{Country: "Bosnia And Herzegovina", City: "Bosnia And Herzegovina", Hostname: "bosniaandherzegovina-ca-version-2.expressnetw.com"}, {Country: "Bolivia", Hostname: "bolivia-ca-version-2.expressnetw.com"},
{Country: "Bosnia and Herzegovina", Hostname: "bosniaandherzegovina-ca-version-2.expressnetw.com"},
{Country: "Brazil", Hostname: "brazil-2-ca-version-2.expressnetw.com"}, {Country: "Brazil", Hostname: "brazil-2-ca-version-2.expressnetw.com"},
{Country: "Brazil", Hostname: "brazil-ca-version-2.expressnetw.com"}, {Country: "Brazil", Hostname: "brazil-ca-version-2.expressnetw.com"},
{Country: "Brunei", Hostname: "brunei-ca-version-2.expressnetw.com"}, {Country: "Brunei", Hostname: "brunei-ca-version-2.expressnetw.com"},
{Country: "Bulgaria", Hostname: "bulgaria-ca-version-2.expressnetw.com"},
{Country: "Cambodia", Hostname: "cambodia-ca-version-2.expressnetw.com"}, {Country: "Cambodia", Hostname: "cambodia-ca-version-2.expressnetw.com"},
{Country: "Canada", City: "Montreal", Hostname: "canada-montreal-ca-version-2.expressnetw.com"}, {Country: "Canada", City: "Montreal", Hostname: "canada-montreal-ca-version-2.expressnetw.com"},
{Country: "Canada", City: "Montreal", Hostname: "canada-montreal-ca-version-2.expressnetw.com"},
{Country: "Canada", City: "Toronto", Hostname: "canada-toronto-2-ca-version-2.expressnetw.com"}, {Country: "Canada", City: "Toronto", Hostname: "canada-toronto-2-ca-version-2.expressnetw.com"},
{Country: "Canada", City: "Toronto", Hostname: "canada-toronto-ca-version-2.expressnetw.com"}, {Country: "Canada", City: "Toronto", Hostname: "canada-toronto-ca-version-2.expressnetw.com"},
{Country: "Canada", City: "Vancouver", Hostname: "canada-vancouver-ca-version-2.expressnetw.com"}, {Country: "Cayman Islands", Hostname: "caymanislands-ca-version-2.expressnetw.com"},
{Country: "Chile", Hostname: "chile-ca-version-2.expressnetw.com"}, {Country: "Chile", Hostname: "chile-ca-version-2.expressnetw.com"},
{Country: "Colombia", Hostname: "colombia-ca-version-2.expressnetw.com"}, {Country: "Colombia", Hostname: "colombia-ca-version-2.expressnetw.com"},
{Country: "Costa Rica", City: "Costa Rica", Hostname: "costarica-ca-version-2.expressnetw.com"}, {Country: "Costa Rica", Hostname: "costarica-ca-version-2.expressnetw.com"},
{Country: "Croatia", Hostname: "croatia-ca-version-2.expressnetw.com"}, {Country: "Croatia", Hostname: "croatia-ca-version-2.expressnetw.com"},
{Country: "Cuba", Hostname: "cuba-ca-version-2.expressnetw.com"},
{Country: "Cyprus", Hostname: "cyprus-ca-version-2.expressnetw.com"}, {Country: "Cyprus", Hostname: "cyprus-ca-version-2.expressnetw.com"},
{Country: "Czech Republic", City: "Czech Republic", Hostname: "czechrepublic-ca-version-2.expressnetw.com"}, {Country: "Czech Republic", Hostname: "czechrepublic-ca-version-2.expressnetw.com"},
{Country: "Denmark", Hostname: "denmark-ca-version-2.expressnetw.com"}, {Country: "Denmark", Hostname: "denmark-ca-version-2.expressnetw.com"},
{Country: "Dominican Republic", Hostname: "dominicanrepublic-ca-version-2.expressnetw.com"},
{Country: "Ecuador", Hostname: "ecuador-ca-version-2.expressnetw.com"}, {Country: "Ecuador", Hostname: "ecuador-ca-version-2.expressnetw.com"},
{Country: "Egypt", Hostname: "egypt-ca-version-2.expressnetw.com"}, {Country: "Egypt", Hostname: "egypt-ca-version-2.expressnetw.com"},
{Country: "Estonia", Hostname: "estonia-ca-version-2.expressnetw.com"}, {Country: "Estonia", Hostname: "estonia-ca-version-2.expressnetw.com"},
{Country: "Finland", Hostname: "finland-ca-version-2.expressnetw.com"}, {Country: "Finland", Hostname: "finland-ca-version-2.expressnetw.com"},
{Country: "France", City: "Alsace", Hostname: "france-alsace-ca-version-2.expressnetw.com"},
{Country: "France", City: "Marseille", Hostname: "france-marseille-ca-version-2.expressnetw.com"},
{Country: "France", City: "Paris", Hostname: "france-paris-1-ca-version-2.expressnetw.com"}, {Country: "France", City: "Paris", Hostname: "france-paris-1-ca-version-2.expressnetw.com"},
{Country: "France", City: "Paris", Hostname: "france-paris-2-ca-version-2.expressnetw.com"}, {Country: "France", City: "Paris", Hostname: "france-paris-2-ca-version-2.expressnetw.com"},
{Country: "France", City: "Strasbourg", Hostname: "france-strasbourg-ca-version-2.expressnetw.com"}, {Country: "France", City: "Strasbourg", Hostname: "france-strasbourg-ca-version-2.expressnetw.com"},
{Country: "Georgia", Hostname: "georgia-ca-version-2.expressnetw.com"}, {Country: "Georgia", Hostname: "georgia-ca-version-2.expressnetw.com"},
{Country: "Germany", City: "Frankfurt", Hostname: "germany-frankfurt-1-ca-version-2.expressnetw.com"},
{Country: "Germany", City: "Frankfurt", Hostname: "germany-frankfurt-2-ca-version-2.expressnetw.com"},
{Country: "Germany", City: "Frankfurt", Hostname: "germany-darmstadt-ca-version-2.expressnetw.com"}, {Country: "Germany", City: "Frankfurt", Hostname: "germany-darmstadt-ca-version-2.expressnetw.com"},
{Country: "Germany", City: "Frankfurt", Hostname: "germany-frankfurt-1-ca-version-2.expressnetw.com"},
{Country: "Germany", City: "Nuremberg", Hostname: "germany-nuremberg-ca-version-2.expressnetw.com"}, {Country: "Germany", City: "Nuremberg", Hostname: "germany-nuremberg-ca-version-2.expressnetw.com"},
{Country: "Ghana", Hostname: "ghana-ca-version-2.expressnetw.com"},
{Country: "Greece", Hostname: "greece-ca-version-2.expressnetw.com"}, {Country: "Greece", Hostname: "greece-ca-version-2.expressnetw.com"},
{Country: "Guam", Hostname: "guam-ca-version-2.expressnetw.com"},
{Country: "Guatemala", Hostname: "guatemala-ca-version-2.expressnetw.com"}, {Country: "Guatemala", Hostname: "guatemala-ca-version-2.expressnetw.com"},
{Country: "Hong Kong", City: "Hong Kong", Hostname: "hongkong-2-ca-version-2.expressnetw.com"}, {Country: "Honduras", Hostname: "honduras-ca-version-2.expressnetw.com"},
{Country: "Hong Kong", City: "Hong Kong", Hostname: "hongkong4-ca-version-2.expressnetw.com"}, {Country: "Hong Kong", Hostname: "hongkong-1-ca-version-2.expressnetw.com"},
{Country: "Hong Kong", Hostname: "hongkong-2-ca-version-2.expressnetw.com"},
{Country: "Hungary", Hostname: "hungary-ca-version-2.expressnetw.com"}, {Country: "Hungary", Hostname: "hungary-ca-version-2.expressnetw.com"},
{Country: "Iceland", Hostname: "iceland-ca-version-2.expressnetw.com"}, {Country: "Iceland", Hostname: "iceland-ca-version-2.expressnetw.com"},
{Country: "India", City: "Chennai", Hostname: "india-chennai-ca-version-2.expressnetw.com"}, {Country: "India (via Singapore)", Hostname: "india-sg-ca-version-2.expressnetw.com"},
{Country: "India", City: "Mumbai", Hostname: "india-mumbai-1-ca-version-2.expressnetw.com"}, {Country: "India (via UK)", Hostname: "india-uk-ca-version-2.expressnetw.com"},
{Country: "Indonesia", Hostname: "indonesia-ca-version-2.expressnetw.com"}, {Country: "Indonesia", Hostname: "indonesia-ca-version-2.expressnetw.com"},
{Country: "Ireland", Hostname: "ireland-ca-version-2.expressnetw.com"}, {Country: "Ireland", Hostname: "ireland-ca-version-2.expressnetw.com"},
{Country: "Isle Of Man", City: "Isle Of Man", Hostname: "isleofman-ca-version-2.expressnetw.com"}, {Country: "Isle of Man", Hostname: "isleofman-ca-version-2.expressnetw.com"},
{Country: "Israel", Hostname: "israel-ca-version-2.expressnetw.com"}, {Country: "Israel", Hostname: "israel-ca-version-2.expressnetw.com"},
{Country: "Italy", City: "Cosenza", Hostname: "italy-cosenza-ca-version-2.expressnetw.com"}, {Country: "Italy", City: "Cosenza", Hostname: "italy-cosenza-ca-version-2.expressnetw.com"},
{Country: "Italy", City: "Milan", Hostname: "italy-milan-ca-version-2.expressnetw.com"}, {Country: "Italy", City: "Milan", Hostname: "italy-milan-ca-version-2.expressnetw.com"},
{Country: "Japan", City: "Kawasaki", Hostname: "japan-kawasaki-ca-version-2.expressnetw.com"}, {Country: "Italy", City: "Naples", Hostname: "italy-naples-ca-version-2.expressnetw.com"},
{Country: "Japan", City: "Tokyo", Hostname: "japan-tokyo-1-ca-version-2.expressnetw.com"}, {Country: "Jamaica", Hostname: "jamaica-ca-version-2.expressnetw.com"},
{Country: "Japan", City: "Tokyo", Hostname: "japan-tokyo-2-ca-version-2.expressnetw.com"}, {Country: "Japan", City: "Osaka", Hostname: "japan-osaka-ca-version-2.expressnetw.com"},
{Country: "Japan", City: "Shibuya", Hostname: "japan-shibuya-ca-version-2.expressnetw.com"},
{Country: "Japan", City: "Tokyo", Hostname: "japan-tokyo-ca-version-2.expressnetw.com"},
{Country: "Japan", City: "Yokohama", Hostname: "japan-yokohama-ca-version-2.expressnetw.com"},
{Country: "Jersey", Hostname: "jersey-ca-version-2.expressnetw.com"}, {Country: "Jersey", Hostname: "jersey-ca-version-2.expressnetw.com"},
{Country: "Kazakhstan", Hostname: "kazakhstan-ca-version-2.expressnetw.com"}, {Country: "Kazakhstan", Hostname: "kazakhstan-ca-version-2.expressnetw.com"},
{Country: "Kenya", Hostname: "kenya-ca-version-2.expressnetw.com"}, {Country: "Kenya", Hostname: "kenya-ca-version-2.expressnetw.com"},
{Country: "Kyrgyzstan", Hostname: "kyrgyzstan-ca-version-2.expressnetw.com"},
{Country: "Laos", Hostname: "laos-ca-version-2.expressnetw.com"}, {Country: "Laos", Hostname: "laos-ca-version-2.expressnetw.com"},
{Country: "Latvia", Hostname: "latvia-ca-version-2.expressnetw.com"}, {Country: "Latvia", Hostname: "latvia-ca-version-2.expressnetw.com"},
{Country: "Lebanon", Hostname: "lebanon-ca-version-2.expressnetw.com"},
{Country: "Liechtenstein", Hostname: "liechtenstein-ca-version-2.expressnetw.com"}, {Country: "Liechtenstein", Hostname: "liechtenstein-ca-version-2.expressnetw.com"},
{Country: "Lithuania", Hostname: "lithuania-ca-version-2.expressnetw.com"}, {Country: "Lithuania", Hostname: "lithuania-ca-version-2.expressnetw.com"},
{Country: "Luxembourg", Hostname: "luxembourg-ca-version-2.expressnetw.com"}, {Country: "Luxembourg", Hostname: "luxembourg-ca-version-2.expressnetw.com"},
@@ -86,21 +99,22 @@ func hardcodedServers() (servers []models.Server) {
{Country: "Monaco", Hostname: "monaco-ca-version-2.expressnetw.com"}, {Country: "Monaco", Hostname: "monaco-ca-version-2.expressnetw.com"},
{Country: "Mongolia", Hostname: "mongolia-ca-version-2.expressnetw.com"}, {Country: "Mongolia", Hostname: "mongolia-ca-version-2.expressnetw.com"},
{Country: "Montenegro", Hostname: "montenegro-ca-version-2.expressnetw.com"}, {Country: "Montenegro", Hostname: "montenegro-ca-version-2.expressnetw.com"},
{Country: "Morocco", Hostname: "morocco-ca-version-2.expressnetw.com"},
{Country: "Myanmar", Hostname: "myanmar-ca-version-2.expressnetw.com"}, {Country: "Myanmar", Hostname: "myanmar-ca-version-2.expressnetw.com"},
{Country: "Nepal", Hostname: "nepal-ca-version-2.expressnetw.com"}, {Country: "Nepal", Hostname: "nepal-ca-version-2.expressnetw.com"},
{Country: "Netherlands", City: "Amsterdam", Hostname: "netherlands-amsterdam-2-ca-version-2.expressnetw.com"},
{Country: "Netherlands", City: "Amsterdam", Hostname: "netherlands-amsterdam-ca-version-2.expressnetw.com"}, {Country: "Netherlands", City: "Amsterdam", Hostname: "netherlands-amsterdam-ca-version-2.expressnetw.com"},
{Country: "Netherlands", City: "Rotterdam", Hostname: "netherlands-rotterdam-ca-version-2.expressnetw.com"}, {Country: "Netherlands", City: "Rotterdam", Hostname: "netherlands-rotterdam-ca-version-2.expressnetw.com"},
{Country: "Netherlands", City: "The Hague", Hostname: "netherlands-thehague-ca-version-2.expressnetw.com"}, {Country: "Netherlands", City: "The Hague", Hostname: "netherlands-thehague-ca-version-2.expressnetw.com"},
{Country: "New Zealand", City: "New Zealand", Hostname: "newzealand-ca-version-2.expressnetw.com"}, {Country: "New Zealand", Hostname: "newzealand-ca-version-2.expressnetw.com"},
{Country: "North Macedonia", City: "North Macedonia", Hostname: "macedonia-ca-version-2.expressnetw.com"}, {Country: "North Macedonia", Hostname: "macedonia-ca-version-2.expressnetw.com"},
{Country: "Norway", Hostname: "norway-ca-version-2.expressnetw.com"}, {Country: "Norway", Hostname: "norway-ca-version-2.expressnetw.com"},
{Country: "Pakistan", Hostname: "pakistan-ca-version-2.expressnetw.com"}, {Country: "Pakistan", Hostname: "pakistan-ca-version-2.expressnetw.com"},
{Country: "Panama", Hostname: "panama-ca-version-2.expressnetw.com"}, {Country: "Panama", Hostname: "panama-ca-version-2.expressnetw.com"},
{Country: "Peru", Hostname: "peru-ca-version-2.expressnetw.com"}, {Country: "Peru", Hostname: "peru-ca-version-2.expressnetw.com"},
{Country: "Philippines Via Singapore", City: "Philippines Via Singapore", Hostname: "ph-via-sing-ca-version-2.expressnetw.com"}, {Country: "Philippines (via Singapore)", Hostname: "ph-via-sing-ca-version-2.expressnetw.com"},
{Country: "Poland", Hostname: "poland-ca-version-2.expressnetw.com"}, {Country: "Poland", Hostname: "poland-ca-version-2.expressnetw.com"},
{Country: "Portugal", Hostname: "portugal-ca-version-2.expressnetw.com"}, {Country: "Portugal", Hostname: "portugal-ca-version-2.expressnetw.com"},
{Country: "Puerto Rico", Hostname: "puertorico-ca-version-2.expressnetw.com"},
{Country: "Romania", Hostname: "romania-ca-version-2.expressnetw.com"}, {Country: "Romania", Hostname: "romania-ca-version-2.expressnetw.com"},
{Country: "Serbia", Hostname: "serbia-ca-version-2.expressnetw.com"}, {Country: "Serbia", Hostname: "serbia-ca-version-2.expressnetw.com"},
{Country: "Singapore", City: "CBD", Hostname: "singapore-cbd-ca-version-2.expressnetw.com"}, {Country: "Singapore", City: "CBD", Hostname: "singapore-cbd-ca-version-2.expressnetw.com"},
@@ -108,43 +122,58 @@ func hardcodedServers() (servers []models.Server) {
{Country: "Singapore", City: "Marina Bay", Hostname: "singapore-marinabay-ca-version-2.expressnetw.com"}, {Country: "Singapore", City: "Marina Bay", Hostname: "singapore-marinabay-ca-version-2.expressnetw.com"},
{Country: "Slovakia", Hostname: "slovakia-ca-version-2.expressnetw.com"}, {Country: "Slovakia", Hostname: "slovakia-ca-version-2.expressnetw.com"},
{Country: "Slovenia", Hostname: "slovenia-ca-version-2.expressnetw.com"}, {Country: "Slovenia", Hostname: "slovenia-ca-version-2.expressnetw.com"},
{Country: "South Africa", City: "South Africa", Hostname: "southafrica-ca-version-2.expressnetw.com"}, {Country: "South Africa", Hostname: "southafrica-ca-version-2.expressnetw.com"},
{Country: "South Korea", City: "South Korea", Hostname: "southkorea2-ca-version-2.expressnetw.com"}, {Country: "South Korea", Hostname: "southkorea2-ca-version-2.expressnetw.com"},
{Country: "Spain", City: "Barcelona", Hostname: "spain-barcelona-ca-version-2.expressnetw.com"}, {Country: "Spain", City: "Barcelona", Hostname: "spain-barcelona-ca-version-2.expressnetw.com"},
{Country: "Spain", City: "Barcelona", Hostname: "spain-barcelona2-ca-version-2.expressnetw.com"},
{Country: "Spain", City: "Madrid", Hostname: "spain-ca-version-2.expressnetw.com"}, {Country: "Spain", City: "Madrid", Hostname: "spain-ca-version-2.expressnetw.com"},
{Country: "Sri Lanka", City: "Sri Lanka", Hostname: "srilanka-ca-version-2.expressnetw.com"}, {Country: "Sri Lanka", Hostname: "srilanka-ca-version-2.expressnetw.com"},
{Country: "Sweden", Hostname: "sweden-ca-version-2.expressnetw.com"}, {Country: "Sweden", Hostname: "sweden-ca-version-2.expressnetw.com"},
{Country: "Sweden", Hostname: "sweden2-ca-version-2.expressnetw.com"},
{Country: "Switzerland", Hostname: "switzerland-2-ca-version-2.expressnetw.com"}, {Country: "Switzerland", Hostname: "switzerland-2-ca-version-2.expressnetw.com"},
{Country: "Switzerland", Hostname: "switzerland-ca-version-2.expressnetw.com"}, {Country: "Switzerland", Hostname: "switzerland-ca-version-2.expressnetw.com"},
{Country: "Taiwan", Hostname: "taiwan-2-ca-version-2.expressnetw.com"}, {Country: "Taiwan", Hostname: "taiwan-3-ca-version-2.expressnetw.com"},
{Country: "Thailand", Hostname: "thailand-ca-version-2.expressnetw.com"}, {Country: "Thailand", Hostname: "thailand-ca-version-2.expressnetw.com"},
{Country: "Trinidad and Tobago", Hostname: "trinidadandtobago-ca-version-2.expressnetw.com"},
{Country: "Turkey", Hostname: "turkey-ca-version-2.expressnetw.com"}, {Country: "Turkey", Hostname: "turkey-ca-version-2.expressnetw.com"},
{Country: "Ukraine", Hostname: "ukraine-ca-version-2.expressnetw.com"}, {Country: "UK", City: "Docklands", Hostname: "uk-1-docklands-ca-version-2.expressnetw.com"},
{Country: "UK", City: "Docklands", Hostname: "uk-berkshire-2-ca-version-2.expressnetw.com"}, {Country: "UK", City: "East London", Hostname: "uk-east-london-ca-version-2.expressnetw.com"},
{Country: "UK", City: "London", Hostname: "uk-east-london-ca-version-2.expressnetw.com"},
{Country: "UK", City: "London", Hostname: "uk-london-ca-version-2.expressnetw.com"}, {Country: "UK", City: "London", Hostname: "uk-london-ca-version-2.expressnetw.com"},
{Country: "UK", City: "Midlands", Hostname: "uk-midlands-ca-version-2.expressnetw.com"},
{Country: "UK", City: "Tottenham", Hostname: "uk-tottenham-ca-version-2.expressnetw.com"},
{Country: "UK", City: "Wembley", Hostname: "uk-wembley-ca-version-2.expressnetw.com"},
{Country: "Ukraine", Hostname: "ukraine-ca-version-2.expressnetw.com"},
{Country: "Uruguay", Hostname: "uruguay-ca-version-2.expressnetw.com"}, {Country: "Uruguay", Hostname: "uruguay-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Albuquerque", Hostname: "usa-albuquerque-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Atlanta", Hostname: "usa-atlanta-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Atlanta", Hostname: "usa-atlanta-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Boston", Hostname: "us-boston-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Chicago", Hostname: "usa-chicago-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Chicago", Hostname: "usa-chicago-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Dallas", Hostname: "usa-dallas-2-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Dallas", Hostname: "usa-dallas-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Dallas", Hostname: "usa-dallas-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Denver", Hostname: "usa-denver-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Denver", Hostname: "usa-denver-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-1-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Houston", Hostname: "usa-houston-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Jackson", Hostname: "us-jackson-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Lincoln Park", Hostname: "usa-lincolnpark-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Little Rock", Hostname: "us-littlerock-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-2-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-2-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-3-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-3-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles5-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles5-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Miami", Hostname: "usa-miami-2-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Miami", Hostname: "usa-miami-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Miami", Hostname: "usa-miami-ca-version-2.expressnetw.com"},
{Country: "USA", City: "New Jersey", Hostname: "usa-newjersey-1-ca-version-2.expressnetw.com"}, {Country: "USA", City: "New Jersey", Hostname: "usa-newjersey-1-ca-version-2.expressnetw.com"},
{Country: "USA", City: "New Jersey", Hostname: "usa-newjersey2-ca-version-2.expressnetw.com"},
{Country: "USA", City: "New Jersey", Hostname: "usa-newjersey-3-ca-version-2.expressnetw.com"}, {Country: "USA", City: "New Jersey", Hostname: "usa-newjersey-3-ca-version-2.expressnetw.com"},
{Country: "USA", City: "New York", Hostname: "us-new-york-2-ca-version-2.expressnetw.com"}, {Country: "USA", City: "New Jersey", Hostname: "usa-newjersey2-ca-version-2.expressnetw.com"},
{Country: "USA", City: "New Orleans", Hostname: "us-neworleans-ca-version-2.expressnetw.com"},
{Country: "USA", City: "New York", Hostname: "usa-newyork-ca-version-2.expressnetw.com"}, {Country: "USA", City: "New York", Hostname: "usa-newyork-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Oklahoma City", Hostname: "us-oklahoma-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Phoenix", Hostname: "usa-phoenix-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Salt Lake City", Hostname: "usa-saltlakecity-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Salt Lake City", Hostname: "usa-saltlakecity-ca-version-2.expressnetw.com"},
{Country: "USA", City: "San Francisco", Hostname: "usa-sanfrancisco-ca-version-2.expressnetw.com"}, {Country: "USA", City: "San Francisco", Hostname: "usa-sanfrancisco-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Santa Monica", Hostname: "usa-santa-monica-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Seattle", Hostname: "usa-seattle-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Seattle", Hostname: "usa-seattle-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Tampa", Hostname: "usa-tampa-1-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Tampa", Hostname: "usa-tampa-1-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Washington DC", Hostname: "usa-washingtondc-ca-version-2.expressnetw.com"}, {Country: "USA", City: "Washington DC", Hostname: "usa-washingtondc-ca-version-2.expressnetw.com"},
{Country: "USA", City: "Wichita", Hostname: "us-wichita-ca-version-2.expressnetw.com"},
{Country: "Uzbekistan", Hostname: "uzbekistan-ca-version-2.expressnetw.com"}, {Country: "Uzbekistan", Hostname: "uzbekistan-ca-version-2.expressnetw.com"},
{Country: "Venezuela", Hostname: "venezuela-ca-version-2.expressnetw.com"}, {Country: "Venezuela", Hostname: "venezuela-ca-version-2.expressnetw.com"},
{Country: "Vietnam", Hostname: "vietnam-ca-version-2.expressnetw.com"}, {Country: "Vietnam", Hostname: "vietnam-ca-version-2.expressnetw.com"},

View File

@@ -407,7 +407,7 @@ func bindPort(ctx context.Context, client *http.Client, apiIPAddress netip.Addr,
// replaceInErr is used to remove sensitive information from errors. // replaceInErr is used to remove sensitive information from errors.
func replaceInErr(err error, substitutions map[string]string) error { func replaceInErr(err error, substitutions map[string]string) error {
s := replaceInString(err.Error(), substitutions) s := replaceInString(err.Error(), substitutions)
return errors.New(s) //nolint:goerr113 return errors.New(s) //nolint:err113
} }
// replaceInString is used to remove sensitive information. // replaceInString is used to remove sensitive information.

View File

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

View File

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

View File

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

View File

@@ -7,17 +7,13 @@ import (
) )
type Updater struct { type Updater struct {
client *http.Client client *http.Client
username string warner common.Warner
password string
warner common.Warner
} }
func New(client *http.Client, warner common.Warner, username, password string) *Updater { func New(client *http.Client, warner common.Warner) *Updater {
return &Updater{ return &Updater{
client: client, client: client,
username: username, warner: warner,
password: password,
warner: warner,
} }
} }

View File

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

View File

@@ -54,7 +54,7 @@ type Extractor interface {
func NewProviders(storage Storage, timeNow func() time.Time, func NewProviders(storage Storage, timeNow func() time.Time,
updaterWarner common.Warner, client *http.Client, unzipper common.Unzipper, updaterWarner common.Warner, client *http.Client, unzipper common.Unzipper,
parallelResolver common.ParallelResolver, ipFetcher common.IPFetcher, parallelResolver common.ParallelResolver, ipFetcher common.IPFetcher,
extractor custom.Extractor, credentials settings.Updater, extractor custom.Extractor,
) *Providers { ) *Providers {
randSource := rand.NewSource(timeNow().UnixNano()) randSource := rand.NewSource(timeNow().UnixNano())
@@ -62,7 +62,7 @@ func NewProviders(storage Storage, timeNow func() time.Time,
providerNameToProvider := map[string]Provider{ providerNameToProvider := map[string]Provider{
providers.Airvpn: airvpn.New(storage, randSource, client), providers.Airvpn: airvpn.New(storage, randSource, client),
providers.Custom: custom.New(extractor), providers.Custom: custom.New(extractor),
providers.Cyberghost: cyberghost.New(storage, randSource, updaterWarner, parallelResolver), providers.Cyberghost: cyberghost.New(storage, randSource, parallelResolver),
providers.Expressvpn: expressvpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver), providers.Expressvpn: expressvpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver),
providers.Fastestvpn: fastestvpn.New(storage, randSource, client, updaterWarner, parallelResolver), providers.Fastestvpn: fastestvpn.New(storage, randSource, client, updaterWarner, parallelResolver),
providers.Giganews: giganews.New(storage, randSource, unzipper, updaterWarner, parallelResolver), providers.Giganews: giganews.New(storage, randSource, unzipper, updaterWarner, parallelResolver),
@@ -75,7 +75,7 @@ func NewProviders(storage Storage, timeNow func() time.Time,
providers.Privado: privado.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver), providers.Privado: privado.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver),
providers.PrivateInternetAccess: privateinternetaccess.New(storage, randSource, timeNow, client), providers.PrivateInternetAccess: privateinternetaccess.New(storage, randSource, timeNow, client),
providers.Privatevpn: privatevpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver), providers.Privatevpn: privatevpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver),
providers.Protonvpn: protonvpn.New(storage, randSource, client, updaterWarner, *credentials.ProtonUsername, *credentials.ProtonPassword), providers.Protonvpn: protonvpn.New(storage, randSource, client, updaterWarner),
providers.Purevpn: purevpn.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver), providers.Purevpn: purevpn.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver),
providers.SlickVPN: slickvpn.New(storage, randSource, client, updaterWarner, parallelResolver), providers.SlickVPN: slickvpn.New(storage, randSource, client, updaterWarner, parallelResolver),
providers.Surfshark: surfshark.New(storage, randSource, client, unzipper, updaterWarner, parallelResolver), providers.Surfshark: surfshark.New(storage, randSource, client, unzipper, updaterWarner, parallelResolver),

View File

@@ -36,7 +36,7 @@ func FetchMultiInfo(ctx context.Context, fetcher InfoFetcher, ips []netip.Addr)
} }
results = make([]models.PublicIP, len(ips)) results = make([]models.PublicIP, len(ips))
for range len(ips) { for range ips {
aResult := <-resultsCh aResult := <-resultsCh
if aResult.err != nil { if aResult.err != nil {
if err == nil { if err == nil {

View File

@@ -26,7 +26,7 @@ type dnsHandler struct {
func (h *dnsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *dnsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.RequestURI = strings.TrimPrefix(r.RequestURI, "/dns") r.RequestURI = strings.TrimPrefix(r.RequestURI, "/dns")
switch r.RequestURI { switch r.RequestURI {
case "/status": //nolint:goconst case "/status":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
h.getStatus(w) h.getStatus(w)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,11 +2,9 @@ package updater
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"time" "time"
"github.com/qdm12/gluetun/internal/provider/common"
"github.com/qdm12/gluetun/internal/updater/unzip" "github.com/qdm12/gluetun/internal/updater/unzip"
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
@@ -50,22 +48,22 @@ func (u *Updater) UpdateServers(ctx context.Context, providers []string,
// TODO support servers offering only TCP or only UDP // TODO support servers offering only TCP or only UDP
// for NordVPN and PureVPN // for NordVPN and PureVPN
err := u.updateProvider(ctx, fetcher, minRatio) err := u.updateProvider(ctx, fetcher, minRatio)
switch { if err == nil {
case err == nil:
continue continue
case errors.Is(err, common.ErrCredentialsMissing):
u.logger.Warn(err.Error() + " - skipping update for " + providerName)
continue
case len(providers) == 1:
// return the only error for the single provider.
return 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 return nil

View File

@@ -99,3 +99,13 @@ type CmdStarter interface {
stdoutLines, stderrLines <-chan string, stdoutLines, stderrLines <-chan string,
waitError <-chan error, startErr error) waitError <-chan error, startErr error)
} }
type HealthChecker interface {
SetConfig(tlsDialAddr string, icmpTarget netip.Addr)
Start(ctx context.Context) (runError <-chan error, err error)
Stop() error
}
type HealthServer interface {
SetError(err error)
}

View File

@@ -13,10 +13,13 @@ import (
) )
type Loop struct { type Loop struct {
statusManager *loopstate.State statusManager *loopstate.State
state *state.State state *state.State
providers Providers providers Providers
storage Storage storage Storage
healthSettings settings.Health
healthChecker HealthChecker
healthServer HealthServer
// Fixed parameters // Fixed parameters
buildInfo models.BuildInformation buildInfo models.BuildInformation
versionInfo bool versionInfo bool
@@ -49,7 +52,8 @@ const (
) )
func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint16, func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint16,
providers Providers, storage Storage, openvpnConf OpenVPN, providers Providers, storage Storage, healthSettings settings.Health,
healthChecker HealthChecker, healthServer HealthServer, openvpnConf OpenVPN,
netLinker NetLinker, fw Firewall, routing Routing, netLinker NetLinker, fw Firewall, routing Routing,
portForward PortForward, starter CmdStarter, portForward PortForward, starter CmdStarter,
publicip PublicIPLoop, dnsLooper DNSLoop, publicip PublicIPLoop, dnsLooper DNSLoop,
@@ -65,29 +69,32 @@ func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint1
state := state.New(statusManager, vpnSettings) state := state.New(statusManager, vpnSettings)
return &Loop{ return &Loop{
statusManager: statusManager, statusManager: statusManager,
state: state, state: state,
providers: providers, providers: providers,
storage: storage, storage: storage,
buildInfo: buildInfo, healthSettings: healthSettings,
versionInfo: versionInfo, healthChecker: healthChecker,
ipv6Supported: ipv6Supported, healthServer: healthServer,
vpnInputPorts: vpnInputPorts, buildInfo: buildInfo,
openvpnConf: openvpnConf, versionInfo: versionInfo,
netLinker: netLinker, ipv6Supported: ipv6Supported,
fw: fw, vpnInputPorts: vpnInputPorts,
routing: routing, openvpnConf: openvpnConf,
portForward: portForward, netLinker: netLinker,
publicip: publicip, fw: fw,
dnsLooper: dnsLooper, routing: routing,
starter: starter, portForward: portForward,
logger: logger, publicip: publicip,
client: client, dnsLooper: dnsLooper,
start: start, starter: starter,
running: running, logger: logger,
stop: stop, client: client,
stopped: stopped, start: start,
userTrigger: true, running: running,
backoffTime: defaultBackoffTime, stop: stop,
stopped: stopped,
userTrigger: true,
backoffTime: defaultBackoffTime,
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/openvpn" "github.com/qdm12/gluetun/internal/openvpn"
"github.com/qdm12/gluetun/internal/provider" "github.com/qdm12/gluetun/internal/provider"
) )
@@ -14,39 +15,38 @@ import (
func setupOpenVPN(ctx context.Context, fw Firewall, func setupOpenVPN(ctx context.Context, fw Firewall,
openvpnConf OpenVPN, providerConf provider.Provider, openvpnConf OpenVPN, providerConf provider.Provider,
settings settings.VPN, ipv6Supported bool, starter CmdStarter, settings settings.VPN, ipv6Supported bool, starter CmdStarter,
logger openvpn.Logger) (runner *openvpn.Runner, serverName string, logger openvpn.Logger) (runner *openvpn.Runner, connection models.Connection, err error,
canPortForward bool, err error,
) { ) {
connection, err := providerConf.GetConnection(settings.Provider.ServerSelection, ipv6Supported) connection, err = providerConf.GetConnection(settings.Provider.ServerSelection, ipv6Supported)
if err != nil { if err != nil {
return nil, "", false, fmt.Errorf("finding a valid server connection: %w", err) return nil, models.Connection{}, fmt.Errorf("finding a valid server connection: %w", err)
} }
lines := providerConf.OpenVPNConfig(connection, settings.OpenVPN, ipv6Supported) lines := providerConf.OpenVPNConfig(connection, settings.OpenVPN, ipv6Supported)
if err := openvpnConf.WriteConfig(lines); err != nil { if err := openvpnConf.WriteConfig(lines); err != nil {
return nil, "", false, fmt.Errorf("writing configuration to file: %w", err) return nil, models.Connection{}, fmt.Errorf("writing configuration to file: %w", err)
} }
if *settings.OpenVPN.User != "" { if *settings.OpenVPN.User != "" {
err := openvpnConf.WriteAuthFile(*settings.OpenVPN.User, *settings.OpenVPN.Password) err := openvpnConf.WriteAuthFile(*settings.OpenVPN.User, *settings.OpenVPN.Password)
if err != nil { if err != nil {
return nil, "", false, fmt.Errorf("writing auth to file: %w", err) return nil, models.Connection{}, fmt.Errorf("writing auth to file: %w", err)
} }
} }
if *settings.OpenVPN.KeyPassphrase != "" { if *settings.OpenVPN.KeyPassphrase != "" {
err := openvpnConf.WriteAskPassFile(*settings.OpenVPN.KeyPassphrase) err := openvpnConf.WriteAskPassFile(*settings.OpenVPN.KeyPassphrase)
if err != nil { if err != nil {
return nil, "", false, fmt.Errorf("writing askpass file: %w", err) return nil, models.Connection{}, fmt.Errorf("writing askpass file: %w", err)
} }
} }
if err := fw.SetVPNConnection(ctx, connection, settings.OpenVPN.Interface); err != nil { if err := fw.SetVPNConnection(ctx, connection, settings.OpenVPN.Interface); err != nil {
return nil, "", false, fmt.Errorf("allowing VPN connection through firewall: %w", err) return nil, models.Connection{}, fmt.Errorf("allowing VPN connection through firewall: %w", err)
} }
runner = openvpn.NewRunner(settings.OpenVPN, starter, logger) runner = openvpn.NewRunner(settings.OpenVPN, starter, logger)
return runner, connection.ServerName, connection.PortForward, nil return runner, connection, nil
} }

View File

@@ -5,6 +5,7 @@ import (
"github.com/qdm12/gluetun/internal/constants" "github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/constants/vpn" "github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/log" "github.com/qdm12/log"
) )
@@ -28,17 +29,17 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
var vpnRunner interface { var vpnRunner interface {
Run(ctx context.Context, waitError chan<- error, tunnelReady chan<- struct{}) Run(ctx context.Context, waitError chan<- error, tunnelReady chan<- struct{})
} }
var serverName, vpnInterface string var vpnInterface string
var canPortForward bool var connection models.Connection
var err error var err error
subLogger := l.logger.New(log.SetComponent(settings.Type)) subLogger := l.logger.New(log.SetComponent(settings.Type))
if settings.Type == vpn.OpenVPN { if settings.Type == vpn.OpenVPN {
vpnInterface = settings.OpenVPN.Interface vpnInterface = settings.OpenVPN.Interface
vpnRunner, serverName, canPortForward, err = setupOpenVPN(ctx, l.fw, vpnRunner, connection, err = setupOpenVPN(ctx, l.fw,
l.openvpnConf, providerConf, settings, l.ipv6Supported, l.starter, subLogger) l.openvpnConf, providerConf, settings, l.ipv6Supported, l.starter, subLogger)
} else { // Wireguard } else { // Wireguard
vpnInterface = settings.Wireguard.Interface vpnInterface = settings.Wireguard.Interface
vpnRunner, serverName, canPortForward, err = setupWireguard(ctx, l.netLinker, l.fw, vpnRunner, connection, err = setupWireguard(ctx, l.netLinker, l.fw,
providerConf, settings, l.ipv6Supported, subLogger) providerConf, settings, l.ipv6Supported, subLogger)
} }
if err != nil { if err != nil {
@@ -46,8 +47,9 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
continue continue
} }
tunnelUpData := tunnelUpData{ tunnelUpData := tunnelUpData{
serverName: serverName, serverIP: connection.IP,
canPortForward: canPortForward, serverName: connection.ServerName,
canPortForward: connection.PortForward,
portForwarder: portForwarder, portForwarder: portForwarder,
vpnIntf: vpnInterface, vpnIntf: vpnInterface,
username: settings.Provider.PortForwarding.Username, username: settings.Provider.PortForwarding.Username,
@@ -73,7 +75,7 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
for stayHere { for stayHere {
select { select {
case <-tunnelReady: case <-tunnelReady:
go l.onTunnelUp(openvpnCtx, tunnelUpData) go l.onTunnelUp(openvpnCtx, ctx, tunnelUpData)
case <-ctx.Done(): case <-ctx.Done():
l.cleanup() l.cleanup()
openvpnCancel() openvpnCancel()

View File

@@ -2,6 +2,7 @@ package vpn
import ( import (
"context" "context"
"net/netip"
"github.com/qdm12/dns/v2/pkg/check" "github.com/qdm12/dns/v2/pkg/check"
"github.com/qdm12/gluetun/internal/constants" "github.com/qdm12/gluetun/internal/constants"
@@ -9,6 +10,8 @@ import (
) )
type tunnelUpData struct { type tunnelUpData struct {
// Healthcheck
serverIP netip.Addr
// Port forwarding // Port forwarding
vpnIntf string vpnIntf string
serverName string // used for PIA serverName string // used for PIA
@@ -18,7 +21,7 @@ type tunnelUpData struct {
portForwarder PortForwarder portForwarder PortForwarder
} }
func (l *Loop) onTunnelUp(ctx context.Context, data tunnelUpData) { func (l *Loop) onTunnelUp(ctx, loopCtx context.Context, data tunnelUpData) {
l.client.CloseIdleConnections() l.client.CloseIdleConnections()
for _, vpnPort := range l.vpnInputPorts { for _, vpnPort := range l.vpnInputPorts {
@@ -28,6 +31,21 @@ func (l *Loop) onTunnelUp(ctx context.Context, data tunnelUpData) {
} }
} }
icmpTarget := l.healthSettings.ICMPTargetIP
if icmpTarget.IsUnspecified() {
icmpTarget = data.serverIP
}
l.healthChecker.SetConfig(l.healthSettings.TargetAddress, icmpTarget)
healthErrCh, err := l.healthChecker.Start(ctx)
l.healthServer.SetError(err)
if err != nil {
// Note this restart call must be done in a separate goroutine
// from the VPN loop goroutine.
l.restartVPN(loopCtx, err)
return
}
if *l.dnsLooper.GetSettings().DoT.Enabled { if *l.dnsLooper.GetSettings().DoT.Enabled {
_, _ = l.dnsLooper.ApplyStatus(ctx, constants.Running) _, _ = l.dnsLooper.ApplyStatus(ctx, constants.Running)
} else { } else {
@@ -37,7 +55,7 @@ func (l *Loop) onTunnelUp(ctx context.Context, data tunnelUpData) {
} }
} }
err := l.publicip.RunOnce(ctx) err = l.publicip.RunOnce(ctx)
if err != nil { if err != nil {
l.logger.Error("getting public IP address information: " + err.Error()) l.logger.Error("getting public IP address information: " + err.Error())
} }
@@ -56,4 +74,41 @@ func (l *Loop) onTunnelUp(ctx context.Context, data tunnelUpData) {
if err != nil { if err != nil {
l.logger.Error(err.Error()) l.logger.Error(err.Error())
} }
l.collectHealthErrors(ctx, loopCtx, healthErrCh)
}
func (l *Loop) collectHealthErrors(ctx, loopCtx context.Context, healthErrCh <-chan error) {
var previousHealthErr error
for {
select {
case <-ctx.Done():
_ = l.healthChecker.Stop()
return
case healthErr := <-healthErrCh:
l.healthServer.SetError(healthErr)
if healthErr != nil {
if *l.healthSettings.RestartVPN {
// Note this restart call must be done in a separate goroutine
// from the VPN loop goroutine.
_ = l.healthChecker.Stop()
l.restartVPN(loopCtx, healthErr)
return
}
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)")
}
previousHealthErr = healthErr
}
}
}
func (l *Loop) restartVPN(ctx context.Context, healthErr error) {
l.logger.Warnf("restarting VPN because it failed to pass the healthcheck: %s", healthErr)
l.logger.Info("👉 See https://github.com/qdm12/gluetun-wiki/blob/main/faq/healthcheck.md")
l.logger.Info("DO NOT OPEN AN ISSUE UNLESS YOU HAVE READ AND TRIED EVERY POSSIBLE SOLUTION")
_, _ = l.ApplyStatus(ctx, constants.Stopped)
_, _ = l.ApplyStatus(ctx, constants.Running)
} }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/provider" "github.com/qdm12/gluetun/internal/provider"
"github.com/qdm12/gluetun/internal/provider/utils" "github.com/qdm12/gluetun/internal/provider/utils"
"github.com/qdm12/gluetun/internal/wireguard" "github.com/qdm12/gluetun/internal/wireguard"
@@ -16,11 +17,11 @@ import (
func setupWireguard(ctx context.Context, netlinker NetLinker, func setupWireguard(ctx context.Context, netlinker NetLinker,
fw Firewall, providerConf provider.Provider, fw Firewall, providerConf provider.Provider,
settings settings.VPN, ipv6Supported bool, logger wireguard.Logger) ( settings settings.VPN, ipv6Supported bool, logger wireguard.Logger) (
wireguarder *wireguard.Wireguard, serverName string, canPortForward bool, err error, wireguarder *wireguard.Wireguard, connection models.Connection, err error,
) { ) {
connection, err := providerConf.GetConnection(settings.Provider.ServerSelection, ipv6Supported) connection, err = providerConf.GetConnection(settings.Provider.ServerSelection, ipv6Supported)
if err != nil { if err != nil {
return nil, "", false, fmt.Errorf("finding a VPN server: %w", err) return nil, models.Connection{}, fmt.Errorf("finding a VPN server: %w", err)
} }
wireguardSettings := utils.BuildWireguardSettings(connection, settings.Wireguard, ipv6Supported) wireguardSettings := utils.BuildWireguardSettings(connection, settings.Wireguard, ipv6Supported)
@@ -31,13 +32,13 @@ func setupWireguard(ctx context.Context, netlinker NetLinker,
wireguarder, err = wireguard.New(wireguardSettings, netlinker, logger) wireguarder, err = wireguard.New(wireguardSettings, netlinker, logger)
if err != nil { if err != nil {
return nil, "", false, fmt.Errorf("creating Wireguard: %w", err) return nil, models.Connection{}, fmt.Errorf("creating Wireguard: %w", err)
} }
err = fw.SetVPNConnection(ctx, connection, settings.Wireguard.Interface) err = fw.SetVPNConnection(ctx, connection, settings.Wireguard.Interface)
if err != nil { if err != nil {
return nil, "", false, fmt.Errorf("setting firewall: %w", err) return nil, models.Connection{}, fmt.Errorf("setting firewall: %w", err)
} }
return wireguarder, connection.ServerName, connection.PortForward, nil return wireguarder, connection, nil
} }