Add support for SSL/TLS

This commit is contained in:
Petri Autero
2019-02-11 15:21:36 +02:00
parent 9378c39c66
commit ebf647ed07
14 changed files with 323 additions and 55 deletions

View File

@@ -0,0 +1,22 @@
# Client Certificate Example
This folder contains an example of how to create client certificates for [Google Cloud SQL](https://cloud.google.com/sql/) database instance.
There can be only one pending operation at a given point of time because of the inherent Cloud SQL system architecture.
This is a limitation on the concurrent writes to a CloudSQL database. To resolve this issue,
we will create the certificate in a separate module.
Creating the certificate while there are other operations ongoing will result in Avoiding `googleapi: Error 409: Operation failed because another operation was already in progress.`
## How do you run this example?
To run this example, you need to:
1. Install [Terraform](https://www.terraform.io/).
1. Open up `vars.tf` and set secrets at the top of the file as environment variables and fill in any other variables in
the file that don't have defaults.
1. `terraform init`.
1. `terraform plan`.
1. If the plan looks good, run `terraform apply`.
When the templates are applied, Terraform will output the IP address of the instance and the instance path for [connecting using the Cloud SQL Proxy](https://cloud.google.com/sql/docs/mysql/connect-admin-proxy).

View File

@@ -0,0 +1,28 @@
# ------------------------------------------------------------------------------
# CREATE A CLIENT CERTIFICATE FOR CLOUD SQL DATABASE
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# CONFIGURE OUR GCP CONNECTION
# ------------------------------------------------------------------------------
provider "google-beta" {
region = "${var.region}"
project = "${var.project}"
}
# Use Terraform 0.10.x so that we can take advantage of Terraform GCP functionality as a separate provider via
# https://github.com/terraform-providers/terraform-provider-google
terraform {
required_version = ">= 0.10.3"
}
# ------------------------------------------------------------------------------
# CREATE CLIENT CERTIFICATE
# ------------------------------------------------------------------------------
resource "google_sql_ssl_cert" "client_cert" {
provider = "google-beta"
common_name = "${var.common_name}"
instance = "${var.database_instance_name}"
}

View File

@@ -0,0 +1,10 @@
output "client_ca_cert" {
description = "Certificate data for the client certificate."
value = "${google_sql_ssl_cert.client_cert.cert}"
}
# In real-world cases, the output for the private key should always be encrypted
output "client_private_key" {
description = "Private key associated with the client certificate."
value = "${google_sql_ssl_cert.client_cert.private_key}"
}

View File

@@ -0,0 +1,21 @@
# ---------------------------------------------------------------------------------------------------------------------
# REQUIRED PARAMETERS
# These variables are expected to be passed in by the operator
# ---------------------------------------------------------------------------------------------------------------------
variable "project" {
description = "The project ID to host the database in."
}
variable "region" {
description = "The region to host the database in."
}
# Note, after a name db instance is used, it cannot be reused for up to one week.
variable "common_name" {
description = "The common name to be used in the certificate to identify the client. Constrained to [a-zA-Z.-_ ]+. Changing this forces a new resource to be created."
}
variable "database_instance_name" {
description = "The name of the Cloud SQL instance. Changing this forces a new resource to be created."
}

View File

@@ -1,21 +1,26 @@
output "instance_name" {
output "master_instance_name" {
description = "The name of the database instance"
value = "${module.mysql.instance_name}"
value = "${module.mysql.master_instance_name}"
}
output "ip_addresses" {
output "master_ip_addresses" {
description = "All IP addresses of the instance as list of maps, see https://www.terraform.io/docs/providers/google/r/sql_database_instance.html#ip_address-0-ip_address"
value = "${module.mysql.ip_addresses}"
value = "${module.mysql.master_ip_addresses}"
}
output "private_ip" {
output "master_private_ip" {
description = "The first IPv4 address of the addresses assigned to the instance. As this instance has only private IP, it is the private IP address."
value = "${module.mysql.first_ip_address}"
value = "${module.mysql.master_first_ip_address}"
}
output "instance" {
output "master_instance" {
description = "Self link to the master instance"
value = "${module.mysql.instance}"
value = "${module.mysql.master_instance}"
}
output "master_proxy_connection" {
description = "Instance path for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy"
value = "${module.mysql.master_proxy_connection}"
}
output "db_name" {
@@ -23,11 +28,6 @@ output "db_name" {
value = "${module.mysql.db_name}"
}
output "proxy_connection" {
description = "Instance path for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy"
value = "${module.mysql.proxy_connection}"
}
output "db" {
description = "Self link to the default database"
value = "${module.mysql.db}"

View File

@@ -62,6 +62,10 @@ module "mysql" {
# addresses, and only allow access from specific trusted networks, servers or applications in your VPC.
enable_public_internet_access = true
# Default setting for this is 'false' in 'variables.tf'
# In the test cases, we're setting this to true, to test forced SSL.
require_ssl = "${var.require_ssl}"
authorized_networks = [
{
name = "allow-all-inbound"

View File

@@ -1,21 +1,31 @@
output "instance_name" {
output "master_instance_name" {
description = "The name of the database instance"
value = "${module.mysql.instance_name}"
value = "${module.mysql.master_instance_name}"
}
output "ip_addresses" {
output "master_ip_addresses" {
description = "All IP addresses of the instance as list of maps, see https://www.terraform.io/docs/providers/google/r/sql_database_instance.html#ip_address-0-ip_address"
value = "${module.mysql.ip_addresses}"
value = "${module.mysql.master_ip_addresses}"
}
output "public_ip" {
output "master_public_ip" {
description = "The first IPv4 address of the addresses assigned to the instance. As this instance has only public IP, it is the public IP address."
value = "${module.mysql.first_ip_address}"
value = "${module.mysql.master_first_ip_address}"
}
output "instance" {
output "master_ca_cert" {
value = "${module.mysql.master_ca_cert}"
description = "The CA Certificate used to connect to the SQL Instance via SSL"
}
output "master_instance" {
description = "Self link to the master instance"
value = "${module.mysql.instance}"
value = "${module.mysql.master_instance}"
}
output "master_proxy_connection" {
description = "Instance path for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy"
value = "${module.mysql.master_proxy_connection}"
}
output "db_name" {
@@ -23,11 +33,6 @@ output "db_name" {
value = "${module.mysql.db_name}"
}
output "proxy_connection" {
description = "Instance path for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy"
value = "${module.mysql.proxy_connection}"
}
output "db" {
description = "Self link to the default database"
value = "${module.mysql.db}"

View File

@@ -47,3 +47,10 @@ variable "name_override" {
description = "You may optionally override the name_prefix + random string by specifying an override"
default = ""
}
# When configuring a public IP instance, you should only allow secure connections
# For testing purposes, we're initially allowing unsecured connections.
variable "require_ssl" {
description = "True if the instance should require SSL/TLS for users connecting over IP. Note: SSL/TLS is needed to provide security when you connect to Cloud SQL using IP addresses. If you are connecting to your instance only by using the Cloud SQL Proxy or the Java Socket Library, you do not need to configure your instance to use SSL/TLS."
default = false
}

View File

@@ -22,11 +22,13 @@ locals {
authorized_networks = ["${var.authorized_networks}"]
ipv4_enabled = "${var.enable_public_internet_access}"
private_network = "${var.private_network}"
require_ssl = "${var.require_ssl}"
}]
"PUBLIC" = [{
authorized_networks = ["${var.authorized_networks}"]
ipv4_enabled = "${var.enable_public_internet_access}"
require_ssl = "${var.require_ssl}"
}]
}
@@ -123,3 +125,10 @@ resource "null_resource" "wait_for" {
instance = "${var.wait_for}"
}
}
# ------------------------------------------------------------------------------
# CREATE A NULL RESOURCE TO SIGNAL ALL RESOURCES HAVE BEEN CREATED
# ------------------------------------------------------------------------------
resource "null_resource" "complete" {
depends_on = ["google_sql_user.default"]
}

View File

@@ -1,34 +1,64 @@
output "instance_name" {
output "master_instance_name" {
description = "The name of the database instance"
value = "${google_sql_database_instance.master.name}"
}
output "ip_addresses" {
output "master_ip_addresses" {
description = "All IP addresses of the instance as list of maps, see https://www.terraform.io/docs/providers/google/r/sql_database_instance.html#ip_address-0-ip_address"
value = "${ google_sql_database_instance.master.ip_address }"
}
output "first_ip_address" {
output "master_first_ip_address" {
description = "The first IPv4 address of the addresses assigned to the instance. If the instance has only public IP, it is the public IP address. If it has only private IP, it the private IP address. If it has both, it is the first item in the list and full IP address details are in 'instance_ip_addresses'"
value = "${ google_sql_database_instance.master.first_ip_address }"
}
output "instance" {
output "master_instance" {
description = "Self link to the master instance"
value = "${google_sql_database_instance.master.self_link}"
}
output "master_proxy_connection" {
description = "Instance path for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy"
value = "${var.project}:${var.region}:${google_sql_database_instance.master.name}"
}
output "master_ca_cert" {
value = "${google_sql_database_instance.master.server_ca_cert.0.cert}"
description = "The CA Certificate used to connect to the SQL Instance via SSL"
}
output "master_ca_cert_common_name" {
value = "${google_sql_database_instance.master.server_ca_cert.0.common_name}"
description = "The CN valid for the CA Cert"
}
output "master_ca_cert_create_time" {
value = "${google_sql_database_instance.master.server_ca_cert.0.create_time}"
description = "Creation time of the CA Cert"
}
output "master_ca_cert_expiration_time" {
value = "${google_sql_database_instance.master.server_ca_cert.0.expiration_time}"
description = "Expiration time of the CA Cert"
}
output "master_ca_cert_sha1_fingerprint" {
value = "${google_sql_database_instance.master.server_ca_cert.0.sha1_fingerprint}"
description = "SHA Fingerprint of the CA Cert"
}
output "db" {
description = "Self link to the default database"
value = "${google_sql_database.default.self_link}"
}
output "db_name" {
description = "Name of the default database"
value = "${google_sql_database.default.name}"
}
output "proxy_connection" {
description = "Instance path for connecting with Cloud SQL Proxy. Read more at https://cloud.google.com/sql/docs/mysql/sql-proxy"
value = "${var.project}:${var.region}:${google_sql_database_instance.master.name}"
}
output "db" {
description = "Self link to the default database"
value = "${google_sql_database.default.self_link}"
output "complete" {
description = "Name of the default database"
value = "${null_resource.complete.id}"
}

View File

@@ -172,6 +172,11 @@ variable "enable_public_internet_access" {
default = false
}
variable "require_ssl" {
description = "True if the instance should require SSL/TLS for users connecting over IP. Note: SSL/TLS is needed to provide security when you connect to Cloud SQL using IP addresses. If you are connecting to your instance only by using the Cloud SQL Proxy or the Java Socket Library, you do not need to configure your instance to use SSL/TLS."
default = false
}
variable "private_network" {
description = "The resource link for the VPC network from which the Cloud SQL instance is accessible for private IP."
default = ""

View File

@@ -54,15 +54,15 @@ func TestMySqlPrivateIP(t *testing.T) {
region := test_structure.LoadString(t, exampleDir, KEY_REGION)
projectId := test_structure.LoadString(t, exampleDir, KEY_PROJECT)
instanceNameFromOutput := terraform.Output(t, terraformOptions, OUTPUT_INSTANCE_NAME)
ipAddressesFromOutput := terraform.Output(t, terraformOptions, OUTPUT_IP_ADDRESSES)
privateIPFromOutput := terraform.Output(t, terraformOptions, OUTPUT_PRIVATE_IP)
instanceNameFromOutput := terraform.Output(t, terraformOptions, OUTPUT_MASTER_INSTANCE_NAME)
ipAddressesFromOutput := terraform.Output(t, terraformOptions, OUTPUT_MASTER_IP_ADDRESSES)
privateIPFromOutput := terraform.Output(t, terraformOptions, OUTPUT_MASTER_PRIVATE_IP)
assert.Contains(t, ipAddressesFromOutput, "PRIVATE", "IP Addresses output has to contain 'PRIVATE'")
assert.Contains(t, ipAddressesFromOutput, privateIPFromOutput, "IP Addresses output has to contain 'private_ip' from output")
dbNameFromOutput := terraform.Output(t, terraformOptions, OUTPUT_DB_NAME)
proxyConnectionFromOutput := terraform.Output(t, terraformOptions, OUTPUT_PROXY_CONNECTION)
proxyConnectionFromOutput := terraform.Output(t, terraformOptions, OUTPUT_MASTER_PROXY_CONNECTION)
expectedDBConn := fmt.Sprintf("%s:%s:%s", projectId, region, instanceNameFromOutput)

View File

@@ -1,10 +1,12 @@
package test
import (
"crypto/tls"
"crypto/x509"
"database/sql"
"fmt"
"github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/dialers/mysql"
_ "github.com/go-sql-driver/mysql"
mydialer "github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/dialers/mysql"
"github.com/go-sql-driver/mysql"
"github.com/gruntwork-io/terratest/modules/gcp"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/terraform"
@@ -19,6 +21,7 @@ import (
const NAME_PREFIX_PUBLIC = "mysql-public"
const EXAMPLE_NAME_PUBLIC = "mysql-public-ip"
const EXAMPLE_NAME_CERT = "client-certificate"
func TestMySqlPublicIP(t *testing.T) {
t.Parallel()
@@ -28,10 +31,15 @@ func TestMySqlPublicIP(t *testing.T) {
//os.Setenv("SKIP_validate_outputs", "true")
//os.Setenv("SKIP_sql_tests", "true")
//os.Setenv("SKIP_proxy_tests", "true")
//os.Setenv("SKIP_deploy_cert", "true")
//os.Setenv("SKIP_redeploy", "true")
//os.Setenv("SKIP_ssl_sql_tests", "true")
//os.Setenv("SKIP_teardown_cert", "true")
//os.Setenv("SKIP_teardown", "true")
_examplesDir := test_structure.CopyTerraformFolderToTemp(t, "../", "examples")
exampleDir := filepath.Join(_examplesDir, EXAMPLE_NAME_PUBLIC)
certExampleDir := filepath.Join(_examplesDir, EXAMPLE_NAME_CERT)
// BOOTSTRAP VARIABLES FOR THE TESTS
test_structure.RunTestStage(t, "bootstrap", func() {
@@ -49,6 +57,11 @@ func TestMySqlPublicIP(t *testing.T) {
terraform.Destroy(t, terraformOptions)
})
defer test_structure.RunTestStage(t, "teardown_cert", func() {
terraformOptions := test_structure.LoadTerraformOptions(t, certExampleDir)
terraform.Destroy(t, terraformOptions)
})
test_structure.RunTestStage(t, "deploy", func() {
region := test_structure.LoadString(t, exampleDir, KEY_REGION)
projectId := test_structure.LoadString(t, exampleDir, KEY_PROJECT)
@@ -65,9 +78,9 @@ func TestMySqlPublicIP(t *testing.T) {
region := test_structure.LoadString(t, exampleDir, KEY_REGION)
projectId := test_structure.LoadString(t, exampleDir, KEY_PROJECT)
instanceNameFromOutput := terraform.Output(t, terraformOptions, OUTPUT_INSTANCE_NAME)
instanceNameFromOutput := terraform.Output(t, terraformOptions, OUTPUT_MASTER_INSTANCE_NAME)
dbNameFromOutput := terraform.Output(t, terraformOptions, OUTPUT_DB_NAME)
proxyConnectionFromOutput := terraform.Output(t, terraformOptions, OUTPUT_PROXY_CONNECTION)
proxyConnectionFromOutput := terraform.Output(t, terraformOptions, OUTPUT_MASTER_PROXY_CONNECTION)
expectedDBConn := fmt.Sprintf("%s:%s:%s", projectId, region, instanceNameFromOutput)
@@ -80,7 +93,7 @@ func TestMySqlPublicIP(t *testing.T) {
test_structure.RunTestStage(t, "sql_tests", func() {
terraformOptions := test_structure.LoadTerraformOptions(t, exampleDir)
publicIp := terraform.Output(t, terraformOptions, OUTPUT_PUBLIC_IP)
publicIp := terraform.Output(t, terraformOptions, OUTPUT_MASTER_PUBLIC_IP)
connectionString := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s", DB_USER, DB_PASS, publicIp, DB_NAME)
@@ -132,13 +145,13 @@ func TestMySqlPublicIP(t *testing.T) {
test_structure.RunTestStage(t, "proxy_tests", func() {
terraformOptions := test_structure.LoadTerraformOptions(t, exampleDir)
proxyConn := terraform.Output(t, terraformOptions, OUTPUT_PROXY_CONNECTION)
proxyConn := terraform.Output(t, terraformOptions, OUTPUT_MASTER_PROXY_CONNECTION)
logger.Logf(t, "Connecting to: %s via Cloud SQL Proxy", proxyConn)
// Use the Cloud SQL Proxy for queries
// See https://cloud.google.com/sql/docs/mysql/sql-proxy
cfg := mysql.Cfg(proxyConn, DB_USER, DB_PASS)
cfg := mydialer.Cfg(proxyConn, DB_USER, DB_PASS)
cfg.DBName = DB_NAME
cfg.ParseTime = true
@@ -148,7 +161,7 @@ func TestMySqlPublicIP(t *testing.T) {
cfg.WriteTimeout = timeout
// Dial in. This one actually pings the database already
db, err := mysql.DialCfg(cfg)
db, err := mydialer.DialCfg(cfg)
require.NoError(t, err, "Failed to open Proxy DB connection")
// Make sure we clean up properly
@@ -176,4 +189,98 @@ func TestMySqlPublicIP(t *testing.T) {
// Since we set the auto increment to 5, modulus should always be 0
assert.Equal(t, int64(0), int64(lastId%5))
})
// CREATE CLIENT CERT
test_structure.RunTestStage(t, "deploy_cert", func() {
region := test_structure.LoadString(t, exampleDir, KEY_REGION)
projectId := test_structure.LoadString(t, exampleDir, KEY_PROJECT)
terraformOptions := test_structure.LoadTerraformOptions(t, exampleDir)
instanceNameFromOutput := terraform.Output(t, terraformOptions, OUTPUT_MASTER_INSTANCE_NAME)
commonName := fmt.Sprintf("%s-client", instanceNameFromOutput)
terraformOptionsForCert := createTerratestOptionsForClientCert(projectId, region, certExampleDir, commonName, instanceNameFromOutput)
test_structure.SaveTerraformOptions(t, certExampleDir, terraformOptionsForCert)
terraform.InitAndApply(t, terraformOptionsForCert)
})
// REDEPLOY WITH FORCED SSL SETTINGS
test_structure.RunTestStage(t, "redeploy", func() {
terraformOptions := test_structure.LoadTerraformOptions(t, exampleDir)
// Force secure connections
terraformOptions.Vars["require_ssl"] = true
terraform.InitAndApply(t, terraformOptions)
})
// RUN TESTS WITH SECURED CONNECTION
test_structure.RunTestStage(t, "ssl_sql_tests", func() {
terraformOptions := test_structure.LoadTerraformOptions(t, exampleDir)
terraformOptionsForCert := test_structure.LoadTerraformOptions(t, certExampleDir)
//********************************************************
// First test that we're not allowed to connect over insecure connection
//********************************************************
publicIp := terraform.Output(t, terraformOptions, OUTPUT_MASTER_PUBLIC_IP)
connectionString := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s", DB_USER, DB_PASS, publicIp, DB_NAME)
// Does not actually open up the connection - just returns a DB ref
logger.Logf(t, "Connecting to: %s", publicIp)
db, err := sql.Open("mysql",
connectionString)
require.NoError(t, err, "Failed to open DB connection")
// Make sure we clean up properly
defer db.Close()
// Run ping to actually test the connection
logger.Log(t, "Ping the DB with forced SSL")
if err = db.Ping(); err != nil {
logger.Logf(t, "Not allowed to ping %s as expected.", publicIp)
} else {
t.Fatalf("Ping %v succeeded against the odds.", publicIp)
}
//********************************************************
// Test connection over secure connection
//********************************************************
// Prepare certificates
rootCertPool := x509.NewCertPool()
serverCertB := []byte(terraform.Output(t, terraformOptions, OUTPUT_MASTER_CA_CERT))
clientCertB := []byte(terraform.Output(t, terraformOptionsForCert, OUTPUT_CLIENT_CA_CERT))
clientPKB := []byte(terraform.Output(t, terraformOptionsForCert, OUTPUT_CLIENT_PRIVATE_KEY))
if ok := rootCertPool.AppendCertsFromPEM(serverCertB); !ok {
t.Fatal("Failed to append PEM.")
}
clientCert := make([]tls.Certificate, 0, 1)
certs, err := tls.X509KeyPair(clientCertB, clientPKB)
require.NoError(t, err, "Failed to create key pair")
clientCert = append(clientCert, certs)
// Register MySQL certificate config
// To avoid certificate validation errors complaining about
// missing IP SANs, we set 'InsecureSkipVerify: true'
mysql.RegisterTLSConfig("custom", &tls.Config{
RootCAs: rootCertPool,
Certificates: clientCert,
InsecureSkipVerify: true,
})
// Prepare the secure connection string and ping the DB
sslConnectionString := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?tls=custom", DB_USER, DB_PASS, publicIp, DB_NAME)
db, err = sql.Open("mysql", sslConnectionString)
// Run ping to actually test the connection with the SSL config
logger.Log(t, "Ping the DB with forced SSL")
if err = db.Ping(); err != nil {
t.Fatalf("Failed to ping DB with forced SSL: %v", err)
}
})
}

View File

@@ -15,12 +15,16 @@ const KEY_PROJECT = "project"
const MYSQL_VERSION = "MYSQL_5_7"
const OUTPUT_IP_ADDRESSES = "ip_addresses"
const OUTPUT_INSTANCE_NAME = "instance_name"
const OUTPUT_PROXY_CONNECTION = "proxy_connection"
const OUTPUT_MASTER_IP_ADDRESSES = "master_ip_addresses"
const OUTPUT_MASTER_INSTANCE_NAME = "master_instance_name"
const OUTPUT_MASTER_PROXY_CONNECTION = "master_proxy_connection"
const OUTPUT_MASTER_PUBLIC_IP = "master_public_ip"
const OUTPUT_MASTER_PRIVATE_IP = "master_private_ip"
const OUTPUT_MASTER_CA_CERT = "master_ca_cert"
const OUTPUT_CLIENT_CA_CERT = "client_ca_cert"
const OUTPUT_CLIENT_PRIVATE_KEY = "client_private_key"
const OUTPUT_DB_NAME = "db_name"
const OUTPUT_PUBLIC_IP = "public_ip"
const OUTPUT_PRIVATE_IP = "private_ip"
const MYSQL_CREATE_TEST_TABLE_WITH_AUTO_INCREMENT_STATEMENT = "CREATE TABLE IF NOT EXISTS test (id int NOT NULL AUTO_INCREMENT, name varchar(10) NOT NULL, PRIMARY KEY (ID))"
const MYSQL_EMPTY_TEST_TABLE_STATEMENT = "DELETE FROM test"
@@ -50,3 +54,19 @@ func createTerratestOptionsForMySql(projectId string, region string, exampleDir
return terratestOptions
}
func createTerratestOptionsForClientCert(projectId string, region string, exampleDir string, commonName string, instanceName string) *terraform.Options {
terratestOptions := &terraform.Options{
// The path to where your Terraform code is located
TerraformDir: exampleDir,
Vars: map[string]interface{}{
"region": region,
"project": projectId,
"common_name": commonName,
"database_instance_name": instanceName,
},
}
return terratestOptions
}