diff --git a/.changelog/4892.txt b/.changelog/4892.txt
new file mode 100644
index 0000000000..d8e2bc7717
--- /dev/null
+++ b/.changelog/4892.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+resource/cloudflare_access_application: add private destination fields to access application
+```
diff --git a/docs/resources/access_application.md b/docs/resources/access_application.md
index e481556f54..3ee12f388d 100644
--- a/docs/resources/access_application.md
+++ b/docs/resources/access_application.md
@@ -138,13 +138,15 @@ Optional:
### Nested Schema for `destinations`
-Required:
-
-- `uri` (String) The URI of the destination. Public destinations can include a domain and path with wildcards. Private destinations are an early access feature and gated behind a feature flag. Private destinations support private IPv4, IPv6, and Server Name Indications (SNI) with optional port ranges.
-
Optional:
+- `cidr` (String) The private CIDR of the destination. Only valid when type=private. IPs are computed as /32 cidr. Private destinations are an early access feature and gated behind a feature flag.
+- `hostname` (String) The private hostname of the destination. Only valid when type=private. Private hostnames currently match only Server Name Indications (SNI). Private destinations are an early access feature and gated behind a feature flag.
+- `l4_protocol` (String) The l4 protocol that matches this destination. Only valid when type=private. Private destinations are an early access feature and gated behind a feature flag.
+- `port_range` (String) The port range of the destination. Only valid when type=private. Single ports are supported. Private destinations are an early access feature and gated behind a feature flag.
- `type` (String) The destination type. Available values: `public`, `private`. Defaults to `public`.
+- `uri` (String) The public URI of the destination. Can include a domain and path with wildcards. Only valid when type=public.
+- `vnet_id` (String) The VNet ID of the destination. Only valid when type=private. Private destinations are an early access feature and gated behind a feature flag.
diff --git a/docs/resources/zero_trust_access_application.md b/docs/resources/zero_trust_access_application.md
index 1c508626fd..00e4dbc928 100644
--- a/docs/resources/zero_trust_access_application.md
+++ b/docs/resources/zero_trust_access_application.md
@@ -119,13 +119,15 @@ Optional:
### Nested Schema for `destinations`
-Required:
-
-- `uri` (String) The URI of the destination. Public destinations can include a domain and path with wildcards. Private destinations are an early access feature and gated behind a feature flag. Private destinations support private IPv4, IPv6, and Server Name Indications (SNI) with optional port ranges.
-
Optional:
+- `cidr` (String) The private CIDR of the destination. Only valid when type=private. IPs are computed as /32 cidr. Private destinations are an early access feature and gated behind a feature flag.
+- `hostname` (String) The private hostname of the destination. Only valid when type=private. Private hostnames currently match only Server Name Indications (SNI). Private destinations are an early access feature and gated behind a feature flag.
+- `l4_protocol` (String) The l4 protocol that matches this destination. Only valid when type=private. Private destinations are an early access feature and gated behind a feature flag.
+- `port_range` (String) The port range of the destination. Only valid when type=private. Single ports are supported. Private destinations are an early access feature and gated behind a feature flag.
- `type` (String) The destination type. Available values: `public`, `private`. Defaults to `public`.
+- `uri` (String) The public URI of the destination. Can include a domain and path with wildcards. Only valid when type=public.
+- `vnet_id` (String) The VNet ID of the destination. Only valid when type=private. Private destinations are an early access feature and gated behind a feature flag.
diff --git a/internal/sdkv2provider/resource_cloudflare_access_application_test.go b/internal/sdkv2provider/resource_cloudflare_access_application_test.go
index 64b43713c6..ffa3bc4e80 100644
--- a/internal/sdkv2provider/resource_cloudflare_access_application_test.go
+++ b/internal/sdkv2provider/resource_cloudflare_access_application_test.go
@@ -3,9 +3,11 @@ package sdkv2provider
import (
"context"
"fmt"
+ "github.com/google/uuid"
"log"
"os"
"regexp"
+ "strings"
"testing"
"github.com/cloudflare/cloudflare-go"
@@ -954,11 +956,18 @@ func TestAccCloudflareAccessApplication_WithDestinations(t *testing.T) {
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID),
resource.TestCheckResourceAttr(name, "name", rnd),
- resource.TestCheckResourceAttr(name, "destinations.#", "2"),
+ resource.TestCheckResourceAttr(name, "destinations.#", "4"),
resource.TestCheckResourceAttr(name, "destinations.0.type", "public"),
resource.TestCheckResourceAttr(name, "destinations.0.uri", fmt.Sprintf("d1.%s.%s", rnd, domain)),
resource.TestCheckResourceAttr(name, "destinations.1.type", "public"),
resource.TestCheckResourceAttr(name, "destinations.1.uri", fmt.Sprintf("d2.%s.%s", rnd, domain)),
+ resource.TestCheckResourceAttr(name, "destinations.2.type", "private"),
+ resource.TestCheckResourceAttr(name, "destinations.2.hostname", fmt.Sprintf("d1.%s.%s.privatenetwork", rnd, domain)),
+ resource.TestCheckResourceAttr(name, "destinations.2.port_range", "443"),
+ resource.TestCheckResourceAttr(name, "destinations.2.l4_protocol", "udp"),
+ resource.TestCheckResourceAttr(name, "destinations.3.type", "private"),
+ resource.TestCheckResourceAttr(name, "destinations.3.cidr", "127.0.0.2/32"),
+ resource.TestCheckResourceAttrWith(name, "destinations.3.vnet_id", uuid.Validate),
resource.TestCheckResourceAttr(name, "name", rnd),
resource.TestCheckResourceAttr(name, "type", "self_hosted"),
resource.TestCheckResourceAttr(name, "session_duration", "24h"),
@@ -1248,7 +1257,7 @@ resource "cloudflare_zero_trust_access_application" "%[1]s" {
name = "rank"
}
}
-
+
hybrid_and_implicit_options {
return_id_token_from_authorization_endpoint = true
return_access_token_from_authorization_endpoint = true
@@ -1485,6 +1494,10 @@ resource "cloudflare_zero_trust_access_application" "%[1]s" {
}
func testAccCloudflareAccessApplicationWithDestinations(rnd string, domain string, identifier *cloudflare.ResourceContainer) string {
+ // make sure the seed string has at least 16 bytes to fill the UUID
+ vnetSeed := strings.Repeat(rnd, 16)
+ vnetID, _ := uuid.FromBytes([]byte(vnetSeed[:16]))
+
return fmt.Sprintf(`
resource "cloudflare_zero_trust_access_application" "%[1]s" {
%[3]s_id = "%[4]s"
@@ -1498,8 +1511,19 @@ resource "cloudflare_zero_trust_access_application" "%[1]s" {
destinations {
uri = "d2.%[1]s.%[2]s"
}
+ destinations {
+ type = "private"
+ hostname = "d1.%[1]s.%[2]s.privatenetwork"
+ port_range = "443"
+ l4_protocol = "udp"
+ }
+ destinations {
+ type = "private"
+ cidr = "127.0.0.2"
+ vnet_id = "%[5]s"
+ }
}
-`, rnd, domain, identifier.Type, identifier.Identifier)
+`, rnd, domain, identifier.Type, identifier.Identifier, vnetID)
}
func testAccCloudflareAccessApplicationWithDestinations2(rnd string, domain string, identifier *cloudflare.ResourceContainer) string {
diff --git a/internal/sdkv2provider/schema_cloudflare_access_application.go b/internal/sdkv2provider/schema_cloudflare_access_application.go
index 7b34f60ebc..0470578b9f 100644
--- a/internal/sdkv2provider/schema_cloudflare_access_application.go
+++ b/internal/sdkv2provider/schema_cloudflare_access_application.go
@@ -3,6 +3,7 @@ package sdkv2provider
import (
"fmt"
"regexp"
+ "strings"
"time"
"github.com/cloudflare/cloudflare-go"
@@ -63,7 +64,7 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema {
Type: schema.TypeString,
Optional: true,
Computed: true,
- ValidateFunc: validation.StringInSlice([]string{"public", "private"}, false),
+ ValidateFunc: validation.StringInSlice([]string{"public"}, false),
Description: fmt.Sprintf("The type of the primary domain. %s", renderAvailableDocumentationValuesStringSlice([]string{"public", "private"})),
DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool {
appType := d.Get("type").(string)
@@ -91,8 +92,57 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema {
},
"uri": {
Type: schema.TypeString,
- Required: true,
- Description: "The URI of the destination. Public destinations can include a domain and path with wildcards. Private destinations are an early access feature and gated behind a feature flag. Private destinations support private IPv4, IPv6, and Server Name Indications (SNI) with optional port ranges.",
+ Optional: true,
+ Description: "The public URI of the destination. Can include a domain and path with wildcards. Only valid when type=public",
+ },
+ "hostname": {
+ Type: schema.TypeString,
+ Optional: true,
+ Description: "The private hostname of the destination. Only valid when type=private. Private hostnames currently match only Server Name Indications (SNI). Private destinations are an early access feature and gated behind a feature flag.",
+ },
+ "cidr": {
+ Type: schema.TypeString,
+ Optional: true,
+ Computed: true,
+ DiffSuppressOnRefresh: true,
+ DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool {
+ // /32 is the same as omitting the mask for an IPV4
+ // And /128 for an ipv6
+ oldIsIpv4 := strings.Count(oldValue, ".") == 3
+ newIsIpv4 := strings.Count(newValue, ".") == 3
+
+ if oldIsIpv4 && newIsIpv4 {
+ return (strings.HasSuffix(oldValue, "/32") && !strings.Contains(newValue, "/")) ||
+ (strings.HasSuffix(newValue, "/32") && !strings.Contains(oldValue, "/"))
+ }
+
+ return (strings.HasSuffix(oldValue, "/128") && !strings.Contains(newValue, "/")) ||
+ (strings.HasSuffix(newValue, "/128") && !strings.Contains(oldValue, "/"))
+ },
+ Description: "The private CIDR of the destination. Only valid when type=private. IPs are computed as /32 cidr. Private destinations are an early access feature and gated behind a feature flag.",
+ },
+ "port_range": {
+ Type: schema.TypeString,
+ Optional: true,
+ Computed: true,
+ DiffSuppressOnRefresh: true,
+ DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool {
+ // Passing a number is the same a range of length 1
+ // E.g., "443" == "443-443"
+ return newValue == fmt.Sprintf("%s-%s", oldValue, oldValue) || oldValue == fmt.Sprintf("%s-%s", newValue, newValue)
+ },
+ Description: "The port range of the destination. Only valid when type=private. Single ports are supported. Private destinations are an early access feature and gated behind a feature flag.",
+ },
+ "vnet_id": {
+ Type: schema.TypeString,
+ Optional: true,
+ Description: "The VNet ID of the destination. Only valid when type=private. Private destinations are an early access feature and gated behind a feature flag.",
+ },
+ "l4_protocol": {
+ Type: schema.TypeString,
+ ValidateFunc: validation.StringInSlice([]string{"tcp", "udp"}, false),
+ Optional: true,
+ Description: "The l4 protocol that matches this destination. Only valid when type=private. Private destinations are an early access feature and gated behind a feature flag.",
},
},
},
@@ -1030,6 +1080,38 @@ func convertSaasSchemaToStruct(d *schema.ResourceData) *cloudflare.SaasApplicati
}
}
+func convertPublicDestinationStruct(payload map[string]any) cloudflare.AccessDestination {
+ dest := cloudflare.AccessDestination{
+ Type: cloudflare.AccessDestinationPublic,
+ }
+ if uri, ok := payload["uri"].(string); ok {
+ dest.URI = uri
+ }
+ return dest
+}
+
+func convertPrivateDestinationStruct(payload map[string]any) cloudflare.AccessDestination {
+ dest := cloudflare.AccessDestination{
+ Type: cloudflare.AccessDestinationPrivate,
+ }
+ if hostname, ok := payload["hostname"].(string); ok {
+ dest.Hostname = hostname
+ }
+ if ip, ok := payload["cidr"].(string); ok {
+ dest.CIDR = ip
+ }
+ if portRange, ok := payload["port_range"].(string); ok {
+ dest.PortRange = portRange
+ }
+ if l4Protocol, ok := payload["l4_protocol"].(string); ok {
+ dest.L4Protocol = l4Protocol
+ }
+ if vnetID, ok := payload["vnet_id"].(string); ok {
+ dest.VnetID = vnetID
+ }
+ return dest
+}
+
func convertDestinationsToStruct(destinationPayloads []interface{}) ([]cloudflare.AccessDestination, error) {
destinations := make([]cloudflare.AccessDestination, len(destinationPayloads))
for i, dp := range destinationPayloads {
@@ -1038,17 +1120,13 @@ func convertDestinationsToStruct(destinationPayloads []interface{}) ([]cloudflar
if dType, ok := dpMap["type"].(string); ok {
switch dType {
case "public":
- destinations[i].Type = cloudflare.AccessDestinationPublic
+ destinations[i] = convertPublicDestinationStruct(dpMap)
case "private":
- destinations[i].Type = cloudflare.AccessDestinationPrivate
+ destinations[i] = convertPrivateDestinationStruct(dpMap)
default:
return nil, fmt.Errorf("failed to parse destination type: value must be one of public or private")
}
}
-
- if uri, ok := dpMap["uri"].(string); ok {
- destinations[i].URI = uri
- }
}
return destinations, nil
@@ -1558,10 +1636,27 @@ func convertScimConfigMappingsStructsToSchema(mappingsData []*cloudflare.AccessA
func convertDestinationsToSchema(destinations []cloudflare.AccessDestination) []interface{} {
schemas := make([]interface{}, len(destinations))
for i, dest := range destinations {
- schemas[i] = map[string]interface{}{
- "type": string(dest.Type),
- "uri": dest.URI,
+ resultDest := make(map[string]interface{})
+ resultDest["type"] = string(dest.Type)
+ if dest.URI != "" {
+ resultDest["uri"] = dest.URI
+ }
+ if dest.Hostname != "" {
+ resultDest["hostname"] = dest.Hostname
+ }
+ if dest.CIDR != "" {
+ resultDest["cidr"] = dest.CIDR
+ }
+ if dest.PortRange != "" {
+ resultDest["port_range"] = dest.PortRange
+ }
+ if dest.L4Protocol != "" {
+ resultDest["l4_protocol"] = dest.L4Protocol
+ }
+ if dest.VnetID != "" {
+ resultDest["vnet_id"] = dest.VnetID
}
+ schemas[i] = resultDest
}
return schemas
}