Compare commits

..

13 Commits

Author SHA1 Message Date
Quentin McGaw
01e9274f7b fix(proton): giving proton password is not mandatory 2025-11-18 21:26:53 +00:00
Quentin McGaw
daff23bfb3 feat(protonvpn): update servers data including paid data 2025-11-18 13:52:45 +00:00
Quentin McGaw
aa6d26e062 fix(protonvpn/updater): API authentification fix using email
- `UPDATER_PROTONVPN_USERNAME` ->  `UPDATER_PROTONVPN_EMAIL`
- `-proton-username` -> `-proton-email`
- fix authentication flow to use email or username when appropriate
- fix #2985
2025-11-18 13:51:31 +00:00
Quentin McGaw
b2859d5a06 fix(storage): only log warning if flushing merged servers to file fails 2025-11-18 13:50:22 +00:00
Quentin McGaw
ad8b0657cb fix(dns): fix panic when using DNS_KEEP_NAMESERVER 2025-11-18 13:49:27 +00:00
Quentin McGaw
c930a4e1be fix(protonvpn): authenticated servers data updating (#2878)
- `-proton-username` flag for cli update
- `-proton-password` flag for cli update
- `UPDATER_PROTONVPN_USERNAME` option for periodic updates
- `UPDATER_PROTONVPN_PASSWORD` option for periodic updates
2025-11-15 17:11:12 +00:00
Quentin McGaw
22834e9477 fix(server/log): log out full URL path not just bottom request URI 2025-11-15 17:03:53 +00:00
Quentin McGaw
62c2679da2 fix(cyberghost): log warnings from updater resolver 2025-11-15 17:03:53 +00:00
Quentin McGaw
5e9ae9fa1f fix(wireguard): specify IP family for new route (#2629) 2025-11-15 17:03:53 +00:00
Quentin McGaw
0f19bcfebd fix(port-forward): clear port file instead of removing it
- Prevent port forwarding loop crash when trying to delete a directly bind mounted file
- See https://github.com/qdm12/gluetun/issues/2942#issuecomment-3468510402
2025-11-15 17:03:53 +00:00
Quentin McGaw
83fc91d3c6 fix(publicip): respect PUBLICIP_ENABLED 2025-11-15 17:03:53 +00:00
mutschler
4adeec8223 fix(vpnunlimited): update certificate values (#2835) 2025-11-15 17:03:53 +00:00
Quentin McGaw
64bfbaa45d fix(cli): fix openvpnconfig command panic due to missing SetDefaults call 2025-11-15 17:03:53 +00:00
35 changed files with 11243 additions and 5888 deletions

View File

@@ -20,6 +20,9 @@ issues:
text: "newCipherDESCBCBlock returns interface \\(github\\.com\\/youmark\\/pkcs8\\.Cipher\\)"
linters:
- ireturn
- source: "^\\/\\/ https\\:\\/\\/.+$"
linters:
- lll
linters:
enable:

View File

@@ -204,6 +204,8 @@ ENV VPN_SERVICE_PROVIDER=pia \
UPDATER_PERIOD=0 \
UPDATER_MIN_RATIO=0.8 \
UPDATER_VPN_SERVICE_PROVIDERS= \
UPDATER_PROTONVPN_EMAIL= \
UPDATER_PROTONVPN_PASSWORD= \
# Public IP
PUBLICIP_FILE="/tmp/gluetun/ip" \
PUBLICIP_ENABLED=on \

View File

@@ -420,7 +420,8 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
parallelResolver := resolver.NewParallelResolver(allSettings.Updater.DNSAddress)
openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, updaterLogger,
httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(), openvpnFileExtractor)
httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(),
openvpnFileExtractor, allSettings.Updater)
vpnLogger := logger.New(log.SetComponent("vpn"))
vpnLooper := vpn.NewLoop(allSettings.VPN, ipv6Supported, allSettings.Firewall.VPNInputPorts,

14
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/qdm12/gluetun
go 1.23
require (
github.com/ProtonMail/go-srp v0.0.7
github.com/breml/rootcerts v0.2.19
github.com/fatih/color v1.18.0
github.com/golang/mock v1.6.0
@@ -22,16 +23,20 @@ require (
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/net v0.31.0
golang.org/x/sys v0.27.0
golang.org/x/text v0.20.0
golang.org/x/sys v0.30.0
golang.org/x/text v0.22.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
gopkg.in/ini.v1 v1.67.0
)
require (
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/go-crypto v1.3.0-proton // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/josharian/native v1.1.0 // indirect
@@ -42,6 +47,7 @@ require (
github.com/mdlayher/socket v0.4.1 // indirect
github.com/miekg/dns v1.1.62 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
@@ -50,9 +56,9 @@ require (
github.com/qdm12/goservices v0.1.0 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
golang.org/x/crypto v0.29.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/tools v0.26.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.35.1 // indirect

56
go.sum
View File

@@ -1,9 +1,23 @@
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.3.0-proton h1:tAQKQRZX/73VmzK6yHSCaRUOvS/3OYSQzhXQsrR7yUM=
github.com/ProtonMail/go-crypto v1.3.0-proton/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/breml/rootcerts v0.2.19 h1:3D/qwAC1xoh82GmZ21mYzQ1NaLOICUVntIo+MRZYr4U=
github.com/breml/rootcerts v0.2.19/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -43,6 +57,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
@@ -84,46 +100,70 @@ github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZla
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk=
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/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.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

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

View File

@@ -56,6 +56,7 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
if err != nil {
return err
}
allSettings.SetDefaults()
ipv6Supported, err := ipv6Checker.IsIPv6Supported()
if err != nil {
@@ -75,7 +76,7 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
openvpnFileExtractor := extract.New()
providers := provider.NewProviders(storage, time.Now, warner, client,
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor)
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, allSettings.Updater)
providerConf := providers.Get(allSettings.VPN.Provider.Name)
connection, err := providerConf.GetConnection(
allSettings.VPN.Provider.ServerSelection, ipv6Supported)

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,6 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
if err == nil {
l.backoffTime = defaultBackoffTime
l.logger.Info("ready")
l.signalOrSetStatus(constants.Running)
break
}
@@ -54,6 +53,7 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
l.logAndWait(ctx, err)
settings = l.GetSettings()
}
l.signalOrSetStatus(constants.Running)
settings = l.GetSettings()
if !*settings.KeepNameserver && !*settings.DoT.Enabled {
@@ -74,15 +74,19 @@ func (l *Loop) runWait(ctx context.Context, runError <-chan error) (exitLoop boo
for {
select {
case <-ctx.Done():
l.stopServer()
// TODO revert OS and Go nameserver when exiting
if !*l.GetSettings().KeepNameserver {
l.stopServer()
// TODO revert OS and Go nameserver when exiting
}
return true
case <-l.stop:
l.userTrigger = true
l.logger.Info("stopping")
const fallback = false
l.useUnencryptedDNS(fallback)
l.stopServer()
if !*l.GetSettings().KeepNameserver {
const fallback = false
l.useUnencryptedDNS(fallback)
l.stopServer()
}
l.stopped <- struct{}{}
case <-l.start:
l.userTrigger = true

View File

@@ -3,7 +3,6 @@ package service
import (
"context"
"fmt"
"os"
"time"
)
@@ -61,10 +60,10 @@ func (s *Service) cleanup() (err error) {
s.ports = nil
filepath := s.settings.Filepath
s.logger.Info("removing port file " + filepath)
err = os.Remove(filepath)
s.logger.Info("clearing port file " + filepath)
err = s.writePortForwardedFile(nil)
if err != nil {
return fmt.Errorf("removing port file: %w", err)
return fmt.Errorf("clearing port file: %w", err)
}
return nil

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,8 @@ func (p *Provider) OpenVPNConfig(connection models.Connection,
Ciphers: []string{openvpn.AES256cbc},
Auth: openvpn.SHA512,
CAs: []string{
"MIID7jCCA1CgAwIBAgIQQTT3w3N+5i8OMfe565xaSjAKBggqhkjOPQQDBDCBojELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMREwDwYDVQQHDAhOZXcgWW9yazEXMBUGA1UECgwOS2VlcFNvbGlkIEluYy4xGjAYBgNVBAsMEUtlZXBTb2xpZCBSb290IENBMRowGAYDVQQDDBFLZWVwU29saWQgUm9vdCBDQTEiMCAGCSqGSIb3DQEJARYTYWRtaW5Aa2VlcHNvbGlkLmNvbTAeFw0yMDA0MDExNjI3MTRaFw0yNTAzMzExNjI3MTRaMIGgMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTlkxETAPBgNVBAcMCE5ldyBZb3JrMRcwFQYDVQQKDA5LZWVwU29saWQgSW5jLjEVMBMGA1UECwwMS2VlcFNvbGlkIENBMR0wGwYDVQQDDBRPcGVuVlBOIFNlcnZlciBTdWJDQTEiMCAGCSqGSIb3DQEJARYTYWRtaW5Aa2VlcHNvbGlkLmNvbTCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEAR9nmoZUraRSSPUhYwIQBLSx+phJdIlqU7F7Hszh95ivnWYkwuizKLaUYy6lSISDohlUtQl9URBlRrGroVctOGlOAdpL2ARTljw5gmUcaavc5cvLiAV7fPJ7BFUgVxInmaVcaMlDwGgKLxmjU2Fw85VLROHbWQjYc93x/BTSFcYO/np4o4IBIzCCAR8wDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQUrUCjH8xe37lJihyzpqjWwxxNOiswgeIGA1UdIwSB2jCB14AU/LRRnTRaEbxct895Pk9DoymNQIqhgaikgaUwgaIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOWTERMA8GA1UEBwwITmV3IFlvcmsxFzAVBgNVBAoMDktlZXBTb2xpZCBJbmMuMRowGAYDVQQLDBFLZWVwU29saWQgUm9vdCBDQTEaMBgGA1UEAwwRS2VlcFNvbGlkIFJvb3QgQ0ExIjAgBgkqhkiG9w0BCQEWE2FkbWluQGtlZXBzb2xpZC5jb22CFEssZFYAz8WhYnIDxLeDgKTLD8p2MAsGA1UdDwQEAwIBBjAKBggqhkjOPQQDBAOBiwAwgYcCQgGuK8UNnpE8k8hAamnT9gxCSs5APqrgmdLe6BxYSz7AptpF2/MPzLFsXgj4YxC6vJP8Rs8e3Hw9VJ7DF0aYgu8DvQJBeyFWjRnk8kmu2zEU+wF9fkvN9AJ7v0xF0iEaFVsdPKv6sJQP1sAL+AIepJQ7TYvh9Q9G/WaRCfItCtcOAEz3SKA=", //nolint:lll
"MIID9zCCA1igAwIBAgIUSyxkVgDPxaFicgPEt4OApMsPynYwCgYIKoZIzj0EAwQwgaIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOWTERMA8GA1UEBwwITmV3IFlvcmsxFzAVBgNVBAoMDktlZXBTb2xpZCBJbmMuMRowGAYDVQQLDBFLZWVwU29saWQgUm9vdCBDQTEaMBgGA1UEAwwRS2VlcFNvbGlkIFJvb3QgQ0ExIjAgBgkqhkiG9w0BCQEWE2FkbWluQGtlZXBzb2xpZC5jb20wIBcNMTkxMjMxMTY1NzMyWhgPMjA1NzA1MTUxNjU3MzJaMIGiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTlkxETAPBgNVBAcMCE5ldyBZb3JrMRcwFQYDVQQKDA5LZWVwU29saWQgSW5jLjEaMBgGA1UECwwRS2VlcFNvbGlkIFJvb3QgQ0ExGjAYBgNVBAMMEUtlZXBTb2xpZCBSb290IENBMSIwIAYJKoZIhvcNAQkBFhNhZG1pbkBrZWVwc29saWQuY29tMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBlmcBvPVcV7mVgiUVkO9Dh8b4Hdw/eyU8OLWJ0qyRiqI1q0ad1cxgi6asy33xwilrMkRxhArDSfB87zpUpUboTEMBSf9n+dCoGRncGfW9G+8IvhzPY3Z3nzVHBGhoKlN1jsCuKzzpjGawqTAeCkJNBPQNd75Dp6Tgl198bAowD+iPX3WjggEjMIIBHzAdBgNVHQ4EFgQU/LRRnTRaEbxct895Pk9DoymNQIowgeIGA1UdIwSB2jCB14AU/LRRnTRaEbxct895Pk9DoymNQIqhgaikgaUwgaIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOWTERMA8GA1UEBwwITmV3IFlvcmsxFzAVBgNVBAoMDktlZXBTb2xpZCBJbmMuMRowGAYDVQQLDBFLZWVwU29saWQgUm9vdCBDQTEaMBgGA1UEAwwRS2VlcFNvbGlkIFJvb3QgQ0ExIjAgBgkqhkiG9w0BCQEWE2FkbWluQGtlZXBzb2xpZC5jb22CFEssZFYAz8WhYnIDxLeDgKTLD8p2MAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgEGMAoGCCqGSM49BAMEA4GMADCBiAJCAbDIYRjZYaDwMf8Gq4udKVS4aWtZt73lVulCVmr951tQ9J2Dzh4OEQZvU5+M688o2N/fVxNQoxwm/NsiJxpc/prQAkIBiRbrcEGvalu9h6UqE6yAXe0JZcF5xn/BIe5XygglOput4kvZKLKtIqPe2bwBmL/dqq6XDL7s5QaTWPo5MtpzGjA=", //nolint:lll
"MIIECjCCA2ygAwIBAgIRAJ/aLZu0PCO7LlOTcPQE9UwwCgYIKoZIzj0EAwQwgasxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOWTERMA8GA1UEBwwITmV3IFlvcmsxFzAVBgNVBAoMDktlZXBTb2xpZCBJbmMuMR4wHAYDVQQLDBVLZWVwU29saWQgVlBOIFJvb3QgQ0ExHjAcBgNVBAMMFUtlZXBTb2xpZCBWUE4gUm9vdCBDQTEjMCEGCSqGSIb3DQEJARYUYWRtaW5zQGtlZXBzb2xpZC5jb20wHhcNMjUwMzMxMTQ0OTU4WhcNMzAwNjEzMTQ0OTU4WjCBqTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMREwDwYDVQQHDAhOZXcgWW9yazEXMBUGA1UECgwOS2VlcFNvbGlkIEluYy4xHTAbBgNVBAsMFEtlZXBTb2xpZCBPcGVuVlBOIENBMR0wGwYDVQQDDBRLZWVwU29saWQgT3BlblZQTiBDQTEjMCEGCSqGSIb3DQEJARYUYWRtaW5zQGtlZXBzb2xpZC5jb20wgZswEAYHKoZIzj0CAQYFK4EEACMDgYYABAEHfJRyn9MZ7HQctQULIxVUNFFw+tWetokml5PvIsS1i3mM4NQnj0HHL5zCCQRKUmSiiWtGvbGlsHEWX/hz+NiVoQGjMqBD2ykdLimiFrceonIofEBZW8to6jTjG3wmJkRykDqsuLyBLUKGc2F5dR3YFGgwyDoRz0NaAYI+qgqWfE+cVaOCASwwggEoMAwGA1UdEwQFMAMBAf8wHQYDVR0OBBYEFB4IhTj1gStDx+fNq+ubBcr+lEbwMIHrBgNVHSMEgeMwgeCAFOEcFx6OcN8T1R8lTdCLhFlYuk5joYGxpIGuMIGrMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTlkxETAPBgNVBAcMCE5ldyBZb3JrMRcwFQYDVQQKDA5LZWVwU29saWQgSW5jLjEeMBwGA1UECwwVS2VlcFNvbGlkIFZQTiBSb290IENBMR4wHAYDVQQDDBVLZWVwU29saWQgVlBOIFJvb3QgQ0ExIzAhBgkqhkiG9w0BCQEWFGFkbWluc0BrZWVwc29saWQuY29tghRnfb8jJuxu5dJzLm5ZdurkedrxzjALBgNVHQ8EBAMCAQYwCgYIKoZIzj0EAwQDgYsAMIGHAkIBg8Cdu474VlljCoP8WEr6xErKL6Bygy5+SO1Ey0Uu3B7q8R22F0EWvrOmqmyNZ3oRyqhpUGaEBqB2aqDGT7u7wGsCQUP3nyMlDbXqCF05byMbhQrBsCz1nyqDNnfzM2uGmT09XwWXGCYTIGdynyJJLzdOlpf3T19ZLvqLSf6Kvq45u6si", //nolint:lll
"MIIEEDCCA3GgAwIBAgIUZ32/IybsbuXScy5uWXbq5Hna8c4wCgYIKoZIzj0EAwQwgasxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOWTERMA8GA1UEBwwITmV3IFlvcmsxFzAVBgNVBAoMDktlZXBTb2xpZCBJbmMuMR4wHAYDVQQLDBVLZWVwU29saWQgVlBOIFJvb3QgQ0ExHjAcBgNVBAMMFUtlZXBTb2xpZCBWUE4gUm9vdCBDQTEjMCEGCSqGSIb3DQEJARYUYWRtaW5zQGtlZXBzb2xpZC5jb20wHhcNMjUwMzMxMTQ0NTUzWhcNMzUwODI2MTQ0NTUzWjCBqzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMREwDwYDVQQHDAhOZXcgWW9yazEXMBUGA1UECgwOS2VlcFNvbGlkIEluYy4xHjAcBgNVBAsMFUtlZXBTb2xpZCBWUE4gUm9vdCBDQTEeMBwGA1UEAwwVS2VlcFNvbGlkIFZQTiBSb290IENBMSMwIQYJKoZIhvcNAQkBFhRhZG1pbnNAa2VlcHNvbGlkLmNvbTCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEAN77xqCz3wrFDnRMtggwScgvO6wPFZYECTUu5WW0JaowgmuIgo+BiQQyTeUzJEICulc1Hg7EaUEV+z8jsSrB+4/EAWazn/ufWOx/51fa5FCv4YooCbgLPb1CzYDuTc7MUR5PLQ88o3W01wCCgT8RoNH8uChyPBLUBh2f4rUfpzl20Bqdo4IBLDCCASgwDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQU4RwXHo5w3xPVHyVN0IuEWVi6TmMwgesGA1UdIwSB4zCB4IAU4RwXHo5w3xPVHyVN0IuEWVi6TmOhgbGkga4wgasxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOWTERMA8GA1UEBwwITmV3IFlvcmsxFzAVBgNVBAoMDktlZXBTb2xpZCBJbmMuMR4wHAYDVQQLDBVLZWVwU29saWQgVlBOIFJvb3QgQ0ExHjAcBgNVBAMMFUtlZXBTb2xpZCBWUE4gUm9vdCBDQTEjMCEGCSqGSIb3DQEJARYUYWRtaW5zQGtlZXBzb2xpZC5jb22CFGd9vyMm7G7l0nMubll26uR52vHOMAsGA1UdDwQEAwIBBjAKBggqhkjOPQQDBAOBjAAwgYgCQgCZtqE+wXwH0ixjWafX3SClp8O3bYeyB/7jbzf8MprXRYBVQ8JjvugjaZTvX82Uy++LaN3oHqK+NUhJUdfZx/eIuQJCAad7HpsKyTYuUUkgAgWXJma4MstxyO9PVRNYozi1oc45Z8deSvwy404n3u1kY5QXLZQaaMY7m2pF+ECs4WkKCh5s", //nolint:lll
},
ExtraLines: []string{
"route-metric 1",

View File

@@ -114,6 +114,11 @@ func (l *Loop) run(runCtx context.Context, runDone chan<- struct{},
continue
}
if !*l.settings.Enabled {
singleRunResult <- nil
continue
}
result, err := l.fetcher.FetchInfo(singleRunCtx, netip.Addr{})
if err != nil {
err = fmt.Errorf("fetching information: %w", err)

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -52,7 +52,7 @@ func (s *Storage) syncServers() (err error) {
err = s.flushToFile(s.filepath)
if err != nil {
return fmt.Errorf("writing servers to file: %w", err)
s.logger.Warn("failed writing servers to file: " + err.Error())
}
return nil
}

View File

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

View File

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

View File

@@ -32,9 +32,14 @@ func (w *Wireguard) addRoutes(link netlink.Link, destinations []netip.Prefix,
func (w *Wireguard) addRoute(link netlink.Link, dst netip.Prefix,
firewallMark uint32,
) (err error) {
family := netlink.FamilyV4
if dst.Addr().Is6() {
family = netlink.FamilyV6
}
route := netlink.Route{
LinkIndex: link.Index,
Dst: dst,
Family: family,
Table: int(firewallMark),
}

View File

@@ -37,6 +37,7 @@ func Test_Wireguard_addRoute(t *testing.T) {
expectedRoute: netlink.Route{
LinkIndex: linkIndex,
Dst: ipPrefix,
Family: netlink.FamilyV4,
Table: firewallMark,
},
},
@@ -49,6 +50,7 @@ func Test_Wireguard_addRoute(t *testing.T) {
expectedRoute: netlink.Route{
LinkIndex: linkIndex,
Dst: ipPrefix,
Family: netlink.FamilyV4,
Table: firewallMark,
},
routeAddErr: errDummy,