diff --git a/.sops.yaml b/.sops.yaml index a156412..f546b4c 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -1,5 +1,2 @@ creation_rules: - - azure_kv: - vault_url: https://elyclover-kv.vault.azure.net - name: sops-key - version: 06473759a48f4ed39ef5343909e2e436 + - azure_keyvault: https://elyclover-kv.vault.azure.net/keys/sops-key/06473759a48f4ed39ef5343909e2e436 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf8db7b --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +# elyclover.com-infra + +# targets to handle SOPS encryption/decryption using Azure keyvault +decrypt: + @./scripts/sops.sh $@ +encrypt: + @./scripts/sops.sh $@ diff --git a/Pulumi.prod.yaml b/Pulumi.prod.yaml index 75028f6..9aedcbc 100644 --- a/Pulumi.prod.yaml +++ b/Pulumi.prod.yaml @@ -5,5 +5,7 @@ config: elyclover.com-infra:keyVaultAzureSubscription: df058eb7-0193-43d2-b1ff-937439336e86 elyclover.com-infra:keyVaultName: elyclover-kv elyclover.com-infra:keyvaultResourceGroup: root + # cloverstack.dev vs elyclover.com? + elyclover.com-infra:prodCertName: cloverstack-apex github:token: secure: AAABALUZicxKxjn0TkjWVU+L79eHIid/l3kt/CNDpISITEQlITsMZAlOIyHae4P77D03rcs0+/7n94yb/5D6I4P8JbA4JL8+HXHx1Yt+hdB6zbs+T5ZL+SyRrvGnKmXNGv3mgZdhH8acVp9oax/eUAjLUrtekqMh+EOXTiI= diff --git a/assets/tls/README.md b/assets/tls/README.md new file mode 100644 index 0000000..740b12c --- /dev/null +++ b/assets/tls/README.md @@ -0,0 +1,3 @@ +# pfx storage + +This directory is for storing pfx certificates that have been encrypted at-rest with SOPS. diff --git a/cdn.go b/cdn.go index b8dc6c9..bcca6e0 100644 --- a/cdn.go +++ b/cdn.go @@ -12,6 +12,12 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config" ) +type epCfgs struct { + userManaged legacycdn.EndpointCustomDomainUserManagedHttpsArgs + cdnManaged legacycdn.EndpointCustomDomainCdnManagedHttpsArgs + domainArgs legacycdn.EndpointCustomDomainArgs +} + func createCdnProfile(ctx *pulumi.Context, cdnName string, azureRg pulumi.StringOutput) (profile *nativecdn.Profile, err error) { var cdnProfileArgs = nativecdn.ProfileArgs{ Location: pulumi.String("global"), @@ -95,32 +101,54 @@ func createCdnEndpoint(ctx *pulumi.Context, epName string, cdnProfile *nativecdn return } -func newEndpointCustomDomain(ctx *pulumi.Context, epdName string, endpoint *nativecdn.Endpoint, domain pulumi.StringOutput) (epd *legacycdn.EndpointCustomDomain, err error) { +func newEndpointCustomDomain(ctx *pulumi.Context, epdName string, endpoint *nativecdn.Endpoint, domain pulumi.StringOutput, + cfg *config.Config) (epd *legacycdn.EndpointCustomDomain, err error) { // Utilize the azure legacy provider since it supports setting up auto-TLS for CDN custom domains - // azure-native provider strangely lacks support for CDN-managed TLS on custom domains... pushing front door? $$$ - cdnManagedHttps := legacycdn.EndpointCustomDomainCdnManagedHttpsArgs{ - CertificateType: pulumi.String("Dedicated"), - ProtocolType: pulumi.String("ServerNameIndication"), - TlsVersion: pulumi.String("TLS12"), + // azure-native provider strangely lacks support for CDN-managed TLS on custom domains + epCfg := epCfgs{ + userManaged: legacycdn.EndpointCustomDomainUserManagedHttpsArgs{ + TlsVersion: pulumi.String("TLS12"), + }, + cdnManaged: legacycdn.EndpointCustomDomainCdnManagedHttpsArgs{ + CertificateType: pulumi.String("Dedicated"), + ProtocolType: pulumi.String("ServerNameIndication"), + TlsVersion: pulumi.String("TLS12"), + }, + domainArgs: legacycdn.EndpointCustomDomainArgs{ + CdnEndpointId: endpoint.ID(), + HostName: domain, + // set user managed or cdn managed https based on stack (env) + }, } - endpointCustomDomainArgs := legacycdn.EndpointCustomDomainArgs{ - CdnEndpointId: endpoint.ID(), - HostName: domain, - CdnManagedHttps: &cdnManagedHttps, + switch ctx.Stack() { + // user managed endpoint https config (byo cert) + case "prod": + cert, err := getSecretByName(ctx, cfg.Require("keyVaultName"), cfg.Require("keyvaultResourceGroup"), + cfg.Require("prodCertName")) + if err != nil { + return epd, err + } + epCfg.userManaged.KeyVaultSecretId = pulumi.String(cert.Properties.SecretUri) + epCfg.domainArgs.UserManagedHttps = epCfg.userManaged + // azure cdn managed endpoint https config (auto-gen/rotated cert) + default: + epCfg.domainArgs.CdnManagedHttps = epCfg.cdnManaged } - epd, err = legacycdn.NewEndpointCustomDomain(ctx, epdName, &endpointCustomDomainArgs) + epd, err = legacycdn.NewEndpointCustomDomain(ctx, epdName, &epCfg.domainArgs) if err != nil { fmt.Println("ERROR: creating custom domain for CDN endpoint failed") return epd, err } + return } -// Non-prod environments use sub-domains (eg dev.tld.com) and Azure CDN (classic) will auto-generate and set up the TLS for us +// Non-prod environments use sub-domains (eg dev.tld.com) and Azure CDN (classic) will auto-generate and set up the TLS for us. // Production uses an apex domain (eg tld.com) which Azure doesn't support free TLS certs + rotation on (:shrug:) +// so we'll need to set up a Service Principal registered under the Azure CDN App profile and give it RBAC access to +// an external Azure KeyVault resource that contains our pfx certificate needed for the prod tld.com domain. func setupTlsTermination(ctx *pulumi.Context, cfg *config.Config, ep *nativecdn.Endpoint, fqdn pulumi.StringOutput) (err error) { - switch ctx.Stack() { - case "prod": + if ctx.Stack() == "prod" { // Register Azure CDN Application as Service Principal in AD/Entra tenant so it can fetch TLS pfx data in external Keystore cdnId := cfg.Require("Microsoft.AzureFrontDoor-Cdn") nsp, err := azuread.NewServicePrincipal(ctx, cdnId, &azuread.ServicePrincipalArgs{ @@ -131,11 +159,11 @@ func setupTlsTermination(ctx *pulumi.Context, cfg *config.Config, ep *nativecdn. if err != nil { return err } - keyVaultScope := pulumi.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s", - cfg.Require("keyVaultAzureSubscription"), cfg.Require("keyvaultResourceGroup"), cfg.Require("keyVaultName")) // assign predefined "Key Vault Secret User" RoleDefinitionId to Service Principal we just created // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#key-vault-secrets-user - // This allows Azure CDN to access the pfx keys we purchase/generate external to pulumi (until ACME support is native) + // This allows Azure CDN to access the pfx keys we purchase/generate external to pulumi (quite a bit cheaper than asking Azure to do it.) + keyVaultScope := pulumi.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s", + cfg.Require("keyVaultAzureSubscription"), cfg.Require("keyvaultResourceGroup"), cfg.Require("keyVaultName")) _, err = authorization.NewRoleAssignment(ctx, "AzureFDCDNreadKVCerts", &authorization.RoleAssignmentArgs{ PrincipalId: nsp.ID(), PrincipalType: pulumi.String("ServicePrincipal"), @@ -145,13 +173,12 @@ func setupTlsTermination(ctx *pulumi.Context, cfg *config.Config, ep *nativecdn. if err != nil { return err } - default: - // newEndpointCustomDomain() will need to be refactored to support conditional for auto-tls setup - // Add Custom Domain to CDN to set up automatic TLS termination/cert rotation - _, err = newEndpointCustomDomain(ctx, cfg.Require("siteKey")+ctx.Stack(), ep, fqdn) - if err != nil { - return err - } + } + + // Add Custom Domain to CDN for prod or non-prod + _, err = newEndpointCustomDomain(ctx, cfg.Require("siteKey")+ctx.Stack(), ep, fqdn, cfg) + if err != nil { + return err } return diff --git a/dns.go b/dns.go index dea898a..33aa1e8 100644 --- a/dns.go +++ b/dns.go @@ -31,7 +31,17 @@ func createDnsRecordByEnv(ctx *pulumi.Context, dnsRG string, dz *dns.LookupZoneR if err != nil { return d, err } - // strip out trailing '.' from CNAME's returned FQDN string within Azure DNS API + // create CNAME 'cdnverify.tld.com' record + cdnVerify := "cdnverify" + cdnVerifyHostname := ep.HostName.ApplyT(func(h string) (r string) { + r = cdnVerify + "." + h + return + }).(pulumi.StringOutput) + _, err = createCNAMERecordPointingAtCdnEndpoint(ctx, dnsRG, dz, cdnVerifyHostname, cdnVerify, siteKey) + if err != nil { + return d, err + } + // strip out trailing '.' from A record returned FQDN string within Azure DNS API d = dnsRecord.Fqdn.ApplyT(func(fqdn string) (string, error) { h, found := strings.CutSuffix(fqdn, ".") if !found { @@ -77,7 +87,6 @@ func createCNAMERecordPointingAtCdnEndpoint(ctx *pulumi.Context, dnsRG string, d func createARecordPointingAtCdnResourceID(ctx *pulumi.Context, dnsRG string, dz *dns.LookupZoneResult, tg pulumi.StringOutput, envKey string, siteKey string) (record *dns.ARecord, err error) { dnsRecordArgs := dns.ARecordArgs{ - //Name: pulumi.String(envKey), Name: pulumi.String("@"), ZoneName: pulumi.String(dz.Name), ResourceGroupName: pulumi.String(dnsRG), diff --git a/go.mod b/go.mod index b8824d1..81219d4 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/pulumi/pulumi-azure-native-sdk/authorization/v2 v2.8.0 github.com/pulumi/pulumi-azure-native-sdk/cdn/v2 v2.6.0 + github.com/pulumi/pulumi-azure-native-sdk/keyvault/v2 v2.8.0 github.com/pulumi/pulumi-azure-native-sdk/resources/v2 v2.6.0 github.com/pulumi/pulumi-azure-native-sdk/storage/v2 v2.6.0 github.com/pulumi/pulumi-azure/sdk/v5 v5.49.0 diff --git a/go.sum b/go.sum index b4243d0..a851c02 100644 --- a/go.sum +++ b/go.sum @@ -147,6 +147,8 @@ github.com/pulumi/pulumi-azure-native-sdk/authorization/v2 v2.8.0 h1:xbTAISsowIK github.com/pulumi/pulumi-azure-native-sdk/authorization/v2 v2.8.0/go.mod h1:R+oOw7l+XFwvli6yGhNQutwpOD7i97Z+yg2D0SkA4+U= github.com/pulumi/pulumi-azure-native-sdk/cdn/v2 v2.6.0 h1:ZhqRnPjcAjtitow78ZJBOPEoUApc7AkHg5Hz6EN6LlQ= github.com/pulumi/pulumi-azure-native-sdk/cdn/v2 v2.6.0/go.mod h1:Zjmi/vd0b+sCj8fifXiUK701lGnhia6FK5Nl67wFJhA= +github.com/pulumi/pulumi-azure-native-sdk/keyvault/v2 v2.8.0 h1:wwVp83pveI4fLh2KHiAD/xSmqPkwrYN2VY8DeWWYPL4= +github.com/pulumi/pulumi-azure-native-sdk/keyvault/v2 v2.8.0/go.mod h1:GyiEY70atSOSUnipRvoBnkt6M/idXgpq+28b8Uug1Ok= github.com/pulumi/pulumi-azure-native-sdk/resources/v2 v2.6.0 h1:TyDRtmPZlEsaEMjkHpNggohZyLup8gyr8dMx2fId+4A= github.com/pulumi/pulumi-azure-native-sdk/resources/v2 v2.6.0/go.mod h1:n25zeuzMVh+zBxgp7ed1yNlOu/h054/Xv47J+/l6Ejk= github.com/pulumi/pulumi-azure-native-sdk/storage/v2 v2.6.0 h1:VwJOP3VNejnpylsWJtuLUNHK7TwzRBlkBp3pxcgA1CE= diff --git a/iam.go b/iam.go index 909b4e7..d1d743f 100644 --- a/iam.go +++ b/iam.go @@ -46,8 +46,8 @@ func generateCICDServicePrincipal(ctx *pulumi.Context, sa *storage.StorageAccoun // authorize new SP to modify any resources required to deploy code to this project ra := []RoleAssignments{ - {cicd + "-storagerole", StorageContributor, ep.ID()}, - {cicd + "-cdnrole", CdnContributor, sa.ID()}, + {cicd + "-storagerole", StorageContributor, sa.ID()}, + {cicd + "-cdnrole", CdnContributor, ep.ID()}, } for _, v := range ra { _, err = authorization.NewRoleAssignment(ctx, v.Name, &authorization.RoleAssignmentArgs{ diff --git a/keyvault.go b/keyvault.go new file mode 100644 index 0000000..09b5782 --- /dev/null +++ b/keyvault.go @@ -0,0 +1,20 @@ +package main + +import ( + //legacykeyvault "github.com/pulumi/pulumi-azure/sdk/v5/go/azure/keyvault" + "github.com/pulumi/pulumi-azure-native-sdk/keyvault/v2" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +func getSecretByName(ctx *pulumi.Context, kv string, rg string, name string) (secret *keyvault.LookupSecretResult, err error) { + secret, err = keyvault.LookupSecret(ctx, &keyvault.LookupSecretArgs{ + VaultName: kv, + SecretName: name, + ResourceGroupName: rg, + }) + if err != nil { + return secret, err + } + + return +} diff --git a/main.go b/main.go index 8960230..6f54a5c 100644 --- a/main.go +++ b/main.go @@ -75,7 +75,7 @@ func main() { return err } - // Set up TLS depending on environment + // Set up TLS depending on environment and custom domain types err = setupTlsTermination(ctx, cfg, endpoint, fqdn) if err != nil { return err diff --git a/scripts/sops.sh b/scripts/sops.sh new file mode 100755 index 0000000..1e2383b --- /dev/null +++ b/scripts/sops.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +TLS_FILE_LOCATION=./assets/tls +FILES=("$TLS_FILE_LOCATION"/*) + +if [ -z "$1" ]; then + echo "requires either 'encrypt or 'decrypt' argument e.g.: ./sops.sh encrypt" +fi + +if [ "$1" == "encrypt" ]; then + for f in "${FILES[@]}" + do + echo encrypting "$f" + sops --encrypt --in-place "$f" + done +elif [ "$1" == "decrypt" ]; then + for f in "${FILES[@]}" + do + echo decrypting "$f" + sops --decrypt --in-place "$f" + done +else + echo "FATAL: unknown argument: $1" +fi