diff --git a/.golangci.yml b/.golangci.yml index 9125581d..9b6af6a4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -56,6 +56,9 @@ linters: - revive path: internal\/provider\/(common|utils)\/.+\.go text: "var-naming: avoid (bad|meaningless) package names" + - linters: + - lll + source: "^// https://.+$" - linters: - err113 - mnd diff --git a/Dockerfile b/Dockerfile index c7db09b5..47b4c746 100644 --- a/Dockerfile +++ b/Dockerfile @@ -206,6 +206,8 @@ ENV VPN_SERVICE_PROVIDER=pia \ UPDATER_PERIOD=0 \ UPDATER_MIN_RATIO=0.8 \ UPDATER_VPN_SERVICE_PROVIDERS= \ + UPDATER_PROTONVPN_USERNAME= \ + UPDATER_PROTONVPN_PASSWORD= \ # Public IP PUBLICIP_FILE="/tmp/gluetun/ip" \ PUBLICIP_ENABLED=on \ diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index b14b0200..cb868dfd 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -427,7 +427,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, diff --git a/go.mod b/go.mod index 10bb9726..e7d9a7f4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/qdm12/gluetun go 1.25.0 require ( + github.com/ProtonMail/go-srp v0.0.7 github.com/breml/rootcerts v0.3.3 github.com/fatih/color v1.18.0 github.com/golang/mock v1.6.0 @@ -30,8 +31,12 @@ require ( ) require ( + github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect + github.com/ProtonMail/go-crypto v1.3.0-proton // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/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 @@ -51,9 +57,10 @@ require ( github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect github.com/vishvananda/netns v0.0.5 // indirect golang.org/x/crypto v0.43.0 // indirect - golang.org/x/mod v0.28.0 // indirect + golang.org/x/mod v0.29.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.38.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 diff --git a/go.sum b/go.sum index fad20f80..a3383d27 100644 --- a/go.sum +++ b/go.sum @@ -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.3.3 h1://GnaRtQ/9BY2+GtMk2wtWxVdCRysiaPr5/xBwl7NKw= github.com/breml/rootcerts v0.3.3/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.6.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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= @@ -84,23 +100,34 @@ github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZla github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk= github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -108,24 +135,37 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/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= diff --git a/internal/cli/openvpnconfig.go b/internal/cli/openvpnconfig.go index 6987d496..44ee7560 100644 --- a/internal/cli/openvpnconfig.go +++ b/internal/cli/openvpnconfig.go @@ -76,7 +76,7 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader, openvpnFileExtractor := extract.New() providers := provider.NewProviders(storage, time.Now, warner, client, - unzipper, parallelResolver, ipFetcher, openvpnFileExtractor) + unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, allSettings.Updater) providerConf := providers.Get(allSettings.VPN.Provider.Name) connection, err := providerConf.GetConnection( allSettings.VPN.Provider.ServerSelection, ipv6Supported) diff --git a/internal/cli/update.go b/internal/cli/update.go index 9e684490..ae05bd23 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -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, 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,8 @@ 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", "", "Username to use to authenticate with Proton") + flagSet.StringVar(&protonPassword, "proton-password", "", "Password to use to authenticate with Proton") if err := flagSet.Parse(args); err != nil { return err } @@ -64,6 +69,11 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e options.Providers = strings.Split(csvProviders, ",") } + if slices.Contains(options.Providers, providers.Protonvpn) { + options.ProtonUsername = &protonUsername + options.ProtonPassword = &protonPassword + } + options.SetDefaults(options.Providers[0]) err := options.Validate() @@ -94,7 +104,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) diff --git a/internal/configuration/settings/errors.go b/internal/configuration/settings/errors.go index 12026978..91c6b9ed 100644 --- a/internal/configuration/settings/errors.go +++ b/internal/configuration/settings/errors.go @@ -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") + ErrUpdaterProtonUsernameMissing = errors.New("proton username 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") diff --git a/internal/configuration/settings/updater.go b/internal/configuration/settings/updater.go index b4bff84d..1b7d52a4 100644 --- a/internal/configuration/settings/updater.go +++ b/internal/configuration/settings/updater.go @@ -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 + // ProtonUsername is the username to authenticate with the Proton API. + ProtonUsername *string + // ProtonPassword is the password to authenticate with the Proton API. + ProtonPassword *string } func (u Updater) Validate() (err error) { @@ -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.ProtonUsername == "" || *u.ProtonPassword == "" + if authenticatedAPI { + switch { + case *u.ProtonUsername == "": + return fmt.Errorf("%w", ErrUpdaterProtonUsernameMissing) + 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), + ProtonUsername: gosettings.CopyPointer(u.ProtonUsername), + 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.ProtonUsername = gosettings.OverrideWithPointer(u.ProtonUsername, other.ProtonUsername) + 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.ProtonUsername = gosettings.DefaultPointer(u.ProtonUsername, "") + 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 username: %s", *u.ProtonUsername) + node.Appendf("Proton API password: %s", gosettings.ObfuscateKey(*u.ProtonPassword)) + } return node } @@ -125,6 +154,14 @@ func (u *Updater) read(r *reader.Reader) (err error) { u.Providers = r.CSV("UPDATER_VPN_SERVICE_PROVIDERS") + u.ProtonUsername = r.Get("UPDATER_PROTONVPN_USERNAME") + if u.ProtonUsername != nil { + // Enforce to use the username not the email address + *u.ProtonUsername = strings.TrimSuffix(*u.ProtonUsername, "@protonmail.com") + *u.ProtonUsername = strings.TrimSuffix(*u.ProtonUsername, "@proton.me") + } + u.ProtonPassword = r.Get("UPDATER_PROTONVPN_PASSWORD") + return nil } diff --git a/internal/provider/common/updater.go b/internal/provider/common/updater.go index 5a11112e..c86c98b5 100644 --- a/internal/provider/common/updater.go +++ b/internal/provider/common/updater.go @@ -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 { diff --git a/internal/provider/protonvpn/provider.go b/internal/provider/protonvpn/provider.go index 59472bd8..64704e23 100644 --- a/internal/provider/protonvpn/provider.go +++ b/internal/provider/protonvpn/provider.go @@ -18,11 +18,12 @@ type Provider struct { func New(storage common.Storage, randSource rand.Source, client *http.Client, updaterWarner common.Warner, + username, password string, ) *Provider { return &Provider{ storage: storage, randSource: randSource, - Fetcher: updater.New(client, updaterWarner), + Fetcher: updater.New(client, updaterWarner, username, password), } } diff --git a/internal/provider/protonvpn/updater/api.go b/internal/provider/protonvpn/updater/api.go index 79b18fd0..55ed516b 100644 --- a/internal/provider/protonvpn/updater/api.go +++ b/internal/provider/protonvpn/updater/api.go @@ -1,15 +1,567 @@ 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, username, password string, +) (authCookie cookie, err error) { + sessionID, err := c.getSessionID(ctx) + if err != nil { + return cookie{}, fmt.Errorf("getting session ID: %w", err) + } + + tokenType, accessToken, refreshToken, uid, err := c.getUnauthSession(ctx, sessionID) + if err != nil { + return cookie{}, fmt.Errorf("getting unauthenticated session data: %w", err) + } + + cookieToken, err := c.cookieToken(ctx, sessionID, tokenType, accessToken, refreshToken, uid) + if err != nil { + return cookie{}, fmt.Errorf("getting cookie token: %w", err) + } + + unauthCookie := cookie{ + uid: uid, + token: cookieToken, + sessionID: sessionID, + } + modulusPGPClearSigned, serverEphemeralBase64, saltBase64, + srpSessionHex, version, err := c.authInfo(ctx, username, unauthCookie) + if err != nil { + return cookie{}, fmt.Errorf("getting auth information: %w", err) + } + + // Prepare SRP proof generator using Proton's official SRP parameters and hashing. + srpAuth, err := srp.NewAuth(version, username, []byte(password), + saltBase64, modulusPGPClearSigned, serverEphemeralBase64) + if err != nil { + return cookie{}, fmt.Errorf("initializing SRP auth: %w", err) + } + + // Generate SRP proofs (A, M1) with the usual 2048-bit modulus. + const modulusBits = 2048 + proofs, err := srpAuth.GenerateProofs(modulusBits) + if err != nil { + return cookie{}, fmt.Errorf("generating SRP proofs: %w", err) + } + + authCookie, err = c.auth(ctx, unauthCookie, username, srpSessionHex, proofs) + if err != nil { + return cookie{}, fmt.Errorf("authentifying: %w", err) + } + + return authCookie, nil +} + +var ErrSessionIDNotFound = errors.New("session ID not found in cookies") + +func (c *apiClient) getSessionID(ctx context.Context) (sessionID string, err error) { + const url = "https://account.proton.me/vpn" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + response, err := c.httpClient.Do(request) + if err != nil { + return "", err + } + err = response.Body.Close() + if err != nil { + return "", fmt.Errorf("closing response body: %w", err) + } + + for _, cookie := range response.Cookies() { + if cookie.Name == "Session-Id" { + return cookie.Value, nil + } + } + + return "", fmt.Errorf("%w", ErrSessionIDNotFound) +} + +var ErrDataFieldMissing = errors.New("data field missing in response") + +func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) ( + tokenType, accessToken, refreshToken, uid string, err error, +) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/auth/v4/sessions", nil) + if err != nil { + return "", "", "", "", fmt.Errorf("creating request: %w", err) + } + unauthCookie := cookie{ + sessionID: sessionID, + } + c.setHeaders(request, unauthCookie) + + response, err := c.httpClient.Do(request) + if err != nil { + return "", "", "", "", err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return "", "", "", "", fmt.Errorf("reading response body: %w", err) + } else if response.StatusCode != http.StatusOK { + return "", "", "", "", buildError(response.StatusCode, responseBody) + } + + var data struct { + Code uint `json:"Code"` // 1000 on success + AccessToken string `json:"AccessToken"` // 32-chars lowercase and digits + RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits + TokenType string `json:"TokenType"` // "Bearer" + Scopes []string `json:"Scopes"` // should be [] for our usage + UID string `json:"UID"` // 32-chars lowercase and digits + LocalID uint `json:"LocalID"` // 0 in my case + } + + err = json.Unmarshal(responseBody, &data) + if err != nil { + return "", "", "", "", fmt.Errorf("decoding response body: %w", err) + } + + const successCode = 1000 + switch { + case data.Code != successCode: + return "", "", "", "", fmt.Errorf("%w: expected %d got %d", + ErrCodeNotSuccess, successCode, data.Code) + case data.AccessToken == "": + return "", "", "", "", fmt.Errorf("%w: access token is empty", ErrDataFieldMissing) + case data.RefreshToken == "": + return "", "", "", "", fmt.Errorf("%w: refresh token is empty", ErrDataFieldMissing) + case data.TokenType == "": + return "", "", "", "", fmt.Errorf("%w: token type is empty", ErrDataFieldMissing) + case data.UID == "": + return "", "", "", "", fmt.Errorf("%w: UID is empty", ErrDataFieldMissing) + } + // Ignore Scopes and LocalID fields, we don't use them. + + return data.TokenType, data.AccessToken, data.RefreshToken, data.UID, nil +} + +var ErrUIDMismatch = errors.New("UID in response does not match request UID") + +func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, accessToken, + refreshToken, uid string, +) (cookieToken string, err error) { + type requestBodySchema struct { + GrantType string `json:"GrantType"` // "refresh_token" + Persistent uint `json:"Persistent"` // 0 + RedirectURI string `json:"RedirectURI"` // "https://protonmail.com" + RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits + ResponseType string `json:"ResponseType"` // "token" + State string `json:"State"` // 24-chars letters and digits + UID string `json:"UID"` // 32-chars lowercase and digits + } + requestBody := requestBodySchema{ + GrantType: "refresh_token", + Persistent: 0, + RedirectURI: "https://protonmail.com", + RefreshToken: refreshToken, + ResponseType: "token", + State: generateLettersDigits(c.generator, 24), //nolint:mnd + UID: uid, + } + + buffer := bytes.NewBuffer(nil) + encoder := json.NewEncoder(buffer) + if err := encoder.Encode(requestBody); err != nil { + return "", fmt.Errorf("encoding request body: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/cookies", buffer) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + unauthCookie := cookie{ + uid: uid, + sessionID: sessionID, + } + c.setHeaders(request, unauthCookie) + request.Header.Set("Authorization", tokenType+" "+accessToken) + + response, err := c.httpClient.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("reading response body: %w", err) + } else if response.StatusCode != http.StatusOK { + return "", buildError(response.StatusCode, responseBody) + } + + var cookies struct { + Code uint `json:"Code"` // 1000 on success + UID string `json:"UID"` // should match request UID + LocalID uint `json:"LocalID"` // 0 + RefreshCounter uint `json:"RefreshCounter"` // 1 + } + err = json.Unmarshal(responseBody, &cookies) + if err != nil { + return "", fmt.Errorf("decoding response body: %w", err) + } + + const successCode = 1000 + switch { + case cookies.Code != successCode: + return "", fmt.Errorf("%w: expected %d got %d", + ErrCodeNotSuccess, successCode, cookies.Code) + case cookies.UID != requestBody.UID: + return "", fmt.Errorf("%w: expected %s got %s", + ErrUIDMismatch, requestBody.UID, cookies.UID) + } + // Ignore LocalID and RefreshCounter fields, we don't use them. + + for _, cookie := range response.Cookies() { + if cookie.Name == "AUTH-"+uid { + return cookie.Value, nil + } + } + + return "", fmt.Errorf("%w", ErrAuthCookieNotFound) +} + +var ( + ErrUsernameDoesNotExist = errors.New("username does not exist") + ErrUsernameMismatch = errors.New("username in response does not match request username") +) + +// authInfo fetches SRP parameters for the account. +func (c *apiClient) authInfo(ctx context.Context, username string, unauthCookie cookie) ( + modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string, + version int, err error, +) { + type requestBodySchema struct { + Intent string `json:"Intent"` // "Proton" + Username string `json:"Username"` // username without @domain.com + } + requestBody := requestBodySchema{ + Intent: "Proton", + Username: username, + } + + buffer := bytes.NewBuffer(nil) + encoder := json.NewEncoder(buffer) + if err := encoder.Encode(requestBody); err != nil { + return "", "", "", "", 0, fmt.Errorf("encoding request body: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/info", buffer) + if err != nil { + return "", "", "", "", 0, fmt.Errorf("creating request: %w", err) + } + c.setHeaders(request, unauthCookie) + + response, err := c.httpClient.Do(request) + if err != nil { + return "", "", "", "", 0, err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return "", "", "", "", 0, fmt.Errorf("reading response body: %w", err) + } else if response.StatusCode != http.StatusOK { + return "", "", "", "", 0, buildError(response.StatusCode, responseBody) + } + + var info struct { + Code uint `json:"Code"` // 1000 on success + Modulus string `json:"Modulus"` // PGP clearsigned modulus string + ServerEphemeral string `json:"ServerEphemeral"` // base64 + Version *uint `json:"Version,omitempty"` // 4 as of 2025-10-26 + Salt string `json:"Salt"` // base64 + SRPSession string `json:"SRPSession"` // hexadecimal + Username string `json:"Username"` // user without @domain.com. Mine has its first letter capitalized. + } + err = json.Unmarshal(responseBody, &info) + if err != nil { + return "", "", "", "", 0, fmt.Errorf("decoding response body: %w", err) + } + + const successCode = 1000 + switch { + case info.Code != successCode: + return "", "", "", "", 0, fmt.Errorf("%w: expected %d got %d", + ErrCodeNotSuccess, successCode, info.Code) + case info.Modulus == "": + return "", "", "", "", 0, fmt.Errorf("%w: modulus is empty", ErrDataFieldMissing) + case info.ServerEphemeral == "": + return "", "", "", "", 0, fmt.Errorf("%w: server ephemeral is empty", ErrDataFieldMissing) + case info.Salt == "": + return "", "", "", "", 0, fmt.Errorf("%w (salt data field is empty)", ErrUsernameDoesNotExist) + case info.SRPSession == "": + return "", "", "", "", 0, fmt.Errorf("%w: SRP session is empty", ErrDataFieldMissing) + + case info.Username != username: + return "", "", "", "", 0, fmt.Errorf("%w: expected %s got %s", + ErrUsernameMismatch, username, info.Username) + case info.Version == nil: + return "", "", "", "", 0, fmt.Errorf("%w: version is missing", ErrDataFieldMissing) + } + + version = int(*info.Version) //nolint:gosec + return info.Modulus, info.ServerEphemeral, info.Salt, + info.SRPSession, version, nil +} + +type cookie struct { + uid string + token string + sessionID string +} + +func (c *cookie) String() string { + s := "" + if c.token != "" { + s += fmt.Sprintf("AUTH-%s=%s; ", c.uid, c.token) + } + if c.sessionID != "" { + s += fmt.Sprintf("Session-Id=%s; ", c.sessionID) + } + if c.token != "" { + s += "Tag=default; iaas=W10; Domain=proton.me; Feature=VPNDashboard:A" + } + return s +} + +var ( + // ErrServerProofNotValid indicates the M2 from the server didn't match the expected proof. + ErrServerProofNotValid = errors.New("server proof from server is not valid") + ErrVPNScopeNotFound = errors.New("VPN scope not found in scopes") + ErrTwoFANotSupported = errors.New("two factor authentication not supported in this client") + ErrAuthCookieNotFound = errors.New("auth cookie not found") +) + +// auth performs the SRP proof submission (and optionally TOTP) to obtain tokens. +func (c *apiClient) auth(ctx context.Context, unauthCookie cookie, + username, srpSession string, proofs *srp.Proofs, +) (authCookie cookie, err error) { + clientEphemeral := base64.StdEncoding.EncodeToString(proofs.ClientEphemeral) + clientProof := base64.StdEncoding.EncodeToString(proofs.ClientProof) + + type requestBodySchema struct { + ClientEphemeral string `json:"ClientEphemeral"` // base64(A) + ClientProof string `json:"ClientProof"` // base64(M1) + Payload map[string]string `json:"Payload,omitempty"` // not sure + SRPSession string `json:"SRPSession"` // hexadecimal + Username string `json:"Username"` // user@protonmail.com + } + requestBody := requestBodySchema{ + ClientEphemeral: clientEphemeral, + ClientProof: clientProof, + SRPSession: srpSession, + Username: username, + } + + buffer := bytes.NewBuffer(nil) + encoder := json.NewEncoder(buffer) + if err := encoder.Encode(requestBody); err != nil { + return cookie{}, fmt.Errorf("encoding request body: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth", buffer) + if err != nil { + return cookie{}, fmt.Errorf("creating request: %w", err) + } + c.setHeaders(request, unauthCookie) + + response, err := c.httpClient.Do(request) + if err != nil { + return cookie{}, err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return cookie{}, fmt.Errorf("reading response body: %w", err) + } else if response.StatusCode != http.StatusOK { + return cookie{}, buildError(response.StatusCode, responseBody) + } + + type twoFAStatus uint + //nolint:unused + const ( + twoFADisabled twoFAStatus = iota + twoFAHasTOTP + twoFAHasFIDO2 + twoFAHasFIDO2AndTOTP + ) + type twoFAInfo struct { + Enabled twoFAStatus `json:"Enabled"` + FIDO2 struct { + AuthenticationOptions any `json:"AuthenticationOptions"` + RegisteredKeys []any `json:"RegisteredKeys"` + } `json:"FIDO2"` + TOTP uint `json:"TOTP"` + } + + var auth struct { + Code uint `json:"Code"` // 1000 on success + LocalID uint `json:"LocalID"` // 7 in my case + Scopes []string `json:"Scopes"` // this should contain "vpn". Same as `Scope` field value. + UID string `json:"UID"` // same as `Uid` field value + UserID string `json:"UserID"` // base64 + EventID string `json:"EventID"` // base64 + PasswordMode uint `json:"PasswordMode"` // 1 in my case + ServerProof string `json:"ServerProof"` // base64(M2) + TwoFactor uint `json:"TwoFactor"` // 0 if 2FA not required + TwoFA twoFAInfo `json:"2FA"` + TemporaryPassword uint `json:"TemporaryPassword"` // 0 in my case + } + + err = json.Unmarshal(responseBody, &auth) + if err != nil { + return cookie{}, fmt.Errorf("decoding response body: %w", err) + } + + m2, err := base64.StdEncoding.DecodeString(auth.ServerProof) + if err != nil { + return cookie{}, fmt.Errorf("decoding server proof: %w", err) + } + if !bytes.Equal(m2, proofs.ExpectedServerProof) { + return cookie{}, fmt.Errorf("%w: expected %x got %x", + ErrServerProofNotValid, proofs.ExpectedServerProof, m2) + } + + const successCode = 1000 + switch { + case auth.Code != successCode: + return cookie{}, fmt.Errorf("%w: expected %d got %d", + ErrCodeNotSuccess, successCode, auth.Code) + case auth.UID != unauthCookie.uid: + return cookie{}, fmt.Errorf("%w: expected %s got %s", + ErrUIDMismatch, unauthCookie.uid, auth.UID) + case auth.TwoFactor != 0: + return cookie{}, fmt.Errorf("%w", ErrTwoFANotSupported) + case !slices.Contains(auth.Scopes, "vpn"): + return cookie{}, fmt.Errorf("%w: in %v", ErrVPNScopeNotFound, auth.Scopes) + } + + for _, setCookieHeader := range response.Header.Values("Set-Cookie") { + parts := strings.Split(setCookieHeader, ";") + for _, part := range parts { + if strings.HasPrefix(part, "AUTH-"+unauthCookie.uid+"=") { + authCookie = unauthCookie + authCookie.token = strings.TrimPrefix(part, "AUTH-"+unauthCookie.uid+"=") + return authCookie, nil + } + } + } + + return cookie{}, fmt.Errorf("%w: in HTTP headers %s", + ErrAuthCookieNotFound, httpHeadersToString(response.Header)) +} + +// generateLettersDigits mimicing Proton's own random string generator: +// https://github.com/ProtonMail/WebClients/blob/e4d7e4ab9babe15b79a131960185f9f8275512cd/packages/utils/generateLettersDigits.ts +func generateLettersDigits(rng *rand.ChaCha8, length uint) string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + return generateFromCharset(rng, length, charset) +} + +func generateFromCharset(rng *rand.ChaCha8, length uint, charset string) string { + result := make([]byte, length) + randomBytes := make([]byte, length) + _, _ = rng.Read(randomBytes) + for i := range length { + result[i] = charset[int(randomBytes[i])%len(charset)] + } + return string(result) +} + +func httpHeadersToString(headers http.Header) string { + var builder strings.Builder + first := true + for key, values := range headers { + for _, value := range values { + if !first { + builder.WriteString(", ") + } + builder.WriteString(fmt.Sprintf("%s: %s", key, value)) + first = false + } + } + return builder.String() +} type apiData struct { LogicalServers []logicalServer `json:"LogicalServers"` @@ -33,25 +585,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 +611,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, ", ")) +} diff --git a/internal/provider/protonvpn/updater/servers.go b/internal/provider/protonvpn/updater/servers.go index c7c3a0fc..e625e09e 100644 --- a/internal/provider/protonvpn/updater/servers.go +++ b/internal/provider/protonvpn/updater/servers.go @@ -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.username == "": + return nil, fmt.Errorf("%w: username is empty", common.ErrCredentialsMissing) + case u.password == "": + return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing) + } + + apiClient, err := newAPIClient(ctx, u.client) if err != nil { - return nil, err + return nil, fmt.Errorf("creating API client: %w", err) + } + + cookie, err := apiClient.authenticate(ctx, u.username, u.password) + if err != nil { + return nil, fmt.Errorf("authentifying with Proton: %w", err) + } + + data, err := apiClient.fetchServers(ctx, cookie) + if err != nil { + return nil, fmt.Errorf("fetching logical servers: %w", err) } countryCodes := constants.CountryCodes() diff --git a/internal/provider/protonvpn/updater/updater.go b/internal/provider/protonvpn/updater/updater.go index ff0d41cd..3159078a 100644 --- a/internal/provider/protonvpn/updater/updater.go +++ b/internal/provider/protonvpn/updater/updater.go @@ -7,13 +7,17 @@ import ( ) type Updater struct { - client *http.Client - warner common.Warner + client *http.Client + username string + password string + warner common.Warner } -func New(client *http.Client, warner common.Warner) *Updater { +func New(client *http.Client, warner common.Warner, username, password string) *Updater { return &Updater{ - client: client, - warner: warner, + client: client, + username: username, + password: password, + warner: warner, } } diff --git a/internal/provider/protonvpn/updater/version.go b/internal/provider/protonvpn/updater/version.go new file mode 100644 index 00000000..fe64cdba --- /dev/null +++ b/internal/provider/protonvpn/updater/version.go @@ -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) +} diff --git a/internal/provider/providers.go b/internal/provider/providers.go index 3189058c..8ed69743 100644 --- a/internal/provider/providers.go +++ b/internal/provider/providers.go @@ -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()) @@ -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.ProtonUsername, *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), diff --git a/internal/updater/providers.go b/internal/updater/providers.go index c3175ec6..5222f961 100644 --- a/internal/updater/providers.go +++ b/internal/updater/providers.go @@ -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 { diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 248ebd28..986d22d4 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -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