diff --git a/docs/data-sources/certificate.md b/docs/data-sources/certificate.md index 0e702e15..99053fdb 100644 --- a/docs/data-sources/certificate.md +++ b/docs/data-sources/certificate.md @@ -3,14 +3,14 @@ page_title: "tls_certificate Data Source - terraform-provider-tls" subcategory: "" description: |- Get information about the TLS certificates securing a host. - Use this data source to get information, such as SHA1 fingerprint or serial number, about the TLS certificates that protects an HTTPS website. + Use this data source to get information, such as SHA1 fingerprint or serial number, about the TLS certificates that protects a URL. --- # tls_certificate (Data Source) Get information about the TLS certificates securing a host. -Use this data source to get information, such as SHA1 fingerprint or serial number, about the TLS certificates that protects an HTTPS website. +Use this data source to get information, such as SHA1 fingerprint or serial number, about the TLS certificates that protects a URL. ## Example Usage diff --git a/docs/index.md b/docs/index.md index 6388111a..3c2b6211 100644 --- a/docs/index.md +++ b/docs/index.md @@ -70,6 +70,57 @@ resource "aws_iam_server_certificate" "example" { } ``` +### Configuring Proxy + +```terraform +# This example fetches the TLS certificate chain +# from `example.com` using an HTTP Proxy. + +provider "tls" { + proxy { + url = "https://corporate.proxy.service" + } +} + +data "tls_certificate" "test" { + url = "https://example.com" +} +``` + +```terraform +# This example fetches the TLS certificate chain +# from `example.com` using an HTTP Proxy. +# The Proxy is discovered via environment variables: +# see https://pkg.go.dev/net/http#ProxyFromEnvironment for details. + +provider "tls" { + proxy { + from_env = true + } +} + +data "tls_certificate" "test" { + url = "https://example.com" +} +``` + + +## Schema + +### Optional + +- `proxy` (Block List, Max: 1) Proxy used by resources and data sources that connect to external endpoints. (see [below for nested schema](#nestedblock--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: `false`). **NOTE**: the default value for this argument will be change to `true` in the next major release. +- `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. + ## Limitations ### `ECDSA` with `P224` elliptic curve diff --git a/examples/provider/provider_with_proxy.tf b/examples/provider/provider_with_proxy.tf new file mode 100644 index 00000000..07e87775 --- /dev/null +++ b/examples/provider/provider_with_proxy.tf @@ -0,0 +1,12 @@ +# This example fetches the TLS certificate chain +# from `example.com` using an HTTP Proxy. + +provider "tls" { + proxy { + url = "https://corporate.proxy.service" + } +} + +data "tls_certificate" "test" { + url = "https://example.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..0b360e50 --- /dev/null +++ b/examples/provider/provider_with_proxy_from_env.tf @@ -0,0 +1,14 @@ +# This example fetches the TLS certificate chain +# from `example.com` using an HTTP Proxy. +# The Proxy is discovered via environment variables: +# see https://pkg.go.dev/net/http#ProxyFromEnvironment for details. + +provider "tls" { + proxy { + from_env = true + } +} + +data "tls_certificate" "test" { + url = "https://example.com" +} diff --git a/go.mod b/go.mod index f28a5a89..5f0b4aa7 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/terraform-providers/terraform-provider-tls go 1.17 require ( + github.com/elazarl/goproxy v0.0.0-20220328115640-894aeddb713e + github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 github.com/hashicorp/terraform-plugin-docs v0.7.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.13.0 golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd diff --git a/go.sum b/go.sum index 50b7e5be..7ebec61d 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,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-20220328115640-894aeddb713e h1:99KFda6F/mw8xSfceY2JEVCrYWX7l+Ms6BcO5wEct+Q= +github.com/elazarl/goproxy v0.0.0-20220328115640-894aeddb713e/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= @@ -215,6 +219,7 @@ github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 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/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= diff --git a/internal/provider/data_source_certificate.go b/internal/provider/data_source_certificate.go index 01d0a87a..1f01b10f 100644 --- a/internal/provider/data_source_certificate.go +++ b/internal/provider/data_source_certificate.go @@ -2,11 +2,15 @@ package provider import ( "crypto/tls" + "crypto/x509" "fmt" + "net/http" "net/url" + "strings" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func dataSourceCertificate() *schema.Resource { @@ -15,13 +19,17 @@ func dataSourceCertificate() *schema.Resource { Description: "Get information about the TLS certificates securing a host.\n\n" + "Use this data source to get information, such as SHA1 fingerprint or serial number, " + - "about the TLS certificates that protects an HTTPS website.", + "about the TLS certificates that protects a URL.", Schema: map[string]*schema.Schema{ "url": { - Type: schema.TypeString, - Required: true, - Description: "The URL of the website to get the certificates from.", + Type: schema.TypeString, + Required: true, + Description: "URL of the endpoint to get the certificates from. " + + fmt.Sprintf("Accepted schemes are: `%s`. ", strings.Join(SupportedURLSchemesStr(), "`, `")) + + "For scheme `https://` it will use the HTTP protocol and apply the `proxy` configuration " + + "of the provider, if set. For scheme `tls://` it will instead use a secure TCP socket.", + ValidateDiagFunc: validation.ToDiagFunc(validation.IsURLWithScheme(SupportedURLSchemesStr())), }, "verify_chain": { Type: schema.TypeBool, @@ -104,32 +112,51 @@ func dataSourceCertificate() *schema.Resource { } } -func dataSourceCertificateRead(d *schema.ResourceData, _ interface{}) error { - u, err := url.Parse(d.Get("url").(string)) +func dataSourceCertificateRead(d *schema.ResourceData, m interface{}) error { + config := m.(*providerConfig) + + targetURL, err := url.Parse(d.Get("url").(string)) if err != nil { return err } - if u.Scheme != "https" { - return fmt.Errorf("invalid scheme") - } - if u.Port() == "" { - u.Host += ":443" - } - verifyChain := d.Get("verify_chain").(bool) + // Determine if we should verify the chain of certificates, or skip said verification + shouldVerifyChain := d.Get("verify_chain").(bool) - conn, err := tls.Dial("tcp", u.Host, &tls.Config{InsecureSkipVerify: !verifyChain}) + // Ensure a port is set on the URL, or return an error + var peerCerts []*x509.Certificate + switch targetURL.Scheme { + case HTTPSScheme.String(): + if targetURL.Port() == "" { + targetURL.Host += ":443" + } + + // TODO remove this branch and default to use `fetchPeerCertificatesViaHTTPS` + // as part of https://github.com/hashicorp/terraform-provider-tls/issues/183 + if config.isProxyConfigured() { + peerCerts, err = fetchPeerCertificatesViaHTTPS(targetURL, shouldVerifyChain, config) + } else { + peerCerts, err = fetchPeerCertificatesViaTLS(targetURL, shouldVerifyChain) + } + case TLSScheme.String(): + if targetURL.Port() == "" { + return fmt.Errorf("port missing from URL: %s", targetURL.String()) + } + + peerCerts, err = fetchPeerCertificatesViaTLS(targetURL, shouldVerifyChain) + default: + // NOTE: This should never happen, given we validate this at the schema level + return fmt.Errorf("unsupported scheme: %s", targetURL.Scheme) + } if err != nil { return err } - defer conn.Close() - state := conn.ConnectionState() - var certs []interface{} - for i := len(state.PeerCertificates) - 1; i >= 0; i-- { - certs = append(certs, certificateToMap(state.PeerCertificates[i])) + // Convert peer certificates to a simple map + certs := make([]interface{}, len(peerCerts)) + for i, peerCert := range peerCerts { + certs[len(peerCerts)-i-1] = certificateToMap(peerCert) } - err = d.Set("certificates", certs) if err != nil { return err @@ -139,3 +166,45 @@ func dataSourceCertificateRead(d *schema.ResourceData, _ interface{}) error { return nil } + +func fetchPeerCertificatesViaTLS(targetURL *url.URL, shouldVerifyChain bool) ([]*x509.Certificate, error) { + conn, err := tls.Dial("tcp", targetURL.Host, &tls.Config{ + InsecureSkipVerify: !shouldVerifyChain, + }) + if err != nil { + return nil, fmt.Errorf("unable to execute TLS connection towards %s: %w", targetURL.Host, err) + } + defer conn.Close() + + return conn.ConnectionState().PeerCertificates, nil +} + +func fetchPeerCertificatesViaHTTPS(targetURL *url.URL, shouldVerifyChain bool, config *providerConfig) ([]*x509.Certificate, error) { + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !shouldVerifyChain, + }, + Proxy: config.proxyForRequestFunc(), + }, + } + + // First attempting an HTTP HEAD: if it fails, ignore errors and move on + resp, err := client.Head(targetURL.String()) + if err == nil && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + defer resp.Body.Close() + return resp.TLS.PeerCertificates, nil + } + + // Then attempting HTTP GET: if this fails we will than report the error + resp, err = client.Get(targetURL.String()) + if err != nil { + return nil, fmt.Errorf("failed to fetch certificates from URL '%s': %w", targetURL.Scheme, err) + } + defer resp.Body.Close() + if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + return resp.TLS.PeerCertificates, nil + } + + return nil, fmt.Errorf("got back response (status: %s) with no certificates from URL '%s': %w", resp.Status, targetURL.Scheme, err) +} diff --git a/internal/provider/data_source_certificate_test.go b/internal/provider/data_source_certificate_test.go index eaba691d..91433a87 100644 --- a/internal/provider/data_source_certificate_test.go +++ b/internal/provider/data_source_certificate_test.go @@ -2,21 +2,19 @@ package provider import ( "fmt" - "log" - "net" - "net/http" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) -func TestAccTlsCertificate_dataSource(t *testing.T) { - server, host, err := newTlsServer() +func TestAccDataSourceCertificate_HTTPSScheme(t *testing.T) { + server, err := newHTTPServer() if err != nil { t.Fatal(err) } defer server.Close() - go server.serve() + go server.ServeTLS() resource.UnitTest(t, resource.TestCase{ Providers: testProviders, @@ -25,73 +23,366 @@ func TestAccTlsCertificate_dataSource(t *testing.T) { { Config: fmt.Sprintf(` -data "tls_certificate" "test" { - url = "https://%s" - verify_chain = false + data "tls_certificate" "test" { + url = "https://%s" + verify_chain = false + } + `, server.Address()), + Check: localTestCertificateChainCheckFunc(), + }, + }, + }) } -`, host), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.#", "2"), - - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.signature_algorithm", "SHA256-RSA"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.public_key_algorithm", "RSA"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.serial_number", "60512478256160404377639062250777657301"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.is_ca", "true"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.version", "3"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.issuer", "CN=Root CA,O=Test Org,L=Here"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.subject", "CN=Root CA,O=Test Org,L=Here"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.not_before", "2019-11-07T15:47:48Z"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.not_after", "2019-12-17T15:47:48Z"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.sha1_fingerprint", "5829a9bcc57f317719c5c98d1f48d6c9957cb44e"), - - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.signature_algorithm", "SHA256-RSA"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.public_key_algorithm", "RSA"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.serial_number", "266244246501122064554217434340898012243"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.is_ca", "false"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.version", "3"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.issuer", "CN=Root CA,O=Test Org,L=Here"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.subject", "CN=Child Cert,O=Child Co.,L=Everywhere"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.not_before", "2019-11-08T09:01:36Z"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.not_after", "2019-11-08T19:01:36Z"), - resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.sha1_fingerprint", "61b65624427d75b61169100836904e44364df817"), - ), + +func TestAccDataSourceCertificate_TLSScheme(t *testing.T) { + server, err := newHTTPServer() + if err != nil { + t.Fatal(err) + } + defer server.Close() + go server.ServeTLS() + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + + Steps: []resource.TestStep{ + { + + Config: fmt.Sprintf(` + data "tls_certificate" "test" { + url = "tls://%s" + verify_chain = false + } + `, server.Address()), + Check: localTestCertificateChainCheckFunc(), }, }, }) } -type tlsServer struct { - listener net.Listener - server *http.Server +func TestAccDataSourceCertificate_HTTPSSchemeViaProxy(t *testing.T) { + server, err := newHTTPServer() + if err != nil { + t.Fatal(err) + } + defer server.Close() + go server.ServeTLS() + + proxy, err := newHTTPProxyServer() + if err != nil { + t.Fatal(err) + } + defer proxy.Close() + go proxy.Serve() + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + + Steps: []resource.TestStep{ + { + + Config: fmt.Sprintf(` + provider "tls" { + proxy { + url = "http://%s" + } + } + + data "tls_certificate" "test" { + url = "https://%s" + verify_chain = false + } + `, proxy.Address(), server.Address()), + Check: localTestCertificateChainCheckFunc(), + }, + }, + }) } -func newTlsServer() (*tlsServer, string, error) { - listener, err := net.Listen("tcp", ":0") +func TestAccDataSourceCertificate_HTTPSSchemeViaProxyWithUsernameAuth(t *testing.T) { + server, err := newHTTPServer() if err != nil { - return nil, "", err + t.Fatal(err) } + defer server.Close() + go server.ServeTLS() + + proxyUsername := "proxyUser" + proxy, err := newHTTPProxyServerWithBasicAuth(proxyUsername, "") + if err != nil { + t.Fatal(err) + } + defer proxy.Close() + go proxy.Serve() + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + + Steps: []resource.TestStep{ + { + + Config: fmt.Sprintf(` + provider "tls" { + proxy { + url = "http://%s" + username = "%s" + } + } + + data "tls_certificate" "test" { + url = "https://%s" + verify_chain = false + } + `, proxy.Address(), proxyUsername, server.Address()), + Check: localTestCertificateChainCheckFunc(), + }, + { + + Config: fmt.Sprintf(` + provider "tls" { + proxy { + url = "http://%s" + username = "wrong-username" + } + } - return &tlsServer{ - listener: listener, - server: &http.Server{ - Addr: listener.Addr().String(), + data "tls_certificate" "test" { + url = "https://%s" + verify_chain = false + } + `, proxy.Address(), server.Address()), + ExpectError: regexp.MustCompile("Proxy Authentication Required"), + }, }, - }, listener.Addr().String(), nil + }) } -func (t *tlsServer) serve() { - err := t.server.ServeTLS(t.listener, "testdata/tls_certs/public.pem", "testdata/tls_certs/private.pem") +func TestAccDataSourceCertificate_HTTPSSchemeViaProxyWithUsernameAndPasswordAuth(t *testing.T) { + server, err := newHTTPServer() if err != nil { - log.Println("Failed to serve TLS server", err) + t.Fatal(err) } + defer server.Close() + go server.ServeTLS() + + proxyUsername := "proxyUser" + proxyPassword := "proxyPwd" + proxy, err := newHTTPProxyServerWithBasicAuth(proxyUsername, proxyPassword) + if err != nil { + t.Fatal(err) + } + defer proxy.Close() + go proxy.Serve() + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + + Steps: []resource.TestStep{ + { + + Config: fmt.Sprintf(` + provider "tls" { + proxy { + url = "http://%s" + username = "%s" + password = "%s" + } + } + + data "tls_certificate" "test" { + url = "https://%s" + verify_chain = false + } + `, proxy.Address(), proxyUsername, proxyPassword, server.Address()), + Check: localTestCertificateChainCheckFunc(), + }, + { + + Config: fmt.Sprintf(` + provider "tls" { + proxy { + url = "http://%s" + username = "%s" + password = "wrong-password" + } + } + + data "tls_certificate" "test" { + url = "https://%s" + verify_chain = false + } + `, proxy.Address(), proxyUsername, server.Address()), + ExpectError: regexp.MustCompile("Proxy Authentication Required"), + }, + }, + }) } -func (t *tlsServer) Close() error { - if err := t.listener.Close(); err != nil { - return err +func TestAccDataSourceCertificate_HTTPSSchemeViaProxyFromEnv(t *testing.T) { + server, err := newHTTPServer() + if err != nil { + t.Fatal(err) } - if err := t.server.Close(); err != nil { - return err + defer server.Close() + go server.ServeTLS() + + proxy, err := newHTTPProxyServer() + if err != nil { + t.Fatal(err) } - return nil + defer proxy.Close() + go proxy.Serve() + t.Setenv("HTTP_PROXY", fmt.Sprintf("http://%s", proxy.Address())) + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + + Steps: []resource.TestStep{ + { + + Config: fmt.Sprintf(` + provider "tls" { + proxy { + from_env = true + } + } + + data "tls_certificate" "test" { + url = "https://%s" + verify_chain = false + } + `, server.Address()), + Check: localTestCertificateChainCheckFunc(), + }, + }, + }) +} + +func TestAccDataSourceCertificate_HTTPSSchemeViaProxyButNoProxyAvailable(t *testing.T) { + server, err := newHTTPServer() + if err != nil { + t.Fatal(err) + } + defer server.Close() + go server.ServeTLS() + + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + + Steps: []resource.TestStep{ + { + + Config: fmt.Sprintf(` + provider "tls" { + proxy { + url = "http://localhost:65535" + } + } + + data "tls_certificate" "test" { + url = "https://%s" + verify_chain = false + } + `, server.Address()), + ExpectError: regexp.MustCompile(`failed to fetch certificates from URL 'https': Get "https://\[::\]:\d+": proxyconnect tcp: dial tcp \[::1\]:65535`), + }, + }, + }) +} + +func localTestCertificateChainCheckFunc() resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.#", "2"), + + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.signature_algorithm", "SHA256-RSA"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.public_key_algorithm", "RSA"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.serial_number", "60512478256160404377639062250777657301"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.is_ca", "true"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.version", "3"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.issuer", "CN=Root CA,O=Test Org,L=Here"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.subject", "CN=Root CA,O=Test Org,L=Here"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.not_before", "2019-11-07T15:47:48Z"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.not_after", "2019-12-17T15:47:48Z"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.0.sha1_fingerprint", "5829a9bcc57f317719c5c98d1f48d6c9957cb44e"), + + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.signature_algorithm", "SHA256-RSA"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.public_key_algorithm", "RSA"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.serial_number", "266244246501122064554217434340898012243"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.is_ca", "false"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.version", "3"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.issuer", "CN=Root CA,O=Test Org,L=Here"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.subject", "CN=Child Cert,O=Child Co.,L=Everywhere"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.not_before", "2019-11-08T09:01:36Z"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.not_after", "2019-11-08T19:01:36Z"), + resource.TestCheckResourceAttr("data.tls_certificate.test", "certificates.1.sha1_fingerprint", "61b65624427d75b61169100836904e44364df817"), + ) +} + +func TestAccDataSourceCertificate_MalformedURL(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + + Steps: []resource.TestStep{ + { + + Config: ` + data "tls_certificate" "test" { + url = "http://no.https.scheme.com" + verify_chain = false + } + `, + ExpectError: regexp.MustCompile(`expected "url" to have a url with schema of: "https,tls", got http://no.https.scheme.com`), + }, + { + + Config: ` + data "tls_certificate" "test" { + url = "unknown://unknown.scheme.com" + verify_chain = false + } + `, + ExpectError: regexp.MustCompile(`expected "url" to have a url with schema of: "https,tls", got unknown://unknown.scheme.com`), + }, + { + + Config: ` + data "tls_certificate" "test" { + url = "tls://host.without.port.com" + verify_chain = false + } + `, + ExpectError: regexp.MustCompile(`port missing from URL: tls://host.without.port.com`), + }, + { + + Config: ` + data "tls_certificate" "test" { + url = "ftp://ftp.scheme.com" + verify_chain = false + } + `, + ExpectError: regexp.MustCompile(`expected "url" to have a url with schema of: "https,tls", got ftp://ftp.scheme.com`), + }, + { + + Config: ` + data "tls_certificate" "test" { + url = "1.2.3.4" + verify_chain = false + } + `, + ExpectError: regexp.MustCompile(`expected "url" to have a host, got 1.2.3.4`), + }, + { + + Config: ` + data "tls_certificate" "test" { + url = "not-a-url-at-all" + verify_chain = false + } + `, + ExpectError: regexp.MustCompile(`expected "url" to have a host, got not-a-url-at-all`), + }, + }, + }) } diff --git a/internal/provider/local_server_test.go b/internal/provider/local_server_test.go new file mode 100644 index 00000000..5049f41b --- /dev/null +++ b/internal/provider/local_server_test.go @@ -0,0 +1,97 @@ +package provider + +import ( + "log" + "net" + "net/http" + + "github.com/elazarl/goproxy" + "github.com/elazarl/goproxy/ext/auth" +) + +// LocalServerTest is a simple HTTP server used for testing. +type LocalServerTest struct { + listener net.Listener + server *http.Server +} + +// newHTTPServer creates an HTTP server that listens on a random port. +func newHTTPServer() (*LocalServerTest, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, err + } + + return &LocalServerTest{ + listener: listener, + server: &http.Server{ + Addr: listener.Addr().String(), + }, + }, nil +} + +// newHTTPProxyServer creates an HTTP Proxy server that listens on a random port. +func newHTTPProxyServer() (*LocalServerTest, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, err + } + + return &LocalServerTest{ + listener: listener, + server: &http.Server{ + Addr: listener.Addr().String(), + Handler: goproxy.NewProxyHttpServer(), + }, + }, nil +} + +// newHTTPProxyServer creates an HTTP Proxy server that listens on a random port and expects HTTP Basic Auth. +func newHTTPProxyServerWithBasicAuth(expectedUsername, expectedPassword string) (*LocalServerTest, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, err + } + + proxy := goproxy.NewProxyHttpServer() + proxy.OnRequest().HandleConnect(auth.BasicConnect("restricted", func(username, password string) bool { + return username == expectedUsername && (expectedPassword == "" || password == expectedPassword) + })) + return &LocalServerTest{ + listener: listener, + server: &http.Server{ + Addr: listener.Addr().String(), + Handler: proxy, + }, + }, nil +} + +// ServeTLS makes the server begin listening for TLS client connections. +func (lst *LocalServerTest) ServeTLS() { + err := lst.server.ServeTLS(lst.listener, "testdata/tls_certs/public.pem", "testdata/tls_certs/private.pem") + if err != nil { + log.Println("Failed to start LocalServerTest with TLS", err) + } +} + +// Serve makes the server begin listening for plain client connections. +func (lst *LocalServerTest) Serve() { + err := lst.server.Serve(lst.listener) + if err != nil { + log.Println("Failed to start LocalServerTest", err) + } +} + +func (lst *LocalServerTest) Close() error { + if err := lst.listener.Close(); err != nil { + return err + } + if err := lst.server.Close(); err != nil { + return err + } + return nil +} + +func (lst *LocalServerTest) Address() string { + return lst.listener.Addr().String() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a5219eeb..5feafb05 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1,7 +1,15 @@ package provider import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func New() *schema.Provider { @@ -16,5 +24,105 @@ func New() *schema.Provider { "tls_public_key": dataSourcePublicKey(), "tls_certificate": dataSourceCertificate(), }, + Schema: map[string]*schema.Schema{ + "proxy": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "url": { + Type: schema.TypeString, + Optional: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.IsURLWithScheme(SupportedProxySchemesStr())), + ConflictsWith: []string{"proxy.0.from_env"}, + Description: "URL used to connect to the Proxy. " + + fmt.Sprintf("Accepted schemes are: `%s`. ", strings.Join(SupportedProxySchemesStr(), "`, `")), + }, + "username": { + Type: schema.TypeString, + Optional: true, + RequiredWith: []string{"proxy.0.url"}, + Description: "Username (or Token) used for Basic authentication against the Proxy.", + }, + "password": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + RequiredWith: []string{"proxy.0.username"}, + Description: "Password used for Basic authentication against the Proxy.", + }, + "from_env": { + Type: schema.TypeBool, + Optional: true, + Default: false, + ConflictsWith: []string{"proxy.0.url", "proxy.0.username", "proxy.0.password"}, + Description: "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: `false`). " + + "**NOTE**: the default value for this argument will be change to `true` in the next major release.", + }, + }, + }, + Description: "Proxy used by resources and data sources that connect to external endpoints.", + }, + }, + ConfigureContextFunc: configureProvider, + } +} + +// providerConfig is produced by configureProvider as part of the provider initialization. +type providerConfig struct { + proxyURL *url.URL + proxyFromEnv bool +} + +func configureProvider(_ context.Context, data *schema.ResourceData) (interface{}, diag.Diagnostics) { + var diags diag.Diagnostics + var config = &providerConfig{} + var err error + + if proxyUrl, ok := data.GetOk("proxy.0.url"); ok { + config.proxyURL, err = url.Parse(proxyUrl.(string)) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("Unable to parse proxy URL '%s': %v", proxyUrl, err), + }) + } + } + + if proxyUsername, ok := data.GetOk("proxy.0.username"); ok { + // NOTE: we know that `.proxyURL` is set, as this is imposed by the provider schema + config.proxyURL.User = url.User(proxyUsername.(string)) + } + + if proxyPassword, ok := data.GetOk("proxy.0.password"); ok { + // NOTE: we know that `.proxyURL.User.Username()` is set, as this is imposed by the provider schema + config.proxyURL.User = url.UserPassword(config.proxyURL.User.Username(), proxyPassword.(string)) + } + + if proxyFromEnv, ok := data.GetOk("proxy.0.from_env"); ok { + config.proxyFromEnv = proxyFromEnv.(bool) } + + return config, diags +} + +// 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. +func (pc *providerConfig) proxyForRequestFunc() func(_ *http.Request) (*url.URL, error) { + if pc.proxyFromEnv { + return http.ProxyFromEnvironment + } + + return func(_ *http.Request) (*url.URL, error) { + return pc.proxyURL, nil + } +} + +func (pc *providerConfig) isProxyConfigured() bool { + return pc.proxyURL != nil || pc.proxyFromEnv } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index adceb417..15274e41 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -1,18 +1,14 @@ package provider import ( + "regexp" "testing" "time" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -func TestProvider(t *testing.T) { - if err := New().InternalValidate(); err != nil { - t.Fatalf("err: %s", err) - } -} - var testProviders = map[string]*schema.Provider{ "tls": New(), } @@ -25,3 +21,86 @@ func setTimeForTest(timeStr string) func() { } } } + +func TestProvider(t *testing.T) { + if err := New().InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvider_InvalidProxyConfig(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + Providers: testProviders, + + Steps: []resource.TestStep{ + { + Config: ` + provider "tls" { + proxy { + url = "https://proxy.host.com" + from_env = true + } + } + resource "tls_private_key" "test" { + algorithm = "ED25519" + } + `, + ExpectError: regexp.MustCompile(`"proxy.0.url": conflicts with proxy.0.from_env|"proxy.0.from_env": conflicts with proxy.0.url`), + }, + { + Config: ` + provider "tls" { + proxy { + username = "user" + } + } + resource "tls_private_key" "test" { + algorithm = "ED25519" + } + `, + ExpectError: regexp.MustCompile("\"proxy.0.username\": all of `proxy.0.url,proxy.0.username` must be specified"), + }, + { + Config: ` + provider "tls" { + proxy { + password = "pwd" + } + } + resource "tls_private_key" "test" { + algorithm = "ED25519" + } + `, + ExpectError: regexp.MustCompile("\"proxy.0.password\": all of `proxy.0.password,proxy.0.username` must be"), + }, + { + Config: ` + provider "tls" { + proxy { + username = "user" + password = "pwd" + } + } + resource "tls_private_key" "test" { + algorithm = "ED25519" + } + `, + ExpectError: regexp.MustCompile("\"proxy.0.username\": all of `proxy.0.url,proxy.0.username` must be specified"), + }, + { + Config: ` + provider "tls" { + proxy { + username = "user" + from_env = true + } + } + resource "tls_private_key" "test" { + algorithm = "ED25519" + } + `, + ExpectError: regexp.MustCompile(`"proxy.0.from_env": conflicts with proxy.0.username`), + }, + }, + }) +} diff --git a/internal/provider/types.go b/internal/provider/types.go index f343c26e..2bf69411 100644 --- a/internal/provider/types.go +++ b/internal/provider/types.go @@ -111,3 +111,65 @@ func PEMBlockToPEMPreamble(block *pem.Block) (PEMPreamble, error) { return "", fmt.Errorf("unsupported PEM preamble/type: %s", block.Type) } } + +// 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 +} + +// URLScheme represents url schemes supported by resources and data-sources of this provider. +type URLScheme string + +const ( + HTTPSScheme URLScheme = "https" + TLSScheme URLScheme = "tls" +) + +func (p URLScheme) String() string { + return string(p) +} + +// SupportedURLSchemes returns an array of URLScheme currently supported by this provider. +func SupportedURLSchemes() []URLScheme { + return []URLScheme{ + HTTPSScheme, + TLSScheme, + } +} + +// SupportedURLSchemesStr returns the same content of SupportedURLSchemes but as a slice of string. +func SupportedURLSchemesStr() []string { + supported := SupportedURLSchemes() + supportedStr := make([]string, len(supported)) + for i := range supported { + supportedStr[i] = string(supported[i]) + } + return supportedStr +} diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index a75692ea..f3e350e9 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -26,6 +26,14 @@ Use the navigation to the left to read about the available resources. {{ tffile "examples/provider/provider.tf" }} +### Configuring Proxy + +{{ tffile "examples/provider/provider_with_proxy.tf" }} + +{{ tffile "examples/provider/provider_with_proxy_from_env.tf" }} + +{{ .SchemaMarkdown | trimspace }} + ## Limitations ### `ECDSA` with `P224` elliptic curve