diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8d5d78f2..48aa633c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -19,9 +19,9 @@ jobs: go-version: 1.17 - name: Setup golangci-lint - uses: golangci/golangci-lint-action@v3.1.0 + uses: golangci/golangci-lint-action@v3.2.0 with: - version: v1.45.0 + version: v1.46.2 args: "--timeout 5m -v -c .golangci.yml" - name: Lint diff --git a/.golangci.yml b/.golangci.yml index 2ea358ae..f49c4e47 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -15,6 +15,7 @@ linters: - testpackage - gochecknoglobals - exhaustivestruct + - exhaustruct - paralleltest - godox - cyclop @@ -29,7 +30,7 @@ linters-settings: min-complexity: 40 funlen: - lines: 200 + lines: 220 statements: 75 nestif: diff --git a/CHANGELOG.md b/CHANGELOG.md index 09efb7e7..ea171dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Load config from Config file, and from env vars. Use viper for that - Automatically alocates a random port, if the specified one is in-use +## [0.3.0] - 2022-07-12 +## Changed +- Added support for setting basic auth header via API, `--site-credentials` flag, or an env var + ## [0.2.0] - 2022-05-30 ## Changed - Upgraded goproxy library to the latest master diff --git a/Makefile b/Makefile index f455c831..4de234a8 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,10 @@ endif test: @go test -timeout 120s -short -v -race -cover -coverprofile=coverage.out ./... +# If you hit too many open files: ulimit -Sn 10000 +bench: + @go test -bench=. -run=XXX ./pkg/proxy + test-integration: @FORWARDER_TEST_MODE=integration go test -timeout 120s -v -race -cover -coverprofile=coverage.out ./... && echo "Test OK" diff --git a/README.md b/README.md index 563473da..f3ece1b7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # forwarder -`forwarder` provides a simple forward proxy. The proxy can be protected with basic auth. It can also forward connections to a parent proxy, and authorize connections against that. -Both local, and parent credentials can be set via environment variables. For local proxy credential, set `PROXY_CREDENTIAL`. For remote proxy credential, set `PROXY_PARENT_CREDENTIAL`. +`forwarder` provides a simple forward proxy. The proxy can be protected with basic auth. +It can also forward connections to a parent proxy, and authorize connections against that. +Both local, and parent credentials can be set via environment variables. +For local proxy credential, set `FORWARDER_LOCALPROXY_AUTH`. For remote proxy credential, set `FORWARDER_UPSTREAMPROXY_AUTH`. ## Install diff --git a/cmd/run.go b/cmd/run.go index b942143a..d3259bd6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -16,6 +16,8 @@ var ( localProxyURI string upstreamProxyURI string + siteCredentials []string + pacProxiesCredentials []string pacURI string @@ -38,6 +40,7 @@ All credentials can be set via env vars: - Upstream proxy: FORWARDER_UPSTREAMPROXY_AUTH - PAC URI: PACMAN_AUTH - PAC proxies: PACMAN_PROXIES_AUTH +- Target URLs: FORWARDER_SITE_CREDENTIALS Note: Can't setup upstream, and PAC at the same time. `, @@ -46,10 +49,10 @@ Note: Can't setup upstream, and PAC at the same time. Start a proxy listening to http://0.0.0.0:8085: $ forwarder run -l "http://0.0.0.0:8085" - + Start a protected proxy: $ forwarder run -l "http://user:pwd@localhost:8085" - + Start a protected proxy, forwarding connection to an upstream proxy running at http://localhost:8089: $ forwarder run \ @@ -61,19 +64,19 @@ Note: Can't setup upstream, and PAC at the same time. $ forwarder run \ -l "http://user:pwd@localhost:8085" \ -u "http://user1:pwd1@localhost:8089" - + Start a protected proxy, forwarding connection to an upstream proxy, setup via PAC - server running at http://localhost:8090: $ forwarder run \ -l "http://user:pwd@localhost:8085" \ -p "http://localhost:8090" - + Start a protected proxy, forwarding connection to an upstream proxy, setup via PAC - protected server running at http://user2:pwd2@localhost:8090: $ forwarder run \ -l "http://user:pwd@localhost:8085" \ -p "http://user2:pwd2@localhost:8090" - + Start a protected proxy, forwarding connection to an upstream proxy, setup via PAC - protected server running at http://user2:pwd2@localhost:8090, specifying credential for protected proxies specified in PAC: @@ -91,6 +94,13 @@ Note: Can't setup upstream, and PAC at the same time. -l "http://user:pwd@localhost:8085" \ -p "http://user2:pwd2@localhost:8090" \ -d "http://user3:pwd4@localhost:8091,http://user4:pwd5@localhost:8092" + + Start a protected proxy that adds basic auth header to requests to foo.bar:8090 + and qux.baz:80. + $ forwarder run \ + -t \ + -l "http://user:pwd@localhost:8085" \ + --site-credentials "user1:pwd1@foo.bar:8090,user2:pwd2@qux:baz:80" `, Run: func(cmd *cobra.Command, args []string) { p, err := proxy.New(localProxyURI, upstreamProxyURI, pacURI, pacProxiesCredentials, &proxy.Options{ @@ -103,6 +113,7 @@ Note: Can't setup upstream, and PAC at the same time. AutomaticallyRetryPort: automaticallyRetryPort, DNSURIs: dnsURIs, ProxyLocalhost: proxyLocalhost, + SiteCredentials: siteCredentials, }) if err != nil { cliLogger.Fatalln(customerror.NewFailedToError("run", customerror.WithError(err))) @@ -115,11 +126,12 @@ Note: Can't setup upstream, and PAC at the same time. func init() { rootCmd.AddCommand(runCmd) - runCmd.Flags().StringVarP(&localProxyURI, "local-proxy-uri", "l", "http://localhost:8080", "Sets local proxy URI") + runCmd.Flags().StringVarP(&localProxyURI, "local-proxy-uri", "l", "http://localhost:8080", "sets local proxy URI") runCmd.Flags().StringVarP(&upstreamProxyURI, "upstream-proxy-uri", "u", "", "sets upstream proxy URI") runCmd.Flags().StringSliceVarP(&dnsURIs, "dns-uri", "n", nil, "sets dns URI") runCmd.Flags().StringVarP(&pacURI, "pac-uri", "p", "", "sets URI to PAC content, or directly, the PAC content") runCmd.Flags().StringSliceVarP(&pacProxiesCredentials, "pac-proxies-credentials", "d", nil, "sets PAC proxies credentials using standard URI format") + runCmd.Flags().StringSliceVar(&siteCredentials, "site-credentials", nil, "sets site based credentials") runCmd.Flags().BoolVarP(&proxyLocalhost, "proxy-localhost", "t", false, "if set, will proxy localhost requests to an upstream proxy - if any") runCmd.Flags().BoolVarP(&automaticallyRetryPort, "find-port", "r", true, "if set, and the specified local proxy port is in-use, it will find, and use an available one") } diff --git a/go.mod b/go.mod index ccfa1424..5d688843 100644 --- a/go.mod +++ b/go.mod @@ -7,15 +7,18 @@ require ( github.com/elazarl/goproxy v0.0.0-20220529153421-8ea89ba92021 github.com/elazarl/goproxy/ext v0.0.0-20220529153421-8ea89ba92021 github.com/go-playground/validator/v10 v10.11.0 + github.com/google/go-cmp v0.5.6 github.com/saucelabs/customerror v1.0.3 github.com/saucelabs/pacman v0.1.1 github.com/saucelabs/randomness v0.0.5 github.com/saucelabs/sypl v1.5.12 github.com/spf13/cobra v1.3.0 + github.com/stretchr/testify v1.8.0 ) require ( github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 // indirect github.com/dop251/goja v0.0.0-20220516123900-4418d4575a41 // indirect github.com/emirpasic/gods v1.12.0 // indirect @@ -28,10 +31,12 @@ require ( github.com/leodido/go-urn v1.2.1 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/saucelabs/lumberjack/v3 v3.0.2 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/crypto v0.0.0-20220214200702-86341886e292 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect golang.org/x/text v0.3.7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e0cbc057..9107378d 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,6 @@ 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/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 h1:Izz0+t1Z5nI16/II7vuEo/nHjodOg0p7+OiDpjX5t1E= github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dop251/goja v0.0.0-20211006122547-7efcb634c641 h1:FeL9DrCQOmJ0Xw5V3hNa3MVDYAvNaa/fVGJkYUgfgLY= -github.com/dop251/goja v0.0.0-20211006122547-7efcb634c641/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= github.com/dop251/goja v0.0.0-20220516123900-4418d4575a41 h1:yRPjAkkuR/E/tsVG7QmhzEeEtD3P2yllxsT1/ftURb0= github.com/dop251/goja v0.0.0-20220516123900-4418d4575a41/go.mod h1:TQJQ+ZNyFVvUtUEtCZxBhfWiH7RJqR3EivNmvD6Waik= @@ -106,15 +104,9 @@ github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= github.com/eapache/go-resiliency v1.2.0 h1:v7g92e/KSN71Rq7vSThKaWIq68fL4YHvWyiUKorFR1Q= github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/elazarl/goproxy v0.0.0-20220115173737-adb46da277ac h1:XDAn206aIqKPdF5YczuuJXSQPx+WOen0Pxbxp5Fq8Pg= -github.com/elazarl/goproxy v0.0.0-20220115173737-adb46da277ac/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= -github.com/elazarl/goproxy v0.0.0-20220417044921-416226498f94 h1:VIy7cdK7ufs7ctpTFkXJHm1uP3dJSnCGSPysEICB1so= -github.com/elazarl/goproxy v0.0.0-20220417044921-416226498f94/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy v0.0.0-20220529153421-8ea89ba92021 h1:EbF0UihnxWRcIMOwoVtqnAylsqcjzqpSvMdjF2Ud4rA= github.com/elazarl/goproxy v0.0.0-20220529153421-8ea89ba92021/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= -github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac h1:9yrT5tmn9Zc0ytWPASlaPwQfQMQYnRf0RSDe1XvHw0Q= -github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/elazarl/goproxy/ext v0.0.0-20220529153421-8ea89ba92021 h1:XO62HGrPPZne8dYsNMZJGCCBOHkhcGUWNxyQdggKE3o= github.com/elazarl/goproxy/ext v0.0.0-20220529153421-8ea89ba92021/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= @@ -150,7 +142,6 @@ github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= -github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0= github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw= github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= @@ -207,6 +198,7 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -287,9 +279,11 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= @@ -353,6 +347,7 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -361,17 +356,11 @@ github.com/saucelabs/customerror v1.0.3 h1:LfQUuQ9iK6/ExBzRXgLFKx0SLAVmz1s1P2HYX github.com/saucelabs/customerror v1.0.3/go.mod h1:16/zfic7+i7QHOi+i7IQC5/6aL4HYOLocOtjXOM0KXY= github.com/saucelabs/lumberjack/v3 v3.0.2 h1:d2xl3L4gtuwhFOnBEWTcTRxZ64wQWyFfUK8cadpe5NA= github.com/saucelabs/lumberjack/v3 v3.0.2/go.mod h1:YWvEpPjHrjk7jKET9K4Vphyk6RFlXFD1e/rP60Fr+JA= -github.com/saucelabs/pacman v0.0.13 h1:kdHLdZCminN/TGbKgXncZvXATNT7NTbXtVAfJQpVAlQ= -github.com/saucelabs/pacman v0.0.13/go.mod h1:CiqtUweQyceGUDccdu65+LK+UxUzfERJFILMwoyQ5us= -github.com/saucelabs/pacman v0.1.0 h1:2LpWMsGaISW79ziXTdtzgpqMLaGJsMemVo8Y8TGwtnw= -github.com/saucelabs/pacman v0.1.0/go.mod h1:sjyZ/L3RVWB0/s5aGasliVlOJgyCnq7EvYsnZPfASJQ= github.com/saucelabs/pacman v0.1.1 h1:QtL6rGSRS5HuXQlo1d5mnD8ElXMdMDnVD3VEatOrFOU= github.com/saucelabs/pacman v0.1.1/go.mod h1:sjyZ/L3RVWB0/s5aGasliVlOJgyCnq7EvYsnZPfASJQ= github.com/saucelabs/randomness v0.0.5 h1:IBgMdKOWb4zCKUbQ03tTjIUXZcxG1rT/cH1iggYjCJE= github.com/saucelabs/randomness v0.0.5/go.mod h1:jleEVfS8aVUKZ6Js4NTnqqM62SyvAaiRs23WEgckP+g= github.com/saucelabs/sypl v1.5.8/go.mod h1:ubSLpo9I9awtabutiS6Npjof7s/km+HJ/9aOOPClMW0= -github.com/saucelabs/sypl v1.5.10 h1:EptSBygwDminwfyg9SJP81ZwNcazX+1x1P4HWi18nkI= -github.com/saucelabs/sypl v1.5.10/go.mod h1:ubSLpo9I9awtabutiS6Npjof7s/km+HJ/9aOOPClMW0= github.com/saucelabs/sypl v1.5.12 h1:48Qtq/A7JtWdXcTFngJzzHI5731ccReedrkZimkslfA= github.com/saucelabs/sypl v1.5.12/go.mod h1:r/KHXrhgQ0XFnmXAazNmRXi1AtmVs/K4VJ1OoNWkRBk= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= @@ -390,14 +379,16 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -430,7 +421,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -680,6 +670,7 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -828,6 +819,7 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -840,8 +832,9 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/logger/logger.go b/internal/logger/logger.go index aba0f932..13e54f03 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -61,7 +61,7 @@ func (o *Options) Default() { // Get returns the logger. If the logger isn't configured, it will exit with fatal. func Get() *sypl.Sypl { if proxyLogger == nil { - log.Fatalln("Logger is not configired") + log.Fatalln("Logger is not configured") } return proxyLogger diff --git a/internal/util/doc.go b/internal/util/doc.go new file mode 100644 index 00000000..c1e98724 --- /dev/null +++ b/internal/util/doc.go @@ -0,0 +1,7 @@ +// Copyright 2021 The forwarder Authors. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// Package util provides common (cross-package) utilities. +// It must not depend on another package in the project. +package util diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 00000000..d50a8cb8 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,44 @@ +// Copyright 2021 The forwarder Authors. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package util + +import ( + "fmt" + "net/url" + "strings" +) + +// normalizeURLScheme ensures that the URL starts with the scheme. +func normalizeURLScheme(uri string) string { + u := uri + scheme := "http" + if strings.HasPrefix(u, "://") { + u = uri[3:] + } + + if strings.Contains(u, "://") { + return u + } + + if strings.HasSuffix(u, ":443") { + scheme = "https" + } + + return fmt.Sprintf("%s://%s", scheme, u) +} + +// NormalizeURI ensures that the url has a scheme. +func NormalizeURI(uriToParse string) (*url.URL, error) { + // Using ParseRequestURI instead of Parse since our use-case is + // full URLs only. url.ParseRequestURI expects uriToParse to have a scheme. + localURL, err := url.ParseRequestURI(normalizeURLScheme(uriToParse)) + if err != nil { + return nil, err + } + if localURL.Scheme == "" { + localURL.Scheme = "http" + } + return localURL, nil +} diff --git a/internal/util/util_test.go b/internal/util/util_test.go new file mode 100644 index 00000000..cbec5c5a --- /dev/null +++ b/internal/util/util_test.go @@ -0,0 +1,68 @@ +// Copyright 2021 The forwarder Authors. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeURI(t *testing.T) { + testCases := []struct { + name string + url string + expected string + err error + }{ + { + name: "Adds http scheme", + url: "example.com", + expected: "http://example.com", + err: nil, + }, + { + name: "Adds http scheme", + url: "example.com:8888", + expected: "http://example.com:8888", + err: nil, + }, + { + name: "Adds http scheme", + url: "://example.com:8888", + expected: "http://example.com:8888", + err: nil, + }, + { + name: "Adds https scheme", + url: "://example.com:443", + expected: "https://example.com:443", + err: nil, + }, + { + name: "Adds https scheme", + url: "example.com:443", + expected: "https://example.com:443", + err: nil, + }, + { + name: "Preserves the scheme", + url: "https://example.com", + expected: "https://example.com", + err: nil, + }, + } + + for _, tc := range testCases { + result, err := NormalizeURI(tc.url) + if tc.err == nil { + assert.Equalf(t, tc.expected, result.String(), "%s: Unexpected result: %v", tc.name, result) + assert.NoErrorf(t, err, + "%s: Unexpected error: %s", tc.name, err) + } else { + assert.Errorf(t, err, "%s: Expected error: %s", tc.name, tc.err) + } + } +} diff --git a/pkg/proxy/doc.go b/pkg/proxy/doc.go index 99c16739..26c1374d 100644 --- a/pkg/proxy/doc.go +++ b/pkg/proxy/doc.go @@ -6,6 +6,6 @@ // HTTP basic authentication. // It can also forward connections to a parent proxy, and authorize // connections against that. Both local, and parent credentials can be set via -// environment variables. For local proxy credential, set `PROXY_CREDENTIAL`. -// For parent proxy credential, set `PROXY_PARENT_CREDENTIAL`. +// environment variables. For local proxy credential, set `FORWARDER_LOCALPROXY_AUTH`. +// For parent proxy credential, set `FORWARDER_UPSTREAMPROXY_AUTH`. package proxy diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 767b6a12..b9a21ba1 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -24,6 +24,7 @@ import ( "github.com/saucelabs/forwarder/internal/credential" "github.com/saucelabs/forwarder/internal/logger" "github.com/saucelabs/forwarder/internal/pac" + "github.com/saucelabs/forwarder/internal/util" "github.com/saucelabs/forwarder/internal/validation" "github.com/saucelabs/randomness" "github.com/saucelabs/sypl" @@ -36,6 +37,10 @@ const ( ConstantBackoff = 300 DNSTimeout = 1 * time.Minute MaxRetry = 3 + httpPort = 80 + httpsPort = 443 + proxyAuthHeader = "Proxy-Authorization" + authHeader = "Authorization" ) // Possible ways to run Forwarder. @@ -82,6 +87,7 @@ var ( ErrInvalidPACURI = customerror.NewInvalidError("PAC URI") ErrInvalidProxyParams = customerror.NewInvalidError("params") ErrInvalidUpstreamProxyURI = customerror.NewInvalidError("upstream proxy URI") + ErrInvalidSiteCredentials = customerror.NewInvalidError("invalid site credentials") ) // LoggingOptions defines logging options. @@ -134,6 +140,12 @@ type Options struct { // ProxyLocalhost if `true`, requests to `localhost`/`127.0.0.1` will be // forwarded to any upstream - if set. ProxyLocalhost bool + // SiteCredentials contains URLs with the credentials, for example: + // - https://usr1:pwd1@foo.bar:4443 + // - http://usr2:pwd2@bar.foo:8080 + // - usr3:pwd3@bar.foo:8080 + // Proxy will add basic auth headers for requests to these URLs. + SiteCredentials []string `json:"site_credentials" validate:"omitempty"` } // Default sets `Options` default values. @@ -203,23 +215,27 @@ type Proxy struct { // Credentials for proxies specified in PAC content. pacProxiesCredentials []string + // credentials for passing basic authentication to requests + siteCredentialsMatcher siteCredentialsMatcher + // Underlying proxy implementation. proxy *goproxy.ProxyHttpServer } +func basicAuth(userpwd string) string { + return base64.StdEncoding.EncodeToString([]byte(userpwd)) +} + // Sets the `Proxy-Authorization` header based on `uri` user info. func setProxyBasicAuthHeader(uri *url.URL, req *http.Request) { - encodedCredential := base64. - StdEncoding. - EncodeToString([]byte(uri.User.String())) - req.Header.Set( - "Proxy-Authorization", - fmt.Sprintf("Basic %s", encodedCredential), + proxyAuthHeader, + fmt.Sprintf("Basic %s", basicAuth(uri.User.String())), ) logger.Get().Debuglnf( - "Proxy-Authorization header set with %s:*** for url %s", + "%s header set with %s:*** for %s", + proxyAuthHeader, uri.User.Username(), req.URL.String(), ) @@ -327,7 +343,7 @@ func setupUpstreamProxyConnection(ctx *goproxy.ProxyCtx, uri *url.URL) { logger.Get().Tracelnf("Connection to the upstream proxy %s is set up", uri.Redacted()) } -// setupUpstreamProxyConnection dynamically forwards connections to an upstream +// setupPACUpstreamProxyConnection dynamically forwards connections to an upstream // proxy setup via PAC. func setupPACUpstreamProxyConnection(p *Proxy, ctx *goproxy.ProxyCtx) error { urlToFindProxyFor := ctx.Req.URL.String() @@ -360,6 +376,86 @@ func setupPACUpstreamProxyConnection(p *Proxy, ctx *goproxy.ProxyCtx) error { return nil } +// parseSiteCredentials takes a list of "user:pass@host:port" strings. +// +// A port of '0' means a wildcard port +// A host of '*' means a wildcard host +// A host:port of '*:0' will match everything +// +// They are converted to a map of: +// - "host:port": base64("user:pass"). +// - "port": base64("user:pass") +// - "host": base64("user:pass") +// and a global wildcard string. +func parseSiteCredentials(creds []string) (map[string]string, map[string]string, map[string]string, string, error) { + hostportMap := make(map[string]string, len(creds)) + hostMap := make(map[string]string, len(creds)) + portMap := make(map[string]string, len(creds)) + global := "" + + for _, credentialText := range creds { + uri, err := util.NormalizeURI(credentialText) + if err != nil { + return nil, nil, nil, "", fmt.Errorf("%w: %s", ErrInvalidSiteCredentials, err) + } + + // Get the base64 of the credentials + pass, found := uri.User.Password() + if !found { + return nil, nil, nil, "", fmt.Errorf("%w: password not found in %s", ErrInvalidSiteCredentials, credentialText) + } + + basicAuth, err := credential.NewBasicAuth(uri.User.Username(), pass) + if err != nil { + return nil, nil, nil, "", err + } + + encoded := basicAuth.ToBase64() + + if uri.Hostname() == "*" && uri.Port() == "0" { + if global != "" { + return nil, nil, nil, "", fmt.Errorf("%w: multiple credentials for global wildcard", ErrInvalidSiteCredentials) + } + + global = encoded + + continue + } + + if uri.Hostname() == "*" { + _, found = portMap[uri.Port()] + if found { + return nil, nil, nil, "", fmt.Errorf("%w: multiple credentials for wildcard host with port %s", ErrInvalidSiteCredentials, uri.Port()) + } + + portMap[uri.Port()] = encoded + + continue + } + + if uri.Port() == "0" { + _, found = hostMap[uri.Hostname()] + if found { + return nil, nil, nil, "", fmt.Errorf("%w: multiple credentials for wildcard port with host %s", ErrInvalidSiteCredentials, uri.Hostname()) + } + + hostMap[uri.Hostname()] = encoded + + continue + } + + // No wildcards, add the host:port directly + _, found = hostportMap[uri.Host] + if found { + return nil, nil, nil, "", fmt.Errorf("%w: multiple credentials for %s", ErrInvalidSiteCredentials, uri.Host) + } + + hostportMap[uri.Host] = encoded + } + + return hostportMap, hostMap, portMap, global, nil +} + // DRY on handler's code. // nolint:exhaustive func (p *Proxy) setupHandlers(ctx *goproxy.ProxyCtx) error { @@ -523,6 +619,82 @@ func (p *Proxy) Run() { } } +func (p *Proxy) setupProxyHandlers() { + p.proxy.OnRequest().HandleConnectFunc(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) { + logger.Get().Debuglnf("%s %s -> %s", ctx.Req.Method, ctx.Req.RemoteAddr, ctx.Req.Host) + logger.Get().Debuglnf("%q", dumpHeaders(ctx.Req)) + + if err := p.setupHandlers(ctx); err != nil { + logger.Get().Errorlnf("Failed to setup handler (HTTPS) for request %s. %+v", ctx.Req.URL.Redacted(), err) + + return goproxy.RejectConnect, host + } + + return nil, host + }) + + p.proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { + logger.Get().Debuglnf("%s %s -> %s %s %s", req.Method, req.RemoteAddr, req.URL.Scheme, req.Host, req.URL.Port()) + logger.Get().Tracelnf("%q", dumpHeaders(ctx.Req)) + + if err := p.setupHandlers(ctx); err != nil { + logger.Get().Errorlnf("Failed to setup handler (HTTP) for request %s. %+v", ctx.Req.URL.Redacted(), err) + + return nil, goproxy.NewResponse( + ctx.Req, + goproxy.ContentTypeText, + http.StatusInternalServerError, + err.Error(), + ) + } + + return ctx.Req, nil + }) + + if p.siteCredentialsMatcher.isSet() { + p.proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { + p.maybeAddAuthHeader(req) + + return ctx.Req, nil + }) + } + + p.proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { + if resp != nil { + logger.Get().Debuglnf("%s <- %s %v (%v bytes)", + resp.Request.RemoteAddr, resp.Request.Host, resp.Status, resp.ContentLength) + } else { + logger.Get().Tracelnf("%s <- %s response is empty", ctx.Req.Host, ctx.Req.RemoteAddr) + } + + return resp + }) +} + +// maybeAddAuthHeader modifies the request and adds an authorization header if necessary. +func (p *Proxy) maybeAddAuthHeader(req *http.Request) { + hostport := req.Host + + if req.URL.Port() == "" { + // When the destination URL doesn't contain an explicit port, Go http-parsed + // URL Port() returns an empty string. + switch req.URL.Scheme { + case "http": + hostport = fmt.Sprintf("%s:%d", req.Host, httpPort) + case "https": + hostport = fmt.Sprintf("%s:%d", req.Host, httpsPort) + default: + logger.Get().Warnlnf("Failed to determine port for %s.", req.URL.Redacted()) + } + } + + creds := p.siteCredentialsMatcher.match(hostport) + + if creds != "" { + req.Header.Set(authHeader, fmt.Sprintf("Basic %s", creds)) + } +} + ////// // Factory ////// @@ -560,15 +732,36 @@ func New( return nil, err } + siteCredentials := options.SiteCredentials + siteCredentialsFromEnv := loadSiteCredentialsFromEnvVar("FORWARDER_SITE_CREDENTIALS") + + if len(siteCredentials) == 0 && siteCredentialsFromEnv != nil { + siteCredentials = siteCredentialsFromEnv + } + + // Parse site credential list into map of host:port -> base64 encoded credentials. + hostportMap, hostMap, portMap, global, err := parseSiteCredentials(siteCredentials) + if err != nil { + return nil, err + } + + credsMatcher := siteCredentialsMatcher{ + siteCredentials: hostportMap, + siteCredentialsHost: hostMap, + siteCredentialsPort: portMap, + siteCredentialsWildcard: global, + } + p := &Proxy{ - LocalProxyURI: localProxyURI, - Mode: Direct, - Options: finalOptions, - PACURI: pacURI, - State: Initializing, - UpstreamProxyURI: upstreamProxyURI, - pacProxiesCredentials: pacProxiesCredentials, - mutex: &sync.RWMutex{}, + LocalProxyURI: localProxyURI, + Mode: Direct, + Options: finalOptions, + PACURI: pacURI, + State: Initializing, + UpstreamProxyURI: upstreamProxyURI, + pacProxiesCredentials: pacProxiesCredentials, + mutex: &sync.RWMutex{}, + siteCredentialsMatcher: credsMatcher, } if err := validation.Get().Struct(p); err != nil { @@ -672,47 +865,8 @@ func New( p.pacParser = pacParser } - p.proxy.OnRequest().HandleConnectFunc(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) { - logger.Get().Debuglnf("%s %s -> %s", ctx.Req.Method, ctx.Req.RemoteAddr, ctx.Req.Host) - logger.Get().Debuglnf("%q", dumpHeaders(ctx.Req)) - - if err := p.setupHandlers(ctx); err != nil { - logger.Get().Errorlnf("Failed to setup handler (HTTPS) for request %s. %+v", ctx.Req.URL.Redacted(), err) - - return goproxy.RejectConnect, host - } - - return nil, host - }) - - p.proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { - logger.Get().Debuglnf("%s %s -> %s", req.Method, req.RemoteAddr, req.Host) - logger.Get().Tracelnf("%q", dumpHeaders(ctx.Req)) - - if err := p.setupHandlers(ctx); err != nil { - logger.Get().Errorlnf("Failed to setup handler (HTTP) for request %s. %+v", ctx.Req.URL.Redacted(), err) - - return nil, goproxy.NewResponse( - ctx.Req, - goproxy.ContentTypeText, - http.StatusInternalServerError, - err.Error(), - ) - } - - return ctx.Req, nil - }) - - p.proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { - if resp != nil { - logger.Get().Debuglnf("%s <- %s %v (%v bytes)", - resp.Request.RemoteAddr, resp.Request.Host, resp.Status, resp.ContentLength) - } else { - logger.Get().Tracelnf("%s <- %s response is empty", ctx.Req.Host, ctx.Req.RemoteAddr) - } - - return resp - }) + // Setup the request and response handlers + p.setupProxyHandlers() // Local proxy authentication. if parsedLocalProxyURI.User.Username() != "" { diff --git a/pkg/proxy/proxy_test.go b/pkg/proxy/proxy_test.go index 98a96435..a8a12676 100644 --- a/pkg/proxy/proxy_test.go +++ b/pkg/proxy/proxy_test.go @@ -6,6 +6,7 @@ package proxy import ( "context" + "encoding/base64" "errors" "fmt" "io/ioutil" @@ -18,6 +19,7 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/saucelabs/forwarder/internal/logger" "github.com/saucelabs/randomness" "github.com/saucelabs/sypl/fields" @@ -158,6 +160,109 @@ func executeRequest(client *http.Client, uri string) (int, string, error) { // Tests ////// +func TestParseSiteCredentials(t *testing.T) { + tests := map[string]struct { + in []string + hostport map[string]string + port map[string]string + host map[string]string + global string + err bool + }{ + "valid with schema": { + in: []string{"https://user:pass@abc"}, + hostport: map[string]string{ + "abc": "dXNlcjpwYXNz", + }, + host: map[string]string{}, + port: map[string]string{}, + }, + "empty user": { + in: []string{":pass@abc"}, + err: true, + }, + "empty password": { + in: []string{"user:@abc"}, + err: true, + }, + "missing password": { + in: []string{"user@abc"}, + err: true, + }, + "missing host": { + in: []string{"user:pass"}, + err: true, + }, + "valid host": { + in: []string{"user:pass@abc"}, + hostport: map[string]string{ + "abc": "dXNlcjpwYXNz", + }, + host: map[string]string{}, + port: map[string]string{}, + }, + "valid host+port": { + in: []string{"user:pass@abc:123"}, + hostport: map[string]string{ + "abc:123": "dXNlcjpwYXNz", + }, + host: map[string]string{}, + port: map[string]string{}, + }, + "wildcard host": { + in: []string{"user:pass@*:123"}, + port: map[string]string{ + "123": "dXNlcjpwYXNz", + }, + host: map[string]string{}, + hostport: map[string]string{}, + }, + "wildcard port": { + in: []string{"user:pass@abc:0"}, + host: map[string]string{ + "abc": "dXNlcjpwYXNz", + }, + hostport: map[string]string{}, + port: map[string]string{}, + }, + "global wildcard": { + in: []string{"user:pass@*:0"}, + global: "dXNlcjpwYXNz", + hostport: map[string]string{}, + host: map[string]string{}, + port: map[string]string{}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + hostport, host, port, global, err := parseSiteCredentials(tc.in) + + if (err == nil) == tc.err { + t.Fatalf("Unexpected error condition: %s", err) + } + + diff := cmp.Diff(tc.hostport, hostport) + if diff != "" { + t.Fatalf(diff) + } + + diff = cmp.Diff(tc.host, host) + if diff != "" { + t.Fatalf(diff) + } + diff = cmp.Diff(tc.port, port) + if diff != "" { + t.Fatalf(diff) + } + diff = cmp.Diff(tc.global, global) + if diff != "" { + t.Fatalf(diff) + } + }) + } +} + //nolint:maintidx func TestNew(t *testing.T) { ////// @@ -176,6 +281,7 @@ func TestNew(t *testing.T) { upstreamProxyURI *url.URL pacURI *url.URL pacProxiesCredentials []string + siteCredentials []string loggingOptions *LoggingOptions } tests := []struct { @@ -202,6 +308,20 @@ func TestNew(t *testing.T) { }, wantErr: false, }, + { + name: "Should work - local proxy with site auth", + args: args{ + localProxyURI: URIBuilder( + defaultProxyHostname, + r.MustGenerate(), + "", + "", + ), + loggingOptions: loggingOptions, + siteCredentials: []string{}, + }, + wantErr: false, + }, { name: "Should work - local proxy - with DNS", args: args{ @@ -362,7 +482,13 @@ func TestNew(t *testing.T) { // Target/end server. ////// - targetServer := createMockedHTTPServer(http.StatusOK, "body", "") + targetCreds := "" + if tt.args.siteCredentials != nil { + targetCreds = base64. + StdEncoding. + EncodeToString([]byte("user:pass")) + } + targetServer := createMockedHTTPServer(http.StatusOK, "body", targetCreds) defer func() { targetServer.Close() }() @@ -402,6 +528,16 @@ func TestNew(t *testing.T) { pacURI = tt.args.pacURI.String() } + var siteCredentials []string + if tt.args.siteCredentials != nil { + uri, err := url.Parse(targetServerURL) + if err != nil { + panic(err) + } + + siteCredentials = append(siteCredentials, "user:pass@"+uri.Host) + } + ////// // Local proxy. // @@ -427,6 +563,8 @@ func TestNew(t *testing.T) { &Options{ DNSURIs: dnsURIs, LoggingOptions: loggingOptions, + // site credentials in standard URI format. + SiteCredentials: siteCredentials, }, ) if err != nil { @@ -580,7 +718,10 @@ func BenchmarkNew(b *testing.B) { localProxyURI := URIBuilder(defaultProxyHostname, r.MustGenerate(), "", "") - proxy, err := New(localProxyURI.String(), "", "", nil, nil) + proxy, err := New(localProxyURI.String(), "", "", nil, + &Options{ + LoggingOptions: loggingOptions, + }) if err != nil { log.Fatalln("Failed to create proxy.", err) } diff --git a/pkg/proxy/site_credentials.go b/pkg/proxy/site_credentials.go new file mode 100644 index 00000000..ce65dd62 --- /dev/null +++ b/pkg/proxy/site_credentials.go @@ -0,0 +1,81 @@ +// Copyright 2021 The forwarder Authors. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package proxy + +import ( + "strings" + + "github.com/saucelabs/forwarder/internal/logger" +) + +type siteCredentialsMatcher struct { + // host:port credentials for passing basic authentication to requests + siteCredentials map[string]string + + // host (wildcard port) credentials for passing basic authentication to requests + siteCredentialsHost map[string]string + + // port (wildcard host) credentials for passing basic authentication to requests + siteCredentialsPort map[string]string + + // Global wildcard credentials for passing basic authentication to requests + siteCredentialsWildcard string +} + +// match matches a `hostport` to one of the configured credentials. +// Priority is exact match, then host, then port, then global wildcard. +func (matcher siteCredentialsMatcher) match(hostport string) string { + if creds, found := matcher.siteCredentials[hostport]; found { + logger.Get().Tracelnf("Found an auth for %s", hostport) + + return creds + } + + // hostport parameter is expected to contain host:port. + partsLen := 2 + + parts := strings.SplitN(hostport, ":", partsLen) + + if len(parts) != partsLen { + logger.Get().Warnlnf("Unexpected host:port parameter: %s; will not match host or port wildcards", hostport) + + return "" + } + + host, port := parts[0], parts[1] + + // Host wildcard - check the port only. + if creds, found := matcher.siteCredentialsPort[port]; found { + logger.Get().Tracelnf("Found an auth for host wildcard and port match %s", port) + + return creds + } + + // Port wildcard - check the host only. + if creds, found := matcher.siteCredentialsHost[host]; found { + logger.Get().Tracelnf("Found an auth header for port wildcard and host match %s", host) + + return creds + } + + // Log whether the global wildcard is set. + // This is a very esoteric use case. It's only added to support a legacy implementation. + if matcher.siteCredentialsWildcard != "" { + logger.Get().Traceln("Found an auth for global wildcard") + } + + return matcher.siteCredentialsWildcard +} + +func (matcher siteCredentialsMatcher) isSet() bool { + if len(matcher.siteCredentials) > 0 || + len(matcher.siteCredentialsPort) > 0 || + len(matcher.siteCredentialsHost) > 0 || + matcher.siteCredentialsWildcard != "" { + return true + } + + return false +} diff --git a/pkg/proxy/site_credentials_test.go b/pkg/proxy/site_credentials_test.go new file mode 100644 index 00000000..b2f3d786 --- /dev/null +++ b/pkg/proxy/site_credentials_test.go @@ -0,0 +1,98 @@ +// Copyright 2021 The forwarder Authors. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package proxy + +import ( + "testing" + + "github.com/saucelabs/forwarder/internal/logger" + "github.com/stretchr/testify/assert" +) + +func TestSiteCredentialsMatcher(t *testing.T) { + tests := map[string]struct { + hostport string + hostPortMap map[string]string + portMap map[string]string + hostMap map[string]string + global string + isSet bool + expected string + }{ + "matcher is not initialized": { + hostPortMap: map[string]string{}, + expected: "", + hostport: "abc:80", + portMap: map[string]string{}, + hostMap: map[string]string{}, + isSet: false, + global: "", + }, + "matches hostport": { + hostPortMap: map[string]string{"abc:80": "user:pass"}, + expected: "user:pass", + hostport: "abc:80", + portMap: map[string]string{"*:80": "foo"}, + hostMap: map[string]string{"abc:0": "bar"}, + isSet: true, + global: "baz", + }, + "matches host wildcard": { + hostPortMap: map[string]string{"qux:80": "foo"}, + expected: "user:pass", + hostport: "abc:80", + portMap: map[string]string{"80": "user:pass"}, + hostMap: map[string]string{"abc": "bar"}, + isSet: true, + global: "baz", + }, + "matches port wildcard": { + hostPortMap: map[string]string{"qux:80": "foo"}, + expected: "user:pass", + hostport: "abc:80", + portMap: map[string]string{"90": "bar"}, + hostMap: map[string]string{"abc": "user:pass"}, + isSet: true, + global: "baz", + }, + "matches global wildcard": { + hostPortMap: map[string]string{"qux:80": "foo"}, + expected: "user:pass", + hostport: "abc:80", + portMap: map[string]string{"90": "bar"}, + hostMap: map[string]string{"qux": "baz"}, + isSet: true, + global: "user:pass", + }, + "no match": { + hostPortMap: map[string]string{"qux:80": "foo"}, + expected: "", + hostport: "foobar:8080", + portMap: map[string]string{"80": "bar"}, + hostMap: map[string]string{"qux": "baz"}, + isSet: true, + global: "", + }, + } + + logger.Setup(&LoggingOptions{ + Level: defaultProxyLoggingLevel, + }, + ) + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + matcher := siteCredentialsMatcher{ + siteCredentials: tc.hostPortMap, + siteCredentialsHost: tc.hostMap, + siteCredentialsPort: tc.portMap, + siteCredentialsWildcard: tc.global, + } + assert.Equalf(t, tc.isSet, matcher.isSet(), "Unexpected isSet: %v", matcher) + creds := matcher.match(tc.hostport) + assert.Equalf(t, tc.expected, creds, "Unexpected result: %v", creds) + }) + } +} diff --git a/pkg/proxy/utils.go b/pkg/proxy/utils.go index f0c158bd..05232fa8 100644 --- a/pkg/proxy/utils.go +++ b/pkg/proxy/utils.go @@ -16,7 +16,7 @@ import ( var ErrFailedToCopyOptions = customerror.NewFailedToError("deepCopy options") -// Loads, validate credential from env var, and set URI's user. +// Load credentials from the env var, validate and set the URL's user:pwd. func loadCredentialFromEnvVar(envVar string, uri *url.URL) error { credentialFromEnvVar := os.Getenv(envVar) @@ -35,6 +35,17 @@ func loadCredentialFromEnvVar(envVar string, uri *url.URL) error { return nil } +// Load URLs and their basic auth from the env var. +func loadSiteCredentialsFromEnvVar(envVar string) []string { + basicAuthURLstr := os.Getenv(envVar) + + if basicAuthURLstr == "" { + return nil + } + + return strings.Split(basicAuthURLstr, ",") +} + // Copy from `source` to `target`. // // Basic deep copy implementation.