From 7fbdbe4a3fd4dfcb6b82861cb99cb2e963ac0ed9 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Fri, 8 Jul 2022 15:19:41 +0100 Subject: [PATCH 1/4] Adding proxy support (#80) --- docs/index.md | 51 +++- examples/provider/provider_with_proxy.tf | 12 + .../provider/provider_with_proxy_from_env.tf | 13 + go.mod | 5 +- go.sum | 5 + internal/provider/data_source_http.go | 16 +- internal/provider/data_source_http_test.go | 212 ++++++++++++++++ internal/provider/provider.go | 222 ++++++++++++++++- internal/provider/provider_test.go | 81 +++++++ internal/provider/validators.go | 226 ++++++++++++++++++ templates/index.md.tmpl | 9 +- 11 files changed, 838 insertions(+), 14 deletions(-) create mode 100644 examples/provider/provider_with_proxy.tf create mode 100644 examples/provider/provider_with_proxy_from_env.tf create mode 100644 internal/provider/validators.go diff --git a/docs/index.md b/docs/index.md index 50774f1f..7c60abad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,5 +9,52 @@ description: |- The HTTP provider is a utility provider for interacting with generic HTTP servers as part of a Terraform configuration. -This provider requires no configuration. For information on the resources -it provides, see the navigation bar. \ No newline at end of file +### Configuring Proxy + +```terraform +# This example makes an HTTP request to `website.com` +# via an HTTP proxy at `corporate.proxy.service`. + +provider "http" { + proxy { + url = "https://corporate.proxy.service" + } +} + +data "http" "test" { + url = "https://website.com" +} +``` + +```terraform +# This example makes an HTTP request to `website.com` +# via an HTTP proxy defined through environment variables (see +# https://pkg.go.dev/net/http#ProxyFromEnvironment for details). + +provider "http" { + proxy { + from_env = true + } +} + +data "http" "test" { + url = "https://website.com" +} +``` + + +## Schema + +### Optional + +- `proxy` (Attributes) Proxy used by resources and data sources that connect to external endpoints. (see [below for nested schema](#nestedatt--proxy)) + + +### Nested Schema for `proxy` + +Optional: + +- `from_env` (Boolean) When `true` the provider will discover the proxy configuration from environment variables. This is based upon [`http.ProxyFromEnvironment`](https://pkg.go.dev/net/http#ProxyFromEnvironment) and it supports the same environment variables (default: `true`). +- `password` (String, Sensitive) Password used for Basic authentication against the Proxy. +- `url` (String) URL used to connect to the Proxy. Accepted schemes are: `http`, `https`, `socks5`. +- `username` (String) Username (or Token) used for Basic authentication against the Proxy. diff --git a/examples/provider/provider_with_proxy.tf b/examples/provider/provider_with_proxy.tf new file mode 100644 index 00000000..47494dcc --- /dev/null +++ b/examples/provider/provider_with_proxy.tf @@ -0,0 +1,12 @@ +# This example makes an HTTP request to `website.com` +# via an HTTP proxy at `corporate.proxy.service`. + +provider "http" { + proxy { + url = "https://corporate.proxy.service" + } +} + +data "http" "test" { + url = "https://website.com" +} diff --git a/examples/provider/provider_with_proxy_from_env.tf b/examples/provider/provider_with_proxy_from_env.tf new file mode 100644 index 00000000..3b30d069 --- /dev/null +++ b/examples/provider/provider_with_proxy_from_env.tf @@ -0,0 +1,13 @@ +# This example makes an HTTP request to `website.com` +# via an HTTP proxy defined through environment variables (see +# https://pkg.go.dev/net/http#ProxyFromEnvironment for details). + +provider "http" { + proxy { + from_env = true + } +} + +data "http" "test" { + url = "https://website.com" +} diff --git a/go.mod b/go.mod index b9e7cfea..23ec692a 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module github.com/terraform-providers/terraform-provider-http go 1.17 require ( + github.com/elazarl/goproxy v0.0.0-20220529153421-8ea89ba92021 github.com/hashicorp/terraform-plugin-docs v0.12.0 github.com/hashicorp/terraform-plugin-framework v0.9.0 github.com/hashicorp/terraform-plugin-go v0.10.0 + github.com/hashicorp/terraform-plugin-log v0.4.1 github.com/hashicorp/terraform-plugin-sdk/v2 v2.18.0 + golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 ) require ( @@ -36,7 +39,6 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.17.2 // indirect github.com/hashicorp/terraform-json v0.14.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.4.1 // indirect github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b572c // indirect github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 // indirect github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect @@ -60,7 +62,6 @@ require ( github.com/vmihailenco/tagparser v0.1.1 // indirect github.com/zclconf/go-cty v1.10.0 // indirect golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect - golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/appengine v1.6.6 // indirect diff --git a/go.sum b/go.sum index 47b2580f..06522b26 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= +github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -237,6 +241,7 @@ github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXq github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= diff --git a/internal/provider/data_source_http.go b/internal/provider/data_source_http.go index a6a42a92..07eacf71 100644 --- a/internal/provider/data_source_http.go +++ b/internal/provider/data_source_http.go @@ -79,13 +79,17 @@ your control should be treated as untrustworthy.`, }, nil } -func (d *httpDataSourceType) NewDataSource(context.Context, tfsdk.Provider) (tfsdk.DataSource, diag.Diagnostics) { - return &httpDataSource{}, nil +func (d *httpDataSourceType) NewDataSource(_ context.Context, p tfsdk.Provider) (tfsdk.DataSource, diag.Diagnostics) { + return &httpDataSource{ + p: p.(*provider), + }, nil } var _ tfsdk.DataSource = (*httpDataSource)(nil) -type httpDataSource struct{} +type httpDataSource struct { + p *provider +} func (d *httpDataSource) Read(ctx context.Context, req tfsdk.ReadDataSourceRequest, resp *tfsdk.ReadDataSourceResponse) { var model modelV0 @@ -98,7 +102,11 @@ func (d *httpDataSource) Read(ctx context.Context, req tfsdk.ReadDataSourceReque url := model.URL.Value headers := model.RequestHeaders - client := &http.Client{} + client := &http.Client{ + Transport: &http.Transport{ + Proxy: d.p.proxyForRequestFunc(ctx), + }, + } request, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { diff --git a/internal/provider/data_source_http_test.go b/internal/provider/data_source_http_test.go index 3dceaa97..befe0957 100644 --- a/internal/provider/data_source_http_test.go +++ b/internal/provider/data_source_http_test.go @@ -1,11 +1,15 @@ package provider import ( + "encoding/base64" "fmt" "net/http" "net/http/httptest" + "regexp" + "strings" "testing" + "github.com/elazarl/goproxy" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) @@ -216,6 +220,214 @@ func TestDataSource_UpgradeFromVersion2_2_0(t *testing.T) { }) } +func TestDataSource_HTTPViaProxy(t *testing.T) { + t.Parallel() + + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer svr.Close() + + proxy := httptest.NewServer(goproxy.NewProxyHttpServer()) + defer proxy.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + provider "http" { + proxy = { + url = "%s" + } + } + data "http" "http_test" { + url = "%s" + } + `, proxy.URL, svr.URL), + Check: resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + }, + }, + }) +} + +func TestDataSource_HTTPViaProxyWithBasicAuthConfig(t *testing.T) { + t.Parallel() + + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer svr.Close() + + p := goproxy.NewProxyHttpServer() + p.OnRequest().DoFunc(proxyAuth()) + + proxy := httptest.NewServer(p) + defer proxy.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + provider "http" { + proxy = { + url = "%s" + username = "correctUsername" + } + } + data "http" "http_test" { + url = "%s" + } + `, proxy.URL, svr.URL), + Check: resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + }, + { + Config: fmt.Sprintf(` + provider "http" { + proxy = { + url = "%s" + username = "incorrectUsername" + } + } + data "http" "http_test" { + url = "%s" + } + `, proxy.URL, svr.URL), + Check: resource.TestCheckResourceAttr("data.http.http_test", "status_code", "407"), + }, + { + Config: fmt.Sprintf(` + provider "http" { + proxy = { + url = "%s" + username = "correctUsername" + password = "correctPassword" + } + } + data "http" "http_test" { + url = "%s" + } + `, proxy.URL, svr.URL), + Check: resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + }, + { + Config: fmt.Sprintf(` + provider "http" { + proxy = { + url = "%s" + username = "correctUsername" + password = "incorrectPassword" + } + } + data "http" "http_test" { + url = "%s" + } + `, proxy.URL, svr.URL), + Check: resource.TestCheckResourceAttr("data.http.http_test", "status_code", "407"), + }, + }, + }) +} + +func TestDataSource_HTTPViaProxyWithEnv(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer svr.Close() + + p := goproxy.NewProxyHttpServer() + p.OnRequest().DoFunc(proxyAuth()) + + proxy := httptest.NewServer(p) + defer proxy.Close() + + t.Setenv("HTTP_PROXY", proxy.URL) + t.Setenv("HTTPS_PROXY", proxy.URL) + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + provider "http" { + proxy = { + from_env = true + } + } + data "http" "http_test" { + url = "%s" + } + `, svr.URL), + Check: resource.TestCheckResourceAttr("data.http.http_test", "status_code", "200"), + }, + }, + }) +} + +func TestDataSource_HTTPNoProxyAvailable(t *testing.T) { + t.Parallel() + + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer svr.Close() + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + provider "http" { + proxy = { + url = "http://not-a-real-proxy.com" + } + } + data "http" "http_test" { + url = "%s" + } + `, svr.URL), + ExpectError: regexp.MustCompile( + fmt.Sprintf(`Error making request: Get "%s": proxyconnect tcp: dial\ntcp: lookup not-a-real-proxy.com: no such host`, + svr.URL, + ), + ), + }, + }, + }) +} + +func proxyAuth() func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { + return func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { + auth := r.Header.Get("Proxy-Authorization") + if auth == "" { + return r, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusForbidden, "") + } + + c, err := base64.StdEncoding.DecodeString(auth[len("Basic "):]) + if err != nil { + return r, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusProxyAuthRequired, "") + } + + cs := string(c) + username, password, ok := strings.Cut(cs, ":") + if !ok { + return r, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusProxyAuthRequired, "") + } + + if username == "correctUsername" && password == "" || password == "correctPassword" { + return r, nil + } + + return r, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusProxyAuthRequired, "") + + } +} + type TestHttpMock struct { server *httptest.Server } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8e894d3a..47c0ac19 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2,9 +2,17 @@ package provider import ( "context" + "fmt" + "net/http" + "net/url" + "strings" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "golang.org/x/net/http/httpproxy" ) func New() tfsdk.Provider { @@ -13,13 +21,130 @@ func New() tfsdk.Provider { var _ tfsdk.Provider = (*provider)(nil) -type provider struct{} +type provider struct { + proxyURL *url.URL + proxyFromEnv bool +} func (p *provider) GetSchema(context.Context) (tfsdk.Schema, diag.Diagnostics) { - return tfsdk.Schema{}, nil + return tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "proxy": { + Optional: true, + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ + "url": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + UrlWithScheme(supportedProxySchemesStr()...), + ConflictsWith(tftypes.NewAttributePath().WithAttributeName("proxy").WithAttributeName("from_env")), + }, + MarkdownDescription: "URL used to connect to the Proxy. " + + fmt.Sprintf("Accepted schemes are: `%s`. ", strings.Join(supportedProxySchemesStr(), "`, `")), + }, + "username": { + Type: types.StringType, + Optional: true, + Validators: []tfsdk.AttributeValidator{ + RequiredWith(tftypes.NewAttributePath().WithAttributeName("proxy").WithAttributeName("url")), + }, + MarkdownDescription: "Username (or Token) used for Basic authentication against the Proxy.", + }, + "password": { + Type: types.StringType, + Optional: true, + Sensitive: true, + Validators: []tfsdk.AttributeValidator{ + RequiredWith(tftypes.NewAttributePath().WithAttributeName("proxy").WithAttributeName("username")), + }, + MarkdownDescription: "Password used for Basic authentication against the Proxy.", + }, + "from_env": { + Type: types.BoolType, + Optional: true, + Computed: true, + Validators: []tfsdk.AttributeValidator{ + ConflictsWith( + tftypes.NewAttributePath().WithAttributeName("proxy").WithAttributeName("url"), + tftypes.NewAttributePath().WithAttributeName("proxy").WithAttributeName("username"), + tftypes.NewAttributePath().WithAttributeName("proxy").WithAttributeName("password"), + ), + }, + MarkdownDescription: "When `true` the provider will discover the proxy configuration from environment variables. " + + "This is based upon [`http.ProxyFromEnvironment`](https://pkg.go.dev/net/http#ProxyFromEnvironment) " + + "and it supports the same environment variables (default: `true`).", + }, + }), + MarkdownDescription: "Proxy used by resources and data sources that connect to external endpoints.", + }, + }, + }, nil } -func (p *provider) Configure(context.Context, tfsdk.ConfigureProviderRequest, *tfsdk.ConfigureProviderResponse) { +// Configure unmarshalls the proxy config onto providerConfigModel and checks that `proxy` is not null or unknown before proceeding. +// Then unmarshalls contents of proxy onto providerProxyConfigModel. +// Then checks each of the fields (i.e., URL, Username, Password, FromEnv) +func (p *provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderRequest, res *tfsdk.ConfigureProviderResponse) { + tflog.Debug(ctx, "Configuring provider") + var err error + + // Load configuration into the model + var conf providerConfigModel + res.Diagnostics.Append(req.Config.Get(ctx, &conf)...) + if res.Diagnostics.HasError() { + return + } + if conf.Proxy.IsNull() || conf.Proxy.IsUnknown() { + tflog.Debug(ctx, "No proxy configuration detected: using provider defaults", map[string]interface{}{ + "provider": fmt.Sprintf("%+v", p), + }) + return + } + + // Load proxy configuration into model + var proxyConf providerProxyConfigModel + diags := conf.Proxy.As(ctx, &proxyConf, types.ObjectAsOptions{}) + res.Diagnostics.Append(diags...) + if res.Diagnostics.HasError() { + return + } + tflog.Debug(ctx, "Loaded provider configuration") + + // Parse the URL + if !proxyConf.URL.IsNull() && !proxyConf.URL.IsUnknown() { + tflog.Debug(ctx, "Configuring Proxy via URL", map[string]interface{}{ + "url": proxyConf.URL.Value, + }) + + p.proxyURL, err = url.Parse(proxyConf.URL.Value) + if err != nil { + res.Diagnostics.AddError(fmt.Sprintf("Unable to parse proxy URL %q", proxyConf.URL.Value), err.Error()) + } + } + + if !proxyConf.Username.IsNull() && !proxyConf.Username.IsUnknown() { + tflog.Debug(ctx, "Adding username to Proxy URL configuration", map[string]interface{}{ + "username": proxyConf.Username.Value, + }) + + // NOTE: we know that `.proxyURL` is set, as this is imposed by the provider schema + p.proxyURL.User = url.User(proxyConf.Username.Value) + } + + if !proxyConf.Password.IsNull() && !proxyConf.Password.IsUnknown() { + tflog.Debug(ctx, "Adding password to Proxy URL configuration") + + // NOTE: we know that `.proxyURL.User.Username()` is set, as this is imposed by the provider schema + p.proxyURL.User = url.UserPassword(p.proxyURL.User.Username(), proxyConf.Password.Value) + } + + if !proxyConf.FromEnv.IsNull() && !proxyConf.FromEnv.IsUnknown() { + tflog.Debug(ctx, "Configuring Proxy via Environment Variables") + + p.proxyFromEnv = proxyConf.FromEnv.Value + } + + tflog.Debug(ctx, "Provider configured") } func (p *provider) GetResources(context.Context) (map[string]tfsdk.ResourceType, diag.Diagnostics) { @@ -31,3 +156,94 @@ func (p *provider) GetDataSources(context.Context) (map[string]tfsdk.DataSourceT "http": &httpDataSourceType{}, }, nil } + +// ProxyScheme represents url schemes supported when providing proxy configuration to this provider. +type ProxyScheme string + +const ( + HTTPProxy ProxyScheme = "http" + HTTPSProxy ProxyScheme = "https" + SOCKS5Proxy ProxyScheme = "socks5" +) + +func (p ProxyScheme) String() string { + return string(p) +} + +// supportedProxySchemes returns an array of ProxyScheme currently supported by this provider. +func supportedProxySchemes() []ProxyScheme { + return []ProxyScheme{ + HTTPProxy, + HTTPSProxy, + SOCKS5Proxy, + } +} + +// supportedProxySchemesStr returns the same content of supportedProxySchemes but as a slice of string. +func supportedProxySchemesStr() []string { + supported := supportedProxySchemes() + supportedStr := make([]string, len(supported)) + for i := range supported { + supportedStr[i] = string(supported[i]) + } + return supportedStr +} + +type providerConfigModel struct { + Proxy types.Object `tfsdk:"proxy"` //< providerProxyConfigModel +} + +type providerProxyConfigModel struct { + URL types.String `tfsdk:"url"` + Username types.String `tfsdk:"username"` + Password types.String `tfsdk:"password"` + FromEnv types.Bool `tfsdk:"from_env"` +} + +// isProxyConfigured returns true if a proxy configuration was detected as part of provider.Configure. +func (p *provider) isProxyConfigured() bool { + return p.proxyURL != nil || p.proxyFromEnv +} + +// proxyForRequestFunc is an adapter that returns the configured proxy. +// +// It works by returning a function that, given an *http.Request, +// provides the http.Client with the *url.URL to the proxy. +// +// It will return nil if there is no proxy configured. +func (p *provider) proxyForRequestFunc(ctx context.Context) func(_ *http.Request) (*url.URL, error) { + if !p.isProxyConfigured() { + tflog.Debug(ctx, "Proxy not configured") + return nil + } + + if p.proxyURL != nil { + tflog.Debug(ctx, "Proxy via URL") + return func(_ *http.Request) (*url.URL, error) { + tflog.Debug(ctx, "Using proxy (URL)", map[string]interface{}{ + "proxy": p.proxyURL, + }) + return p.proxyURL, nil + } + } + + if p.proxyFromEnv { + tflog.Debug(ctx, "Proxy via ENV") + return func(req *http.Request) (*url.URL, error) { + // NOTE: this is based upon `http.ProxyFromEnvironment`, + // but it avoids a memoization optimization (i.e. fetching environment variables once) + // that causes issues when testing the provider. + proxyURL, err := httpproxy.FromEnvironment().ProxyFunc()(req.URL) + if err != nil { + return nil, err + } + + tflog.Debug(ctx, "Using proxy (ENV)", map[string]interface{}{ + "proxy": proxyURL, + }) + return proxyURL, err + } + } + + return nil +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 361402a5..7afca4f7 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -1,8 +1,12 @@ package provider import ( + "regexp" + "testing" + "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) //nolint:unparam @@ -11,3 +15,80 @@ func protoV6ProviderFactories() map[string]func() (tfprotov6.ProviderServer, err "http": providerserver.NewProtocol6WithError(New()), } } + +func TestProvider_InvalidProxyConfig(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + + Steps: []resource.TestStep{ + { + Config: ` + provider "http" { + proxy = { + url = "https://proxy.host.com" + from_env = true + } + } + resource "http" "test" { + url = "" + } + `, + ExpectError: regexp.MustCompile(`"proxy.url" cannot be specified when "proxy.from_env" is specified|"proxy.from_env" cannot be specified when "proxy.url" is specified`), + }, + { + Config: ` + provider "http" { + proxy = { + username = "user" + } + } + resource "http" "test" { + url = "" + } + `, + ExpectError: regexp.MustCompile(`"proxy.url" must be specified when "proxy.username" is specified`), + }, + { + Config: ` + provider "http" { + proxy = { + password = "pwd" + } + } + resource "http" "test" { + url = "" + } + `, + ExpectError: regexp.MustCompile(`"proxy.username" must be specified when "proxy.password" is specified`), + }, + { + Config: ` + provider "http" { + proxy = { + username = "user" + password = "pwd" + } + } + resource "http" "test" { + url = "" + } + `, + ExpectError: regexp.MustCompile(`"proxy.url" must be specified when "proxy.username" is specified`), + }, + { + Config: ` + provider "http" { + proxy = { + username = "user" + from_env = true + } + } + resource "http" "test" { + url = "" + } + `, + ExpectError: regexp.MustCompile(`"proxy.username" cannot be specified when "proxy.from_env" is specified|"proxy.url" must be specified when "proxy.username" is specified`), + }, + }, + }) +} diff --git a/internal/provider/validators.go b/internal/provider/validators.go new file mode 100644 index 00000000..948959db --- /dev/null +++ b/internal/provider/validators.go @@ -0,0 +1,226 @@ +package provider + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// urlWithSchemeAttributeValidator checks that a types.String attribute +// is indeed a URL and its scheme is one of the given `acceptableSchemes`. +// +// Instances should be created via UrlWithScheme function. +type urlWithSchemeAttributeValidator struct { + acceptableSchemes []string +} + +// UrlWithScheme is a helper to instantiate a urlWithSchemeAttributeValidator. +func UrlWithScheme(acceptableSchemes ...string) tfsdk.AttributeValidator { + return &urlWithSchemeAttributeValidator{acceptableSchemes} +} + +var _ tfsdk.AttributeValidator = (*urlWithSchemeAttributeValidator)(nil) + +func (av *urlWithSchemeAttributeValidator) Description(ctx context.Context) string { + return av.MarkdownDescription(ctx) +} + +func (av *urlWithSchemeAttributeValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Ensures that the attribute is a URL and its scheme is one of: %q", av.acceptableSchemes) +} + +func (av *urlWithSchemeAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { + if req.AttributeConfig.IsNull() || req.AttributeConfig.IsUnknown() { + return + } + + tflog.Debug(ctx, "Validating attribute value is a URL with acceptable scheme", map[string]interface{}{ + "attribute": attrPathToString(req.AttributePath), + "acceptableSchemes": strings.Join(av.acceptableSchemes, ","), + }) + + var v types.String + diags := tfsdk.ValueAs(ctx, req.AttributeConfig, &v) + if diags.HasError() { + res.Diagnostics.Append(diags...) + return + } + + if v.IsNull() || v.IsUnknown() { + return + } + + u, err := url.Parse(v.Value) + if err != nil { + res.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid URL", + fmt.Sprintf("Parsing URL %q failed: %v", v.Value, err), + ) + return + } + + if u.Host == "" { + res.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid URL", + fmt.Sprintf("URL %q contains no host", u.String()), + ) + return + } + + for _, s := range av.acceptableSchemes { + if u.Scheme == s { + return + } + } + + res.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid URL scheme", + fmt.Sprintf("URL %q expected to use scheme from %q, got: %q", u.String(), av.acceptableSchemes, u.Scheme), + ) +} + +// requiredWithAttributeValidator checks that a set of *tftypes.AttributePath, +// including the attribute it's applied to, are set simultaneously. +// This implements the validation logic declaratively within the tfsdk.Schema. +// +// The provided tftypes.AttributePath must be "absolute", +// and starting with top level attribute names. +type requiredWithAttributeValidator struct { + attrPaths []*tftypes.AttributePath +} + +// RequiredWith is a helper to instantiate requiredWithAttributeValidator. +func RequiredWith(attributePaths ...*tftypes.AttributePath) tfsdk.AttributeValidator { + return &requiredWithAttributeValidator{attributePaths} +} + +var _ tfsdk.AttributeValidator = (*requiredWithAttributeValidator)(nil) + +func (av requiredWithAttributeValidator) Description(ctx context.Context) string { + return av.MarkdownDescription(ctx) +} + +func (av requiredWithAttributeValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Ensure that if an attribute is set, also these are set: %q", av.attrPaths) +} + +func (av requiredWithAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { + tflog.Debug(ctx, "Validating attribute is set together with other required attributes", map[string]interface{}{ + "attribute": attrPathToString(req.AttributePath), + "requiredAttributes": av.attrPaths, + }) + + var v attr.Value + res.Diagnostics.Append(tfsdk.ValueAs(ctx, req.AttributeConfig, &v)...) + if res.Diagnostics.HasError() { + return + } + + for _, path := range av.attrPaths { + var o attr.Value + res.Diagnostics.Append(req.Config.GetAttribute(ctx, path, &o)...) + if res.Diagnostics.HasError() { + return + } + + if !v.IsNull() && o.IsNull() { + res.Diagnostics.AddAttributeError( + req.AttributePath, + fmt.Sprintf("Attribute %q missing", attrPathToString(path)), + fmt.Sprintf("%q must be specified when %q is specified", attrPathToString(path), attrPathToString(req.AttributePath)), + ) + return + } + } +} + +// conflictsWithAttributeValidator checks that a set of *tftypes.AttributePath, +// including the attribute it's applied to, are not set simultaneously. +// This implements the validation logic declaratively within the tfsdk.Schema. +// +// The provided tftypes.AttributePath must be "absolute", +// and starting with top level attribute names. +type conflictsWithAttributeValidator struct { + attrPaths []*tftypes.AttributePath +} + +// ConflictsWith is a helper to instantiate conflictsWithAttributeValidator. +func ConflictsWith(attributePaths ...*tftypes.AttributePath) tfsdk.AttributeValidator { + return &conflictsWithAttributeValidator{attributePaths} +} + +var _ tfsdk.AttributeValidator = (*conflictsWithAttributeValidator)(nil) + +func (av conflictsWithAttributeValidator) Description(ctx context.Context) string { + return av.MarkdownDescription(ctx) +} + +func (av conflictsWithAttributeValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Ensure that if an attribute is set, these are not set: %q", av.attrPaths) +} + +func (av conflictsWithAttributeValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, res *tfsdk.ValidateAttributeResponse) { + tflog.Debug(ctx, "Validating attribute is not set together with other conflicting attributes", map[string]interface{}{ + "attribute": attrPathToString(req.AttributePath), + "conflictingAttributes": av.attrPaths, + }) + + var v attr.Value + res.Diagnostics.Append(tfsdk.ValueAs(ctx, req.AttributeConfig, &v)...) + if res.Diagnostics.HasError() { + return + } + + for _, path := range av.attrPaths { + var o attr.Value + res.Diagnostics.Append(req.Config.GetAttribute(ctx, path, &o)...) + if res.Diagnostics.HasError() { + return + } + + if !v.IsNull() && !o.IsNull() { + res.Diagnostics.AddAttributeError( + req.AttributePath, + fmt.Sprintf("Attribute %q conflicting", attrPathToString(path)), + fmt.Sprintf("%q cannot be specified when %q is specified", attrPathToString(path), attrPathToString(req.AttributePath)), + ) + return + } + } +} + +// attrPathToString takes all the tftypes.AttributePathStep in a tftypes.AttributePath and concatenates them, +// using `.` as separator. +// +// This should be used only when trying to "print out" a tftypes.AttributePath in a log or an error message. +func attrPathToString(path *tftypes.AttributePath) string { + var res strings.Builder + for pos, step := range path.Steps() { + if pos != 0 { + res.WriteString(".") + } + switch v := step.(type) { + case tftypes.AttributeName: + res.WriteString(string(v)) + case tftypes.ElementKeyString: + res.WriteString(string(v)) + case tftypes.ElementKeyInt: + res.WriteString(strconv.FormatInt(int64(v), 10)) + case tftypes.ElementKeyValue: + res.WriteString(tftypes.Value(v).String()) + } + } + + return res.String() +} diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index c0fff439..c024d27b 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -9,7 +9,10 @@ description: |- The HTTP provider is a utility provider for interacting with generic HTTP servers as part of a Terraform configuration. -This provider requires no configuration. For information on the resources -it provides, see the navigation bar. +### Configuring Proxy -{{- /* No schema in this provider, so no need for this: .SchemaMarkdown | trimspace */ -}} \ No newline at end of file +{{ tffile "examples/provider/provider_with_proxy.tf" }} + +{{ tffile "examples/provider/provider_with_proxy_from_env.tf" }} + +{{ .SchemaMarkdown | trimspace }} From 033a32f6bd31fbdc4e08c2bfd28798a318dd17c6 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Fri, 8 Jul 2022 15:26:07 +0100 Subject: [PATCH 2/4] Updating CHANGELOG.md (#80) --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9736ad06..ca153b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ -## 3.0.0 (unreleased) +## 3.1.0 (unreleased) + +ENHANCEMENTS: + +* provider: Added optional HTTP `proxy` configuration [#154](https://github.com/hashicorp/terraform-provider-tls/pull/154)). + +## 3.0.0-rc.1 (unreleased) NOTES: From 2a40addcb61e3d6b17d0eee2f7aa439edd8f6944 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Fri, 8 Jul 2022 15:26:55 +0100 Subject: [PATCH 3/4] Updating comment (#80) --- internal/provider/provider.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 47c0ac19..c0aeee72 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -81,9 +81,6 @@ func (p *provider) GetSchema(context.Context) (tfsdk.Schema, diag.Diagnostics) { }, nil } -// Configure unmarshalls the proxy config onto providerConfigModel and checks that `proxy` is not null or unknown before proceeding. -// Then unmarshalls contents of proxy onto providerProxyConfigModel. -// Then checks each of the fields (i.e., URL, Username, Password, FromEnv) func (p *provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderRequest, res *tfsdk.ConfigureProviderResponse) { tflog.Debug(ctx, "Configuring provider") var err error From 7c217b1950caa504a5551013a3b353936fbda823 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Fri, 8 Jul 2022 15:36:55 +0100 Subject: [PATCH 4/4] Replacing usage of strings.Cut as only available in go >= 1.18 (#80) --- internal/provider/data_source_http_test.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/provider/data_source_http_test.go b/internal/provider/data_source_http_test.go index befe0957..c3934520 100644 --- a/internal/provider/data_source_http_test.go +++ b/internal/provider/data_source_http_test.go @@ -408,18 +408,22 @@ func proxyAuth() func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *h return r, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusForbidden, "") } - c, err := base64.StdEncoding.DecodeString(auth[len("Basic "):]) + dec, err := base64.StdEncoding.DecodeString(auth[len("Basic "):]) if err != nil { return r, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusProxyAuthRequired, "") } - cs := string(c) - username, password, ok := strings.Cut(cs, ":") - if !ok { + decStr := string(dec) + + separatorIndex := strings.IndexByte(decStr, ':') + if separatorIndex < 0 { return r, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusProxyAuthRequired, "") } - if username == "correctUsername" && password == "" || password == "correctPassword" { + username := decStr[:separatorIndex] + password := decStr[separatorIndex+1:] + + if username == "correctUsername" && (password == "" || password == "correctPassword") { return r, nil }