Compare commits

..

2 Commits

57 changed files with 529 additions and 3377 deletions
+2 -2
View File
@@ -4,12 +4,12 @@ Contributions are [released](https://help.github.com/articles/github-terms-of-se
## Submitting a pull request
1. [Fork](https://github.com/passteque/gluetun/fork) and clone the repository
1. [Fork](https://github.com/qdm12/gluetun/fork) and clone the repository
1. Create a new branch `git checkout -b my-branch-name`
1. Modify the code
1. Ensure the docker build succeeds `docker build .` (you might need `export DOCKER_BUILDKIT=1`)
1. Commit your modifications
1. Push to your fork and [submit a pull request](https://github.com/passteque/gluetun/compare)
1. Push to your fork and [submit a pull request](https://github.com/qdm12/gluetun/compare)
## Resources
+2 -2
View File
@@ -4,8 +4,8 @@ contact_links:
url: https://github.com/qdm12/gluetun-wiki/issues/new/choose
about: Please create an issue on the gluetun-wiki repository.
- name: Configuration help?
url: https://github.com/passteque/gluetun/discussions/new/choose
url: https://github.com/qdm12/gluetun/discussions/new/choose
about: Please create a Github discussion.
- name: Unraid template issue
url: https://github.com/passteque/gluetun/discussions/550
url: https://github.com/qdm12/gluetun/discussions/550
about: Please read the relevant Github discussion.
+5 -31
View File
@@ -4,38 +4,12 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
interval: "daily"
- package-ecosystem: docker
directory: /
schedule:
interval: "weekly"
- # Servers data dependency that should be updated as soon as
# possible when a new version is released, to have the latest
# servers available
package-ecosystem: "gomod"
directory: "/"
interval: "daily"
- package-ecosystem: gomod
directory: /
schedule:
interval: "weekly"
ignore:
- # In particular avoid amneziawg-go which have v1.x.y versions available
# on the Go modules proxy, but are not in the Github repository tags
# and are not the latest releases either. Most likely a mistake from the
# maintainers, which is persisted on the Go proxy.
dependency-name: "github.com/amnezia-vpn/amneziawg-go"
versions: ["1.x"]
groups:
low-importance:
patterns:
- "github.com/breml/rootcerts"
- "github.com/fatih/color"
- "github.com/golang/mock"
- "github.com/klauspost/compress"
- "github.com/klauspost/pgzip"
- "github.com/pelletier/go-toml/v2"
- "github.com/qdm12/goshutdown"
- "github.com/qdm12/gosplash"
- "github.com/qdm12/gotree"
- "github.com/qdm12/log"
- "github.com/stretchr/testify"
- "github.com/ulikunitz/xz"
- "gopkg.in/ini.v1"
interval: "daily"
-2
View File
@@ -140,8 +140,6 @@
color: "ffc7ea"
- name: "Category: Shadowsocks 🔁"
color: "ffc7ea"
- name: "Category: Socks5 proxy 🔁"
color: "ffc7ea"
- name: "Category: control server ⚙️"
color: "ffc7ea"
- name: "Category: kernel 🧠"
+1
View File
@@ -8,4 +8,5 @@
# 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/)
+13 -20
View File
@@ -28,10 +28,6 @@ on:
- go.mod
- go.sum
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
@@ -48,6 +44,7 @@ jobs:
locale: "US"
level: error
exclude: |
./internal/storage/servers.json
./.golangci.yml
*.md
@@ -95,13 +92,13 @@ jobs:
verify-private:
if: |
github.repository == 'passteque/gluetun' &&
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]
needs: [ verify ]
runs-on: ubuntu-latest
environment: secrets
steps:
@@ -123,8 +120,7 @@ jobs:
- name: Run Gluetun container with ProtonVPN Wireguard and port forwarding
configuration
run:
echo -e "${{ secrets.PROTONVPN_WIREGUARD_PRIVATE_KEY }}" | ./ci/runner
run: echo -e "${{ secrets.PROTONVPN_WIREGUARD_PRIVATE_KEY }}" | ./ci/runner
protonvpn-wireguard-port-forwarding
- name: Run Gluetun container with ProtonVPN OpenVPN and port forwarding
@@ -133,12 +129,11 @@ jobs:
secrets.PROTONVPN_OPENVPN_PASSWORD }}" | ./ci/runner
protonvpn-openvpn-port-forwarding
# - name:
# Run Gluetun container with Private Internet Access OpenVPN and port
# forwarding configuration
# run: echo -e "${{ secrets.PRIVATEINTERNETACCESS_OPENVPN_USER }}\n${{
# secrets.PRIVATEINTERNETACCESS_OPENVPN_PASSWORD }}" | ./ci/runner
# private-internet-access-openvpn-port-forwarding
- name: Run Gluetun container with Private Internet Access OpenVPN and port
forwarding configuration
run: echo -e "${{ secrets.PRIVATEINTERNETACCESS_OPENVPN_USER }}\n${{
secrets.PRIVATEINTERNETACCESS_OPENVPN_PASSWORD }}" | ./ci/runner
private-internet-access-openvpn-port-forwarding
- name: Run Gluetun container with AirVPN Wireguard configuration
run: echo -e "${{ secrets.AIRVPN_WIREGUARD_PRIVATE_KEY }}\n${{
@@ -146,8 +141,7 @@ jobs:
secrets.AIRVPN_WIREGUARD_ADDRESSES }}" | ./ci/runner airvpn-wireguard
- name: Run Gluetun container with AirVPN OpenVPN configuration
run:
echo -e "${{ secrets.AIRVPN_OPENVPN_KEY }}\n${{ secrets.AIRVPN_OPENVPN_CERT
run: echo -e "${{ secrets.AIRVPN_OPENVPN_KEY }}\n${{ secrets.AIRVPN_OPENVPN_CERT
}}" | ./ci/runner airvpn-openvpn
codeql:
@@ -169,19 +163,18 @@ jobs:
publish:
if: |
github.repository == 'passteque/gluetun' &&
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, verify-private, codeql]
needs: [ verify, verify-private, codeql ]
permissions:
actions: read
contents: read
packages: write
runs-on: ubuntu-latest
environment: secrets
steps:
- uses: actions/checkout@v6
@@ -216,7 +209,7 @@ jobs:
with:
registry: ghcr.io
username: qdm12
password: ${{ secrets.GHCR_PAT }}
password: ${{ github.token }}
- name: Short commit
id: shortcommit
+1 -6
View File
@@ -11,17 +11,12 @@ on:
- "**.md"
- .github/workflows/markdown.yml
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
markdown:
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
environment: secrets
steps:
- uses: actions/checkout@v6
@@ -43,7 +38,7 @@ jobs:
config-file: .github/workflows/configs/mlc-config.json
- uses: peter-evans/dockerhub-description@v5
if: github.repository == 'passteque/gluetun' && github.event_name == 'push'
if: github.repository == 'qdm12/gluetun' && github.event_name == 'push'
with:
username: qmcgaw
password: ${{ secrets.DOCKERHUB_PASSWORD }}
+98
View File
@@ -0,0 +1,98 @@
name: Update servers list
on:
workflow_dispatch:
inputs:
provider:
description: "VPN Provider to update"
required: true
default: "all"
type: choice
options:
- all
- airvpn
- cyberghost
- expressvpn
- fastestvpn
- giganews
- hidemyass
- ipvanish
- ivpn
- mullvad
- nordvpn
- perfect privacy
- privado
- private internet access
- privatevpn
- protonvpn
- purevpn
- slickvpn
- surfshark
- torguard
- vpnsecure
- vpn unlimited
- vyprvpn
- windscribe
schedule:
- cron: "11 3 1 */2 *" # Run at 03:11 on the 1st of every 2nd month
jobs:
update-servers-list:
if: github.repository == 'qdm12/gluetun'
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Update servers list
run: |
SELECTED_PROVIDER="${{ github.event.inputs.provider || 'all' }}"
if [ "$SELECTED_PROVIDER" = "all" ]; then
FLAGS="-all"
else
FLAGS="-providers $SELECTED_PROVIDER"
fi
go run ./cmd/gluetun/main.go update $FLAGS \
-maintainer \
-proton-email "${{ secrets.PROTON_EMAIL }}" \
-proton-password "${{ secrets.PROTON_PASSWORD }}"
- name: Check for changes
run: |
if git diff --exit-code internal/storage/servers.json >/dev/null; then
echo "Error: internal/storage/servers.json was not modified."
exit 1
fi
- name: Check no other file changes
run: |
if ! git diff --exit-code --quiet ':!internal/storage/servers.json'; then
echo "Error: Unexpected changes detected in files other than servers.json"
git status --short
exit 1
fi
- name: Create Pull Request
id: createpr
uses: peter-evans/create-pull-request@v8
with:
branch-suffix: timestamp
branch: bot/update-servers-list
base: master
delete-branch: true
title: "feat(providers/${{ github.event.inputs.provider || 'all' }}): servers data update"
body: |
This PR was automatically generated by the [Update servers list](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow run.
# - name: Merge Pull Request
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# run: |
# gh pr merge ${{ steps.createpr.outputs.pull-request-number }} --auto -m -d
-4
View File
@@ -12,10 +12,6 @@ formatters:
- builtin$
- examples$
run:
build-tags:
- integration
linters:
settings:
misspell:
+1 -1
View File
@@ -3,7 +3,7 @@
// to develop this project.
"files.eol": "\n",
"editor.formatOnSave": true,
"go.buildTags": "linux,integration",
"go.buildTags": "linux",
"go.toolsEnvVars": {
"CGO_ENABLED": "0"
},
+4 -9
View File
@@ -72,9 +72,9 @@ LABEL \
org.opencontainers.image.created=$CREATED \
org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$COMMIT \
org.opencontainers.image.url="https://github.com/passteque/gluetun" \
org.opencontainers.image.documentation="https://github.com/passteque/gluetun" \
org.opencontainers.image.source="https://github.com/passteque/gluetun" \
org.opencontainers.image.url="https://github.com/qdm12/gluetun" \
org.opencontainers.image.documentation="https://github.com/qdm12/gluetun" \
org.opencontainers.image.source="https://github.com/qdm12/gluetun" \
org.opencontainers.image.title="VPN swiss-knife like client for multiple VPN providers" \
org.opencontainers.image.description="VPN swiss-knife like client to tunnel to multiple VPN servers using OpenVPN, IPtables, DNS over TLS, Shadowsocks, an HTTP proxy and Alpine Linux"
ENV VPN_SERVICE_PROVIDER=pia \
@@ -240,11 +240,6 @@ ENV VPN_SERVICE_PROVIDER=pia \
SHADOWSOCKS_PASSWORD= \
SHADOWSOCKS_PASSWORD_SECRETFILE=/run/secrets/shadowsocks_password \
SHADOWSOCKS_CIPHER=chacha20-ietf-poly1305 \
# Socks5
SOCKS5_ENABLED=off \
SOCKS5_LISTENING_ADDRESS=":1080" \
SOCKS5_USER= \
SOCKS5_PASSWORD= \
# Control server
HTTP_CONTROL_SERVER_LOG=on \
HTTP_CONTROL_SERVER_ADDRESS=":8000" \
@@ -276,7 +271,7 @@ ENV VPN_SERVICE_PROVIDER=pia \
PUID=1000 \
PGID=1000
ENTRYPOINT ["/gluetun-entrypoint"]
EXPOSE 8000/tcp 8888/tcp 8388/tcp 8388/udp 1080/tcp 1080/udp
EXPOSE 8000/tcp 8888/tcp 8388/tcp 8388/udp
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=3 CMD /gluetun-entrypoint healthcheck
ARG TARGETPLATFORM
RUN apk add --no-cache --update -l wget && \
+19 -20
View File
@@ -6,9 +6,9 @@ Lightweight swiss-army-knife-like VPN client to multiple VPN service providers
🗯️ this repository will be migrated to [github.com/passteque/gluetun](https://github.com/passteque/gluetun) on 2026-05-21, which is a Github organization under my sole control, so don't get alarmed if you get redirected in the coming days 😉 Reason being migrating Github sponsors to the Open source collective due to my personal situation, basically annoying paperwork. On the plus side, it will be more transparent and funds donated will only be used for the project. The Docker image names will remain the same.
![Title image](https://raw.githubusercontent.com/passteque/gluetun/master/title.svg)
![Title image](https://raw.githubusercontent.com/qdm12/gluetun/master/title.svg)
[![Build status](https://github.com/passteque/gluetun/actions/workflows/ci.yml/badge.svg)](https://github.com/passteque/gluetun/actions/workflows/ci.yml)
[![Build status](https://github.com/qdm12/gluetun/actions/workflows/ci.yml/badge.svg)](https://github.com/qdm12/gluetun/actions/workflows/ci.yml)
[![Docker pulls qmcgaw/gluetun](https://img.shields.io/docker/pulls/qmcgaw/gluetun.svg)](https://hub.docker.com/r/qmcgaw/gluetun)
[![Docker pulls qmcgaw/private-internet-access](https://img.shields.io/docker/pulls/qmcgaw/private-internet-access.svg)](https://hub.docker.com/r/qmcgaw/gluetun)
@@ -16,23 +16,23 @@ Lightweight swiss-army-knife-like VPN client to multiple VPN service providers
[![Docker stars qmcgaw/gluetun](https://img.shields.io/docker/stars/qmcgaw/gluetun.svg)](https://hub.docker.com/r/qmcgaw/gluetun)
[![Docker stars qmcgaw/private-internet-access](https://img.shields.io/docker/stars/qmcgaw/private-internet-access.svg)](https://hub.docker.com/r/qmcgaw/gluetun)
![Last release](https://img.shields.io/github/release/passteque/gluetun?label=Last%20release)
![Last release](https://img.shields.io/github/release/qdm12/gluetun?label=Last%20release)
![Last Docker tag](https://img.shields.io/docker/v/qmcgaw/gluetun?sort=semver&label=Last%20Docker%20tag)
[![Last release size](https://img.shields.io/docker/image-size/qmcgaw/gluetun?sort=semver&label=Last%20released%20image)](https://hub.docker.com/r/qmcgaw/gluetun/tags?page=1&ordering=last_updated)
![GitHub last release date](https://img.shields.io/github/release-date/passteque/gluetun?label=Last%20release%20date)
![Commits since release](https://img.shields.io/github/commits-since/passteque/gluetun/latest?sort=semver)
![GitHub last release date](https://img.shields.io/github/release-date/qdm12/gluetun?label=Last%20release%20date)
![Commits since release](https://img.shields.io/github/commits-since/qdm12/gluetun/latest?sort=semver)
[![Latest size](https://img.shields.io/docker/image-size/qmcgaw/gluetun/latest?label=Latest%20image)](https://hub.docker.com/r/qmcgaw/gluetun/tags)
[![GitHub last commit](https://img.shields.io/github/last-commit/passteque/gluetun.svg)](https://github.com/passteque/gluetun/commits/master)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/y/passteque/gluetun.svg)](https://github.com/passteque/gluetun/graphs/contributors)
[![GitHub closed PRs](https://img.shields.io/github/issues-pr-closed/passteque/gluetun.svg)](https://github.com/passteque/gluetun/pulls?q=is%3Apr+is%3Aclosed)
[![GitHub issues](https://img.shields.io/github/issues/passteque/gluetun.svg)](https://github.com/passteque/gluetun/issues)
[![GitHub closed issues](https://img.shields.io/github/issues-closed/passteque/gluetun.svg)](https://github.com/passteque/gluetun/issues?q=is%3Aissue+is%3Aclosed)
[![GitHub last commit](https://img.shields.io/github/last-commit/qdm12/gluetun.svg)](https://github.com/qdm12/gluetun/commits/master)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/y/qdm12/gluetun.svg)](https://github.com/qdm12/gluetun/graphs/contributors)
[![GitHub closed PRs](https://img.shields.io/github/issues-pr-closed/qdm12/gluetun.svg)](https://github.com/qdm12/gluetun/pulls?q=is%3Apr+is%3Aclosed)
[![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)
![Code size](https://img.shields.io/github/languages/code-size/passteque/gluetun)
![GitHub repo size](https://img.shields.io/github/repo-size/passteque/gluetun)
![Go version](https://img.shields.io/github/go-mod/go-version/passteque/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)
![Go version](https://img.shields.io/github/go-mod/go-version/qdm12/gluetun)
![Visitors count](https://visitor-badge.laobi.icu/badge?page_id=gluetun.readme)
@@ -42,10 +42,10 @@ Lightweight swiss-army-knife-like VPN client to multiple VPN service providers
- [Features](#features)
- Problem?
- Check the Wiki [common errors](https://github.com/qdm12/gluetun-wiki/tree/main/errors) and [faq](https://github.com/qdm12/gluetun-wiki/tree/main/faq)
- [Start a discussion](https://github.com/passteque/gluetun/discussions)
- [Fix the Unraid template](https://github.com/passteque/gluetun/discussions/550)
- [Start a discussion](https://github.com/qdm12/gluetun/discussions)
- [Fix the Unraid template](https://github.com/qdm12/gluetun/discussions/550)
- Suggestion?
- [Create an issue](https://github.com/passteque/gluetun/issues)
- [Create an issue](https://github.com/qdm12/gluetun/issues)
- Happy?
- Sponsor me on [github.com/sponsors/qdm12](https://github.com/sponsors/qdm12)
- Donate to [paypal.me/qmcgaw](https://www.paypal.me/qmcgaw)
@@ -66,14 +66,13 @@ Lightweight swiss-army-knife-like VPN client to multiple VPN service providers
- For **AirVPN**, **FastestVPN**, **Ivpn**, **Mullvad**, **NordVPN**, **Perfect privacy**, **ProtonVPN**, **Surfshark** and **Windscribe**
- For **Cyberghost**, **Private Internet Access**, **PrivateVPN**, **PureVPN**, **Torguard**, **VPN Unlimited** and **VyprVPN** using [the custom provider](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/custom.md)
- For custom Wireguard configurations using [the custom provider](https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/custom.md)
- More in progress, see [#134](https://github.com/passteque/gluetun/issues/134)
- More in progress, see [#134](https://github.com/qdm12/gluetun/issues/134)
- Supports AmneziaWG only with the custom provider for now
- DNS over TLS baked in with service provider(s) of your choice
- DNS fine blocking of malicious/ads/surveillance hostnames and IP addresses, with live update every 24 hours
- Choose the vpn network protocol, `udp` or `tcp`
- Built in firewall kill switch to allow traffic only with needed the VPN servers and LAN devices
- Built in Shadowsocks proxy server (protocol based on SOCKS5 with an encryption layer, tunnels TCP+UDP)
- Built in Socks5 proxy server (tunnels TCP+UDP) - partial credits to @angelakis and @adjscent
- Built in HTTP proxy (tunnels HTTP and HTTPS through TCP)
- [Connect other containers to it](https://github.com/qdm12/gluetun-wiki/blob/main/setup/connect-a-container-to-gluetun.md)
- [Connect LAN devices to it](https://github.com/qdm12/gluetun-wiki/blob/main/setup/connect-a-lan-device-to-gluetun.md)
@@ -131,8 +130,8 @@ services:
## Fun graphs
[![Star History Chart](https://api.star-history.com/svg?repos=passteque/gluetun&type=date&legend=top-left)](https://www.star-history.com/#passteque/gluetun&type=date&legend=top-left)
[![Star History Chart](https://api.star-history.com/svg?repos=qdm12/gluetun&type=date&legend=top-left)](https://www.star-history.com/#qdm12/gluetun&type=date&legend=top-left)
## License
[![MIT](https://img.shields.io/github/license/passteque/gluetun)](https://github.com/passteque/gluetun/blob/master/LICENSE)
[![MIT](https://img.shields.io/github/license/qdm12/gluetun)](https://github.com/qdm12/gluetun/blob/master/LICENSE)
+1 -16
View File
@@ -41,7 +41,6 @@ import (
"github.com/qdm12/gluetun/internal/routing"
"github.com/qdm12/gluetun/internal/server"
"github.com/qdm12/gluetun/internal/shadowsocks"
"github.com/qdm12/gluetun/internal/socks5"
"github.com/qdm12/gluetun/internal/storage"
updater "github.com/qdm12/gluetun/internal/updater/loop"
"github.com/qdm12/gluetun/internal/updater/resolver"
@@ -412,18 +411,6 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
return fmt.Errorf("starting public ip loop: %w", err)
}
socks5Loop := socks5.NewLoop(socks5.Settings{
Enabled: *allSettings.Socks5.Enabled,
Username: *allSettings.Socks5.Username,
Password: *allSettings.Socks5.Password,
Address: allSettings.Socks5.ListeningAddress,
Logger: logger.New(log.SetComponent("socks5")),
})
socks5RunError, err := socks5Loop.Start(ctx)
if err != nil {
return fmt.Errorf("starting SOCKS5 server loop: %w", err)
}
healthLogger := logger.New(log.SetComponent("healthcheck"))
healthcheckServer := healthcheck.NewServer(allSettings.Health, healthLogger)
healthServerHandler, healthServerCtx, healthServerDone := goshutdown.NewGoRoutineHandler(
@@ -519,7 +506,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
String() string
Stop() error
}{
portForwardLooper, publicIPLooper, socks5Loop,
portForwardLooper, publicIPLooper,
}
for _, stopper := range stoppers {
err := stopper.Stop()
@@ -531,8 +518,6 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
logger.Errorf("port forwarding loop crashed: %s", err)
case err := <-publicIPRunError:
logger.Errorf("public IP loop crashed: %s", err)
case err := <-socks5RunError:
logger.Errorf("SOCKS5 server loop crashed: %s", err)
}
return orderHandler.Shutdown(context.Background())
+14 -14
View File
@@ -4,20 +4,18 @@ go 1.25.0
require (
github.com/ProtonMail/go-srp v0.0.7
github.com/amnezia-vpn/amneziawg-go v0.2.18
github.com/amnezia-vpn/amneziawg-go v0.2.16
github.com/breml/rootcerts v0.3.4
github.com/fatih/color v1.18.0
github.com/golang/mock v1.6.0
github.com/jsimonetti/rtnetlink v1.4.2
github.com/klauspost/compress v1.18.4
github.com/klauspost/pgzip v1.2.6
github.com/mdlayher/genetlink v1.4.0
github.com/mdlayher/netlink v1.11.2
github.com/miekg/dns v1.1.62
github.com/mdlayher/genetlink v1.3.2
github.com/mdlayher/netlink v1.9.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/qdm12/dns/v2 v2.0.0-rc9.0.20260421173011-9de8e7fdbe3a
github.com/qdm12/gluetun-servers v0.1.0
github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978
github.com/qdm12/gosettings v0.4.4
github.com/qdm12/goshutdown v0.3.0
github.com/qdm12/gosplash v0.2.1-0.20260305164749-b713de4fee6c
@@ -28,9 +26,11 @@ require (
github.com/ti-mo/netfilter v0.5.3
github.com/ulikunitz/xz v0.5.15
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.38.0
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/mod v0.33.0
golang.org/x/net v0.51.0
golang.org/x/sys v0.42.0
golang.org/x/text v0.35.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
gopkg.in/ini.v1 v1.67.1
@@ -47,7 +47,8 @@ require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/socket v0.6.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/miekg/dns v1.1.62 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -55,12 +56,11 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.60.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+22 -22
View File
@@ -6,8 +6,8 @@ github.com/ProtonMail/go-crypto v1.3.0-proton h1:tAQKQRZX/73VmzK6yHSCaRUOvS/3OYS
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/amnezia-vpn/amneziawg-go v0.2.18 h1:pUn7/P8qdGmHd6JmE3bCQXPblZs3vruWR98nLODQLJg=
github.com/amnezia-vpn/amneziawg-go v0.2.18/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A=
github.com/amnezia-vpn/amneziawg-go v0.2.16 h1:XY6HOq/xtqH8ZXMncRWkjFs85EKdN10NLNnw23kTpE0=
github.com/amnezia-vpn/amneziawg-go v0.2.16/go.mod h1:nRkPpIzjCxMW8pZKXTRkpqAQVlmFJdVOGkeQSC7wbms=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/breml/rootcerts v0.3.4 h1:9i7WNl/ctd9OEAOaTfLy//Wrlfxq/tRQ7v4okYFN9Ys=
@@ -48,12 +48,12 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco=
github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
@@ -120,15 +120,15 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -136,14 +136,14 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -156,8 +156,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -167,8 +167,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -176,8 +176,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-5
View File
@@ -5,7 +5,6 @@ import (
"errors"
"flag"
"fmt"
"net"
"net/http"
"slices"
"strings"
@@ -105,10 +104,6 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
if err != nil {
return fmt.Errorf("creating DoH dialer: %w", err)
}
net.DefaultResolver = &net.Resolver{
PreferGo: true,
Dial: dnsDialer.Dial,
}
const clientTimeout = 10 * time.Second
httpClient := &http.Client{Timeout: clientTimeout}
+4 -10
View File
@@ -9,9 +9,8 @@ import (
)
// Start launches a command and streams stdout and stderr to channels.
// stdoutLines and stderrLines channels will be closed when there is no more
// output to read, in order for the caller to catch all lines even after the
// command has finished. The waitError channel returned will never be closed.
// All the channels returned are ready only and won't be closed
// if the command fails later.
func (c *Cmder) Start(cmd *exec.Cmd) (
stdoutLines, stderrLines <-chan string,
waitError <-chan error, startErr error,
@@ -39,7 +38,6 @@ func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
if err != nil {
_ = stdout.Close()
<-stdoutDone
close(stdoutLinesCh)
return nil, nil, nil, err
}
go streamToChannel(stderrReady, stderrDone, stderr, stderrLinesCh)
@@ -47,11 +45,9 @@ func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
err = cmd.Start()
if err != nil {
_ = stdout.Close()
<-stdoutDone
close(stdoutLinesCh)
_ = stderr.Close()
<-stdoutDone
<-stderrDone
close(stderrLinesCh)
return nil, nil, nil, err
}
@@ -59,10 +55,8 @@ func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
go func() {
err := cmd.Wait()
<-stdoutDone
close(stdoutLinesCh)
_ = stdout.Close()
<-stderrDone
close(stderrLinesCh)
_ = stdout.Close()
_ = stderr.Close()
waitErrorCh <- err
}()
+24 -42
View File
@@ -89,48 +89,30 @@ func Test_start(t *testing.T) {
require.NoError(t, err)
collectAndCheckChannels(t, stdoutLines, stderrLines, waitError,
testCase.stdout, testCase.stderr, testCase.waitErr)
var stdoutIndex, stderrIndex int
done := false
for !done {
select {
case line := <-stdoutLines:
assert.Equal(t, testCase.stdout[stdoutIndex], line)
stdoutIndex++
case line := <-stderrLines:
assert.Equal(t, testCase.stderr[stderrIndex], line)
stderrIndex++
case err := <-waitError:
if testCase.waitErr != nil {
require.Error(t, err)
assert.Equal(t, testCase.waitErr.Error(), err.Error())
} else {
assert.NoError(t, err)
}
done = true
}
}
assert.Equal(t, len(testCase.stdout), stdoutIndex)
assert.Equal(t, len(testCase.stderr), stderrIndex)
})
}
}
func collectAndCheckChannels(t *testing.T, stdoutLines, stderrLines <-chan string,
waitError <-chan error, expectedStdout, expectedStderr []string, expectedWaitErr error,
) {
t.Helper()
stdoutIndex := 0
stderrIndex := 0
done := false
for !done {
select {
case line, ok := <-stdoutLines:
if !ok {
stdoutLines = nil
continue
}
assert.Equal(t, expectedStdout[stdoutIndex], line)
stdoutIndex++
case line, ok := <-stderrLines:
if !ok {
stderrLines = nil
continue
}
assert.Equal(t, expectedStderr[stderrIndex], line)
stderrIndex++
case err := <-waitError:
if expectedWaitErr != nil {
require.Error(t, err)
assert.Equal(t, expectedWaitErr.Error(), err.Error())
} else {
assert.NoError(t, err)
}
done = true
}
}
assert.Equal(t, len(expectedStdout), stdoutIndex)
assert.Equal(t, len(expectedStderr), stderrIndex)
}
+13 -21
View File
@@ -18,39 +18,31 @@ func (c *Cmder) RunAndLog(ctx context.Context, command string, logger Logger) (e
return err
}
streamCtx, streamCancel := context.WithCancel(context.Background())
streamDone := make(chan struct{})
go streamLines(streamDone, logger, stdout, stderr)
go streamLines(streamCtx, streamDone, logger, stdout, stderr)
err = <-waitError
streamCancel()
<-streamDone
return err
}
func streamLines(done chan<- struct{}, logger Logger,
stdout, stderr <-chan string,
func streamLines(ctx context.Context, done chan<- struct{},
logger Logger, stdout, stderr <-chan string,
) {
defer close(done)
var line string
for {
select {
case line, ok := <-stdout:
if ok {
logger.Info(line)
break
}
if stderr == nil {
return
}
stdout = nil
case line, ok := <-stderr:
if ok {
logger.Error(line)
break
}
if stdout == nil {
return
}
stderr = nil
case <-ctx.Done():
return
case line = <-stdout:
logger.Info(line)
case line = <-stderr:
logger.Error(line)
}
}
}
@@ -1,10 +1,10 @@
package settings
import (
"maps"
"slices"
"github.com/qdm12/gosettings/reader"
"golang.org/x/exp/maps"
)
func readObsolete(r *reader.Reader) (warnings []string) {
@@ -17,7 +17,7 @@ func readObsolete(r *reader.Reader) (warnings []string) {
"DNS_KEEP_NAMESERVER": "DNS_KEEP_NAMESERVER is obsolete because you should use the built-in server which now " +
"forwards local names to private DNS resolvers found in /etc/resolv.conf at container start",
}
sortedKeys := slices.Collect(maps.Keys(keyToMessage))
sortedKeys := maps.Keys(keyToMessage)
slices.Sort(sortedKeys)
warnings = make([]string, 0, len(keyToMessage))
for _, key := range sortedKeys {
@@ -95,7 +95,7 @@ func (p PortForwarding) Validate(vpnProvider string) (err error) {
return errors.New("port forwarding password is empty")
}
case providers.Protonvpn:
const maxPortsCount = 5
const maxPortsCount = 4
if p.PortsCount > maxPortsCount {
return fmt.Errorf("ports count too high: %d > %d", p.PortsCount, maxPortsCount)
}
@@ -20,7 +20,6 @@ type Settings struct {
HTTPProxy HTTPProxy
Log Log
PublicIP PublicIP
Socks5 Socks5
Shadowsocks Shadowsocks
Storage Storage
System System
@@ -50,7 +49,6 @@ func (s *Settings) Validate(filterChoicesGetter FilterChoicesGetter, ipv6Support
"http proxy": s.HTTPProxy.validate,
"log": s.Log.validate,
"public ip check": s.PublicIP.validate,
"socks5": s.Socks5.validate,
"shadowsocks": s.Shadowsocks.validate,
"storage": s.Storage.validate,
"system": s.System.validate,
@@ -83,7 +81,6 @@ func (s *Settings) copy() (copied Settings) {
HTTPProxy: s.HTTPProxy.copy(),
Log: s.Log.copy(),
PublicIP: s.PublicIP.copy(),
Socks5: s.Socks5.copy(),
Shadowsocks: s.Shadowsocks.copy(),
Storage: s.Storage.copy(),
System: s.System.copy(),
@@ -107,7 +104,6 @@ func (s *Settings) OverrideWith(other Settings,
patchedSettings.HTTPProxy.overrideWith(other.HTTPProxy)
patchedSettings.Log.overrideWith(other.Log)
patchedSettings.PublicIP.overrideWith(other.PublicIP)
patchedSettings.Socks5.overrideWith(other.Socks5)
patchedSettings.Shadowsocks.overrideWith(other.Shadowsocks)
patchedSettings.Storage.overrideWith(other.Storage)
patchedSettings.System.overrideWith(other.System)
@@ -135,7 +131,6 @@ func (s *Settings) SetDefaults() {
s.Log.setDefaults()
s.IPv6.setDefaults()
s.PublicIP.setDefaults()
s.Socks5.setDefaults()
s.Shadowsocks.setDefaults()
s.Storage.SetDefaults()
s.System.setDefaults()
@@ -159,7 +154,6 @@ func (s Settings) toLinesNode() (node *gotree.Node) {
node.AppendNode(s.Log.toLinesNode())
node.AppendNode(s.IPv6.toLinesNode())
node.AppendNode(s.Health.toLinesNode())
node.AppendNode(s.Socks5.toLinesNode())
node.AppendNode(s.Shadowsocks.toLinesNode())
node.AppendNode(s.HTTPProxy.toLinesNode())
node.AppendNode(s.ControlServer.toLinesNode())
@@ -218,7 +212,6 @@ func (s *Settings) Read(r *reader.Reader, warner Warner) (err error) {
"public ip": func(r *reader.Reader) error {
return s.PublicIP.read(r, warner)
},
"socks5": s.Socks5.read,
"shadowsocks": s.Shadowsocks.read,
"storage": s.Storage.Read,
"system": s.System.read,
@@ -81,8 +81,6 @@ func Test_Settings_String(t *testing.T) {
| | 1.1.1.1
| | 8.8.8.8
| Restart VPN on healthcheck failure: yes
SOCKS5 proxy server settings:
| Enabled: no
Shadowsocks server settings:
| Enabled: no
HTTP proxy settings:
-91
View File
@@ -1,91 +0,0 @@
package settings
import (
"errors"
"fmt"
"os"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gosettings/validate"
"github.com/qdm12/gotree"
)
// Socks5 contains settings to configure the Socks5 proxy server.
type Socks5 struct {
Enabled *bool
ListeningAddress string
Username *string
Password *string
}
func (s Socks5) validate() (err error) {
err = validate.ListeningAddress(s.ListeningAddress, os.Getuid())
if err != nil {
return fmt.Errorf("server listening address is not valid: %w", err)
}
switch {
case *s.Username != "" && *s.Password == "":
return errors.New("password must be set if username is set")
case *s.Username == "" && *s.Password != "":
return errors.New("username must be set if password is set")
}
return nil
}
func (s *Socks5) copy() (copied Socks5) {
return Socks5{
Enabled: gosettings.CopyPointer(s.Enabled),
ListeningAddress: s.ListeningAddress,
Username: gosettings.CopyPointer(s.Username),
Password: gosettings.CopyPointer(s.Password),
}
}
func (s *Socks5) overrideWith(other Socks5) {
s.Enabled = gosettings.OverrideWithPointer(s.Enabled, other.Enabled)
s.ListeningAddress = gosettings.OverrideWithComparable(s.ListeningAddress, other.ListeningAddress)
s.Username = gosettings.OverrideWithPointer(s.Username, other.Username)
s.Password = gosettings.OverrideWithPointer(s.Password, other.Password)
}
func (s *Socks5) setDefaults() {
s.Enabled = gosettings.DefaultPointer(s.Enabled, false)
s.ListeningAddress = gosettings.DefaultComparable(s.ListeningAddress, ":1080")
s.Username = gosettings.DefaultPointer(s.Username, "")
s.Password = gosettings.DefaultPointer(s.Password, "")
}
func (s Socks5) String() string {
return s.toLinesNode().String()
}
func (s Socks5) toLinesNode() (node *gotree.Node) {
node = gotree.New("SOCKS5 proxy server settings:")
node.Appendf("Enabled: %s", gosettings.BoolToYesNo(s.Enabled))
if !*s.Enabled {
return node
}
node.Appendf("Listening address: %s", s.ListeningAddress)
if *s.Username != "" || *s.Password != "" {
node.Appendf("Username: %s", *s.Username)
node.Appendf("Password: %s", gosettings.ObfuscateKey(*s.Password))
}
return node
}
func (s *Socks5) read(r *reader.Reader) (err error) {
s.Enabled, err = r.BoolPtr("SOCKS5_ENABLED")
if err != nil {
return err
}
s.ListeningAddress = r.String("SOCKS5_LISTENING_ADDRESS")
s.Username = r.Get("SOCKS5_USER", reader.ForceLowercase(false))
s.Password = r.Get("SOCKS5_PASSWORD", reader.ForceLowercase(false))
return nil
}
+18 -16
View File
@@ -1,6 +1,7 @@
package iptables
import (
"bufio"
"context"
"fmt"
"os/exec"
@@ -96,24 +97,25 @@ func saveData(ctx context.Context, binary string) (data string, err error) {
}
return "", fmt.Errorf("running %s-save: %w", binary, err)
}
return filterData(output)
err = checkData(string(output))
if err != nil {
return "", fmt.Errorf("checking saved data: %w", err)
}
return string(output), nil
}
func filterData(cmdOutput []byte) (filtered string, err error) {
lines := strings.Split(string(cmdOutput), "\n")
filteredLines := make([]string, 0, len(lines))
for _, line := range lines {
switch {
case strings.HasPrefix(line, ":DOCKER_OUTPUT"),
strings.HasPrefix(line, ":DOCKER_POSTROUTING"),
strings.HasPrefix(line, "-A DOCKER_OUTPUT"),
strings.HasPrefix(line, "-A DOCKER_POSTROUTING"):
// Do not touch (aka save and restore) NAT rules added by Docker
continue
case strings.Contains(line, "[unsupported revision]"):
return "", fmt.Errorf("mismatch container iptables-save and kernel: %s", line)
func checkData(data string) error {
scanner := bufio.NewScanner(strings.NewReader(data))
i := 0
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "[unsupported") {
return fmt.Errorf("unsupported revision marker found in line %d: %s", i+1, line)
}
filteredLines = append(filteredLines, line)
i++
}
return strings.Join(filteredLines, "\n"), nil
if scanner.Err() != nil {
return fmt.Errorf("scanning data: %w", scanner.Err())
}
return nil
}
+1 -1
View File
@@ -278,6 +278,7 @@ func (c *Config) RedirectPort(ctx context.Context, intf string,
appendOrDelete(remove), interfaceFlag, destinationPort),
})
if err != nil {
restore(ctx) // just in case
errMessage := err.Error()
if strings.Contains(errMessage, "can't initialize ip6tables table `nat': Table does not exist") {
if !remove {
@@ -285,7 +286,6 @@ func (c *Config) RedirectPort(ctx context.Context, intf string,
}
return nil
}
restore(ctx)
return fmt.Errorf("redirecting IPv6 source port %d to destination port %d on interface %s: %w",
sourcePort, destinationPort, intf, err)
}
+4 -1
View File
@@ -29,16 +29,19 @@ func (r *Runner) Run(ctx context.Context, errCh chan<- error, ready chan<- struc
return
}
streamCtx, streamCancel := context.WithCancel(context.Background())
streamDone := make(chan struct{})
go streamLines(streamDone, r.logger,
go streamLines(streamCtx, streamDone, r.logger,
stdoutLines, stderrLines, ready)
select {
case <-ctx.Done():
<-waitError
streamCancel()
<-streamDone
errCh <- ctx.Err()
case err := <-waitError:
streamCancel()
<-streamDone
errCh <- err
}
+9 -20
View File
@@ -1,37 +1,26 @@
package openvpn
import (
"context"
"strings"
)
func streamLines(done chan<- struct{},
func streamLines(ctx context.Context, done chan<- struct{},
logger Logger, stdout, stderr <-chan string,
tunnelReady chan<- struct{},
) {
defer close(done)
var line string
for {
var line string
var ok bool
errLine := false
select {
case line, ok = <-stdout:
if ok {
break
}
if stderr == nil {
return
}
stdout = nil
case line, ok = <-stderr:
if ok {
errLine = true
break
}
if stdout == nil {
return
}
stderr = nil
case <-ctx.Done():
return
case line = <-stdout:
case line = <-stderr:
errLine = true
}
line, level := processLogLine(line)
if line == "" {
+1 -5
View File
@@ -15,11 +15,7 @@ func runCommand(ctx context.Context, cmder Cmder, logger Logger,
}
portsString := strings.Join(portStrings, ",")
commandString := strings.ReplaceAll(commandTemplate, "{{PORTS}}", portsString)
var firstPort string
if len(portStrings) > 0 {
firstPort = portStrings[0]
}
commandString = strings.ReplaceAll(commandString, "{{PORT}}", firstPort)
commandString = strings.ReplaceAll(commandString, "{{PORT}}", portStrings[0])
commandString = strings.ReplaceAll(commandString, "{{VPN_INTERFACE}}", vpnInterface)
return cmder.RunAndLog(ctx, commandString, logger)
}
+2 -5
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
@@ -60,10 +59,6 @@ func (s *Service) SetPortsForwarded(ctx context.Context, ports []uint16) (err er
s.portMutex.Lock()
defer s.portMutex.Unlock()
if s.settings.PortForwarder != nil {
return errors.New("setting port forwarded at runtime is not supported with internally running port forwarding code")
}
slices.Sort(ports)
if slices.Equal(s.ports, ports) {
return nil
@@ -83,5 +78,7 @@ func (s *Service) SetPortsForwarded(ctx context.Context, ports []uint16) (err er
return fmt.Errorf("handling new ports: %w", err)
}
s.logger.Info("updated: " + portsToString(s.ports))
return nil
}
+1 -1
View File
@@ -88,7 +88,7 @@ func (s *Settings) Validate(forStartup bool) (err error) {
return errors.New("password not set")
}
case providers.Protonvpn:
const maxPortsCount = 5
const maxPortsCount = 4
if s.PortsCount > maxPortsCount {
return fmt.Errorf("ports count too high: %d > %d", s.PortsCount, maxPortsCount)
}
+10 -4
View File
@@ -92,9 +92,13 @@ func (s *Service) onNewPorts(ctx context.Context, internalToExternalPorts map[ui
s.logger.Info(portPairsToString(internalToExternalPorts))
externalPorts := slices.Collect(maps.Values(internalToExternalPorts))
autoRedirectionNeeded := false
externalToInternalPorts := make(map[uint16]uint16, len(internalToExternalPorts))
for internal, external := range internalToExternalPorts {
externalToInternalPorts[external] = internal
if internal != external {
autoRedirectionNeeded = true
}
}
slices.Sort(externalPorts)
userRedirectionEnabled := !slices.Equal(s.settings.ListeningPorts, []uint16{0})
@@ -105,21 +109,23 @@ func (s *Service) onNewPorts(ctx context.Context, internalToExternalPorts map[ui
return fmt.Errorf("allowing port in firewall: %w", err)
}
var destinationPort uint16
var sourcePort, destinationPort uint16
switch {
case userRedirectionEnabled: // precedence over auto redirection
sourcePort = externalToInternalPorts[port]
destinationPort = s.settings.ListeningPorts[i]
case port != internalPort: // auto redirection needed, source and destination ports differ
case autoRedirectionNeeded:
sourcePort = externalToInternalPorts[port]
destinationPort = port
default:
// No redirection needed, source and destination ports are the same.
continue
}
err = s.portAllower.RedirectPort(ctx, s.settings.Interface, internalPort, destinationPort)
err = s.portAllower.RedirectPort(ctx, s.settings.Interface, sourcePort, destinationPort)
if err != nil {
return fmt.Errorf("redirecting port %d to %d in firewall: %w",
internalPort, destinationPort, err)
sourcePort, destinationPort, err)
}
}
+1 -6
View File
@@ -6,7 +6,6 @@ import (
"strings"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/constants/openvpn"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/provider/utils"
@@ -66,11 +65,7 @@ func modifyConfig(lines []string, connection models.Connection,
}
// Add values
protocol := connection.Protocol
if protocol == constants.TCP {
protocol = "tcp-client"
}
modified = append(modified, "proto "+protocol)
modified = append(modified, "proto "+connection.Protocol)
modified = append(modified, fmt.Sprintf("remote %s %d", connection.IP, connection.Port))
modified = append(modified, "dev "+settings.Interface)
modified = append(modified, "mute-replay-warnings")
+61 -68
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"maps"
"net/netip"
"strings"
"time"
@@ -13,14 +12,14 @@ import (
"github.com/qdm12/gluetun/internal/provider/utils"
)
const nonSymmetricPortStart uint16 = 56789
// PortForward obtains a VPN server side port forwarded from ProtonVPN gateway.
func (p *Provider) PortForward(ctx context.Context, objects utils.PortForwardObjects) (
internalToExternalPorts map[uint16]uint16, err error,
) {
if !objects.CanPortForward {
return nil, errors.New("server does not support port forwarding")
} else if objects.PortsCount == 0 {
return nil, nil //nolint:nilnil
}
client := natpmp.New()
@@ -40,75 +39,38 @@ func (p *Provider) PortForward(ctx context.Context, objects utils.PortForwardObj
logger := objects.Logger
logger.Debug("gateway external IPv4 address is " + externalIPv4Address.String())
p.internalToExternalPorts = make(map[uint16]uint16, objects.PortsCount)
const externalPort = 0
const lifetime = 60 * time.Second
// Only one port can be a symmetric mapping
const internalPort, externalPort = 0, 1
_, assignedExternalPort, err := addPortMappingTCPUDP(ctx,
client, logger, objects.Gateway, internalPort, externalPort, lifetime)
// Note the returned assignedInternalPort is always 0 in this case
if err != nil {
return nil, fmt.Errorf("adding first port mapping: %w", err)
}
p.internalToExternalPorts[assignedExternalPort] = assignedExternalPort
// Extra ports must be non-symmetric, meaning that the internal port is
// different from the external port.
const nonSymmetricPortStart = uint16(56789)
nonSymmetricPortStartMinusOne := nonSymmetricPortStart - 1
if _, ok := p.internalToExternalPorts[nonSymmetricPortStart]; ok {
nonSymmetricPortStartMinusOne++
}
for i := uint16(1); i < objects.PortsCount; i++ {
internalPort := nonSymmetricPortStartMinusOne + i
const externalPort = 0
assignedInternalPort, assignedExternalPort, err := addPortMappingTCPUDP(ctx,
client, logger, objects.Gateway, internalPort, externalPort, lifetime)
if err != nil {
return nil, fmt.Errorf("adding %d/%d port mapping: %w", i+1, objects.PortsCount, err)
p.internalToExternalPorts = make(map[uint16]uint16, objects.PortsCount)
for i := range objects.PortsCount {
internalPort := nonSymmetricPortStart + i
protoToInternalPort := map[string]uint16{
"udp": 0,
"tcp": 0,
}
p.internalToExternalPorts[assignedInternalPort] = assignedExternalPort
protoToExternalPort := maps.Clone(protoToInternalPort)
for protocol := range protoToExternalPort {
_, assignedInternalPort, assignedExternalPort, assignedLifetime, err := client.AddPortMapping(
ctx, objects.Gateway, protocol, internalPort, externalPort, lifetime)
if err != nil {
return nil, fmt.Errorf("adding %d/%d %s port mapping: %w",
i+1, objects.PortsCount, strings.ToUpper(protocol), err)
}
checkLifetime(logger, strings.ToUpper(protocol), lifetime, assignedLifetime)
checkInternalPort(logger, internalPort, assignedInternalPort)
protoToInternalPort[protocol] = assignedInternalPort
protoToExternalPort[protocol] = assignedExternalPort
}
checkInternalPorts(logger, protoToInternalPort["udp"], protoToInternalPort["tcp"])
checkExternalPorts(logger, protoToExternalPort["udp"], protoToExternalPort["tcp"])
p.internalToExternalPorts[protoToInternalPort["tcp"]] = protoToExternalPort["tcp"]
}
return maps.Clone(p.internalToExternalPorts), nil
}
func addPortMappingTCPUDP(ctx context.Context, client *natpmp.Client, logger utils.Logger,
gateway netip.Addr, internalPort, externalPort uint16, lifetime time.Duration,
) (assignedInternalPort, assignedExternalPort uint16, err error) {
var assignedLifetime time.Duration
protocolToExternalPort := map[string]uint16{
"tcp": 0,
"udp": 0,
}
for _, protocol := range [...]string{"udp", "tcp"} {
protocolStr := strings.ToUpper(protocol)
_, assignedInternalPort, assignedExternalPort, assignedLifetime, err = client.AddPortMapping(
ctx, gateway, protocol, internalPort, externalPort, lifetime)
if err != nil {
return 0, 0, fmt.Errorf("adding %s port mapping: %w", protocolStr, err)
}
protocolToExternalPort[protocol] = assignedExternalPort
checkLifetime(logger, protocolStr, lifetime, assignedLifetime)
if internalPort != assignedInternalPort {
return 0, 0, fmt.Errorf("%s internal port requested as %d but received %d",
protocolStr, internalPort, assignedInternalPort)
} else if externalPort != 0 && externalPort != 1 && externalPort != assignedExternalPort {
return 0, 0, fmt.Errorf("%s external port requested as %d but received %d",
protocolStr, externalPort, assignedExternalPort)
}
}
if protocolToExternalPort["tcp"] != protocolToExternalPort["udp"] {
return 0, 0, fmt.Errorf("TCP and UDP external ports differ: %d and %d",
protocolToExternalPort["tcp"], protocolToExternalPort["udp"])
}
return assignedInternalPort, assignedExternalPort, nil
}
func checkLifetime(logger utils.Logger, protocol string,
requested, actual time.Duration,
) {
@@ -119,6 +81,27 @@ func checkLifetime(logger utils.Logger, protocol string,
}
}
func checkInternalPort(logger utils.Logger, sent, received uint16) {
if sent != received {
logger.Warn(fmt.Sprintf("internal port assigned %d differs from requested internal port %d",
sent, received))
}
}
func checkInternalPorts(logger utils.Logger, udpPort, tcpPort uint16) {
if udpPort != tcpPort {
logger.Warn(fmt.Sprintf("UDP internal port %d differs from TCP internal port %d",
udpPort, tcpPort))
}
}
func checkExternalPorts(logger utils.Logger, udpPort, tcpPort uint16) {
if udpPort != tcpPort {
logger.Warn(fmt.Sprintf("UDP external port %d differs from TCP external port %d",
udpPort, tcpPort))
}
}
func (p *Provider) KeepPortForward(ctx context.Context,
objects utils.PortForwardObjects,
) (err error) {
@@ -134,12 +117,22 @@ func (p *Provider) KeepPortForward(ctx context.Context,
}
objects.Logger.Debug("refreshing forwarded ports since 45 seconds have elapsed")
networkProtocols := [...]string{"udp", "tcp"}
const lifetime = 60 * time.Second
for internalPort, externalPort := range p.internalToExternalPorts {
_, _, err := addPortMappingTCPUDP(ctx, client, logger, objects.Gateway, internalPort, externalPort, lifetime)
if err != nil {
return fmt.Errorf("refreshing port mapping for internal port %d and external port %d: %w",
internalPort, externalPort, err)
for _, networkProtocol := range networkProtocols {
_, assignedInternalPort, assignedExternalPort, assignedLiftetime, err := client.AddPortMapping(
ctx, objects.Gateway, networkProtocol, internalPort, externalPort, lifetime)
if err != nil {
return fmt.Errorf("adding port mapping: %w", err)
}
checkLifetime(logger, networkProtocol, lifetime, assignedLiftetime)
if externalPort != assignedExternalPort {
return fmt.Errorf("external port changed from %d to %d", externalPort, assignedExternalPort)
} else if internalPort != assignedInternalPort {
return fmt.Errorf("internal port changed from %d (for external port %d) to %d",
internalPort, externalPort, assignedInternalPort)
}
}
objects.Logger.Debug(fmt.Sprintf("port forwarded %d maintained", externalPort))
}
+43 -24
View File
@@ -16,22 +16,25 @@ import (
"strings"
srp "github.com/ProtonMail/go-srp"
"github.com/qdm12/gluetun/internal/provider/common"
)
// apiClient is a minimal Proton v4 API client which can handle all the
// oddities of Proton's authentication flow they want to keep hidden
// from the public.
type apiClient struct {
apiURLBase string
httpClient *http.Client
appVersion string
userAgent string
generator *rand.ChaCha8
apiURLBase string
httpClient *http.Client
appVersion string
vpnGtkAppVersion string
userAgent string
generator *rand.ChaCha8
warner common.Warner
}
// newAPIClient returns an [apiClient] with sane defaults matching Proton's
// insane expectations.
func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClient, err error) {
func newAPIClient(ctx context.Context, httpClient *http.Client, warner common.Warner) (client *apiClient, err error) {
var seed [32]byte
_, _ = crand.Read(seed[:])
generator := rand.NewChaCha8(seed)
@@ -46,17 +49,23 @@ func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClie
}
userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))]
appVersion, err := getMostRecentStableTag(ctx, httpClient)
appVersion, err := getMostRecentStableWebAccountTag(ctx, httpClient)
if err != nil {
return nil, fmt.Errorf("getting most recent version for proton app: %w", err)
return nil, fmt.Errorf("getting most recent version for web-account: %w", err)
}
vpnGtkAppVersion, err := getMostRecentStableVPNGtkAppTag(ctx, httpClient)
if err != nil {
return nil, fmt.Errorf("getting most recent version for linux VPN GTK app: %w", err)
}
return &apiClient{
apiURLBase: "https://account.proton.me/api",
httpClient: httpClient,
appVersion: appVersion,
userAgent: userAgent,
generator: generator,
apiURLBase: "https://account.proton.me/api",
httpClient: httpClient,
appVersion: appVersion,
vpnGtkAppVersion: vpnGtkAppVersion,
userAgent: userAgent,
generator: generator,
warner: warner,
}, nil
}
@@ -64,10 +73,10 @@ func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClie
// 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) {
func (c *apiClient) setHeaders(request *http.Request, cookie cookie, appVersion string) {
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-appversion", appVersion)
request.Header.Set("x-pm-locale", "en_US")
request.Header.Set("x-pm-uid", cookie.uid)
}
@@ -98,7 +107,11 @@ func (c *apiClient) authenticate(ctx context.Context, email, password string,
}
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64,
srpSessionHex, version, err := c.authInfo(ctx, email, unauthCookie)
if err != nil {
switch {
case errors.Is(err, errUsernameEmpty):
c.warner.Warn("Username is empty in auth info response, trying with email address instead")
username = email
case err != nil:
return cookie{}, fmt.Errorf("getting auth information: %w", err)
}
@@ -159,7 +172,7 @@ func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) (
unauthCookie := cookie{
sessionID: sessionID,
}
c.setHeaders(request, unauthCookie)
c.setHeaders(request, unauthCookie, c.appVersion)
response, err := c.httpClient.Do(request)
if err != nil {
@@ -244,7 +257,7 @@ func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, acces
uid: uid,
sessionID: sessionID,
}
c.setHeaders(request, unauthCookie)
c.setHeaders(request, unauthCookie, c.appVersion)
request.Header.Set("Authorization", tokenType+" "+accessToken)
response, err := c.httpClient.Do(request)
@@ -291,6 +304,8 @@ func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, acces
return "", errors.New("auth cookie not found")
}
var errUsernameEmpty = errors.New("username is empty in response")
// authInfo fetches SRP parameters for the account.
func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie cookie) (
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string,
@@ -315,7 +330,7 @@ func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie coo
if err != nil {
return "", "", "", "", "", 0, fmt.Errorf("creating request: %w", err)
}
c.setHeaders(request, unauthCookie)
c.setHeaders(request, unauthCookie, c.appVersion)
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
@@ -358,15 +373,17 @@ func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie coo
return "", "", "", "", "", 0, errors.New("salt is empty in response")
case info.SRPSession == "":
return "", "", "", "", "", 0, errors.New("SRP session is empty in response")
case info.Username == "":
return "", "", "", "", "", 0, errors.New("username is empty in response")
case info.Version == nil:
return "", "", "", "", "", 0, errors.New("version is missing in response")
case info.Username == "":
// Return a sentinel error the caller can handle to try with the email address instead of the username.
// Some accounts seem to have no username.
err = fmt.Errorf("%w", errUsernameEmpty)
}
version = int(*info.Version) //nolint:gosec
return info.Username, info.Modulus, info.ServerEphemeral, info.Salt,
info.SRPSession, version, nil
info.SRPSession, version, err
}
type cookie struct {
@@ -422,7 +439,7 @@ func (c *apiClient) auth(ctx context.Context, unauthCookie cookie,
if err != nil {
return cookie{}, fmt.Errorf("creating request: %w", err)
}
c.setHeaders(request, unauthCookie)
c.setHeaders(request, unauthCookie, c.appVersion)
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
@@ -573,7 +590,9 @@ func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) (
if err != nil {
return data, err
}
c.setHeaders(request, cookie)
// Note we use the vpnGtkAppVersion field given it produces an output of more servers
c.setHeaders(request, cookie, c.vpnGtkAppVersion)
request.Header.Set("x-pm-appversion", "linux-vpn@4.15.2")
response, err := c.httpClient.Do(request)
if err != nil {
@@ -20,7 +20,7 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing)
}
apiClient, err := newAPIClient(ctx, u.client)
apiClient, err := newAPIClient(ctx, u.client, u.warner)
if err != nil {
return nil, fmt.Errorf("creating API client: %w", err)
}
+47 -2
View File
@@ -7,15 +7,18 @@ import (
"io"
"net/http"
"regexp"
"sort"
"strings"
"time"
"golang.org/x/mod/semver"
)
// getMostRecentStableTag finds the most recent proton-account stable tag version,
// getMostRecentStableWebAccountTag 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) {
func getMostRecentStableWebAccountTag(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 {
@@ -69,3 +72,45 @@ func getMostRecentStableTag(ctx context.Context, client *http.Client) (version s
return "", fmt.Errorf("%w (queried %d pages)", context.Canceled, page)
}
// getMostRecentStableVPNGtkAppTag finds the latest proton-vpn-gtk-app semver tag,
// in order to use it in the x-pm-appversion http request header ONLY to fetch servers
// data. Because if we do fall behind on versioning, Proton doesn't like it because they like
// to create complications where there is no need for it. Hence this function.
func getMostRecentStableVPNGtkAppTag(ctx context.Context, client *http.Client) (version string, err error) {
const url = "https://api.github.com/repos/ProtonVPN/proton-vpn-gtk-app/tags?per_page=30"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
request.Header.Set("Accept", "application/vnd.github.v3+json")
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP status code not OK: %s", response.Status)
}
decoder := json.NewDecoder(response.Body)
var data []struct {
Name string `json:"name"`
}
err = decoder.Decode(&data)
if err != nil {
return "", fmt.Errorf("decoding JSON response: %w", err)
}
// Sort tags by semver. Invalid tags are placed at the end and we ignore them.
// Yes, proton does push invalid semver tag names sometimes. Good job yet again.
sort.Slice(data, func(i, j int) bool {
return semver.Compare(data[i].Name, data[j].Name) > 0
})
version = "linux-vpn@" + data[0].Name[1:] // remove leading v
return version, nil
}
-83
View File
@@ -1,83 +0,0 @@
package socks5
import "fmt"
// See https://datatracker.ietf.org/doc/html/rfc1928#section-3
type authMethod byte
const (
authNotRequired authMethod = 0
authGssapi authMethod = 1
authUsernamePassword authMethod = 2
authNotAcceptable authMethod = 255
)
func (a authMethod) String() string {
switch a {
case authNotRequired:
return "no authentication required"
case authGssapi:
return "GSSAPI"
case authUsernamePassword:
return "username/password"
case authNotAcceptable:
return "no acceptable methods"
default:
return fmt.Sprintf("unknown method (%d)", a)
}
}
// Subnegotiation version
// See https://datatracker.ietf.org/doc/html/rfc1929#section-2
const (
authUsernamePasswordSubNegotiation1 byte = 1
)
// SOCKS versions.
const (
socks5Version byte = 5
)
// See https://datatracker.ietf.org/doc/html/rfc1928#section-4
type cmdType byte
const (
connect cmdType = 1
udpAssociate cmdType = 3
)
func (c cmdType) String() string {
switch c {
case connect:
return "connect"
case udpAssociate:
return "UDP associate"
default:
return fmt.Sprintf("unknown command (%d)", c)
}
}
// See https://datatracker.ietf.org/doc/html/rfc1928#section-4 and
// https://datatracker.ietf.org/doc/html/rfc1928#section-5
type addrType byte
const (
ipv4 addrType = 1
domainName addrType = 3
ipv6 addrType = 4
)
// See https://datatracker.ietf.org/doc/html/rfc1928#section-6
type replyCode byte
const (
succeeded replyCode = iota
generalServerFailure
connectionNotAllowedByRuleset
networkUnreachable
hostUnreachable
connectionRefused
ttlExpired
commandNotSupported
addressTypeNotSupported
)
-6
View File
@@ -1,6 +0,0 @@
package socks5
type Logger interface {
Infof(format string, a ...interface{})
Warnf(format string, a ...interface{})
}
-106
View File
@@ -1,106 +0,0 @@
package socks5
import (
"context"
"sync"
"time"
"github.com/qdm12/goservices"
)
type Loop struct {
settings Settings
mutex sync.Mutex
runCancel context.CancelFunc
runDone <-chan error
}
func NewLoop(settings Settings) *Loop {
return &Loop{
settings: settings,
}
}
func (l *Loop) String() string {
return "SOCKS5 server loop"
}
func (l *Loop) Start(_ context.Context) (runError <-chan error, err error) {
l.mutex.Lock()
defer l.mutex.Unlock()
var runCtx context.Context
runCtx, l.runCancel = context.WithCancel(context.Background())
runDone := make(chan error)
l.runDone = runDone
go run(runCtx, runDone, l.settings)
return nil, nil //nolint:nilnil
}
func run(ctx context.Context, done chan<- error, settings Settings) {
defer close(done)
logger := settings.Logger
for ctx.Err() == nil {
var server goservices.Service
if settings.Enabled {
server = newServer(settings)
} else {
server = new(noopService)
}
errorCh, err := server.Start(ctx)
if err != nil {
logger.Warnf("failed starting SOCKS5 server: %s", err)
waitBeforeRetry(ctx)
continue
}
select {
case <-ctx.Done():
done <- server.Stop()
return
case err := <-errorCh:
if ctx.Err() != nil {
return
}
logger.Warnf("SOCKS5 server crashed: %s", err)
waitBeforeRetry(ctx)
}
}
}
func (l *Loop) Stop() (err error) {
l.mutex.Lock()
defer l.mutex.Unlock()
l.runCancel()
return <-l.runDone
}
func waitBeforeRetry(ctx context.Context) {
const retryDelay = 10 * time.Second
timer := time.NewTimer(retryDelay)
select {
case <-timer.C:
case <-ctx.Done():
}
}
type noopService struct{}
func (s noopService) Start(_ context.Context) (runErr <-chan error, err error) {
return nil, nil //nolint:nilnil
}
func (s noopService) Stop() error {
return nil
}
func (s noopService) String() string {
return "noop service"
}
-3
View File
@@ -1,3 +0,0 @@
package socks5
//go:generate mockgen -destination=mocks_test.go -package=$GOPACKAGE . Logger
-68
View File
@@ -1,68 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/qdm12/gluetun/internal/socks5 (interfaces: Logger)
// Package socks5 is a generated GoMock package.
package socks5
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockLogger is a mock of Logger interface.
type MockLogger struct {
ctrl *gomock.Controller
recorder *MockLoggerMockRecorder
}
// MockLoggerMockRecorder is the mock recorder for MockLogger.
type MockLoggerMockRecorder struct {
mock *MockLogger
}
// NewMockLogger creates a new mock instance.
func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
mock := &MockLogger{ctrl: ctrl}
mock.recorder = &MockLoggerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLogger) EXPECT() *MockLoggerMockRecorder {
return m.recorder
}
// Infof mocks base method.
func (m *MockLogger) Infof(arg0 string, arg1 ...interface{}) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Infof", varargs...)
}
// Infof indicates an expected call of Infof.
func (mr *MockLoggerMockRecorder) Infof(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...)
}
// Warnf mocks base method.
func (m *MockLogger) Warnf(arg0 string, arg1 ...interface{}) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Warnf", varargs...)
}
// Warnf indicates an expected call of Warnf.
func (mr *MockLoggerMockRecorder) Warnf(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*MockLogger)(nil).Warnf), varargs...)
}
-109
View File
@@ -1,109 +0,0 @@
package socks5
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/netip"
)
// See https://datatracker.ietf.org/doc/html/rfc1928#section-6
func (c *socksConn) encodeFailedResponse(writer io.Writer, socksVersion byte, reply replyCode) {
_, err := writer.Write([]byte{
socksVersion,
byte(reply),
0, // RSV byte
// The RFC requires a full response frame even for failures.
// Use IPv4 address type with zeroed address and port.
byte(ipv4), // ATYP
0, 0, 0, 0, // BND.ADDR (zeroed)
0, 0, // BND.PORT (zeroed)
})
if err != nil {
c.logger.Warnf("failed writing failed response: %s", err)
}
}
// See https://datatracker.ietf.org/doc/html/rfc1928#section-6
func (c *socksConn) encodeSuccessResponse(writer io.Writer, socksVersion byte,
reply replyCode, bindAddrType addrType, bindAddress string,
bindPort uint16,
) (err error) {
bindData, err := encodeBindData(bindAddrType, bindAddress, bindPort)
if err != nil {
return fmt.Errorf("encoding bind data: %w", err)
}
const initialPacketLength = 3
capacity := initialPacketLength + len(bindData)
packet := make([]byte, initialPacketLength, capacity)
packet[0] = socksVersion
packet[1] = byte(reply)
packet[2] = 0 // RSV byte
packet = append(packet, bindData...)
_, err = writer.Write(packet)
if err != nil {
return fmt.Errorf("writing packet: %w", err)
}
return nil
}
var (
ErrIPVersionUnexpected = errors.New("ip version is unexpected")
ErrDomainNameTooLong = errors.New("domain name is too long")
)
func encodeBindData(addrType addrType, address string, port uint16) (
data []byte, err error,
) {
capacity := bindDataLength(addrType, address)
data = make([]byte, 0, capacity)
data = append(data, byte(addrType))
switch addrType {
case ipv4, ipv6:
ip, err := netip.ParseAddr(address)
if err != nil {
return nil, fmt.Errorf("parsing IP address: %w", err)
}
switch {
case addrType == ipv4 && !ip.Is4():
return nil, fmt.Errorf("%w: expected IPv4 for %s", ErrIPVersionUnexpected, ip)
case addrType == ipv6 && !ip.Is6():
return nil, fmt.Errorf("%w: expected IPv6 for %s", ErrIPVersionUnexpected, ip)
}
data = append(data, ip.AsSlice()...)
case domainName:
const maxDomainNameLength = 255
if len(address) > maxDomainNameLength {
return nil, fmt.Errorf("%w: %s", ErrDomainNameTooLong, address)
}
data = append(data, byte(len(address)))
data = append(data, []byte(address)...)
default:
panic(fmt.Sprintf("unsupported address type %d", addrType))
}
data = binary.BigEndian.AppendUint16(data, port)
return data, nil
}
func bindDataLength(addrType addrType, address string) (maxLength uint) {
maxLength++ // address type
switch addrType {
case ipv4:
maxLength += net.IPv4len
case domainName:
maxLength++ // domain name length
maxLength += uint(len([]byte(address)))
case ipv6:
maxLength += net.IPv6len
default:
panic("unsupported address type: " + fmt.Sprint(addrType))
}
maxLength += 2 // port
return maxLength
}
-162
View File
@@ -1,162 +0,0 @@
package socks5
import (
"context"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
)
type server struct {
username string
password string
address string
logger Logger
// internal fields
tcpListener net.Listener
udpRouter *udpRouter
listening atomic.Bool
socksConnCtx context.Context //nolint:containedctx
socksConnCancel context.CancelFunc
done <-chan error
stopCh chan<- struct{}
}
func newServer(settings Settings) *server {
return &server{
username: settings.Username,
password: settings.Password,
address: settings.Address,
logger: settings.Logger,
}
}
func (s *server) String() string {
return "SOCKS5 server"
}
func (s *server) Start(ctx context.Context) (runErr <-chan error, err error) {
s.socksConnCtx, s.socksConnCancel = context.WithCancel(context.Background())
config := &net.ListenConfig{}
s.tcpListener, err = config.Listen(ctx, "tcp", s.address)
if err != nil {
return nil, fmt.Errorf("TCP listening on %s: %w", s.address, err)
}
s.udpRouter, err = newUDPRouter(ctx, s.address, s.logger)
if err != nil {
_ = s.tcpListener.Close()
return nil, fmt.Errorf("creating UDP router: %w", err)
}
s.listening.Store(true)
s.logger.Infof("SOCKS5 TCP server listening on %s", s.tcpListener.Addr())
s.logger.Infof("SOCKS5 UDP server listening on %s", s.udpRouter.localAddress())
ready := make(chan struct{})
runErrCh := make(chan error)
runErr = runErrCh
done := make(chan error)
s.done = done
stop := make(chan struct{})
s.stopCh = stop
go s.runServer(ready, runErrCh, stop, done)
select {
case <-ready:
case <-ctx.Done():
_ = s.Stop()
return nil, fmt.Errorf("starting server: %w", ctx.Err())
}
return runErr, nil
}
func (s *server) runServer(ready chan<- struct{},
runErrCh chan<- error, stop <-chan struct{}, done chan<- error,
) {
close(ready)
defer close(done)
udpErrCh := make(chan error)
go func() {
udpErrCh <- s.udpRouter.run(s.socksConnCtx)
}()
tcpErrCh := make(chan error)
go func() {
var wg sync.WaitGroup
defer wg.Wait()
dialer := &net.Dialer{}
for {
connection, err := s.tcpListener.Accept()
if err != nil {
s.socksConnCancel() // stop ongoing TCP socks connections - no impact on UDP
tcpErrCh <- fmt.Errorf("accepting connection: %w", err)
return
}
wg.Go(func() {
connection := connection // capture loop variable
socksConn := &socksConn{
dialer: dialer,
username: s.username,
password: s.password,
clientConn: connection,
udpRouter: s.udpRouter,
logger: s.logger,
}
err := socksConn.run(s.socksConnCtx)
if err != nil {
s.logger.Infof("running socks connection: %s", err)
}
})
}
}()
select {
case <-stop:
s.listening.Store(false)
var errs []error
err := s.tcpListener.Close()
if err != nil {
errs = append(errs, fmt.Errorf("closing TCP listener: %w", err))
}
// stop ongoing TCP socks connections. This impacts the udpRouter run error when it is being closed.
s.socksConnCancel()
<-tcpErrCh // wait for TCP server to stop
err = s.udpRouter.close()
if err != nil {
errs = append(errs, fmt.Errorf("closing UDP router: %w", err))
}
<-udpErrCh // wait for UDP router to stop
if len(errs) > 0 {
// Only write to the done channel if the [server.Stop] method is waiting to read from it
done <- errors.Join(errs...)
}
// If no error, the done channel is closed so the error is effectively `nil`
// Note: do NOT write an error the runError channel, since we are stopping the server gracefully.
case err := <-udpErrCh:
_ = s.tcpListener.Close() // stop accepting new TCP connections
s.socksConnCancel() // stop ongoing TCP socks connections
<-tcpErrCh // wait for TCP server to stop
runErrCh <- fmt.Errorf("running UDP router: %w", err)
case err := <-tcpErrCh:
s.socksConnCancel()
_ = s.udpRouter.close() // stop UDP router
<-udpErrCh // wait for UDP router to stop
runErrCh <- fmt.Errorf("running TCP server: %w", err)
}
}
func (s *server) Stop() (err error) {
close(s.stopCh)
return <-s.done
}
func (s *server) listeningAddress() net.Addr {
if s.listening.Load() {
return s.tcpListener.Addr()
}
return nil
}
-112
View File
@@ -1,112 +0,0 @@
//go:build integration
package socks5
import (
"math/rand/v2"
"net"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_Server_UDPResolution(t *testing.T) {
t.Parallel()
ctx := t.Context()
server := newServer(Settings{
Address: "127.0.0.1:0",
Logger: noopLogger{},
})
runErr, err := server.Start(ctx)
require.NoError(t, err, "starting SOCKS5 server")
const timeout = 3 * time.Second
// Connect to the SOCKS5 server via TCP to negotiate UDP associate
dialer := &net.Dialer{Timeout: timeout}
tcpConn, err := dialer.DialContext(ctx, "tcp", server.listeningAddress().String())
require.NoError(t, err, "tcp connecting to SOCKS5 server")
t.Cleanup(func() { tcpConn.Close() })
negotiateSOCKS5(t, tcpConn, "", "")
// UDP Associate Command: [VERSION (5), CMD (3 = UDP ASSOC), RSV (0), ATYP (1 = IPv4), ADDR (0.0.0.0), PORT (0)]
_, err = tcpConn.Write([]byte{5, 3, 0, 1, 0, 0, 0, 0, 0, 0})
require.NoError(t, err, "sending UDP ASSOC request")
relayAddressString, err := readSOCKS5ResponseAddress(t, tcpConn)
require.NoError(t, err, "reading UDP ASSOC reply")
relayAddress, err := net.ResolveUDPAddr("udp", relayAddressString)
require.NoError(t, err, "resolving udp relay address")
// Dial the relay using IPv4 so source IP family matches the control connection.
udpConn, err := net.DialUDP("udp4", nil, relayAddress)
require.NoError(t, err, "dialing UDP relay")
t.Cleanup(func() { _ = udpConn.Close() })
queryID := uint16(rand.Uint32()) //nolint:gosec
dnsRequest := &dns.Msg{
MsgHdr: dns.MsgHdr{
Id: queryID,
RecursionDesired: true,
},
Question: []dns.Question{{
Name: dns.Fqdn("github.com"),
Qtype: dns.TypeA,
Qclass: dns.ClassINET,
}},
}
dnsQuery, err := dnsRequest.Pack()
require.NoError(t, err)
// Encapsulate DNS payload into SOCKS5 UDP Request Header
// [RSV (0,0), FRAG (0), ATYP (1 = IPv4), DST.ADDR (1.1.1.1), DST.PORT (53)]
packet := append([]byte{0, 0, 0, 1, 1, 1, 1, 1, 0, 53}, dnsQuery...)
// Send encapsulated packet to the proxy's UDP relay address
_, err = udpConn.Write(packet)
require.NoError(t, err, "sending UDP packet to relay")
// Read response from the proxy relay
err = udpConn.SetReadDeadline(time.Now().Add(timeout))
require.NoError(t, err, "setting read deadline on UDP connection")
buffer := make([]byte, 2048)
n, err := udpConn.Read(buffer)
require.NoError(t, err, "receiving UDP response from relay")
const minimumHeaderSize = 10
require.GreaterOrEqual(t, n, minimumHeaderSize, "received UDP packet too short to contain valid SOCKS5 header")
// Verify header layout and slice out the raw DNS response
// Header format: RSV(2) FRAG(1) ATYP(1) DST.ADDR(variable) DST.PORT(2)
atyp := buffer[3]
var headerSize int
switch atyp {
case 1: // IPv4
headerSize = 10
case 3: // Domain name
headerSize = 4 + 1 + int(buffer[4]) + 2
case 4: // IPv6
headerSize = 22
default:
t.Fatalf("Unknown ATYP in SOCKS5 UDP header: %d", atyp)
}
dnsResponse := new(dns.Msg)
err = dnsResponse.Unpack(buffer[headerSize:n])
require.NoError(t, err, "unpacking DNS response from SOCKS5 UDP packet")
assert.Equal(t, queryID, dnsResponse.Id, "DNS response ID should match query ID")
select {
case err := <-runErr:
require.NoError(t, err, "SOCKS5 server run error")
default:
}
err = server.Stop()
require.NoError(t, err, "stopping SOCKS5 server")
}
-9
View File
@@ -1,9 +0,0 @@
package socks5
type Settings struct {
Enabled bool
Username string
Password string
Address string
Logger Logger
}
-538
View File
@@ -1,538 +0,0 @@
package socks5
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/netip"
"strconv"
"strings"
"sync"
)
var (
errNoMethodIdentifiers = errors.New("no method identifiers")
errNoValidMethodIdentifier = errors.New("no valid method identifier")
)
type socksConn struct {
// Injected fields
dialer *net.Dialer
username string
password string
clientConn net.Conn
udpRouter *udpRouter
logger Logger
}
func (c *socksConn) closeClientConn(ctxErr error) {
err := c.clientConn.Close()
if err != nil && ctxErr == nil {
c.logger.Warnf("closing client connection: %s", err)
}
}
func (c *socksConn) run(ctx context.Context) error {
// Monitoring context cancellation to close the connection and stop
// reading operations on clientConn.
done := make(chan struct{})
ctxWatcherDone := make(chan struct{})
go func() {
defer close(ctxWatcherDone)
select {
case <-done:
case <-ctx.Done():
// unblock read operations
c.closeClientConn(ctx.Err())
}
}()
defer func() {
close(done)
<-ctxWatcherDone
}()
authMethod := authNotRequired
if c.username != "" || c.password != "" {
authMethod = authUsernamePassword
}
err := verifyFirstNegotiation(c.clientConn, authMethod)
if err != nil {
replyMethod := authMethod
if errors.Is(err, errNoMethodIdentifiers) || errors.Is(err, errNoValidMethodIdentifier) {
replyMethod = authNotAcceptable
}
_, writeErr := c.clientConn.Write([]byte{socks5Version, byte(replyMethod)})
if writeErr != nil {
c.logger.Warnf("failed writing first negotiation reply: %s", writeErr)
}
c.closeClientConn(ctx.Err())
return fmt.Errorf("verifying first negotiation: %w", err)
}
_, err = c.clientConn.Write([]byte{socks5Version, byte(authMethod)})
if err != nil {
c.closeClientConn(ctx.Err())
return fmt.Errorf("writing first negotiation reply: %w", err)
}
switch authMethod {
case authNotRequired, authNotAcceptable:
case authGssapi:
panic("not implemented")
case authUsernamePassword:
// See https://datatracker.ietf.org/doc/html/rfc1929#section-2
err = usernamePasswordSubnegotiate(c.clientConn, c.username, c.password)
if err != nil {
// If the server returns a `failure' (STATUS value other than X'00') status,
// it MUST close the connection.
c.closeClientConn(ctx.Err())
return fmt.Errorf("subnegotiating username and password: %w", err)
}
default:
panic(fmt.Sprintf("unimplemented auth method %d", authMethod))
}
err = c.handleRequest(ctx)
c.closeClientConn(ctx.Err())
if err != nil {
return fmt.Errorf("handling request: %w", err)
}
return nil
}
func (c *socksConn) handleRequest(ctx context.Context) error {
const socksVersion = socks5Version
request, err := decodeRequest(c.clientConn, socksVersion)
if err != nil {
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
return err
}
switch request.command {
case connect:
err = c.handleConnectRequest(ctx, socksVersion, request)
if err != nil {
return fmt.Errorf("handling %s request: %w", request.command, err)
}
return nil
case udpAssociate:
err = c.handleUDPAssociateRequest(ctx, socksVersion, request)
if err != nil {
return fmt.Errorf("handling %s request: %w", request.command, err)
}
return nil
default:
c.encodeFailedResponse(c.clientConn, socksVersion, commandNotSupported)
return fmt.Errorf("command %s is not supported", request.command)
}
}
func (c *socksConn) handleConnectRequest(ctx context.Context,
socksVersion byte, request request,
) error {
destinationAddress := net.JoinHostPort(request.destination, fmt.Sprint(request.port))
destinationConn, err := c.dialer.DialContext(ctx, "tcp", destinationAddress)
if err != nil {
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
return err
}
defer destinationConn.Close()
destinationServerAddress := destinationConn.LocalAddr().String()
destinationAddr, destinationPortStr, err := net.SplitHostPort(destinationServerAddress)
if err != nil {
return fmt.Errorf("splitting destination address: %w", err)
}
destinationPort, err := strconv.ParseUint(destinationPortStr, 10, 16)
if err != nil {
return fmt.Errorf("port is malformed: %q", destinationPortStr)
}
var bindAddrType addrType
if ip := net.ParseIP(destinationAddr); ip != nil {
if ip.To4() != nil {
bindAddrType = ipv4
} else {
bindAddrType = ipv6
}
} else {
bindAddrType = domainName
}
err = c.encodeSuccessResponse(c.clientConn, socksVersion, succeeded, bindAddrType,
destinationAddr, uint16(destinationPort))
if err != nil {
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
return fmt.Errorf("writing successful %s response: %w", request.command, err)
}
const capacity = 2 // if one goroutine fails, we don't want to leak the other one
errc := make(chan error, capacity)
go func() {
_, err := io.Copy(c.clientConn, destinationConn)
if err != nil {
err = fmt.Errorf("from backend to client: %w", err)
}
errc <- err
}()
go func() {
_, err := io.Copy(destinationConn, c.clientConn)
if err != nil {
err = fmt.Errorf("from client to backend: %w", err)
}
errc <- err
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
_ = destinationConn.Close()
_ = c.clientConn.Close()
return nil
}
}
func (c *socksConn) handleUDPAssociateRequest(ctx context.Context,
socksVersion byte, request request,
) error {
expectedAddrPort, err := udpAssociateExpectedClientEndpoint(request)
if err != nil {
c.encodeFailedResponse(c.clientConn, socksVersion, addressTypeNotSupported)
return fmt.Errorf("deriving expected client address and port from request: %w", err)
}
bindAddress, bindPort, bindAddrType, err := c.udpAssociationAddresses()
if err != nil {
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
return fmt.Errorf("getting udp association addresses: %w", err)
}
association, err := c.udpRouter.registerAssociation(c.clientConn, expectedAddrPort)
if err != nil {
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
return fmt.Errorf("registering udp association: %w", err)
}
defer c.udpRouter.unregisterAssociation(association)
err = c.encodeSuccessResponse(c.clientConn, socksVersion, succeeded,
bindAddrType, bindAddress, bindPort)
if err != nil {
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
return fmt.Errorf("writing successful %s response: %w", udpAssociate, err)
}
associationCtx, associationCancel := context.WithCancel(ctx)
defer associationCancel()
var wg sync.WaitGroup
wg.Go(func() {
c.udpRouter.runAssociationHandler(associationCtx, association)
})
wg.Go(func() {
_, _ = io.Copy(io.Discard, c.clientConn)
associationCancel()
})
<-associationCtx.Done()
wg.Wait()
return nil
}
func udpAssociateExpectedClientEndpoint(request request) (expectedAddrPort netip.AddrPort, err error) {
switch request.addressType {
case ipv4, ipv6:
expectedClientAddress, parseErr := netip.ParseAddr(request.destination)
if parseErr != nil {
return netip.AddrPort{}, fmt.Errorf("parsing destination address: %w", parseErr)
}
expectedClientAddress = expectedClientAddress.Unmap()
if !expectedClientAddress.IsUnspecified() {
return netip.AddrPortFrom(expectedClientAddress, request.port), nil
}
return netip.AddrPortFrom(netip.Addr{}, request.port), nil
case domainName:
// For UDP associate, client endpoint matching is based on observed UDP source
// address/port. A hostname is not directly matchable at this stage, so we
// ignore the domain name request destination entirely.
return netip.AddrPortFrom(netip.Addr{}, request.port), nil
default:
return netip.AddrPort{}, fmt.Errorf("address type %d is not supported", request.addressType)
}
}
func (c *socksConn) udpAssociationAddresses() (bindAddress string,
bindPort uint16, bindAddrType addrType, err error,
) {
localAddress := c.udpRouter.localAddress().String()
host, portString, err := net.SplitHostPort(localAddress)
if err != nil {
return "", 0, 0, fmt.Errorf("splitting local address: %w", err)
}
port, err := strconv.ParseUint(portString, 10, 16)
if err != nil {
return "", 0, 0, fmt.Errorf("parsing local port: %w", err)
}
bindAddress = host
bindPort = uint16(port)
if isUnspecifiedIPAddress(bindAddress) {
controlLocalAddress := c.clientConn.LocalAddr().String()
controlLocalHost, _, splitErr := net.SplitHostPort(controlLocalAddress)
if splitErr != nil {
return "", 0, 0, fmt.Errorf("splitting control connection local address: %w", splitErr)
}
bindAddress = controlLocalHost
}
ipAddress := net.ParseIP(bindAddress)
if ipAddress == nil {
bindAddrType = domainName
return bindAddress, bindPort, bindAddrType, nil
}
if ipAddress.To4() != nil {
bindAddrType = ipv4
} else {
bindAddrType = ipv6
}
return bindAddress, bindPort, bindAddrType, nil
}
func isUnspecifiedIPAddress(address string) bool {
ipAddress, err := netip.ParseAddr(address)
if err != nil {
return false
}
return ipAddress.IsUnspecified()
}
func decodeUDPDatagram(packet []byte) (destination string, payload []byte, err error) {
const minimumPacketLength = 4
if len(packet) < minimumPacketLength {
return "", nil, fmt.Errorf("packet is too short: %d", len(packet))
}
if packet[0] != 0 || packet[1] != 0 {
return "", nil, fmt.Errorf("reserved bytes are invalid: %x %x", packet[0], packet[1])
}
if packet[2] != 0 {
return "", nil, fmt.Errorf("fragmentation is not supported")
}
offset := 3
addressType := addrType(packet[offset])
offset++
switch addressType {
case ipv4:
const ipv4Length = 4
if len(packet) < offset+ipv4Length+2 {
return "", nil, fmt.Errorf("packet is too short for IPv4 address")
}
var ip [ipv4Length]byte
copy(ip[:], packet[offset:offset+ipv4Length])
destination = netip.AddrFrom4(ip).String()
offset += ipv4Length
case ipv6:
const ipv6Length = 16
if len(packet) < offset+ipv6Length+2 {
return "", nil, fmt.Errorf("packet is too short for IPv6 address")
}
var ip [ipv6Length]byte
copy(ip[:], packet[offset:offset+ipv6Length])
destination = netip.AddrFrom16(ip).String()
offset += ipv6Length
case domainName:
if len(packet) < offset+1 {
return "", nil, fmt.Errorf("packet is too short for domain name length")
}
domainNameLength := int(packet[offset])
offset++
if len(packet) < offset+domainNameLength+2 {
return "", nil, fmt.Errorf("packet is too short for domain name")
}
destination = string(packet[offset : offset+domainNameLength])
offset += domainNameLength
default:
return "", nil, fmt.Errorf("address type is not supported: %d", addressType)
}
port := binary.BigEndian.Uint16(packet[offset : offset+2])
destination = net.JoinHostPort(destination, fmt.Sprint(port))
offset += 2
payload = packet[offset:]
return destination, payload, nil
}
func encodeUDPDatagramToBuffer(writer io.Writer, sourceAddrPort netip.AddrPort,
payload []byte,
) error {
address := sourceAddrPort.Addr()
if !address.IsValid() {
return errors.New("source address is not valid")
}
err := writeUDPDatagramSourceAddress(writer, address)
if err != nil {
return fmt.Errorf("writing source address: %w", err)
}
var portBytes [2]byte
binary.BigEndian.PutUint16(portBytes[:], sourceAddrPort.Port())
_, err = writer.Write(portBytes[:])
if err != nil {
return fmt.Errorf("writing destination port: %w", err)
}
_, err = writer.Write(payload)
if err != nil {
return fmt.Errorf("writing payload: %w", err)
}
return nil
}
func writeUDPDatagramSourceAddress(writer io.Writer, address netip.Addr) error {
var addrType addrType
var addressBytes []byte
switch {
case address.Is4():
addrType = ipv4
array := address.As4()
addressBytes = array[:]
case address.Is6():
addrType = ipv6
array := address.As16()
addressBytes = array[:]
default:
return fmt.Errorf("address type is not supported: %v", address)
}
_, err := writer.Write([]byte{0, 0, 0, byte(addrType)})
if err != nil {
return fmt.Errorf("writing header: %w", err)
}
_, err = writer.Write(addressBytes)
if err != nil {
return fmt.Errorf("writing IP address: %w", err)
}
return nil
}
// See https://datatracker.ietf.org/doc/html/rfc1928#section-3
func verifyFirstNegotiation(reader io.Reader, requiredMethod authMethod) error {
const headerLength = 2 // version + nMethods bytes
header := make([]byte, headerLength)
_, err := io.ReadFull(reader, header)
if err != nil {
return fmt.Errorf("reading header: %w", err)
}
if header[0] != socks5Version {
return fmt.Errorf("version is not supported: %d", header[0])
}
nMethods := header[1]
if nMethods == 0 {
return fmt.Errorf("%w", errNoMethodIdentifiers)
}
methodIdentifiers := make([]byte, nMethods)
_, err = io.ReadFull(reader, methodIdentifiers)
if err != nil {
return fmt.Errorf("reading method identifiers: %w", err)
}
for _, methodIdentifier := range methodIdentifiers {
if methodIdentifier == byte(requiredMethod) {
return nil
}
}
return makeNoAcceptableMethodError(requiredMethod, methodIdentifiers)
}
func makeNoAcceptableMethodError(requiredAuthMethod authMethod, methodIdentifiers []byte) error {
methodNames := make([]string, len(methodIdentifiers))
for i, methodIdentifier := range methodIdentifiers {
methodNames[i] = fmt.Sprintf("%q", authMethod(methodIdentifier))
}
return fmt.Errorf("%w: none of %s matches %s",
errNoValidMethodIdentifier, strings.Join(methodNames, ", "),
requiredAuthMethod)
}
// See https://datatracker.ietf.org/doc/html/rfc1928#section-4
type request struct {
command cmdType
destination string
port uint16
addressType addrType
}
func decodeRequest(reader io.Reader, expectedVersion byte) (req request, err error) {
const headerLength = 4
header := [headerLength]byte{}
_, err = io.ReadFull(reader, header[:])
if err != nil {
return request{}, fmt.Errorf("reading header: %w", err)
}
version := header[0]
switch {
case version != expectedVersion:
return request{}, fmt.Errorf("version is not supported: expected %d and got %d",
expectedVersion, version)
case header[2] != 0:
return request{}, fmt.Errorf("reserved header byte must be 0 but got %d", header[2])
}
req.command = cmdType(header[1])
// header[2] is RSV byte
req.addressType = addrType(header[3])
switch req.addressType {
case ipv4:
var ip [4]byte
_, err = io.ReadFull(reader, ip[:])
if err != nil {
return request{}, fmt.Errorf("reading IPv4 address: %w", err)
}
req.destination = netip.AddrFrom4(ip).String()
case ipv6:
var ip [16]byte
_, err = io.ReadFull(reader, ip[:])
if err != nil {
return request{}, fmt.Errorf("reading IPv6 address: %w", err)
}
req.destination = netip.AddrFrom16(ip).String()
case domainName:
var header [1]byte
_, err = io.ReadFull(reader, header[:])
if err != nil {
return request{}, fmt.Errorf("reading domain name header: %w", err)
}
domainName := make([]byte, header[0])
_, err = io.ReadFull(reader, domainName)
if err != nil {
return request{}, fmt.Errorf("reading domain name bytes: %w", err)
}
req.destination = string(domainName)
default:
return request{}, fmt.Errorf("address type is not supported: %d", req.addressType)
}
var portBytes [2]byte
_, err = io.ReadFull(reader, portBytes[:])
if err != nil {
return request{}, fmt.Errorf("reading port: %w", err)
}
req.port = binary.BigEndian.Uint16(portBytes[:])
return req, nil
}
File diff suppressed because it is too large Load Diff
-370
View File
@@ -1,370 +0,0 @@
package socks5
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"net/netip"
"sync"
)
type udpAssociation struct {
id uint64
clientAddrPort netip.AddrPort
expectedAddrPort netip.AddrPort
controlConnAddr netip.Addr
packetCh chan *bytes.Buffer
}
type udpRouter struct {
logger Logger
listener net.PacketConn
mutex sync.Mutex
bufferPool sync.Pool
nextAssociationID uint64
clientAddrPortToAssociation map[netip.AddrPort]udpAssociation
clientIPToPendingAssociations map[netip.Addr][]udpAssociation
associationIDToClientAddrPort map[uint64]netip.AddrPort
}
const (
maxUDPPacketLength = 65535
maxSOCKS5UDPDatagramOverhead = 3 + 1 + 16 + 2
pooledUDPPacketBufferCapacity = maxUDPPacketLength + maxSOCKS5UDPDatagramOverhead
)
func newUDPRouter(ctx context.Context, address string, logger Logger) (router *udpRouter, err error) {
config := &net.ListenConfig{}
listener, err := config.ListenPacket(ctx, "udp", address)
if err != nil {
return nil, fmt.Errorf("UDP listening: %w", err)
}
return &udpRouter{
logger: logger,
listener: listener,
bufferPool: sync.Pool{
New: func() any {
return bytes.NewBuffer(make([]byte, 0, pooledUDPPacketBufferCapacity))
},
},
nextAssociationID: 1,
clientAddrPortToAssociation: make(map[netip.AddrPort]udpAssociation),
clientIPToPendingAssociations: make(map[netip.Addr][]udpAssociation),
associationIDToClientAddrPort: make(map[uint64]netip.AddrPort),
}, nil
}
func (r *udpRouter) localAddress() net.Addr {
return r.listener.LocalAddr()
}
func (r *udpRouter) close() error {
return r.listener.Close()
}
func (r *udpRouter) registerAssociation(controlConn net.Conn, expectedAddrPort netip.AddrPort) (udpAssociation, error) {
controlConnAddrPort, err := netip.ParseAddrPort(controlConn.RemoteAddr().String())
if err != nil {
return udpAssociation{}, fmt.Errorf("parsing control connection address: %w", err)
}
controlConnAddr := controlConnAddrPort.Addr().Unmap()
r.mutex.Lock()
defer r.mutex.Unlock()
const udpPacketChannelBuffer = 64
associationID := r.nextAssociationID
r.nextAssociationID++
association := udpAssociation{
id: associationID,
expectedAddrPort: expectedAddrPort,
controlConnAddr: controlConnAddr,
packetCh: make(chan *bytes.Buffer, udpPacketChannelBuffer),
}
if expectedAddrPort.Addr().IsValid() && expectedAddrPort.Port() != 0 {
association.clientAddrPort = expectedAddrPort
r.clientAddrPortToAssociation[association.clientAddrPort] = association
r.associationIDToClientAddrPort[association.id] = association.clientAddrPort
return association, nil
}
pendingAssociations := r.clientIPToPendingAssociations[controlConnAddr]
pendingAssociations = append(pendingAssociations, association)
r.clientIPToPendingAssociations[controlConnAddr] = pendingAssociations
return association, nil
}
func (r *udpRouter) unregisterAssociation(association udpAssociation) {
r.mutex.Lock()
defer r.mutex.Unlock()
clientAddrPort, hasClientAddress := r.associationIDToClientAddrPort[association.id]
if hasClientAddress {
delete(r.associationIDToClientAddrPort, association.id)
delete(r.clientAddrPortToAssociation, clientAddrPort)
}
pendingAssociations := r.clientIPToPendingAssociations[association.controlConnAddr]
for i, pendingAssociation := range pendingAssociations {
if pendingAssociation.id == association.id {
pendingAssociations = append(pendingAssociations[:i], pendingAssociations[i+1:]...)
break
}
}
if len(pendingAssociations) == 0 {
delete(r.clientIPToPendingAssociations, association.controlConnAddr)
} else {
r.clientIPToPendingAssociations[association.controlConnAddr] = pendingAssociations
}
}
func (r *udpRouter) run(ctx context.Context) error {
packetBuffer := make([]byte, maxUDPPacketLength)
for {
packetLength, sourceAddress, err := r.listener.ReadFrom(packetBuffer)
if err != nil {
if ctx.Err() != nil && errors.Is(err, net.ErrClosed) {
return nil
}
return fmt.Errorf("reading UDP packet: %w", err)
}
sourceAddrPort, err := netAddrToNetipAddrPort(sourceAddress)
if err != nil {
r.logger.Warnf("parsing source address: %s", err)
continue
}
buffer := r.bufferPool.Get().(*bytes.Buffer) //nolint:forcetypeassert
buffer.Reset()
_, err = buffer.Write(packetBuffer[:packetLength])
if err != nil {
r.bufferPool.Put(buffer)
r.logger.Warnf("buffering packet: %s", err)
continue
}
err = r.routePacket(sourceAddrPort, buffer)
if err != nil {
r.logger.Warnf("failed routing UDP packet: %s", err)
}
}
}
func (r *udpRouter) routePacket(sourceAddrPort netip.AddrPort, packet *bytes.Buffer) error {
r.mutex.Lock()
association, packetFromClient := r.findClientAssociation(sourceAddrPort)
r.mutex.Unlock()
if !packetFromClient {
r.bufferPool.Put(packet)
return nil
}
select {
case association.packetCh <- packet:
return nil
default:
r.bufferPool.Put(packet)
return errors.New("association packet queue full")
}
}
func (r *udpRouter) findClientAssociation(sourceAddrPort netip.AddrPort) (
association udpAssociation, ok bool,
) {
association, ok = r.clientAddrPortToAssociation[sourceAddrPort]
if ok {
return association, true
}
sourceAddr := sourceAddrPort.Addr()
pendingAssociations := r.clientIPToPendingAssociations[sourceAddr]
if len(pendingAssociations) == 0 {
return udpAssociation{}, false
}
index := -1
for i, pendingAssociation := range pendingAssociations {
if matchesExpectedClientEndpoint(pendingAssociation, sourceAddrPort) {
association = pendingAssociation
index = i
break
}
}
if index == -1 {
return udpAssociation{}, false
}
r.clientIPToPendingAssociations[sourceAddr] = append(pendingAssociations[:index], pendingAssociations[index+1:]...)
if len(r.clientIPToPendingAssociations[sourceAddr]) == 0 {
delete(r.clientIPToPendingAssociations, sourceAddr)
}
association.clientAddrPort = sourceAddrPort
r.clientAddrPortToAssociation[sourceAddrPort] = association
r.associationIDToClientAddrPort[association.id] = sourceAddrPort
return association, true
}
func matchesExpectedClientEndpoint(association udpAssociation, sourceAddrPort netip.AddrPort) bool {
switch {
case association.expectedAddrPort.Addr().IsValid() && sourceAddrPort.Addr() != association.expectedAddrPort.Addr():
return false
case association.expectedAddrPort.Port() != 0 && sourceAddrPort.Port() != association.expectedAddrPort.Port():
return false
}
return true
}
func (r *udpRouter) clientAddrPortForAssociation(associationID uint64) (
clientAddrPort netip.AddrPort, ok bool,
) {
r.mutex.Lock()
defer r.mutex.Unlock()
clientAddrPort, ok = r.associationIDToClientAddrPort[associationID]
return clientAddrPort, ok
}
func (r *udpRouter) runAssociationHandler(ctx context.Context, association udpAssociation) {
config := &net.ListenConfig{}
socket, err := config.ListenPacket(ctx, "udp", ":0")
if err != nil {
r.logger.Warnf("creating per-association UDP socket: %s", err)
return
}
defer socket.Close()
go closeSocketOnContextDone(ctx, socket)
packetBuffer := make([]byte, maxUDPPacketLength)
forwardDoneCh := make(chan struct{})
go r.forwardClientPackets(ctx, socket, association.packetCh, forwardDoneCh)
for {
packetLength, sourceAddress, err := socket.ReadFrom(packetBuffer)
if err != nil {
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
<-forwardDoneCh
return
}
r.logger.Warnf("reading from per-association UDP socket: %s", err)
continue
}
sourceAddrPort, err := netAddrToNetipAddrPort(sourceAddress)
if err != nil {
r.logger.Warnf("parsing source address from destination: %s", err)
continue
}
buffer := r.bufferPool.Get().(*bytes.Buffer) //nolint:forcetypeassert
buffer.Reset()
err = encodeUDPDatagramToBuffer(buffer, sourceAddrPort, packetBuffer[:packetLength])
if err != nil {
r.bufferPool.Put(buffer)
r.logger.Warnf("encoding response datagram: %s", err)
continue
}
clientAddrPort, found := r.clientAddrPortForAssociation(association.id)
if !found {
r.bufferPool.Put(buffer)
r.logger.Warnf("client address not found for association id %d", association.id)
continue
}
clientUDPAddress := &net.UDPAddr{
IP: clientAddrPort.Addr().AsSlice(),
Port: int(clientAddrPort.Port()),
}
_, err = r.listener.WriteTo(buffer.Bytes(), clientUDPAddress)
r.bufferPool.Put(buffer)
if err != nil {
r.logger.Warnf("writing response to client: %s", err)
}
}
}
func closeSocketOnContextDone(ctx context.Context, socket net.PacketConn) {
<-ctx.Done()
_ = socket.Close()
}
func (r *udpRouter) forwardClientPackets(ctx context.Context, socket net.PacketConn,
packetCh <-chan *bytes.Buffer, done chan<- struct{},
) {
defer close(done)
for {
select {
case <-ctx.Done():
return
case buffer, ok := <-packetCh:
if !ok {
return
}
err := r.writeClientPacketToDestination(ctx, socket, buffer)
r.bufferPool.Put(buffer)
if err != nil {
r.logger.Warnf("forwarding client packet to destination: %s", err)
}
}
}
}
func (r *udpRouter) writeClientPacketToDestination(ctx context.Context,
socket net.PacketConn, packet *bytes.Buffer,
) error {
destination, payload, err := decodeUDPDatagram(packet.Bytes())
if err != nil {
return fmt.Errorf("decoding UDP datagram: %w", err)
}
host, portStr, err := net.SplitHostPort(destination)
if err != nil {
return fmt.Errorf("splitting destination host and port: %w", err)
}
if _, err := netip.ParseAddr(host); err != nil { // domain name
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
return fmt.Errorf("resolving destination host: %w", err)
}
if len(addrs) == 0 {
return fmt.Errorf("resolving destination host: no addresses found for %q", host)
}
destination = net.JoinHostPort(addrs[0], portStr)
}
resolvedDestinationUDPAddress, err := net.ResolveUDPAddr("udp", destination)
if err != nil {
return fmt.Errorf("resolving destination UDP address: %w", err)
}
_, err = socket.WriteTo(payload, resolvedDestinationUDPAddress)
if err != nil && ctx.Err() == nil {
return fmt.Errorf("writing payload to destination: %w", err)
}
return nil
}
func netAddrToNetipAddrPort(addr net.Addr) (netip.AddrPort, error) {
addrPort, err := netip.ParseAddrPort(addr.String())
if err != nil {
return netip.AddrPort{}, fmt.Errorf("parsing address: %w", err)
}
return netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port()), nil
}
@@ -1,164 +0,0 @@
//go:build integration
package socks5
import (
"bytes"
"context"
"math/rand/v2"
"net"
"net/netip"
"strconv"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_udpRouter_ResolveGithubFromCloudflareDNS(t *testing.T) {
t.Parallel()
ctx := t.Context()
var cancel context.CancelFunc
deadline, hasDeadline := ctx.Deadline()
if hasDeadline {
const deadlineBuffer = 500 * time.Millisecond
deadline = deadline.Add(-deadlineBuffer)
} else {
const defaultTimeout = 10 * time.Second
deadline = time.Now().Add(defaultTimeout)
}
ctx, cancel = context.WithDeadline(ctx, deadline)
ctrl := gomock.NewController(t)
logger := NewMockLogger(ctrl)
router, err := newUDPRouter(ctx, "127.0.0.1:0", logger)
require.NoError(t, err)
routerRunErrCh := make(chan error)
go func() {
routerRunErrCh <- router.run(ctx)
}()
t.Cleanup(func() {
cancel()
err := router.close()
assert.NoError(t, err, "closing router")
runErr := <-routerRunErrCh
assert.NoError(t, runErr)
})
controlListener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
err := controlListener.Close()
assert.NoError(t, err, "closing control listener")
})
acceptedConnCh := make(chan net.Conn)
go func() {
acceptedConn, acceptErr := controlListener.Accept()
assert.NoError(t, acceptErr, "accepting control connection")
if acceptErr != nil {
return
}
acceptedConnCh <- acceptedConn
}()
clientControlConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", controlListener.Addr().String())
require.NoError(t, err)
t.Cleanup(func() {
err = clientControlConn.Close()
assert.NoError(t, err, "closing client control connection")
})
serverControlConn := <-acceptedConnCh
t.Cleanup(func() {
err := serverControlConn.Close()
assert.NoError(t, err, "closing server control connection")
})
association, err := router.registerAssociation(serverControlConn, netip.AddrPort{})
require.NoError(t, err)
t.Cleanup(func() {
router.unregisterAssociation(association)
})
associationCtx, associationCancel := context.WithCancel(ctx)
handlerDoneCh := make(chan struct{})
go func() {
router.runAssociationHandler(associationCtx, association)
close(handlerDoneCh)
}()
t.Cleanup(func() {
associationCancel()
<-handlerDoneCh
})
udpRouterAddress, err := net.ResolveUDPAddr("udp", router.localAddress().String())
require.NoError(t, err)
clientUDPConn, err := net.DialUDP("udp", nil, udpRouterAddress)
require.NoError(t, err)
t.Cleanup(func() {
err := clientUDPConn.Close()
assert.NoError(t, err, "closing client UDP connection")
})
queryID := uint16(rand.Uint32()) //nolint:gosec
dnsRequest := &dns.Msg{
MsgHdr: dns.MsgHdr{
Id: queryID,
RecursionDesired: true,
},
Question: []dns.Question{{
Name: dns.Fqdn("github.com"),
Qtype: dns.TypeA,
Qclass: dns.ClassINET,
}},
}
dnsQuery, err := dnsRequest.Pack()
require.NoError(t, err)
targetAddrPort := netip.MustParseAddrPort("1.1.1.1:53")
socksDatagramBuffer := bytes.NewBuffer(nil)
err = encodeUDPDatagramToBuffer(socksDatagramBuffer, targetAddrPort, dnsQuery)
require.NoError(t, err)
socksDatagram := socksDatagramBuffer.Bytes()
err = clientUDPConn.SetDeadline(deadline)
require.NoError(t, err)
_, err = clientUDPConn.Write(socksDatagram)
require.NoError(t, err)
responseBuffer := make([]byte, maxUDPPacketLength)
responseLength, err := clientUDPConn.Read(responseBuffer)
require.NoError(t, err)
responseDestination, responsePayload, err := decodeUDPDatagram(responseBuffer[:responseLength])
require.NoError(t, err)
responseHost, responsePortString, err := net.SplitHostPort(responseDestination)
require.NoError(t, err)
responsePort, err := strconv.ParseUint(responsePortString, 10, 16)
require.NoError(t, err)
assert.Equal(t, uint64(53), responsePort)
assert.NotEmpty(t, responseHost)
dnsResponse := new(dns.Msg)
err = dnsResponse.Unpack(responsePayload)
require.NoError(t, err)
assert.Equal(t, queryID, dnsResponse.Id)
assert.True(t, dnsResponse.Response)
assert.Equal(t, dns.RcodeSuccess, dnsResponse.Rcode)
require.NotEmpty(t, dnsResponse.Question)
assert.Equal(t, dns.Fqdn("github.com"), dnsResponse.Question[0].Name)
assert.Equal(t, dns.TypeA, dnsResponse.Question[0].Qtype)
assert.NotEmpty(t, dnsResponse.Answer)
require.NoError(t, err)
}
-62
View File
@@ -1,62 +0,0 @@
package socks5
import (
"fmt"
"io"
)
// See https://datatracker.ietf.org/doc/html/rfc1929#section-2
func usernamePasswordSubnegotiate(conn io.ReadWriter, username, password string) (err error) {
status := byte(1)
const defaultVersion = byte(1)
const headerLength = 2
var header [headerLength]byte
_, err = io.ReadFull(conn, header[:])
if err != nil {
_, _ = conn.Write([]byte{defaultVersion, status})
return fmt.Errorf("reading header: %w", err)
}
if header[0] != authUsernamePasswordSubNegotiation1 {
_, _ = conn.Write([]byte{defaultVersion, status})
return fmt.Errorf("subnegotiation version not supported: %d", header[0])
}
version := header[0]
usernameBytes := make([]byte, header[1])
_, err = io.ReadFull(conn, usernameBytes)
if err != nil {
_, _ = conn.Write([]byte{version, status})
return fmt.Errorf("reading username bytes: %w", err)
} else if username != string(usernameBytes) {
_, _ = conn.Write([]byte{version, status})
return fmt.Errorf("username received is not valid")
}
const passwordHeaderLength = 1
passwordHeader := make([]byte, passwordHeaderLength)
_, err = io.ReadFull(conn, passwordHeader)
if err != nil {
_, _ = conn.Write([]byte{version, status})
return fmt.Errorf("reading password length: %w", err)
}
passwordBytes := make([]byte, passwordHeader[0])
_, err = io.ReadFull(conn, passwordBytes)
if err != nil {
_, _ = conn.Write([]byte{version, status})
return fmt.Errorf("reading password bytes: %w", err)
} else if password != string(passwordBytes) {
_, _ = conn.Write([]byte{version, status})
return fmt.Errorf("password not valid for username %q", string(usernameBytes))
}
status = 0
_, err = conn.Write([]byte{version, status})
if err != nil {
return fmt.Errorf("writing success status: %w", err)
}
return nil
}
+19 -11
View File
@@ -1,23 +1,32 @@
package storage
import (
"embed"
"encoding/json"
"fmt"
"path/filepath"
"path"
serversmodule "github.com/qdm12/gluetun-servers/pkg/servers"
"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gluetun/internal/models"
)
func parseHardcodedServers() (allServers models.AllServers) {
allProviders := providers.All()
//go:embed servers.json
var allServersEmbedFS embed.FS
const version = 1
allServers.ProviderToServers = make(map[string]models.Servers, len(allProviders))
allServers.Version = version
for _, provider := range allProviders {
filename := provider + ".json"
func parseHardcodedServers() (allServers models.AllServers) {
f, err := allServersEmbedFS.Open("servers.json")
if err != nil {
panic(err)
}
defer f.Close() // no-op
decoder := json.NewDecoder(f)
err = decoder.Decode(&allServers)
if err != nil {
panic("decoding servers.json: " + err.Error())
}
for provider, metadata := range allServers.ProviderToServers {
filename := path.Base(metadata.Filepath)
providerFile, err := serversmodule.Files.Open(filename)
if err != nil {
panic(fmt.Sprintf("reading embedded provider file %s for %s: %s", filename, provider, err))
@@ -35,8 +44,7 @@ func parseHardcodedServers() (allServers models.AllServers) {
filename, provider))
}
const serversPath = "/gluetun/servers/"
providerServers.Filepath = filepath.Join(serversPath, filename)
providerServers.Filepath = metadata.Filepath // inherit filepath from servers.json
allServers.ProviderToServers[provider] = providerServers
}
+2 -1
View File
@@ -3,5 +3,6 @@ package storage
import "fmt"
func panicOnProviderMissingHardcoded(provider string) {
panic(fmt.Sprintf("provider %s not found in hardcoded servers map", provider))
panic(fmt.Sprintf("provider %s not found in hardcoded servers map; "+
"did you add the provider key in the embedded servers.json?", provider))
}
+2 -1
View File
@@ -152,7 +152,8 @@ func Test_extractServersFromBytes(t *testing.T) {
allProviders[0]: 1,
// Missing provider allProviders[1]
}
expectedPanicValue := fmt.Sprintf("provider %s not found in hardcoded servers map", allProviders[1])
expectedPanicValue := fmt.Sprintf("provider %s not found in hardcoded servers map; "+
"did you add the provider key in the embedded servers.json?", allProviders[1])
assert.PanicsWithValue(t, expectedPanicValue, func() {
_, _ = s.extractServersFromBytes(b, hardcodedVersions)
})
+72
View File
@@ -0,0 +1,72 @@
{
"version": 1,
"airvpn": {
"filepath": "/gluetun/servers/airvpn.json"
},
"cyberghost": {
"filepath": "/gluetun/servers/cyberghost.json"
},
"expressvpn": {
"filepath": "/gluetun/servers/expressvpn.json"
},
"fastestvpn": {
"filepath": "/gluetun/servers/fastestvpn.json"
},
"giganews": {
"filepath": "/gluetun/servers/giganews.json"
},
"hidemyass": {
"filepath": "/gluetun/servers/hidemyass.json"
},
"ipvanish": {
"filepath": "/gluetun/servers/ipvanish.json"
},
"ivpn": {
"filepath": "/gluetun/servers/ivpn.json"
},
"mullvad": {
"filepath": "/gluetun/servers/mullvad.json"
},
"nordvpn": {
"filepath": "/gluetun/servers/nordvpn.json"
},
"perfect privacy": {
"filepath": "/gluetun/servers/perfect privacy.json"
},
"privado": {
"filepath": "/gluetun/servers/privado.json"
},
"private internet access": {
"filepath": "/gluetun/servers/private internet access.json"
},
"privatevpn": {
"filepath": "/gluetun/servers/privatevpn.json"
},
"protonvpn": {
"filepath": "/gluetun/servers/protonvpn.json"
},
"purevpn": {
"filepath": "/gluetun/servers/purevpn.json"
},
"slickvpn": {
"filepath": "/gluetun/servers/slickvpn.json"
},
"surfshark": {
"filepath": "/gluetun/servers/surfshark.json"
},
"torguard": {
"filepath": "/gluetun/servers/torguard.json"
},
"vpn unlimited": {
"filepath": "/gluetun/servers/vpn unlimited.json"
},
"vpnsecure": {
"filepath": "/gluetun/servers/vpnsecure.json"
},
"vyprvpn": {
"filepath": "/gluetun/servers/vyprvpn.json"
},
"windscribe": {
"filepath": "/gluetun/servers/windscribe.json"
}
}
+2 -2
View File
@@ -31,7 +31,7 @@ func getGithubReleases(ctx context.Context, client *http.Client) (releases []git
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
const url = "https://api.github.com/repos/passteque/gluetun/releases"
const url = "https://api.github.com/repos/qdm12/gluetun/releases"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -62,7 +62,7 @@ func getGithubCommits(ctx context.Context, client *http.Client) (commits []githu
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
const url = "https://api.github.com/repos/passteque/gluetun/commits"
const url = "https://api.github.com/repos/qdm12/gluetun/commits"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
+6
View File
@@ -1,13 +1,19 @@
# Maintenance
- Change `Run` methods to `Start`+`Stop`, returning channels rather than injecting them
- Go 1.18
- gofumpt
- Use netip
- Split servers.json
- Common slice of Wireguard providers in config settings
- DNS block lists as LFS and built in image
- Add HTTP server v3 as json rpc
- Use `github.com/qdm12/ddns-updater/pkg/publicip`
- Windows and Darwin development support
## Features
- Authentication with the control server
- Get announcement from Github file
- Support multiple connections in custom ovpn
- Automate IPv6 detection for OpenVPN