diff --git a/docs/index.md b/docs/index.md index 38b1857..846f882 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,6 +50,8 @@ provider "restapi" { - `insecure` (Boolean) When using https, this disables TLS verification of the host. - `key_file` (String) When set with the cert_file parameter, the provider will load a client certificate as a file for mTLS authentication. Note that this mechanism simply delegates to golang's tls.LoadX509KeyPair which does not support passphrase protected private keys. The most robust security protections available to the key_file are simple file system permissions. - `key_string` (String) When set with the cert_string parameter, the provider will load a client certificate as a string for mTLS authentication. Note that this mechanism simply delegates to golang's tls.LoadX509KeyPair which does not support passphrase protected private keys. The most robust security protections available to the key_file are simple file system permissions. +- `root_ca_file` (String) When set the provider will use this file as the root certificate authority for the API server. This is useful if the API server is using a self-signed certificate. +- `root_ca_string` (String) When set the provider will use this string as the root certificate authority for the API server. This is useful if the API server is using a self-signed certificate. - `oauth_client_credentials` (Block List, Max: 1) Configuration for oauth client credential flow (see [below for nested schema](#nestedblock--oauth_client_credentials)) - `password` (String) When set, will use this password for BASIC auth to the API. - `rate_limit` (Number) Set this to limit the number of requests per second made to the API. diff --git a/restapi/api_client.go b/restapi/api_client.go index 65bc6a3..7394b04 100644 --- a/restapi/api_client.go +++ b/restapi/api_client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/tls" + "crypto/x509" "errors" "fmt" "io/ioutil" @@ -12,6 +13,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "os" "strings" "time" @@ -48,8 +50,10 @@ type apiClientOpt struct { oauthEndpointParams url.Values certFile string keyFile string + rootCAFile string certString string keyString string + rootCAString string debug bool } @@ -133,6 +137,33 @@ func NewAPIClient(opt *apiClientOpt) (*APIClient, error) { tlsConfig.Certificates = []tls.Certificate{cert} } + // Load root CA + if opt.rootCAFile != "" || opt.rootCAString != "" { + caCertPool := x509.NewCertPool() + var rootCA []byte + var err error + + if opt.rootCAFile != "" { + if opt.debug { + log.Printf("api_client.go: Reading root CA file: %s\n", opt.rootCAFile) + } + rootCA, err = os.ReadFile(opt.rootCAFile) + if err != nil { + return nil, fmt.Errorf("could not read root CA file: %v", err) + } + } else { + if opt.debug { + log.Printf("api_client.go: Using provided root CA string\n") + } + rootCA = []byte(opt.rootCAString) + } + + if !caCertPool.AppendCertsFromPEM(rootCA) { + return nil, errors.New("failed to append root CA certificate") + } + tlsConfig.RootCAs = caCertPool + } + tr := &http.Transport{ TLSClientConfig: tlsConfig, Proxy: http.ProxyFromEnvironment, diff --git a/restapi/api_client_test.go b/restapi/api_client_test.go index 87c5eec..7dab17c 100644 --- a/restapi/api_client_test.go +++ b/restapi/api_client_test.go @@ -1,13 +1,30 @@ package restapi import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "log" + "math/big" + "net" "net/http" + "os" "testing" "time" ) -var apiClientServer *http.Server +var ( + apiClientServer *http.Server + apiClientTLSServer *http.Server + rootCA *x509.Certificate + rootCAKey *ecdsa.PrivateKey + serverCertPEM, serverKeyPEM []byte + rootCAFilePath = "rootCA.pem" +) func TestAPIClient(t *testing.T) { debug := false @@ -89,6 +106,42 @@ func TestAPIClient(t *testing.T) { if debug { log.Println("client_test.go: Done") } + + // Setup and test HTTPS client with root CA + setupAPIClientTLSServer() + defer shutdownAPIClientTLSServer() + defer os.Remove(rootCAFilePath) + + httpsOpt := &apiClientOpt{ + uri: "https://127.0.0.1:8443/", + insecure: false, + username: "", + password: "", + headers: make(map[string]string), + timeout: 2, + idAttribute: "id", + copyKeys: make([]string, 0), + writeReturnsObject: false, + createReturnsObject: false, + rateLimit: 1, + rootCAFile: rootCAFilePath, + debug: debug, + } + httpsClient, httpsClientErr := NewAPIClient(httpsOpt) + + if httpsClientErr != nil { + t.Fatalf("client_test.go: %s", httpsClientErr) + } + if debug { + log.Printf("api_client_test.go: Testing HTTPS standard OK request\n") + } + res, err = httpsClient.sendRequest("GET", "/ok", "") + if err != nil { + t.Fatalf("client_test.go: %s", err) + } + if res != "It works!" { + t.Fatalf("client_test.go: Got back '%s' but expected 'It works!'\n", res) + } } func setupAPIClientServer() { @@ -116,3 +169,74 @@ func setupAPIClientServer() { func shutdownAPIClientServer() { apiClientServer.Close() } + +func setupAPIClientTLSServer() { + generateCertificates() + + cert, _ := tls.X509KeyPair(serverCertPEM, serverKeyPEM) + + serverMux := http.NewServeMux() + serverMux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("It works!")) + }) + + apiClientTLSServer = &http.Server{ + Addr: "127.0.0.1:8443", + Handler: serverMux, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, + } + go apiClientTLSServer.ListenAndServeTLS("", "") + /* let the server start */ + time.Sleep(1 * time.Second) +} + +func shutdownAPIClientTLSServer() { + apiClientTLSServer.Close() +} + +func generateCertificates() { + // Create a CA certificate and key + rootCAKey, _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + rootCA = &x509.Certificate{ + SerialNumber: big.NewInt(2024), + Subject: pkix.Name{ + Organization: []string{"Test Root CA"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + } + rootCABytes, _ := x509.CreateCertificate(rand.Reader, rootCA, rootCA, &rootCAKey.PublicKey, rootCAKey) + + // Create a server certificate and key + serverKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + serverCert := &x509.Certificate{ + SerialNumber: big.NewInt(2024), + Subject: pkix.Name{ + Organization: []string{"Test Server"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, 1), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + } + + // Add IP SANs to the server certificate + serverCert.IPAddresses = append(serverCert.IPAddresses, net.ParseIP("127.0.0.1")) + + serverCertBytes, _ := x509.CreateCertificate(rand.Reader, serverCert, rootCA, &serverKey.PublicKey, rootCAKey) + + // PEM encode the certificates and keys + serverCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverCertBytes}) + + // Marshal the server private key + serverKeyBytes, _ := x509.MarshalECPrivateKey(serverKey) + serverKeyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: serverKeyBytes}) + + rootCAPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCABytes}) + _ = os.WriteFile(rootCAFilePath, rootCAPEM, 0644) +} diff --git a/restapi/provider.go b/restapi/provider.go index 6dab97a..1d3b4bd 100644 --- a/restapi/provider.go +++ b/restapi/provider.go @@ -192,6 +192,18 @@ func Provider() *schema.Provider { DefaultFunc: schema.EnvDefaultFunc("REST_API_KEY_FILE", nil), Description: "When set with the cert_file parameter, the provider will load a client certificate as a file for mTLS authentication. Note that this mechanism simply delegates to golang's tls.LoadX509KeyPair which does not support passphrase protected private keys. The most robust security protections available to the key_file are simple file system permissions.", }, + "root_ca_file": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("REST_API_ROOT_CA_FILE", nil), + Description: "When set, the provider will load a root CA certificate as a file for mTLS authentication. This is useful when the API server is using a self-signed certificate and the client needs to trust it.", + }, + "root_ca_string": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("REST_API_ROOT_CA_STRING", nil), + Description: "When set, the provider will load a root CA certificate as a string for mTLS authentication. This is useful when the API server is using a self-signed certificate and the client needs to trust it.", + }, }, ResourcesMap: map[string]*schema.Resource{ /* Could only get terraform to recognize this resource if @@ -284,7 +296,13 @@ func configureProvider(d *schema.ResourceData) (interface{}, error) { if v, ok := d.GetOk("key_string"); ok { opt.keyString = v.(string) } + if v, ok := d.GetOk("root_ca_file"); ok { + opt.rootCAFile = v.(string) + } + if v, ok := d.GetOk("root_ca_string"); ok { + opt.rootCAString = v.(string) + } client, err := NewAPIClient(opt) if v, ok := d.GetOk("test_path"); ok {