From 2d41d72a3418a3ebc865c6b5365d4cee189244aa Mon Sep 17 00:00:00 2001 From: maxiscoding28 Date: Tue, 28 Jul 2026 16:11:43 -0700 Subject: [PATCH 1/6] terraform/aws: add aws-app-connector-s3-privatelink Route one specific S3 bucket privately through a Tailscale app connector while all other buckets and web traffic continue to egress publicly. An app connector pointed at a public S3 endpoint advertises whatever that endpoint resolves to. S3 regional endpoints are large shared address pools that rotate, so the advertised route set grows without bound. This example gives the bucket a stable private address with an S3 interface VPC endpoint first, then advertises that one address, so the route set is a fixed pair. Three pieces have to line up: 1. The interface endpoint, with private DNS and private_dns_only_for_inbound_resolver_endpoint = false, gives the bucket a stable ENI address inside the VPC. 2. A split DNS entry on the exact bucket FQDN sends only that one name to the VPC resolver. Sibling buckets do not match and stay public. 3. The connector advertises the VPC resolver and the endpoint ENI, and SNATs both so they arrive as ordinary in-VPC traffic. Co-Authored-By: Claude Opus 5 (1M context) --- .../README.md | 101 +++++++ .../aws-app-connector-s3-privatelink/main.tf | 275 ++++++++++++++++++ .../outputs.tf | 45 +++ .../versions.tf | 18 ++ 4 files changed, 439 insertions(+) create mode 100644 terraform/aws/aws-app-connector-s3-privatelink/README.md create mode 100644 terraform/aws/aws-app-connector-s3-privatelink/main.tf create mode 100644 terraform/aws/aws-app-connector-s3-privatelink/outputs.tf create mode 100644 terraform/aws/aws-app-connector-s3-privatelink/versions.tf diff --git a/terraform/aws/aws-app-connector-s3-privatelink/README.md b/terraform/aws/aws-app-connector-s3-privatelink/README.md new file mode 100644 index 0000000..498168d --- /dev/null +++ b/terraform/aws/aws-app-connector-s3-privatelink/README.md @@ -0,0 +1,101 @@ +# aws-app-connector-s3-privatelink + +> :information_source: This example is intended for users who want tailnet clients to reach **one specific S3 bucket** over a private path, while every other bucket and all other web traffic continues to go out over the public internet as before. + +An app connector pointed at a public S3 endpoint advertises whatever that endpoint resolves to. S3's regional endpoints are large shared address pools that rotate, so the advertised route set grows without bound and never settles. This example gives the bucket a stable private address first, using an S3 interface VPC endpoint (AWS PrivateLink), and then advertises that one address. The connector's route set is a fixed pair instead of a moving target. + +This example creates the following: + +- a VPC and related resources including a NAT Gateway +- a private S3 bucket with one object, readable only through the VPC endpoint +- an S3 interface VPC endpoint (AWS PrivateLink) with private DNS, scoped by endpoint policy to that one bucket +- an EC2 instance running Tailscale as an app connector, advertising the VPC resolver and the endpoint ENI +- a Tailscale split DNS entry mapping the bucket's regional domain to the VPC resolver +- a Tailnet device key to authenticate the Tailscale device + +## How the pieces fit + +Three things have to line up for a single bucket to go private: + +1. **The interface endpoint gives the bucket a private address.** With `private_dns_enabled` and `private_dns_only_for_inbound_resolver_endpoint = false`, resolving the bucket's regional domain from inside the VPC returns the endpoint's ENI address rather than a public S3 address. + +2. **Split DNS sends only that one name to the VPC resolver.** The split DNS domain is the exact bucket FQDN (`.s3..amazonaws.com`), not `s3..amazonaws.com`. Split DNS matches a domain and its subdomains, not its siblings, so other buckets in the same region never match, resolve publicly, and never involve the connector. + +3. **The connector advertises the two addresses that path needs.** The VPC resolver (VPC base address + 2) is the only resolver that knows the endpoint's private DNS mapping, and a client outside the VPC cannot reach it directly, so its query has to ride the connector. The endpoint ENI carries the object bytes. The connector SNATs both, so they arrive as ordinary in-VPC traffic. + +The instance is registered as an app connector for the bucket domain, which is what lets you write access rules against the domain name. Its routes are advertised explicitly rather than left to discovery: split DNS means discovery would resolve to the same ENI address anyway, and declaring them keeps the advertised set fixed and readable. + +## Policy File Example + +```json +{ + "tagOwners": { + "tag:example-infra": ["autogroup:admin"], + "tag:example-appconnector": ["autogroup:admin"], + }, + + "autoApprovers": { + "routes": { + "0.0.0.0/0": ["tag:example-appconnector"], + }, + }, + + "nodeAttrs": [ + { + // "target" must be "*". The "connectors" field is what scopes this + // to the tagged device; the API rejects a tag in "target". + "target": ["*"], + "app": { + "tailscale.com/app-connectors": [ + { + "name": "example-s3", + "connectors": ["tag:example-appconnector"], + // Replace with the "s3_domain" output after applying. + "domains": ["example-bucket.s3.us-west-2.amazonaws.com"], + }, + ], + }, + }, + ], +} +``` + +## Considerations + +- The `domains` list in the app connector definition contains the bucket's regional domain, which is not known until after `terraform apply`. Apply first, then take the `s3_domain` output and add it to your policy file. +- Any advertised routes must still be approved in the Tailscale Admin Console. The policy above uses [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) instead; narrow the auto-approved prefix if a blanket `0.0.0.0/0` is broader than you want. +- Only [virtual-hosted-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html) requests (`https://.s3..amazonaws.com/`) take the private path. A path-style request uses the shared regional hostname, which does not match the split DNS entry and goes out publicly. +- The bucket policy grants reads only when `aws:SourceVpce` matches the endpoint, so a request from the public internet gets a 403. That condition is what keeps the grant non-public and acceptable under S3 Block Public Access. +- The endpoint policy allows `s3:*` on this one bucket rather than just `s3:GetObject`. A client whose split DNS routes the bucket through the endpoint sends its control-plane calls the same way, and a read-only endpoint policy causes tooling such as the AWS CLI to fail against the bucket. The bucket ARN in `Resource` is what keeps the scope narrow. +- The connector must live in the same VPC as the endpoint. The ENI and the VPC resolver are reachable only from inside the VPC. +- `enable_dns_support` and `enable_dns_hostnames` must both be enabled on the VPC or the endpoint's private DNS override does nothing. The VPC module used here enables both by default. + +## To use + +Follow the documentation to configure the Terraform providers: + +- [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) +- [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) + +### Deploy + +```shell +terraform init +terraform apply +``` + +Add the `s3_domain` output to the app connector definition in your policy file, then confirm the private path from a tailnet client: + +```shell +# Resolves to the endpoint ENI (a private address), not a public S3 address. +dig +short "$(terraform output -raw s3_domain)" + +# 200 from a tailnet client using the connector, 403 from the public internet. +curl -sI "$(terraform output -raw s3_object_url)" | head -1 +``` + +## To destroy + +```shell +terraform destroy +``` diff --git a/terraform/aws/aws-app-connector-s3-privatelink/main.tf b/terraform/aws/aws-app-connector-s3-privatelink/main.tf new file mode 100644 index 0000000..41f45aa --- /dev/null +++ b/terraform/aws/aws-app-connector-s3-privatelink/main.tf @@ -0,0 +1,275 @@ +locals { + name = "example-${basename(path.cwd)}" + + aws_tags = { + Name = local.name + } + + tailscale_acl_tags = [ + "tag:example-infra", + "tag:example-appconnector", + ] + + # The connector plays two roles at once. --advertise-connector registers it as + # an app connector for the bucket domain named in the policy file, which is the + # handle you write access rules against. --advertise-routes pins the two + # addresses that domain actually needs, so the advertised route set is a fixed + # pair rather than whatever S3's public endpoint happens to resolve to. + tailscale_set_preferences = [ + "--auto-update", + "--ssh", + "--advertise-connector", + "--advertise-routes=${join(",", local.s3_advertised_routes)}", + ] + + # The two routes that make one bucket private. The first is the VPC's own + # Route 53 Resolver (VPC base + 2), the only resolver that knows the interface + # endpoint's private DNS mapping. The second is the endpoint ENI itself, which + # carries the object bytes. Both are stable for the life of the VPC. + s3_advertised_routes = concat( + ["${local.vpc_resolver_ip}/32"], + [for eni in data.aws_network_interface.s3_endpoint : "${eni.private_ip}/32"], + ) + vpc_resolver_ip = cidrhost(local.vpc_cidr_block, 2) + + # Modify these to use your own VPC. enable_dns_support and enable_dns_hostnames + # must both be on, or the interface endpoint's private DNS override does + # nothing. The community VPC module defaults both to true. + vpc_cidr_block = "10.0.80.0/22" + vpc_id = module.vpc.vpc_id + subnet_id = module.vpc.public_subnets[0] + security_group_ids = [aws_security_group.tailscale.id] + instance_type = "c7g.medium" + + # The bucket that goes private. S3 bucket names are globally unique, so the + # directory-based name gets a random suffix. + s3_bucket_name = "${local.name}-${random_id.bucket_suffix.hex}" +} + +# Remove this to use your own VPC. +module "vpc" { + source = "../internal-modules/aws-vpc" + + name = local.name + tags = local.aws_tags + + cidr = local.vpc_cidr_block +} + +data "aws_region" "current" {} + +# +# The bucket to be reached privately +# + +resource "random_id" "bucket_suffix" { + byte_length = 4 +} + +resource "aws_s3_bucket" "main" { + bucket = local.s3_bucket_name + force_destroy = true + + tags = merge(local.aws_tags, { + Name = local.s3_bucket_name + }) +} + +resource "aws_s3_bucket_public_access_block" "main" { + bucket = aws_s3_bucket.main.id + + block_public_acls = true + ignore_public_acls = true + block_public_policy = true + restrict_public_buckets = true +} + +# Reads succeed only when the request arrived through the interface endpoint. A +# request from the open internet has no aws:SourceVpce and is denied, so this is +# what proves the traffic took the private path. An aws:SourceVpce condition is +# not a public grant, so the policy is accepted under Block Public Access. +resource "aws_s3_bucket_policy" "main" { + bucket = aws_s3_bucket.main.id + depends_on = [aws_s3_bucket_public_access_block.main] + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowThroughS3PrivateLink" + Effect = "Allow" + Principal = "*" + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.main.arn}/*" + Condition = { + StringEquals = { + "aws:SourceVpce" = aws_vpc_endpoint.s3.id + } + } + }, + ] + }) +} + +resource "aws_s3_object" "hello" { + bucket = aws_s3_bucket.main.id + key = "hello.txt" + content = "Reached ${local.s3_bucket_name} over AWS PrivateLink via a Tailscale app connector.\n" + content_type = "text/plain" +} + +# +# The private path into S3 +# + +# private_dns_enabled with private_dns_only_for_inbound_resolver_endpoint set to +# false is what makes in-VPC resolution of the S3 regional domain return this +# endpoint's ENI address instead of a public one. The default (true) applies only +# to queries arriving through a Route 53 Resolver inbound endpoint and also +# requires a gateway endpoint, neither of which is in play here. +resource "aws_vpc_endpoint" "s3" { + vpc_id = local.vpc_id + service_name = "com.amazonaws.${data.aws_region.current.region}.s3" + vpc_endpoint_type = "Interface" + + subnet_ids = [local.subnet_id] + security_group_ids = [aws_security_group.s3_endpoint.id] + private_dns_enabled = true + + dns_options { + private_dns_only_for_inbound_resolver_endpoint = false + } + + # Scope the endpoint to this one bucket, so it cannot become a general private + # door into every bucket in the account. The action list is s3:* rather than + # just GetObject because a client whose split DNS routes this bucket through + # the endpoint sends its control-plane calls the same way, and a read-only + # endpoint policy makes tooling such as the AWS CLI fail against the bucket. + # The bucket ARN in Resource is what keeps the scope narrow. + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "OnlyThisBucket" + Effect = "Allow" + Principal = "*" + Action = "s3:*" + Resource = [aws_s3_bucket.main.arn, "${aws_s3_bucket.main.arn}/*"] + }, + ] + }) + + tags = merge(local.aws_tags, { + Name = "${local.name}-s3" + }) +} + +# The endpoint's ENI addresses, read back so they can be advertised as routes. +# One ENI per subnet, and this example uses one subnet. count is used rather than +# for_each because the instance count is known at plan time while the ENI IDs are +# not, and a for_each over unknown IDs cannot be planned. +data "aws_network_interface" "s3_endpoint" { + count = 1 + + id = tolist(aws_vpc_endpoint.s3.network_interface_ids)[count.index] +} + +# Tell the tailnet that this one bucket name resolves at the VPC resolver. Only +# this exact FQDN is sent there, so sibling buckets keep resolving publicly and +# never touch the connector. This is the piece that scopes "private" to a single +# bucket rather than to all of S3. +resource "tailscale_dns_split_nameservers" "s3" { + domain = aws_s3_bucket.main.bucket_regional_domain_name + nameservers = [local.vpc_resolver_ip] +} + +# +# The app connector +# + +resource "tailscale_tailnet_key" "main" { + ephemeral = true + preauthorized = true + reusable = true + recreate_if_invalid = "always" + tags = local.tailscale_acl_tags +} + +module "tailscale_aws_ec2" { + source = "../internal-modules/aws-ec2-instance" + + instance_type = local.instance_type + instance_tags = local.aws_tags + + subnet_id = local.subnet_id + vpc_security_group_ids = local.security_group_ids + + # Variables for Tailscale resources + tailscale_hostname = local.name + tailscale_auth_key = tailscale_tailnet_key.main.key + tailscale_set_preferences = local.tailscale_set_preferences + + depends_on = [ + module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available + ] +} + +# +# Security groups +# + +resource "aws_security_group" "tailscale" { + vpc_id = local.vpc_id + name = local.name + + tags = local.aws_tags +} + +resource "aws_security_group_rule" "tailscale_ingress" { + security_group_id = aws_security_group.tailscale.id + type = "ingress" + from_port = 41641 + to_port = 41641 + protocol = "udp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] +} + +resource "aws_security_group_rule" "egress" { + security_group_id = aws_security_group.tailscale.id + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] +} + +resource "aws_security_group_rule" "internal_vpc_ingress_ipv4" { + security_group_id = aws_security_group.tailscale.id + type = "ingress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [local.vpc_cidr_block] +} + +resource "aws_security_group" "s3_endpoint" { + vpc_id = local.vpc_id + name = "${local.name}-s3" + description = "S3 interface VPC endpoint. HTTPS from within the VPC only." + + tags = merge(local.aws_tags, { + Name = "${local.name}-s3" + }) +} + +resource "aws_security_group_rule" "s3_endpoint_ingress" { + security_group_id = aws_security_group.s3_endpoint.id + description = "HTTPS from within the VPC" + type = "ingress" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [local.vpc_cidr_block] +} diff --git a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf new file mode 100644 index 0000000..6ff56d7 --- /dev/null +++ b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf @@ -0,0 +1,45 @@ +output "resource_name_prefix" { + value = local.name +} + +output "vpc_id" { + value = module.vpc.vpc_id +} + +output "vpc_cidr" { + value = module.vpc.vpc_cidr_block +} + +output "instance_ids" { + value = module.tailscale_aws_ec2[*].instance_id +} + +output "s3_bucket" { + description = "Name of the bucket reachable over PrivateLink" + value = aws_s3_bucket.main.bucket +} + +output "s3_domain" { + description = "Bucket regional domain. This is the app connector domain and the split DNS domain." + value = aws_s3_bucket.main.bucket_regional_domain_name +} + +output "s3_object_url" { + description = "Object URL. Returns 200 from a tailnet client using the connector, 403 from the public internet." + value = "https://${aws_s3_bucket.main.bucket_regional_domain_name}/hello.txt" +} + +output "s3_vpc_endpoint_id" { + value = aws_vpc_endpoint.s3.id +} + +output "s3_advertised_routes" { + description = "Routes the connector advertises: the VPC resolver and the endpoint ENI" + value = local.s3_advertised_routes +} + +output "user_data_md5" { + description = "MD5 hash of the VM user_data script - for detecting changes" + value = module.tailscale_aws_ec2.user_data_md5 + sensitive = true +} diff --git a/terraform/aws/aws-app-connector-s3-privatelink/versions.tf b/terraform/aws/aws-app-connector-s3-privatelink/versions.tf new file mode 100644 index 0000000..30d36de --- /dev/null +++ b/terraform/aws/aws-app-connector-s3-privatelink/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0, < 7.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0, < 4.0" + } + tailscale = { + source = "tailscale/tailscale" + version = ">= 0.24" + } + } + + required_version = ">= 1.0, < 2.0" +} From 546ea26455f270eee41df1eb6657e96fcfedbcf5 Mon Sep 17 00:00:00 2001 From: maxiscoding28 Date: Tue, 28 Jul 2026 16:19:47 -0700 Subject: [PATCH 2/6] terraform/aws: align example with the repo style guide Match the mock example in CONTRIBUTING.md more closely. Cut comments from 51 lines to 26, in line with the other AWS examples. Move the subnet CIDR into locals and use the same local names as the mock (vpc_cidr_block, instance_subnet_id, instance_security_group_ids). Add the missing Name tag on the S3 object and rename resources to "main". Co-Authored-By: Claude Opus 5 (1M context) --- .../README.md | 46 ++++--- .../aws-app-connector-s3-privatelink/main.tf | 112 +++++++----------- .../outputs.tf | 2 +- 3 files changed, 68 insertions(+), 92 deletions(-) diff --git a/terraform/aws/aws-app-connector-s3-privatelink/README.md b/terraform/aws/aws-app-connector-s3-privatelink/README.md index 498168d..d5122d3 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/README.md +++ b/terraform/aws/aws-app-connector-s3-privatelink/README.md @@ -1,32 +1,32 @@ # aws-app-connector-s3-privatelink -> :information_source: This example is intended for users who want tailnet clients to reach **one specific S3 bucket** over a private path, while every other bucket and all other web traffic continues to go out over the public internet as before. +> :information_source: This example routes traffic for **one specific S3 bucket** over a private path. Every other bucket, and all other traffic, continues to egress publicly. A use case for this is keeping one sensitive bucket off the public internet without changing how anything else is reached. -An app connector pointed at a public S3 endpoint advertises whatever that endpoint resolves to. S3's regional endpoints are large shared address pools that rotate, so the advertised route set grows without bound and never settles. This example gives the bucket a stable private address first, using an S3 interface VPC endpoint (AWS PrivateLink), and then advertises that one address. The connector's route set is a fixed pair instead of a moving target. +Pointing an app connector at a public S3 endpoint does not work well. S3 regional endpoints resolve to a large pool of addresses that rotates, so the connector keeps discovering new addresses and the advertised route set grows without settling. This example gives the bucket a stable private address first, then advertises that. This example creates the following: - a VPC and related resources including a NAT Gateway - a private S3 bucket with one object, readable only through the VPC endpoint -- an S3 interface VPC endpoint (AWS PrivateLink) with private DNS, scoped by endpoint policy to that one bucket +- an S3 interface VPC endpoint (AWS PrivateLink) with private DNS, scoped by policy to that one bucket - an EC2 instance running Tailscale as an app connector, advertising the VPC resolver and the endpoint ENI -- a Tailscale split DNS entry mapping the bucket's regional domain to the VPC resolver +- a Tailscale split DNS entry mapping the bucket domain to the VPC resolver - a Tailnet device key to authenticate the Tailscale device -## How the pieces fit +## How it works -Three things have to line up for a single bucket to go private: +Three pieces have to line up. -1. **The interface endpoint gives the bucket a private address.** With `private_dns_enabled` and `private_dns_only_for_inbound_resolver_endpoint = false`, resolving the bucket's regional domain from inside the VPC returns the endpoint's ENI address rather than a public S3 address. +The **interface endpoint** gives the bucket a private address. With `private_dns_enabled` and `private_dns_only_for_inbound_resolver_endpoint = false`, resolving the bucket domain inside the VPC returns the endpoint ENI instead of a public address. -2. **Split DNS sends only that one name to the VPC resolver.** The split DNS domain is the exact bucket FQDN (`.s3..amazonaws.com`), not `s3..amazonaws.com`. Split DNS matches a domain and its subdomains, not its siblings, so other buckets in the same region never match, resolve publicly, and never involve the connector. +**Split DNS** sends only that one name to the VPC resolver. The domain is the exact bucket FQDN, not `s3..amazonaws.com`. Split DNS matches a domain and its subdomains, not its siblings, so other buckets never match and stay public. -3. **The connector advertises the two addresses that path needs.** The VPC resolver (VPC base address + 2) is the only resolver that knows the endpoint's private DNS mapping, and a client outside the VPC cannot reach it directly, so its query has to ride the connector. The endpoint ENI carries the object bytes. The connector SNATs both, so they arrive as ordinary in-VPC traffic. - -The instance is registered as an app connector for the bucket domain, which is what lets you write access rules against the domain name. Its routes are advertised explicitly rather than left to discovery: split DNS means discovery would resolve to the same ENI address anyway, and declaring them keeps the advertised set fixed and readable. +The **app connector** advertises the two addresses that path needs. The VPC resolver is the only resolver that knows the private DNS mapping, and a client outside the VPC cannot reach it directly, so the query rides the connector. The endpoint ENI carries the object bytes. ## Policy File Example +Replace the domain with the `s3_domain` output after applying. + ```json { "tagOwners": { @@ -42,15 +42,14 @@ The instance is registered as an app connector for the bucket domain, which is w "nodeAttrs": [ { - // "target" must be "*". The "connectors" field is what scopes this - // to the tagged device; the API rejects a tag in "target". + // "target" must be "*". The "connectors" field scopes this to the + // tagged device; the API rejects a tag in "target". "target": ["*"], "app": { "tailscale.com/app-connectors": [ { "name": "example-s3", "connectors": ["tag:example-appconnector"], - // Replace with the "s3_domain" output after applying. "domains": ["example-bucket.s3.us-west-2.amazonaws.com"], }, ], @@ -62,13 +61,12 @@ The instance is registered as an app connector for the bucket domain, which is w ## Considerations -- The `domains` list in the app connector definition contains the bucket's regional domain, which is not known until after `terraform apply`. Apply first, then take the `s3_domain` output and add it to your policy file. -- Any advertised routes must still be approved in the Tailscale Admin Console. The policy above uses [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) instead; narrow the auto-approved prefix if a blanket `0.0.0.0/0` is broader than you want. -- Only [virtual-hosted-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html) requests (`https://.s3..amazonaws.com/`) take the private path. A path-style request uses the shared regional hostname, which does not match the split DNS entry and goes out publicly. -- The bucket policy grants reads only when `aws:SourceVpce` matches the endpoint, so a request from the public internet gets a 403. That condition is what keeps the grant non-public and acceptable under S3 Block Public Access. -- The endpoint policy allows `s3:*` on this one bucket rather than just `s3:GetObject`. A client whose split DNS routes the bucket through the endpoint sends its control-plane calls the same way, and a read-only endpoint policy causes tooling such as the AWS CLI to fail against the bucket. The bucket ARN in `Resource` is what keeps the scope narrow. -- The connector must live in the same VPC as the endpoint. The ENI and the VPC resolver are reachable only from inside the VPC. -- `enable_dns_support` and `enable_dns_hostnames` must both be enabled on the VPC or the endpoint's private DNS override does nothing. The VPC module used here enables both by default. +- The bucket domain is not known until after `terraform apply`, so the app connector definition cannot be written first. Apply, then add the `s3_domain` output to your policy file. +- Any advertised routes must still be approved in the Tailscale Admin Console. The policy above uses [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) instead. Narrow the prefix if `0.0.0.0/0` is broader than you want. +- Only [virtual-hosted-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html) requests take the private path. A path-style request uses the shared regional hostname, does not match the split DNS entry, and goes out publicly. +- The endpoint policy allows `s3:*` on this one bucket rather than just `s3:GetObject`. A client routing the bucket through the endpoint sends its control-plane calls the same way, and a read-only endpoint policy makes tooling such as the AWS CLI fail against the bucket. +- The connector must be in the same VPC as the endpoint. The ENI and the VPC resolver are only reachable from inside the VPC. +- `enable_dns_support` and `enable_dns_hostnames` must both be enabled on the VPC or the private DNS override does nothing. The VPC module used here enables both by default. ## To use @@ -84,13 +82,13 @@ terraform init terraform apply ``` -Add the `s3_domain` output to the app connector definition in your policy file, then confirm the private path from a tailnet client: +Add the `s3_domain` output to your policy file, then check the path from a tailnet client: ```shell -# Resolves to the endpoint ENI (a private address), not a public S3 address. +# Resolves to the endpoint ENI, a private address. dig +short "$(terraform output -raw s3_domain)" -# 200 from a tailnet client using the connector, 403 from the public internet. +# 200 through the connector, 403 from the public internet. curl -sI "$(terraform output -raw s3_object_url)" | head -1 ``` diff --git a/terraform/aws/aws-app-connector-s3-privatelink/main.tf b/terraform/aws/aws-app-connector-s3-privatelink/main.tf index 41f45aa..4e56dd2 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/main.tf +++ b/terraform/aws/aws-app-connector-s3-privatelink/main.tf @@ -1,20 +1,18 @@ +# All customizable parameters in locals for easy customization. locals { + # common name based on the directory name name = "example-${basename(path.cwd)}" + # common tags used across all resources aws_tags = { Name = local.name } + # tailscale-specific arguments tailscale_acl_tags = [ "tag:example-infra", "tag:example-appconnector", ] - - # The connector plays two roles at once. --advertise-connector registers it as - # an app connector for the bucket domain named in the policy file, which is the - # handle you write access rules against. --advertise-routes pins the two - # addresses that domain actually needs, so the advertised route set is a fixed - # pair rather than whatever S3's public endpoint happens to resolve to. tailscale_set_preferences = [ "--auto-update", "--ssh", @@ -22,28 +20,27 @@ locals { "--advertise-routes=${join(",", local.s3_advertised_routes)}", ] - # The two routes that make one bucket private. The first is the VPC's own - # Route 53 Resolver (VPC base + 2), the only resolver that knows the interface - # endpoint's private DNS mapping. The second is the endpoint ENI itself, which - # carries the object bytes. Both are stable for the life of the VPC. + # The VPC resolver knows the interface endpoint's private DNS mapping, and the + # endpoint ENI carries the object bytes. Both are stable, unlike the public S3 + # addresses an app connector would otherwise discover and advertise. + vpc_resolver_ip = cidrhost(local.vpc_cidr_block, 2) s3_advertised_routes = concat( ["${local.vpc_resolver_ip}/32"], [for eni in data.aws_network_interface.s3_endpoint : "${eni.private_ip}/32"], ) - vpc_resolver_ip = cidrhost(local.vpc_cidr_block, 2) - # Modify these to use your own VPC. enable_dns_support and enable_dns_hostnames - # must both be on, or the interface endpoint's private DNS override does - # nothing. The community VPC module defaults both to true. - vpc_cidr_block = "10.0.80.0/22" - vpc_id = module.vpc.vpc_id - subnet_id = module.vpc.public_subnets[0] - security_group_ids = [aws_security_group.tailscale.id] - instance_type = "c7g.medium" - - # The bucket that goes private. S3 bucket names are globally unique, so the - # directory-based name gets a random suffix. + # Modify these to use your own VPC. + vpc_id = module.vpc.vpc_id + vpc_cidr_block = "10.0.80.0/22" + vpc_public_subnet_cidr_blocks = ["10.0.80.0/24"] + + instance_subnet_id = module.vpc.public_subnets[0] + instance_security_group_ids = [aws_security_group.tailscale.id] + instance_type = "c7g.medium" + + # S3 bucket names are globally unique, so the directory-based name gets a suffix. s3_bucket_name = "${local.name}-${random_id.bucket_suffix.hex}" + s3_object_key = "hello.txt" } # Remove this to use your own VPC. @@ -53,15 +50,12 @@ module "vpc" { name = local.name tags = local.aws_tags - cidr = local.vpc_cidr_block + cidr = local.vpc_cidr_block + public_subnets = local.vpc_public_subnet_cidr_blocks } data "aws_region" "current" {} -# -# The bucket to be reached privately -# - resource "random_id" "bucket_suffix" { byte_length = 4 } @@ -84,10 +78,9 @@ resource "aws_s3_bucket_public_access_block" "main" { restrict_public_buckets = true } -# Reads succeed only when the request arrived through the interface endpoint. A -# request from the open internet has no aws:SourceVpce and is denied, so this is -# what proves the traffic took the private path. An aws:SourceVpce condition is -# not a public grant, so the policy is accepted under Block Public Access. +# Reads succeed only through the endpoint. A request from the internet has no +# aws:SourceVpce and gets a 403. That condition also keeps the grant non-public, +# so the policy is accepted under Block Public Access. resource "aws_s3_bucket_policy" "main" { bucket = aws_s3_bucket.main.id depends_on = [aws_s3_bucket_public_access_block.main] @@ -111,28 +104,27 @@ resource "aws_s3_bucket_policy" "main" { }) } -resource "aws_s3_object" "hello" { +resource "aws_s3_object" "main" { bucket = aws_s3_bucket.main.id - key = "hello.txt" + key = local.s3_object_key content = "Reached ${local.s3_bucket_name} over AWS PrivateLink via a Tailscale app connector.\n" content_type = "text/plain" -} -# -# The private path into S3 -# + tags = merge(local.aws_tags, { + Name = "${local.name}-${local.s3_object_key}" + }) +} -# private_dns_enabled with private_dns_only_for_inbound_resolver_endpoint set to -# false is what makes in-VPC resolution of the S3 regional domain return this -# endpoint's ENI address instead of a public one. The default (true) applies only -# to queries arriving through a Route 53 Resolver inbound endpoint and also -# requires a gateway endpoint, neither of which is in play here. +# private_dns_only_for_inbound_resolver_endpoint = false is what makes in-VPC +# resolution of the bucket domain return this endpoint's ENI address. The default +# (true) applies only to queries arriving through a Route 53 Resolver inbound +# endpoint and also requires a gateway endpoint. resource "aws_vpc_endpoint" "s3" { vpc_id = local.vpc_id service_name = "com.amazonaws.${data.aws_region.current.region}.s3" vpc_endpoint_type = "Interface" - subnet_ids = [local.subnet_id] + subnet_ids = [local.instance_subnet_id] security_group_ids = [aws_security_group.s3_endpoint.id] private_dns_enabled = true @@ -140,12 +132,10 @@ resource "aws_vpc_endpoint" "s3" { private_dns_only_for_inbound_resolver_endpoint = false } - # Scope the endpoint to this one bucket, so it cannot become a general private - # door into every bucket in the account. The action list is s3:* rather than - # just GetObject because a client whose split DNS routes this bucket through - # the endpoint sends its control-plane calls the same way, and a read-only - # endpoint policy makes tooling such as the AWS CLI fail against the bucket. - # The bucket ARN in Resource is what keeps the scope narrow. + # Scoped to one bucket so the endpoint cannot become a private door into every + # bucket in the account. The action list is s3:* because a client routing this + # bucket through the endpoint sends its control-plane calls the same way, and a + # read-only policy breaks tooling such as the AWS CLI. policy = jsonencode({ Version = "2012-10-17" Statement = [ @@ -164,29 +154,21 @@ resource "aws_vpc_endpoint" "s3" { }) } -# The endpoint's ENI addresses, read back so they can be advertised as routes. # One ENI per subnet, and this example uses one subnet. count is used rather than -# for_each because the instance count is known at plan time while the ENI IDs are -# not, and a for_each over unknown IDs cannot be planned. +# for_each because the ENI IDs are not known at plan time. data "aws_network_interface" "s3_endpoint" { count = 1 id = tolist(aws_vpc_endpoint.s3.network_interface_ids)[count.index] } -# Tell the tailnet that this one bucket name resolves at the VPC resolver. Only -# this exact FQDN is sent there, so sibling buckets keep resolving publicly and -# never touch the connector. This is the piece that scopes "private" to a single -# bucket rather than to all of S3. -resource "tailscale_dns_split_nameservers" "s3" { +# Only this exact bucket name resolves at the VPC resolver. Sibling buckets do +# not match, resolve publicly, and never involve the connector. +resource "tailscale_dns_split_nameservers" "main" { domain = aws_s3_bucket.main.bucket_regional_domain_name nameservers = [local.vpc_resolver_ip] } -# -# The app connector -# - resource "tailscale_tailnet_key" "main" { ephemeral = true preauthorized = true @@ -201,8 +183,8 @@ module "tailscale_aws_ec2" { instance_type = local.instance_type instance_tags = local.aws_tags - subnet_id = local.subnet_id - vpc_security_group_ids = local.security_group_ids + subnet_id = local.instance_subnet_id + vpc_security_group_ids = local.instance_security_group_ids # Variables for Tailscale resources tailscale_hostname = local.name @@ -214,10 +196,6 @@ module "tailscale_aws_ec2" { ] } -# -# Security groups -# - resource "aws_security_group" "tailscale" { vpc_id = local.vpc_id name = local.name diff --git a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf index 6ff56d7..e8f14d4 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf +++ b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf @@ -26,7 +26,7 @@ output "s3_domain" { output "s3_object_url" { description = "Object URL. Returns 200 from a tailnet client using the connector, 403 from the public internet." - value = "https://${aws_s3_bucket.main.bucket_regional_domain_name}/hello.txt" + value = "https://${aws_s3_bucket.main.bucket_regional_domain_name}/${aws_s3_object.main.key}" } output "s3_vpc_endpoint_id" { From 7785cd7adebed91a9bc25f438b146e9226be24ec Mon Sep 17 00:00:00 2001 From: maxiscoding28 Date: Tue, 28 Jul 2026 16:29:48 -0700 Subject: [PATCH 3/6] terraform/aws: print the manual policy step as an output The Tailscale provider has no app connector resource, so that stanza has to be added to the policy file by hand. Add a next_step_app_connector_policy output that prints it with the bucket domain and advertised routes filled in, ready to paste. Say plainly in the README why this step is manual. Co-Authored-By: Claude Opus 5 (1M context) --- .../README.md | 12 +++++-- .../outputs.tf | 33 ++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/terraform/aws/aws-app-connector-s3-privatelink/README.md b/terraform/aws/aws-app-connector-s3-privatelink/README.md index d5122d3..a9403da 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/README.md +++ b/terraform/aws/aws-app-connector-s3-privatelink/README.md @@ -25,7 +25,7 @@ The **app connector** advertises the two addresses that path needs. The VPC reso ## Policy File Example -Replace the domain with the `s3_domain` output after applying. +Terraform creates the split DNS entry and the device key. The app connector definition is the one piece it cannot create, because the provider has no resource for it and it lives in the policy file. After applying, the `next_step_app_connector_policy` output prints this stanza with the real domain filled in. ```json { @@ -61,7 +61,7 @@ Replace the domain with the `s3_domain` output after applying. ## Considerations -- The bucket domain is not known until after `terraform apply`, so the app connector definition cannot be written first. Apply, then add the `s3_domain` output to your policy file. +- The app connector definition has to be added to your policy file by hand. The Tailscale provider has no app connector resource, and the only way to write `nodeAttrs` from Terraform is `tailscale_acl`, which replaces the entire policy file. The `next_step_app_connector_policy` output prints exactly what to add. The connector advertises no routes until you do. - Any advertised routes must still be approved in the Tailscale Admin Console. The policy above uses [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) instead. Narrow the prefix if `0.0.0.0/0` is broader than you want. - Only [virtual-hosted-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html) requests take the private path. A path-style request uses the shared regional hostname, does not match the split DNS entry, and goes out publicly. - The endpoint policy allows `s3:*` on this one bucket rather than just `s3:GetObject`. A client routing the bucket through the endpoint sends its control-plane calls the same way, and a read-only endpoint policy makes tooling such as the AWS CLI fail against the bucket. @@ -82,7 +82,13 @@ terraform init terraform apply ``` -Add the `s3_domain` output to your policy file, then check the path from a tailnet client: +Add the app connector to your policy file: + +```shell +terraform output -raw next_step_app_connector_policy +``` + +Then check the path from a tailnet client: ```shell # Resolves to the endpoint ENI, a private address. diff --git a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf index e8f14d4..d017e45 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf +++ b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf @@ -20,10 +20,41 @@ output "s3_bucket" { } output "s3_domain" { - description = "Bucket regional domain. This is the app connector domain and the split DNS domain." + description = "Bucket regional domain. Terraform already points the split DNS entry at this. Add it to the app connector in your policy file." value = aws_s3_bucket.main.bucket_regional_domain_name } +# The provider has no app connector resource, and the definition lives in the +# policy file, so this one step is manual. Print the exact stanza to paste. +output "next_step_app_connector_policy" { + description = "Add this to nodeAttrs in your policy file. The connector advertises no routes until you do." + value = <<-EOT + + Add this to "nodeAttrs" in your tailnet policy file: + + { + "target": ["*"], + "app": { + "tailscale.com/app-connectors": [ + { + "name": "${local.name}", + "connectors": ["tag:example-appconnector"], + "domains": ["${aws_s3_bucket.main.bucket_regional_domain_name}"], + }, + ], + }, + }, + + Then approve the connector's routes, or add this to "autoApprovers": + + "routes": { + "0.0.0.0/0": ["tag:example-appconnector"], + }, + + Advertised routes: ${join(", ", local.s3_advertised_routes)} + EOT +} + output "s3_object_url" { description = "Object URL. Returns 200 from a tailnet client using the connector, 403 from the public internet." value = "https://${aws_s3_bucket.main.bucket_regional_domain_name}/${aws_s3_object.main.key}" From c08b6f207f11fd9c802e8e15062db8fed1f984da Mon Sep 17 00:00:00 2001 From: maxiscoding28 Date: Tue, 28 Jul 2026 16:38:35 -0700 Subject: [PATCH 4/6] terraform/aws: declare routes in the policy file, not --advertise-routes The connector was passing both --advertise-connector and --advertise-routes. The second flag is not needed. App connector routes belong in the "routes" field of the app connector definition, where they are implicitly approved and need no autoApprovers entry. Move the routes into the stanza the output prints and drop the autoApprovers block from the README, which is no longer required. Co-Authored-By: Claude Opus 5 (1M context) --- .../aws/aws-app-connector-s3-privatelink/README.md | 10 +++------- terraform/aws/aws-app-connector-s3-privatelink/main.tf | 5 +++-- .../aws/aws-app-connector-s3-privatelink/outputs.tf | 10 +++------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/terraform/aws/aws-app-connector-s3-privatelink/README.md b/terraform/aws/aws-app-connector-s3-privatelink/README.md index a9403da..d8cc648 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/README.md +++ b/terraform/aws/aws-app-connector-s3-privatelink/README.md @@ -34,12 +34,6 @@ Terraform creates the split DNS entry and the device key. The app connector defi "tag:example-appconnector": ["autogroup:admin"], }, - "autoApprovers": { - "routes": { - "0.0.0.0/0": ["tag:example-appconnector"], - }, - }, - "nodeAttrs": [ { // "target" must be "*". The "connectors" field scopes this to the @@ -51,6 +45,8 @@ Terraform creates the split DNS entry and the device key. The app connector defi "name": "example-s3", "connectors": ["tag:example-appconnector"], "domains": ["example-bucket.s3.us-west-2.amazonaws.com"], + // Routes declared here are implicitly approved. + "routes": ["10.0.80.2/32", "10.0.80.47/32"], }, ], }, @@ -62,7 +58,7 @@ Terraform creates the split DNS entry and the device key. The app connector defi ## Considerations - The app connector definition has to be added to your policy file by hand. The Tailscale provider has no app connector resource, and the only way to write `nodeAttrs` from Terraform is `tailscale_acl`, which replaces the entire policy file. The `next_step_app_connector_policy` output prints exactly what to add. The connector advertises no routes until you do. -- Any advertised routes must still be approved in the Tailscale Admin Console. The policy above uses [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) instead. Narrow the prefix if `0.0.0.0/0` is broader than you want. +- The routes are declared in the `routes` field of the app connector definition rather than passed to `--advertise-routes`. Routes declared there are implicitly approved, so nothing needs approving in the admin console and no [Auto Approvers](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) entry is required. - Only [virtual-hosted-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html) requests take the private path. A path-style request uses the shared regional hostname, does not match the split DNS entry, and goes out publicly. - The endpoint policy allows `s3:*` on this one bucket rather than just `s3:GetObject`. A client routing the bucket through the endpoint sends its control-plane calls the same way, and a read-only endpoint policy makes tooling such as the AWS CLI fail against the bucket. - The connector must be in the same VPC as the endpoint. The ENI and the VPC resolver are only reachable from inside the VPC. diff --git a/terraform/aws/aws-app-connector-s3-privatelink/main.tf b/terraform/aws/aws-app-connector-s3-privatelink/main.tf index 4e56dd2..b7381dd 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/main.tf +++ b/terraform/aws/aws-app-connector-s3-privatelink/main.tf @@ -17,12 +17,13 @@ locals { "--auto-update", "--ssh", "--advertise-connector", - "--advertise-routes=${join(",", local.s3_advertised_routes)}", ] # The VPC resolver knows the interface endpoint's private DNS mapping, and the # endpoint ENI carries the object bytes. Both are stable, unlike the public S3 - # addresses an app connector would otherwise discover and advertise. + # addresses an app connector would otherwise discover and advertise. These go in + # the "routes" field of the app connector definition in the policy file, where + # they are implicitly approved. See the next_step_app_connector_policy output. vpc_resolver_ip = cidrhost(local.vpc_cidr_block, 2) s3_advertised_routes = concat( ["${local.vpc_resolver_ip}/32"], diff --git a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf index d017e45..39fbf66 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf +++ b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf @@ -40,18 +40,14 @@ output "next_step_app_connector_policy" { "name": "${local.name}", "connectors": ["tag:example-appconnector"], "domains": ["${aws_s3_bucket.main.bucket_regional_domain_name}"], + "routes": ${jsonencode(local.s3_advertised_routes)}, }, ], }, }, - Then approve the connector's routes, or add this to "autoApprovers": - - "routes": { - "0.0.0.0/0": ["tag:example-appconnector"], - }, - - Advertised routes: ${join(", ", local.s3_advertised_routes)} + Routes declared here are implicitly approved, so no autoApprovers entry is + needed and nothing has to be approved in the admin console. EOT } From 46d2dfa1c5c144b268e4bf698180da16b6da9a95 Mon Sep 17 00:00:00 2001 From: maxiscoding28 Date: Tue, 28 Jul 2026 16:46:17 -0700 Subject: [PATCH 5/6] terraform/aws: split the policy file steps by ordering The policy example mixed a prerequisite with a post-apply step. The tag owners have to exist before apply or key creation fails, while the app connector definition needs values the apply produces. Split the section into "before you apply" and "after you apply", and say in the deploy steps that the tag owners come first. Co-Authored-By: Claude Opus 5 (1M context) --- .../aws-app-connector-s3-privatelink/README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/terraform/aws/aws-app-connector-s3-privatelink/README.md b/terraform/aws/aws-app-connector-s3-privatelink/README.md index d8cc648..77bb3f3 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/README.md +++ b/terraform/aws/aws-app-connector-s3-privatelink/README.md @@ -25,7 +25,11 @@ The **app connector** advertises the two addresses that path needs. The VPC reso ## Policy File Example -Terraform creates the split DNS entry and the device key. The app connector definition is the one piece it cannot create, because the provider has no resource for it and it lives in the policy file. After applying, the `next_step_app_connector_policy` output prints this stanza with the real domain filled in. +Two edits to your tailnet policy file, one before `terraform apply` and one after. + +### Before you apply + +Terraform requests a device key carrying these tags. If nothing owns them, the apply fails when it creates the key. ```json { @@ -33,7 +37,17 @@ Terraform creates the split DNS entry and the device key. The app connector defi "tag:example-infra": ["autogroup:admin"], "tag:example-appconnector": ["autogroup:admin"], }, +} +``` + +### After you apply +Terraform creates the split DNS entry and the device key. The app connector definition is the one piece it cannot create, because the provider has no resource for it and it lives in the policy file. The bucket domain and the routes are not known until the apply finishes, so this edit comes second. + +Run `terraform output -raw next_step_app_connector_policy` to get this stanza with the real values filled in. + +```json +{ "nodeAttrs": [ { // "target" must be "*". The "connectors" field scopes this to the @@ -73,6 +87,8 @@ Follow the documentation to configure the Terraform providers: ### Deploy +Add the `tagOwners` entries above to your policy file first, then: + ```shell terraform init terraform apply From ee187ed773dad0a4438b441f598c05d5f1609b18 Mon Sep 17 00:00:00 2001 From: maxiscoding28 Date: Tue, 28 Jul 2026 18:29:54 -0700 Subject: [PATCH 6/6] terraform/aws: drop the instructional output, note the CIDR coupling No other example prints instructions as an output. The README already shows the stanza, and s3_domain and s3_advertised_routes carry the two values that need filling in, so point at those instead. Also note that the VPC resolver address is derived from vpc_cidr_block, which matters when swapping in your own VPC. Co-Authored-By: Claude Opus 5 (1M context) --- .../README.md | 9 +++--- .../aws-app-connector-s3-privatelink/main.tf | 5 +-- .../outputs.tf | 31 ++----------------- 3 files changed, 10 insertions(+), 35 deletions(-) diff --git a/terraform/aws/aws-app-connector-s3-privatelink/README.md b/terraform/aws/aws-app-connector-s3-privatelink/README.md index 77bb3f3..494751f 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/README.md +++ b/terraform/aws/aws-app-connector-s3-privatelink/README.md @@ -44,7 +44,7 @@ Terraform requests a device key carrying these tags. If nothing owns them, the a Terraform creates the split DNS entry and the device key. The app connector definition is the one piece it cannot create, because the provider has no resource for it and it lives in the policy file. The bucket domain and the routes are not known until the apply finishes, so this edit comes second. -Run `terraform output -raw next_step_app_connector_policy` to get this stanza with the real values filled in. +Fill in `domains` from the `s3_domain` output and `routes` from the `s3_advertised_routes` output. ```json { @@ -71,7 +71,7 @@ Run `terraform output -raw next_step_app_connector_policy` to get this stanza wi ## Considerations -- The app connector definition has to be added to your policy file by hand. The Tailscale provider has no app connector resource, and the only way to write `nodeAttrs` from Terraform is `tailscale_acl`, which replaces the entire policy file. The `next_step_app_connector_policy` output prints exactly what to add. The connector advertises no routes until you do. +- The app connector definition has to be added to your policy file by hand. The Tailscale provider has no app connector resource, and the only way to write `nodeAttrs` from Terraform is `tailscale_acl`, which replaces the entire policy file. The connector advertises no routes until you add it. - The routes are declared in the `routes` field of the app connector definition rather than passed to `--advertise-routes`. Routes declared there are implicitly approved, so nothing needs approving in the admin console and no [Auto Approvers](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) entry is required. - Only [virtual-hosted-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html) requests take the private path. A path-style request uses the shared regional hostname, does not match the split DNS entry, and goes out publicly. - The endpoint policy allows `s3:*` on this one bucket rather than just `s3:GetObject`. A client routing the bucket through the endpoint sends its control-plane calls the same way, and a read-only endpoint policy makes tooling such as the AWS CLI fail against the bucket. @@ -94,10 +94,11 @@ terraform init terraform apply ``` -Add the app connector to your policy file: +Add the app connector to your policy file, using these values: ```shell -terraform output -raw next_step_app_connector_policy +terraform output s3_domain +terraform output s3_advertised_routes ``` Then check the path from a tailnet client: diff --git a/terraform/aws/aws-app-connector-s3-privatelink/main.tf b/terraform/aws/aws-app-connector-s3-privatelink/main.tf index b7381dd..246b5ff 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/main.tf +++ b/terraform/aws/aws-app-connector-s3-privatelink/main.tf @@ -23,14 +23,15 @@ locals { # endpoint ENI carries the object bytes. Both are stable, unlike the public S3 # addresses an app connector would otherwise discover and advertise. These go in # the "routes" field of the app connector definition in the policy file, where - # they are implicitly approved. See the next_step_app_connector_policy output. + # they are implicitly approved. See the s3_advertised_routes output. vpc_resolver_ip = cidrhost(local.vpc_cidr_block, 2) s3_advertised_routes = concat( ["${local.vpc_resolver_ip}/32"], [for eni in data.aws_network_interface.s3_endpoint : "${eni.private_ip}/32"], ) - # Modify these to use your own VPC. + # Modify these to use your own VPC. The VPC resolver address is derived from + # the CIDR, so vpc_cidr_block must match the VPC you point this at. vpc_id = module.vpc.vpc_id vpc_cidr_block = "10.0.80.0/22" vpc_public_subnet_cidr_blocks = ["10.0.80.0/24"] diff --git a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf index 39fbf66..afe0bc0 100644 --- a/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf +++ b/terraform/aws/aws-app-connector-s3-privatelink/outputs.tf @@ -20,37 +20,10 @@ output "s3_bucket" { } output "s3_domain" { - description = "Bucket regional domain. Terraform already points the split DNS entry at this. Add it to the app connector in your policy file." + description = "Bucket regional domain. Terraform points the split DNS entry at this. Use it as the app connector domain in your policy file." value = aws_s3_bucket.main.bucket_regional_domain_name } -# The provider has no app connector resource, and the definition lives in the -# policy file, so this one step is manual. Print the exact stanza to paste. -output "next_step_app_connector_policy" { - description = "Add this to nodeAttrs in your policy file. The connector advertises no routes until you do." - value = <<-EOT - - Add this to "nodeAttrs" in your tailnet policy file: - - { - "target": ["*"], - "app": { - "tailscale.com/app-connectors": [ - { - "name": "${local.name}", - "connectors": ["tag:example-appconnector"], - "domains": ["${aws_s3_bucket.main.bucket_regional_domain_name}"], - "routes": ${jsonencode(local.s3_advertised_routes)}, - }, - ], - }, - }, - - Routes declared here are implicitly approved, so no autoApprovers entry is - needed and nothing has to be approved in the admin console. - EOT -} - output "s3_object_url" { description = "Object URL. Returns 200 from a tailnet client using the connector, 403 from the public internet." value = "https://${aws_s3_bucket.main.bucket_regional_domain_name}/${aws_s3_object.main.key}" @@ -61,7 +34,7 @@ output "s3_vpc_endpoint_id" { } output "s3_advertised_routes" { - description = "Routes the connector advertises: the VPC resolver and the endpoint ENI" + description = "The VPC resolver and the endpoint ENI. Use these as the app connector routes in your policy file." value = local.s3_advertised_routes }