tls provider
As of this commit this provider has only logical resources that allow the creation of private keys, self-signed certs and certificate requests. These can be useful when creating other resources that use TLS certificates, such as AWS Elastic Load Balancers. Later it could grow to include support for real certificate provision from CAs using the LetsEncrypt ACME protocol, once it is stable.
This commit is contained in:
parent
864b2dee01
commit
f6fd41e7b5
|
@ -0,0 +1,12 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/hashicorp/terraform/builtin/providers/tls"
|
||||||
|
"github.com/hashicorp/terraform/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
plugin.Serve(&plugin.ServeOpts{
|
||||||
|
ProviderFunc: tls.Provider,
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/helper/schema"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Provider() terraform.ResourceProvider {
|
||||||
|
return &schema.Provider{
|
||||||
|
ResourcesMap: map[string]*schema.Resource{
|
||||||
|
"tls_private_key": resourcePrivateKey(),
|
||||||
|
"tls_self_signed_cert": resourceSelfSignedCert(),
|
||||||
|
"tls_cert_request": resourceCertRequest(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashForState(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
hash := sha1.Sum([]byte(strings.TrimSpace(value)))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func nameFromResourceData(nameMap map[string]interface{}) (*pkix.Name, error) {
|
||||||
|
result := &pkix.Name{}
|
||||||
|
|
||||||
|
if value := nameMap["common_name"]; value != nil {
|
||||||
|
result.CommonName = value.(string)
|
||||||
|
}
|
||||||
|
if value := nameMap["organization"]; value != nil {
|
||||||
|
result.Organization = []string{value.(string)}
|
||||||
|
}
|
||||||
|
if value := nameMap["organizational_unit"]; value != nil {
|
||||||
|
result.OrganizationalUnit = []string{value.(string)}
|
||||||
|
}
|
||||||
|
if value := nameMap["street_address"]; value != nil {
|
||||||
|
valueI := value.([]interface{})
|
||||||
|
result.StreetAddress = make([]string, len(valueI))
|
||||||
|
for i, vi := range valueI {
|
||||||
|
result.StreetAddress[i] = vi.(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := nameMap["locality"]; value != nil {
|
||||||
|
result.Locality = []string{value.(string)}
|
||||||
|
}
|
||||||
|
if value := nameMap["province"]; value != nil {
|
||||||
|
result.Province = []string{value.(string)}
|
||||||
|
}
|
||||||
|
if value := nameMap["country"]; value != nil {
|
||||||
|
result.Country = []string{value.(string)}
|
||||||
|
}
|
||||||
|
if value := nameMap["postal_code"]; value != nil {
|
||||||
|
result.PostalCode = []string{value.(string)}
|
||||||
|
}
|
||||||
|
if value := nameMap["serial_number"]; value != nil {
|
||||||
|
result.SerialNumber = value.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameSchema *schema.Resource = &schema.Resource{
|
||||||
|
Schema: map[string]*schema.Schema{
|
||||||
|
"organization": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
"common_name": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
"organizational_unit": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
"street_address": &schema.Schema{
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Optional: true,
|
||||||
|
Elem: &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"locality": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
"province": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
"country": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
"postal_code": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
"serial_number": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/helper/schema"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProvider(t *testing.T) {
|
||||||
|
if err := Provider().(*schema.Provider).InternalValidate(); err != nil {
|
||||||
|
t.Fatalf("err: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var testProviders = map[string]terraform.ResourceProvider{
|
||||||
|
"tls": Provider(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var testPrivateKey = `
|
||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIICXAIBAAKBgQDPLaq43D9C596ko9yQipWUf2FbRhFs18D3wBDBqXLIoP7W3rm5
|
||||||
|
S292/JiNPa+mX76IYFF416zTBGG9J5w4d4VFrROn8IuMWqHgdXsCUf2szN7EnJcV
|
||||||
|
BsBzTxxWqz4DjX315vbm/PFOLlKzC0Ngs4h1iDiCD9Hk2MajZuFnJiqj1QIDAQAB
|
||||||
|
AoGAG6eQ3lQn7Zpd0cQ9sN2O0d+e8zwLH2g9TdTJZ9Bijf1Phwb764vyOQPGqTPO
|
||||||
|
unqVSEbzGRpQ62nuUf1zkOYDV+gKMNO3mj9Zu+qPNr/nQPHIaGZksPdD34qDUnBl
|
||||||
|
eRWVGNTyEGQsRPNN0RtFj8ifa4+OWiE30n95PBq2bUGZj4ECQQDZvS5X/4jYxnzw
|
||||||
|
CscaL4vO9OCVd/Fzdpfak0DQE/KCVmZxzcXu6Q8WuhybCynX84WKHQxuFAo+nBvr
|
||||||
|
kgtWXX7dAkEA85Vs5ehuDujBKCu3NJYI2R5ie49L9fEMFJVZK9FpkKacoAkET5BZ
|
||||||
|
UzaZrx4Fg3Zhcv1TssZKSyle+2lYiIydWQJBAMW8/aJi6WdcUsg4MXrBZSlsz6xO
|
||||||
|
AhOGxv90LS8KfnkJd/2wDyoZs19DY4kWSUjZ2hOEr+4j+u3DHcQAnJUxUW0CQGXP
|
||||||
|
DrUJcPbKUfF4VBqmmwwkpwT938Hr/iCcS6kE3hqXiN9a5XJb4vnk2FdZNPS9hf2J
|
||||||
|
5HHUbzj7EbgDT/3CyAECQG0qv6LNQaQMm2lmQKmqpi43Bqj9wvx0xGai1qCOvSeL
|
||||||
|
rpxCHbX0xSJh0s8j7exRHMF8W16DHjjkc265YdWPXWo=
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
`
|
|
@ -0,0 +1,135 @@
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/helper/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resourceCertRequest() *schema.Resource {
|
||||||
|
return &schema.Resource{
|
||||||
|
Create: CreateCertRequest,
|
||||||
|
Delete: DeleteCertRequest,
|
||||||
|
Read: ReadCertRequest,
|
||||||
|
|
||||||
|
Schema: map[string]*schema.Schema{
|
||||||
|
|
||||||
|
"dns_names": &schema.Schema{
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Optional: true,
|
||||||
|
Description: "List of DNS names to use as subjects of the certificate",
|
||||||
|
ForceNew: true,
|
||||||
|
Elem: &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"ip_addresses": &schema.Schema{
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Optional: true,
|
||||||
|
Description: "List of IP addresses to use as subjects of the certificate",
|
||||||
|
ForceNew: true,
|
||||||
|
Elem: &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"key_algorithm": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Required: true,
|
||||||
|
Description: "Name of the algorithm to use to generate the certificate's private key",
|
||||||
|
ForceNew: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"private_key_pem": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Required: true,
|
||||||
|
Description: "PEM-encoded private key that the certificate will belong to",
|
||||||
|
ForceNew: true,
|
||||||
|
StateFunc: func(v interface{}) string {
|
||||||
|
return hashForState(v.(string))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"subject": &schema.Schema{
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Required: true,
|
||||||
|
Elem: nameSchema,
|
||||||
|
ForceNew: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"cert_request_pem": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Computed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateCertRequest(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
keyAlgoName := d.Get("key_algorithm").(string)
|
||||||
|
var keyFunc keyParser
|
||||||
|
var ok bool
|
||||||
|
if keyFunc, ok = keyParsers[keyAlgoName]; !ok {
|
||||||
|
return fmt.Errorf("invalid key_algorithm %#v", keyAlgoName)
|
||||||
|
}
|
||||||
|
keyBlock, _ := pem.Decode([]byte(d.Get("private_key_pem").(string)))
|
||||||
|
if keyBlock == nil {
|
||||||
|
return fmt.Errorf("no PEM block found in private_key_pem")
|
||||||
|
}
|
||||||
|
key, err := keyFunc(keyBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode private_key_pem: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectConfs := d.Get("subject").([]interface{})
|
||||||
|
if len(subjectConfs) != 1 {
|
||||||
|
return fmt.Errorf("must have exactly one 'subject' block")
|
||||||
|
}
|
||||||
|
subjectConf := subjectConfs[0].(map[string]interface{})
|
||||||
|
subject, err := nameFromResourceData(subjectConf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid subject block: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certReq := x509.CertificateRequest{
|
||||||
|
Subject: *subject,
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsNamesI := d.Get("dns_names").([]interface{})
|
||||||
|
for _, nameI := range dnsNamesI {
|
||||||
|
certReq.DNSNames = append(certReq.DNSNames, nameI.(string))
|
||||||
|
}
|
||||||
|
ipAddressesI := d.Get("ip_addresses").([]interface{})
|
||||||
|
for _, ipStrI := range ipAddressesI {
|
||||||
|
ip := net.ParseIP(ipStrI.(string))
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("invalid IP address %#v", ipStrI.(string))
|
||||||
|
}
|
||||||
|
certReq.IPAddresses = append(certReq.IPAddresses, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
certReqBytes, err := x509.CreateCertificateRequest(rand.Reader, &certReq, key)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Errorf("Error creating certificate request: %s", err)
|
||||||
|
}
|
||||||
|
certReqPem := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: certReqBytes}))
|
||||||
|
|
||||||
|
d.SetId(hashForState(string(certReqBytes)))
|
||||||
|
d.Set("cert_request_pem", certReqPem)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCertRequest(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
d.SetId("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadCertRequest(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
r "github.com/hashicorp/terraform/helper/resource"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCertRequest(t *testing.T) {
|
||||||
|
r.Test(t, r.TestCase{
|
||||||
|
Providers: testProviders,
|
||||||
|
Steps: []r.TestStep{
|
||||||
|
r.TestStep{
|
||||||
|
Config: fmt.Sprintf(`
|
||||||
|
resource "tls_cert_request" "test" {
|
||||||
|
subject {
|
||||||
|
common_name = "example.com"
|
||||||
|
organization = "Example, Inc"
|
||||||
|
organizational_unit = "Department of Terraform Testing"
|
||||||
|
street_address = ["5879 Cotton Link"]
|
||||||
|
locality = "Pirate Harbor"
|
||||||
|
province = "CA"
|
||||||
|
country = "US"
|
||||||
|
postal_code = "95559-1227"
|
||||||
|
serial_number = "2"
|
||||||
|
}
|
||||||
|
|
||||||
|
dns_names = [
|
||||||
|
"example.com",
|
||||||
|
"example.net",
|
||||||
|
]
|
||||||
|
|
||||||
|
ip_addresses = [
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.0.0.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
key_algorithm = "RSA"
|
||||||
|
private_key_pem = <<EOT
|
||||||
|
%s
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
output "key_pem" {
|
||||||
|
value = "${tls_cert_request.test.cert_request_pem}"
|
||||||
|
}
|
||||||
|
`, testPrivateKey),
|
||||||
|
Check: func(s *terraform.State) error {
|
||||||
|
got := s.RootModule().Outputs["key_pem"]
|
||||||
|
if !strings.HasPrefix(got, "-----BEGIN CERTIFICATE REQUEST----") {
|
||||||
|
return fmt.Errorf("key is missing CSR PEM preamble")
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode([]byte(got))
|
||||||
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing CSR: %s", err)
|
||||||
|
}
|
||||||
|
if expected, got := "2", csr.Subject.SerialNumber; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject serial number: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "example.com", csr.Subject.CommonName; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject common name: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "Example, Inc", csr.Subject.Organization[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject organization: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "Department of Terraform Testing", csr.Subject.OrganizationalUnit[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject organizational unit: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "5879 Cotton Link", csr.Subject.StreetAddress[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject street address: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "Pirate Harbor", csr.Subject.Locality[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject locality: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "CA", csr.Subject.Province[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject province: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "US", csr.Subject.Country[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject country: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "95559-1227", csr.Subject.PostalCode[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject postal code: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected, got := 2, len(csr.DNSNames); got != expected {
|
||||||
|
return fmt.Errorf("incorrect number of DNS names: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "example.com", csr.DNSNames[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect DNS name 0: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "example.net", csr.DNSNames[1]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect DNS name 0: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected, got := 2, len(csr.IPAddresses); got != expected {
|
||||||
|
return fmt.Errorf("incorrect number of IP addresses: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "127.0.0.1", csr.IPAddresses[0].String(); got != expected {
|
||||||
|
return fmt.Errorf("incorrect IP address 0: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "127.0.0.2", csr.IPAddresses[1].String(); got != expected {
|
||||||
|
return fmt.Errorf("incorrect IP address 0: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,144 @@
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/helper/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyAlgo func(d *schema.ResourceData) (interface{}, error)
|
||||||
|
type keyParser func([]byte) (interface{}, error)
|
||||||
|
|
||||||
|
var keyAlgos map[string]keyAlgo = map[string]keyAlgo{
|
||||||
|
"RSA": func(d *schema.ResourceData) (interface{}, error) {
|
||||||
|
rsaBits := d.Get("rsa_bits").(int)
|
||||||
|
return rsa.GenerateKey(rand.Reader, rsaBits)
|
||||||
|
},
|
||||||
|
"ECDSA": func(d *schema.ResourceData) (interface{}, error) {
|
||||||
|
curve := d.Get("ecdsa_curve").(string)
|
||||||
|
switch curve {
|
||||||
|
case "P224":
|
||||||
|
return ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
|
||||||
|
case "P256":
|
||||||
|
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
case "P384":
|
||||||
|
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||||
|
case "P521":
|
||||||
|
return ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid ecdsa_curve; must be P224, P256, P384 or P521")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyParsers map[string]keyParser = map[string]keyParser{
|
||||||
|
"RSA": func(der []byte) (interface{}, error) {
|
||||||
|
return x509.ParsePKCS1PrivateKey(der)
|
||||||
|
},
|
||||||
|
"ECDSA": func(der []byte) (interface{}, error) {
|
||||||
|
return x509.ParseECPrivateKey(der)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourcePrivateKey() *schema.Resource {
|
||||||
|
return &schema.Resource{
|
||||||
|
Create: CreatePrivateKey,
|
||||||
|
Delete: DeletePrivateKey,
|
||||||
|
Read: ReadPrivateKey,
|
||||||
|
|
||||||
|
Schema: map[string]*schema.Schema{
|
||||||
|
"algorithm": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Required: true,
|
||||||
|
Description: "Name of the algorithm to use to generate the private key",
|
||||||
|
ForceNew: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"rsa_bits": &schema.Schema{
|
||||||
|
Type: schema.TypeInt,
|
||||||
|
Optional: true,
|
||||||
|
Description: "Number of bits to use when generating an RSA key",
|
||||||
|
ForceNew: true,
|
||||||
|
Default: 2048,
|
||||||
|
},
|
||||||
|
|
||||||
|
"ecdsa_curve": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
Description: "ECDSA curve to use when generating a key",
|
||||||
|
ForceNew: true,
|
||||||
|
Default: "P224",
|
||||||
|
},
|
||||||
|
|
||||||
|
"private_key_pem": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Computed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePrivateKey(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
keyAlgoName := d.Get("algorithm").(string)
|
||||||
|
var keyFunc keyAlgo
|
||||||
|
var ok bool
|
||||||
|
if keyFunc, ok = keyAlgos[keyAlgoName]; !ok {
|
||||||
|
return fmt.Errorf("invalid key_algorithm %#v", keyAlgoName)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := keyFunc(d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyPemBlock *pem.Block
|
||||||
|
switch k := key.(type) {
|
||||||
|
case *rsa.PrivateKey:
|
||||||
|
keyPemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
|
||||||
|
case *ecdsa.PrivateKey:
|
||||||
|
b, err := x509.MarshalECPrivateKey(k)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error encoding key to PEM: %s", err)
|
||||||
|
}
|
||||||
|
keyPemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported private key type")
|
||||||
|
}
|
||||||
|
keyPem := string(pem.EncodeToMemory(keyPemBlock))
|
||||||
|
|
||||||
|
pubKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey(key))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal public key: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.SetId(hashForState(string((pubKeyBytes))))
|
||||||
|
d.Set("private_key_pem", keyPem)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePrivateKey(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
d.SetId("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadPrivateKey(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicKey(priv interface{}) interface{} {
|
||||||
|
switch k := priv.(type) {
|
||||||
|
case *rsa.PrivateKey:
|
||||||
|
return &k.PublicKey
|
||||||
|
case *ecdsa.PrivateKey:
|
||||||
|
return &k.PublicKey
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
r "github.com/hashicorp/terraform/helper/resource"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrivateKeyRSA(t *testing.T) {
|
||||||
|
r.Test(t, r.TestCase{
|
||||||
|
Providers: testProviders,
|
||||||
|
Steps: []r.TestStep{
|
||||||
|
r.TestStep{
|
||||||
|
Config: `
|
||||||
|
resource "tls_private_key" "test" {
|
||||||
|
algorithm = "RSA"
|
||||||
|
}
|
||||||
|
output "key_pem" {
|
||||||
|
value = "${tls_private_key.test.private_key_pem}"
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
Check: func(s *terraform.State) error {
|
||||||
|
got := s.RootModule().Outputs["key_pem"]
|
||||||
|
if !strings.HasPrefix(got, "-----BEGIN RSA PRIVATE KEY----") {
|
||||||
|
return fmt.Errorf("key is missing RSA key PEM preamble")
|
||||||
|
}
|
||||||
|
if len(got) > 1700 {
|
||||||
|
return fmt.Errorf("key PEM looks too long for a 2048-bit key (got %v characters)", len(got))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
r.TestStep{
|
||||||
|
Config: `
|
||||||
|
resource "tls_private_key" "test" {
|
||||||
|
algorithm = "RSA"
|
||||||
|
rsa_bits = 4096
|
||||||
|
}
|
||||||
|
output "key_pem" {
|
||||||
|
value = "${tls_private_key.test.private_key_pem}"
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
Check: func(s *terraform.State) error {
|
||||||
|
got := s.RootModule().Outputs["key_pem"]
|
||||||
|
if !strings.HasPrefix(got, "-----BEGIN RSA PRIVATE KEY----") {
|
||||||
|
return fmt.Errorf("key is missing RSA key PEM preamble")
|
||||||
|
}
|
||||||
|
if len(got) < 1700 {
|
||||||
|
return fmt.Errorf("key PEM looks too short for a 4096-bit key (got %v characters)", len(got))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateKeyECDSA(t *testing.T) {
|
||||||
|
r.Test(t, r.TestCase{
|
||||||
|
Providers: testProviders,
|
||||||
|
Steps: []r.TestStep{
|
||||||
|
r.TestStep{
|
||||||
|
Config: `
|
||||||
|
resource "tls_private_key" "test" {
|
||||||
|
algorithm = "ECDSA"
|
||||||
|
}
|
||||||
|
output "key_pem" {
|
||||||
|
value = "${tls_private_key.test.private_key_pem}"
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
Check: func(s *terraform.State) error {
|
||||||
|
got := s.RootModule().Outputs["key_pem"]
|
||||||
|
if !strings.HasPrefix(got, "-----BEGIN EC PRIVATE KEY----") {
|
||||||
|
return fmt.Errorf("Key is missing EC key PEM preamble")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,265 @@
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/helper/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
var keyUsages map[string]x509.KeyUsage = map[string]x509.KeyUsage{
|
||||||
|
"digital_signature": x509.KeyUsageDigitalSignature,
|
||||||
|
"content_commitment": x509.KeyUsageContentCommitment,
|
||||||
|
"key_encipherment": x509.KeyUsageKeyEncipherment,
|
||||||
|
"data_encipherment": x509.KeyUsageDataEncipherment,
|
||||||
|
"key_agreement": x509.KeyUsageKeyAgreement,
|
||||||
|
"cert_signing": x509.KeyUsageCertSign,
|
||||||
|
"crl_signing": x509.KeyUsageCRLSign,
|
||||||
|
"encipher_only": x509.KeyUsageEncipherOnly,
|
||||||
|
"decipher_only": x509.KeyUsageDecipherOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
var extKeyUsages map[string]x509.ExtKeyUsage = map[string]x509.ExtKeyUsage{
|
||||||
|
"any_extended": x509.ExtKeyUsageAny,
|
||||||
|
"server_auth": x509.ExtKeyUsageServerAuth,
|
||||||
|
"client_auth": x509.ExtKeyUsageClientAuth,
|
||||||
|
"code_signing": x509.ExtKeyUsageCodeSigning,
|
||||||
|
"email_protection": x509.ExtKeyUsageEmailProtection,
|
||||||
|
"ipsec_end_system": x509.ExtKeyUsageIPSECEndSystem,
|
||||||
|
"ipsec_tunnel": x509.ExtKeyUsageIPSECTunnel,
|
||||||
|
"ipsec_user": x509.ExtKeyUsageIPSECUser,
|
||||||
|
"timestamping": x509.ExtKeyUsageTimeStamping,
|
||||||
|
"ocsp_signing": x509.ExtKeyUsageOCSPSigning,
|
||||||
|
"microsoft_server_gated_crypto": x509.ExtKeyUsageMicrosoftServerGatedCrypto,
|
||||||
|
"netscape_server_gated_crypto": x509.ExtKeyUsageNetscapeServerGatedCrypto,
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceSelfSignedCert() *schema.Resource {
|
||||||
|
return &schema.Resource{
|
||||||
|
Create: CreateSelfSignedCert,
|
||||||
|
Delete: DeleteSelfSignedCert,
|
||||||
|
Read: ReadSelfSignedCert,
|
||||||
|
|
||||||
|
Schema: map[string]*schema.Schema{
|
||||||
|
|
||||||
|
"dns_names": &schema.Schema{
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Optional: true,
|
||||||
|
Description: "List of DNS names to use as subjects of the certificate",
|
||||||
|
ForceNew: true,
|
||||||
|
Elem: &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"ip_addresses": &schema.Schema{
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Optional: true,
|
||||||
|
Description: "List of IP addresses to use as subjects of the certificate",
|
||||||
|
ForceNew: true,
|
||||||
|
Elem: &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"validity_period_hours": &schema.Schema{
|
||||||
|
Type: schema.TypeInt,
|
||||||
|
Required: true,
|
||||||
|
Description: "Number of hours that the certificate will remain valid for",
|
||||||
|
ForceNew: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"early_renewal_hours": &schema.Schema{
|
||||||
|
Type: schema.TypeInt,
|
||||||
|
Optional: true,
|
||||||
|
Default: 0,
|
||||||
|
Description: "Number of hours before the certificates expiry when a new certificate will be generated",
|
||||||
|
ForceNew: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"is_ca_certificate": &schema.Schema{
|
||||||
|
Type: schema.TypeBool,
|
||||||
|
Optional: true,
|
||||||
|
Description: "Whether the generated certificate will be usable as a CA certificate",
|
||||||
|
ForceNew: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"allowed_uses": &schema.Schema{
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Required: true,
|
||||||
|
Description: "Uses that are allowed for the certificate",
|
||||||
|
ForceNew: true,
|
||||||
|
Elem: &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"key_algorithm": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Required: true,
|
||||||
|
Description: "Name of the algorithm to use to generate the certificate's private key",
|
||||||
|
ForceNew: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"private_key_pem": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Required: true,
|
||||||
|
Description: "PEM-encoded private key that the certificate will belong to",
|
||||||
|
ForceNew: true,
|
||||||
|
StateFunc: func(v interface{}) string {
|
||||||
|
return hashForState(v.(string))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"subject": &schema.Schema{
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Required: true,
|
||||||
|
Elem: nameSchema,
|
||||||
|
ForceNew: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"cert_pem": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Computed: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"validity_start_time": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Computed: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"validity_end_time": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Computed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateSelfSignedCert(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
keyAlgoName := d.Get("key_algorithm").(string)
|
||||||
|
var keyFunc keyParser
|
||||||
|
var ok bool
|
||||||
|
if keyFunc, ok = keyParsers[keyAlgoName]; !ok {
|
||||||
|
return fmt.Errorf("invalid key_algorithm %#v", keyAlgoName)
|
||||||
|
}
|
||||||
|
keyBlock, _ := pem.Decode([]byte(d.Get("private_key_pem").(string)))
|
||||||
|
if keyBlock == nil {
|
||||||
|
return fmt.Errorf("no PEM block found in private_key_pem")
|
||||||
|
}
|
||||||
|
key, err := keyFunc(keyBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode private_key_pem: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notBefore := time.Now()
|
||||||
|
notAfter := notBefore.Add(time.Duration(d.Get("validity_period_hours").(int)) * time.Hour)
|
||||||
|
|
||||||
|
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||||
|
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate serial number: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectConfs := d.Get("subject").([]interface{})
|
||||||
|
if len(subjectConfs) != 1 {
|
||||||
|
return fmt.Errorf("must have exactly one 'subject' block")
|
||||||
|
}
|
||||||
|
subjectConf := subjectConfs[0].(map[string]interface{})
|
||||||
|
subject, err := nameFromResourceData(subjectConf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid subject block: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert := x509.Certificate{
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
Subject: *subject,
|
||||||
|
NotBefore: notBefore,
|
||||||
|
NotAfter: notAfter,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
keyUsesI := d.Get("allowed_uses").([]interface{})
|
||||||
|
for _, keyUseI := range keyUsesI {
|
||||||
|
keyUse := keyUseI.(string)
|
||||||
|
if usage, ok := keyUsages[keyUse]; ok {
|
||||||
|
cert.KeyUsage |= usage
|
||||||
|
}
|
||||||
|
if usage, ok := extKeyUsages[keyUse]; ok {
|
||||||
|
cert.ExtKeyUsage = append(cert.ExtKeyUsage, usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsNamesI := d.Get("dns_names").([]interface{})
|
||||||
|
for _, nameI := range dnsNamesI {
|
||||||
|
cert.DNSNames = append(cert.DNSNames, nameI.(string))
|
||||||
|
}
|
||||||
|
ipAddressesI := d.Get("ip_addresses").([]interface{})
|
||||||
|
for _, ipStrI := range ipAddressesI {
|
||||||
|
ip := net.ParseIP(ipStrI.(string))
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("invalid IP address %#v", ipStrI.(string))
|
||||||
|
}
|
||||||
|
cert.IPAddresses = append(cert.IPAddresses, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Get("is_ca_certificate").(bool) {
|
||||||
|
cert.IsCA = true
|
||||||
|
}
|
||||||
|
|
||||||
|
certBytes, err := x509.CreateCertificate(rand.Reader, &cert, &cert, publicKey(key), key)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Errorf("Error creating certificate: %s", err)
|
||||||
|
}
|
||||||
|
certPem := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
|
||||||
|
|
||||||
|
validFromBytes, err := notBefore.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error serializing validity_start_time: %s", err)
|
||||||
|
}
|
||||||
|
validToBytes, err := notAfter.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error serializing validity_end_time: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.SetId(serialNumber.String())
|
||||||
|
d.Set("cert_pem", certPem)
|
||||||
|
d.Set("validity_start_time", string(validFromBytes))
|
||||||
|
d.Set("validity_end_time", string(validToBytes))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteSelfSignedCert(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
d.SetId("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadSelfSignedCert(d *schema.ResourceData, meta interface{}) error {
|
||||||
|
|
||||||
|
endTimeStr := d.Get("validity_end_time").(string)
|
||||||
|
endTime := time.Now()
|
||||||
|
err := endTime.UnmarshalText([]byte(endTimeStr))
|
||||||
|
if err != nil {
|
||||||
|
// If end time is invalid then we'll just throw away the whole
|
||||||
|
// thing so we can generate a new one.
|
||||||
|
d.SetId("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
earlyRenewalPeriod := time.Duration(-d.Get("early_renewal_hours").(int)) * time.Hour
|
||||||
|
endTime = endTime.Add(earlyRenewalPeriod)
|
||||||
|
|
||||||
|
if time.Now().After(endTime) {
|
||||||
|
// Treat an expired certificate as not existing, so we'll generate
|
||||||
|
// a new one with the next plan.
|
||||||
|
d.SetId("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
package tls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
r "github.com/hashicorp/terraform/helper/resource"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelfSignedCert(t *testing.T) {
|
||||||
|
r.Test(t, r.TestCase{
|
||||||
|
Providers: testProviders,
|
||||||
|
Steps: []r.TestStep{
|
||||||
|
r.TestStep{
|
||||||
|
Config: fmt.Sprintf(`
|
||||||
|
resource "tls_self_signed_cert" "test" {
|
||||||
|
subject {
|
||||||
|
common_name = "example.com"
|
||||||
|
organization = "Example, Inc"
|
||||||
|
organizational_unit = "Department of Terraform Testing"
|
||||||
|
street_address = ["5879 Cotton Link"]
|
||||||
|
locality = "Pirate Harbor"
|
||||||
|
province = "CA"
|
||||||
|
country = "US"
|
||||||
|
postal_code = "95559-1227"
|
||||||
|
serial_number = "2"
|
||||||
|
}
|
||||||
|
|
||||||
|
dns_names = [
|
||||||
|
"example.com",
|
||||||
|
"example.net",
|
||||||
|
]
|
||||||
|
|
||||||
|
ip_addresses = [
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.0.0.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
validity_period_hours = 1
|
||||||
|
|
||||||
|
allowed_uses = [
|
||||||
|
"key_encipherment",
|
||||||
|
"digital_signature",
|
||||||
|
"server_auth",
|
||||||
|
"client_auth",
|
||||||
|
]
|
||||||
|
|
||||||
|
key_algorithm = "RSA"
|
||||||
|
private_key_pem = <<EOT
|
||||||
|
%s
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
output "key_pem" {
|
||||||
|
value = "${tls_self_signed_cert.test.cert_pem}"
|
||||||
|
}
|
||||||
|
`, testPrivateKey),
|
||||||
|
Check: func(s *terraform.State) error {
|
||||||
|
got := s.RootModule().Outputs["key_pem"]
|
||||||
|
if !strings.HasPrefix(got, "-----BEGIN CERTIFICATE----") {
|
||||||
|
return fmt.Errorf("key is missing cert PEM preamble")
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode([]byte(got))
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing cert: %s", err)
|
||||||
|
}
|
||||||
|
if expected, got := "2", cert.Subject.SerialNumber; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject serial number: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "example.com", cert.Subject.CommonName; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject common name: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "Example, Inc", cert.Subject.Organization[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject organization: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "Department of Terraform Testing", cert.Subject.OrganizationalUnit[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject organizational unit: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "5879 Cotton Link", cert.Subject.StreetAddress[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject street address: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "Pirate Harbor", cert.Subject.Locality[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject locality: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "CA", cert.Subject.Province[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject province: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "US", cert.Subject.Country[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject country: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "95559-1227", cert.Subject.PostalCode[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect subject postal code: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected, got := 2, len(cert.DNSNames); got != expected {
|
||||||
|
return fmt.Errorf("incorrect number of DNS names: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "example.com", cert.DNSNames[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect DNS name 0: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "example.net", cert.DNSNames[1]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect DNS name 0: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected, got := 2, len(cert.IPAddresses); got != expected {
|
||||||
|
return fmt.Errorf("incorrect number of IP addresses: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "127.0.0.1", cert.IPAddresses[0].String(); got != expected {
|
||||||
|
return fmt.Errorf("incorrect IP address 0: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := "127.0.0.2", cert.IPAddresses[1].String(); got != expected {
|
||||||
|
return fmt.Errorf("incorrect IP address 0: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected, got := 2, len(cert.ExtKeyUsage); got != expected {
|
||||||
|
return fmt.Errorf("incorrect number of ExtKeyUsage: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := x509.ExtKeyUsageServerAuth, cert.ExtKeyUsage[0]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect ExtKeyUsage[0]: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
if expected, got := x509.ExtKeyUsageClientAuth, cert.ExtKeyUsage[1]; got != expected {
|
||||||
|
return fmt.Errorf("incorrect ExtKeyUsage[1]: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected, got := x509.KeyUsageKeyEncipherment|x509.KeyUsageDigitalSignature, cert.KeyUsage; got != expected {
|
||||||
|
return fmt.Errorf("incorrect KeyUsage: expected %v, got %v", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This time checking is a bit sloppy to avoid inconsistent test results
|
||||||
|
// depending on the power of the machine running the tests.
|
||||||
|
now := time.Now()
|
||||||
|
if cert.NotBefore.After(now) {
|
||||||
|
return fmt.Errorf("certificate validity begins in the future")
|
||||||
|
}
|
||||||
|
if now.Sub(cert.NotBefore) > (2 * time.Minute) {
|
||||||
|
return fmt.Errorf("certificate validity begins more than two minutes in the past")
|
||||||
|
}
|
||||||
|
if cert.NotAfter.Sub(cert.NotBefore) != time.Hour {
|
||||||
|
return fmt.Errorf("certificate validity is not one hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -23,6 +23,7 @@ body.layout-openstack,
|
||||||
body.layout-packet,
|
body.layout-packet,
|
||||||
body.layout-rundeck,
|
body.layout-rundeck,
|
||||||
body.layout-template,
|
body.layout-template,
|
||||||
|
body.layout-tls,
|
||||||
body.layout-vsphere,
|
body.layout-vsphere,
|
||||||
body.layout-docs,
|
body.layout-docs,
|
||||||
body.layout-downloads,
|
body.layout-downloads,
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
---
|
||||||
|
layout: "tls"
|
||||||
|
page_title: "Provider: TLS"
|
||||||
|
sidebar_current: "docs-tls-index"
|
||||||
|
description: |-
|
||||||
|
The TLS provider provides utilities for working with Transport Layer Security keys and certificates.
|
||||||
|
---
|
||||||
|
|
||||||
|
# TLS Provider
|
||||||
|
|
||||||
|
The TLS provider provides utilities for working with *Transport Layer Security*
|
||||||
|
keys and certificates. It provides resources that
|
||||||
|
allow private keys, certificates and certficate requests to be
|
||||||
|
created as part of a Terraform deployment.
|
||||||
|
|
||||||
|
Another name for Transport Layer Security is *Secure Sockets Layer*,
|
||||||
|
or SSL. TLS and SSL are equivalent when considering the resources
|
||||||
|
managed by this provider.
|
||||||
|
|
||||||
|
This provider is not particularly useful on its own, but it can be
|
||||||
|
used to create certificates and credentials that can then be used
|
||||||
|
with other providers when creating resources that expose TLS
|
||||||
|
services or that themselves provision TLS certificates.
|
||||||
|
|
||||||
|
Use the navigation to the left to read about the available resources.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
## This example create a self-signed certificate for a development
|
||||||
|
## environment.
|
||||||
|
## THIS IS NOT RECOMMENDED FOR PRODUCTION SERVICES.
|
||||||
|
## See the detailed documentation of each resource for further
|
||||||
|
## security considerations and other practical tradeoffs.
|
||||||
|
|
||||||
|
resource "tls_private_key" "example" {
|
||||||
|
algorithm = "ECDSA"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "tls_self_signed_cert" "example" {
|
||||||
|
key_algorithm = "${tls_private_key.example.algorithm}"
|
||||||
|
private_key_pem = "${tls_private_key.example.private_key_pem}"
|
||||||
|
|
||||||
|
# Certificate expires after 12 hours.
|
||||||
|
validity_period_hours = 12
|
||||||
|
|
||||||
|
# Generate a new certificate if Terraform is run within three
|
||||||
|
# hours of the certificate's expiration time.
|
||||||
|
early_renewal_hours = 3
|
||||||
|
|
||||||
|
# Reasonable set of uses for a server SSL certificate.
|
||||||
|
allowed_uses = [
|
||||||
|
"key_encipherment",
|
||||||
|
"digital_signature",
|
||||||
|
"server_auth",
|
||||||
|
]
|
||||||
|
|
||||||
|
dns_names = ["example.com", "example.net"]
|
||||||
|
|
||||||
|
subject {
|
||||||
|
common_name = "example.com"
|
||||||
|
organization = "ACME Examples, Inc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# For example, this can be used to populate an AWS IAM server certificate.
|
||||||
|
resource "aws_iam_server_certificate" "example" {
|
||||||
|
name = "example_self_signed_cert"
|
||||||
|
certificate_body = "${tls_self_signed_cert.example.cert_pem}"
|
||||||
|
private_key = "${tls_private_key.example.private_key_pem}"
|
||||||
|
}
|
||||||
|
```
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
layout: "tls"
|
||||||
|
page_title: "TLS: tls_cert_request"
|
||||||
|
sidebar_current: "docs-tls-resourse-cert-request"
|
||||||
|
description: |-
|
||||||
|
Creates a PEM-encoded certificate request.
|
||||||
|
---
|
||||||
|
|
||||||
|
# tls\_cert\_request
|
||||||
|
|
||||||
|
Generates a *Certificate Signing Request* (CSR) in PEM format, which is the
|
||||||
|
typical format used to request a certificate from a certificate authority.
|
||||||
|
|
||||||
|
This resource is intended to be used in conjunction with a Terraform provider
|
||||||
|
for a particular certificate authority in order to provision a new certificate.
|
||||||
|
This is a *logical resource*, so it contributes only to the current Terraform
|
||||||
|
state and does not create any external managed resources.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
resource "tls_cert_request" "example" {
|
||||||
|
key_algorithm = "ECDSA"
|
||||||
|
private_key_pem = "${file(\"private_key.pem\")}"
|
||||||
|
|
||||||
|
subject {
|
||||||
|
common_name = "example.com"
|
||||||
|
organization = "ACME Examples, Inc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Argument Reference
|
||||||
|
|
||||||
|
The following arguments are supported:
|
||||||
|
|
||||||
|
* `key_algorithm` - (Required) The name of the algorithm for the key provided
|
||||||
|
in `private_key_pem`.
|
||||||
|
|
||||||
|
* `private_key_pem` - (Required) PEM-encoded private key data. This can be
|
||||||
|
read from a separate file using the ``file`` interpolation function. Only
|
||||||
|
an irreversable secure hash of the private key will be stored in the Terraform
|
||||||
|
state.
|
||||||
|
|
||||||
|
* `subject` - (Required) The subject for which a certificate is being requested. This is
|
||||||
|
a nested configuration block whose structure is described below.
|
||||||
|
|
||||||
|
* `dns_names` - (Optional) List of DNS names for which a certificate is being requested.
|
||||||
|
|
||||||
|
* `ip_addresses` - (Optional) List of IP addresses for which a certificate is being requested.
|
||||||
|
|
||||||
|
The nested `subject` block accepts the following arguments, all optional, with their meaning
|
||||||
|
corresponding to the similarly-named attributes defined in
|
||||||
|
[RFC5290](https://tools.ietf.org/html/rfc5280#section-4.1.2.4):
|
||||||
|
|
||||||
|
* `common_name` (string)
|
||||||
|
|
||||||
|
* `organization` (string)
|
||||||
|
|
||||||
|
* `organizational_unit` (string)
|
||||||
|
|
||||||
|
* `street_address` (list of strings)
|
||||||
|
|
||||||
|
* `locality` (string)
|
||||||
|
|
||||||
|
* `province` (string)
|
||||||
|
|
||||||
|
* `country` (string)
|
||||||
|
|
||||||
|
* `postal_code` (string)
|
||||||
|
|
||||||
|
* `serial_number` (string)
|
||||||
|
|
||||||
|
## Attributes Reference
|
||||||
|
|
||||||
|
The following attributes are exported:
|
||||||
|
|
||||||
|
* `cert_request_pem` - The certificate request data in PEM format.
|
|
@ -0,0 +1,66 @@
|
||||||
|
---
|
||||||
|
layout: "tls"
|
||||||
|
page_title: "TLS: tls_private_key"
|
||||||
|
sidebar_current: "docs-tls-resourse-private-key"
|
||||||
|
description: |-
|
||||||
|
Creates a PEM-encoded private key.
|
||||||
|
---
|
||||||
|
|
||||||
|
# tls\_private\_key
|
||||||
|
|
||||||
|
Generates a secure private key and encodes it as PEM. This resource is
|
||||||
|
primarily intended for easily bootstrapping throwaway development
|
||||||
|
environments.
|
||||||
|
|
||||||
|
~> **Important Security Notice** The private key generated by this resource will
|
||||||
|
be stored *unencrypted* in your Terraform state file. **Use of this resource
|
||||||
|
for production deployments is *not* recommended**. Instead, generate
|
||||||
|
a private key file outside of Terraform and distribute it securely
|
||||||
|
to the system where Terraform will be run.
|
||||||
|
|
||||||
|
This is a *logical resource*, so it contributes only to the current Terraform
|
||||||
|
state and does not create any external managed resources.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
resource "tls_private_key" "example" {
|
||||||
|
algorithm = "ECDSA"
|
||||||
|
ecdsa_curve = "P384"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Argument Reference
|
||||||
|
|
||||||
|
The following arguments are supported:
|
||||||
|
|
||||||
|
* `algorithm` - (Required) The name of the algorithm to use for
|
||||||
|
the key. Currently-supported values are "RSA" and "ECDSA".
|
||||||
|
|
||||||
|
* `rsa_bits` - (Optional) When `algorithm` is "RSA", the size of the generated
|
||||||
|
RSA key in bits. Defaults to 2048.
|
||||||
|
|
||||||
|
* `ecdsa_curve` - (Optional) When `algorithm` is "ECDSA", the name of the elliptic
|
||||||
|
curve to use. May be any one of "P224", "P256", "P384" or "P521", with "P224" as the
|
||||||
|
default.
|
||||||
|
|
||||||
|
## Attributes Reference
|
||||||
|
|
||||||
|
The following attributes are exported:
|
||||||
|
|
||||||
|
* `algorithm` - The algorithm that was selected for the key.
|
||||||
|
* `private_key_pem` - The private key data in PEM format.
|
||||||
|
|
||||||
|
## Generating a New Key
|
||||||
|
|
||||||
|
Since a private key is a logical resource that lives only in the Terraform state,
|
||||||
|
it will persist until it is explicitly destroyed by the user.
|
||||||
|
|
||||||
|
In order to force the generation of a new key within an existing state, the
|
||||||
|
private key instance can be "tainted":
|
||||||
|
|
||||||
|
```
|
||||||
|
terraform taint tls_private_key.example
|
||||||
|
```
|
||||||
|
|
||||||
|
A new key will then be generated on the next ``terraform apply``.
|
|
@ -0,0 +1,137 @@
|
||||||
|
---
|
||||||
|
layout: "tls"
|
||||||
|
page_title: "TLS: tls_self_signed_cert"
|
||||||
|
sidebar_current: "docs-tls-resourse-self-signed-cert"
|
||||||
|
description: |-
|
||||||
|
Creates a self-signed TLS certificate in PEM format.
|
||||||
|
---
|
||||||
|
|
||||||
|
# tls\_self\_signed\_cert
|
||||||
|
|
||||||
|
Generates a *self-signed* TLS certificate in PEM format, which is the typical
|
||||||
|
format used to configure TLS server software.
|
||||||
|
|
||||||
|
Self-signed certificates are generally not trusted by client software such
|
||||||
|
as web browsers. Therefore clients are likely to generate trust warnings when
|
||||||
|
connecting to a server that has a self-signed certificate. Self-signed certificates
|
||||||
|
are usually used only in development environments or apps deployed internally
|
||||||
|
to an organization.
|
||||||
|
|
||||||
|
This resource is intended to be used in conjunction with a Terraform provider
|
||||||
|
that has a resource that requires a TLS certificate, such as:
|
||||||
|
|
||||||
|
* ``aws_iam_server_certificate`` to register certificates for use with AWS *Elastic
|
||||||
|
Load Balancer*, *Elastic Beanstalk*, *CloudFront* or *OpsWorks*.
|
||||||
|
|
||||||
|
* ``heroku_cert`` to register certificates for applications deployed on Heroku.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
resource "tls_self_signed_cert" "example" {
|
||||||
|
key_algorithm = "ECDSA"
|
||||||
|
private_key_pem = "${file(\"private_key.pem\")}"
|
||||||
|
|
||||||
|
subject {
|
||||||
|
common_name = "example.com"
|
||||||
|
organization = "ACME Examples, Inc"
|
||||||
|
}
|
||||||
|
|
||||||
|
validity_period_hours = 12
|
||||||
|
|
||||||
|
allowed_uses = [
|
||||||
|
"key_encipherment",
|
||||||
|
"digital_signature",
|
||||||
|
"server_auth",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Argument Reference
|
||||||
|
|
||||||
|
The following arguments are supported:
|
||||||
|
|
||||||
|
* `key_algorithm` - (Required) The name of the algorithm for the key provided
|
||||||
|
in `private_key_pem`.
|
||||||
|
|
||||||
|
* `private_key_pem` - (Required) PEM-encoded private key data. This can be
|
||||||
|
read from a separate file using the ``file`` interpolation function. If the
|
||||||
|
certificate is being generated to be used for a throwaway development
|
||||||
|
environment or other non-critical application, the `tls_private_key` resource
|
||||||
|
can be used to generate a TLS private key from within Terraform. Only
|
||||||
|
an irreversable secure hash of the private key will be stored in the Terraform
|
||||||
|
state.
|
||||||
|
|
||||||
|
* `subject` - (Required) The subject for which a certificate is being requested. This is
|
||||||
|
a nested configuration block whose structure is described below.
|
||||||
|
|
||||||
|
* `validity_period_hours` - (Required) The number of hours after initial issuing that the
|
||||||
|
certificate will become invalid.
|
||||||
|
|
||||||
|
* `allowed_uses` - (Required) List of keywords each describing a use that is permitted
|
||||||
|
for the issued certificate. The valid keywords are listed below.
|
||||||
|
|
||||||
|
* `dns_names` - (Optional) List of DNS names for which a certificate is being requested.
|
||||||
|
|
||||||
|
* `ip_addresses` - (Optional) List of IP addresses for which a certificate is being requested.
|
||||||
|
|
||||||
|
* `early_renewal_hours` - (Optional) If set, the resource will consider the certificate to
|
||||||
|
have expired the given number of hours before its actual expiry time. This can be useful
|
||||||
|
to deploy an updated certificate in advance of the expiration of the current certificate.
|
||||||
|
Note however that the old certificate remains valid until its true expiration time, since
|
||||||
|
this resource does not (and cannot) support certificate revocation. Note also that this
|
||||||
|
advance update can only be performed should the Terraform configuration be applied during the
|
||||||
|
early renewal period.
|
||||||
|
|
||||||
|
* `is_ca_certificate` - (Optional) Boolean controlling whether the CA flag will be set in the
|
||||||
|
generated certificate. Defaults to `false`, meaning that the certificate does not represent
|
||||||
|
a certificate authority.
|
||||||
|
|
||||||
|
The `allowed_uses` list accepts the following keywords, combining the set of flags defined by
|
||||||
|
both [Key Usage](https://tools.ietf.org/html/rfc5280#section-4.2.1.3) and
|
||||||
|
[Extended Key Usage](https://tools.ietf.org/html/rfc5280#section-4.2.1.12) in
|
||||||
|
[RFC5280](https://tools.ietf.org/html/rfc5280):
|
||||||
|
|
||||||
|
* `digital_signature`
|
||||||
|
* `content_commitment`
|
||||||
|
* `key_encipherment`
|
||||||
|
* `data_encipherment`
|
||||||
|
* `key_agreement`
|
||||||
|
* `cert_signing`
|
||||||
|
* `encipher_only`
|
||||||
|
* `decipher_only`
|
||||||
|
* `any_extended`
|
||||||
|
* `server_auth`
|
||||||
|
* `client_auth`
|
||||||
|
* `code_signing`
|
||||||
|
* `email_protection`
|
||||||
|
* `ipsec_end_system`
|
||||||
|
* `ipsec_tunnel`
|
||||||
|
* `ipsec_user`
|
||||||
|
* `timestamping`
|
||||||
|
* `ocsp_signing`
|
||||||
|
* `microsoft_server_gated_crypto`
|
||||||
|
* `netscape_server_gated_crypto`
|
||||||
|
|
||||||
|
## Attributes Reference
|
||||||
|
|
||||||
|
The following attributes are exported:
|
||||||
|
|
||||||
|
* `cert_pem` - The certificate data in PEM format.
|
||||||
|
* `validity_start_time` - The time after which the certificate is valid, as an
|
||||||
|
[RFC3339](https://tools.ietf.org/html/rfc3339) timestamp.
|
||||||
|
* `validity_end_time` - The time until which the certificate is invalid, as an
|
||||||
|
[RFC3339](https://tools.ietf.org/html/rfc3339) timestamp.
|
||||||
|
|
||||||
|
## Automatic Renewal
|
||||||
|
|
||||||
|
This resource considers its instances to have been deleted after either their validity
|
||||||
|
periods ends or the early renewal period is reached. At this time, applying the
|
||||||
|
Terraform configuration will cause a new certificate to be generated for the instance.
|
||||||
|
|
||||||
|
Therefore in a development environment with frequent deployments it may be convenient
|
||||||
|
to set a relatively-short expiration time and use early renewal to automatically provision
|
||||||
|
a new certificate when the current one is about to expire.
|
||||||
|
|
||||||
|
The creation of a new certificate may of course cause dependent resources to be updated
|
||||||
|
or replaced, depending on the lifecycle rules applying to those resources.
|
|
@ -189,9 +189,13 @@
|
||||||
<a href="/docs/providers/template/index.html">Template</a>
|
<a href="/docs/providers/template/index.html">Template</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li<%= sidebar_current("docs-providers-vsphere") %>>
|
<li<%= sidebar_current("docs-providers-tls") %>>
|
||||||
<a href="/docs/providers/vsphere/index.html">vSphere</a>
|
<a href="/docs/providers/tls/index.html">TLS</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li<%= sidebar_current("docs-providers-vsphere") %>>
|
||||||
|
<a href="/docs/providers/vsphere/index.html">vSphere</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<% wrap_layout :inner do %>
|
||||||
|
<% content_for :sidebar do %>
|
||||||
|
<div class="docs-sidebar hidden-print affix-top" role="complementary">
|
||||||
|
<ul class="nav docs-sidenav">
|
||||||
|
<li<%#= sidebar_current("docs-home") %>>
|
||||||
|
<a href="/docs/providers/index.html">« Documentation Home</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li<%= sidebar_current("docs-tls-index") %>>
|
||||||
|
<a href="/docs/providers/tls/index.html">TLS Provider</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li<%= sidebar_current(/^docs-tls-resource/) %>>
|
||||||
|
<a href="#">Resources</a>
|
||||||
|
<ul class="nav nav-visible">
|
||||||
|
<li<%= sidebar_current("docs-tls-resourse-private-key") %>>
|
||||||
|
<a href="/docs/providers/tls/r/private_key.html">tls_private_key</a>
|
||||||
|
</li>
|
||||||
|
<li<%= sidebar_current("docs-tls-resourse-self-signed-cert") %>>
|
||||||
|
<a href="/docs/providers/tls/r/self_signed_cert.html">tls_self_signed_cert</a>
|
||||||
|
</li>
|
||||||
|
<li<%= sidebar_current("docs-tls-resourse-cert-request") %>>
|
||||||
|
<a href="/docs/providers/tls/r/cert_request.html">tls_cert_request</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= yield %>
|
||||||
|
<% end %>
|
Loading…
Reference in New Issue