diff --git a/aws-privatelink/.claude/settings.local.json b/aws-privatelink/.claude/settings.local.json new file mode 100644 index 00000000..a1b8e131 --- /dev/null +++ b/aws-privatelink/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(rm -rf ~/.claude/plugins/cache/claude-code-warp/warp/2.0.0)", + "Bash(cp -R ~/.claude/plugins/marketplaces/claude-code-warp/plugins/warp ~/.claude/plugins/cache/claude-code-warp/warp/2.0.0)", + "Bash(mkdir -p ~/.claude/plugins/cache/claude-code-warp/warp/2.0.0/.claude-plugin)", + "Bash(cp ~/.claude/plugins/marketplaces/claude-code-warp/plugins/warp/.claude-plugin/plugin.json ~/.claude/plugins/cache/claude-code-warp/warp/2.0.0/.claude-plugin/)" + ] + } +} diff --git a/aws-privatelink/.gitignore b/aws-privatelink/.gitignore new file mode 100644 index 00000000..5ae87b89 --- /dev/null +++ b/aws-privatelink/.gitignore @@ -0,0 +1,46 @@ +# Terraform state (never commit — holds resource IDs, IPs, and sensitive output values) +*.tfstate +*.tfstate.* +*.tfstate.backup + +# Saved plan files (binary snapshots; may include sensitive input values) +tfplan +*.plan + +# Provider plugins + local module caches +.terraform/ + +# Variable files (safety net — all inputs come via TF_VAR_ env vars) +*.tfvars +*.tfvars.json + +# Override files +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Crash logs +crash.log +crash.*.log + +# Sensor artifact cache (holds the ~100MB RPM the fetch script downloads; +# meant to be workstation-local and regenerated per apply) +.sensor-cache/ + +# Python +__pycache__/ +*.pyc +.venv/ + +# OS +.DS_Store +Thumbs.db + +# Editor +.idea/ +.vscode/ +*.swp + +# NOTE: .terraform.lock.hcl IS committed on purpose — it pins provider versions +# so anyone cloning gets reproducible plans. diff --git a/aws-privatelink/README.md b/aws-privatelink/README.md index 2aed8d9d..a896039c 100644 --- a/aws-privatelink/README.md +++ b/aws-privatelink/README.md @@ -1,75 +1,202 @@ -![CrowdStrike Falcon](https://raw.githubusercontent.com/CrowdStrike/falconpy/main/docs/asset/cs-logo.png) +![CrowdStrike Logo (Light)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-light-mode.png#gh-light-mode-only) +![CrowdStrike Logo (Dark)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-dark-mode.png#gh-dark-mode-only) +# CrowdStrike Falcon on AWS PrivateLink + +This guide explains how to connect AWS workloads to the CrowdStrike Falcon +sensor cloud over AWS PrivateLink using the newer cross-region connectivity +model. It replaces designs that previously required customer-managed +inter-region routing to reach Falcon PrivateLink services from workload Regions +outside the Falcon cloud home Region. -![Twitter URL](https://img.shields.io/twitter/url?label=Follow%20%40CrowdStrike&style=social&url=https%3A%2F%2Ftwitter.com%2FCrowdStrike) +This repo showcases three small-scale labs that look at common cloud-native +architecture patterns unlocked by cross-region PrivateLink. Use them to +understand the network topology options and get ideas for scaling the pattern +into your own AWS environment. These examples are learning deployments, not +production-ready modules, and they are not the only valid designs. -# Leverage AWS PrivateLink to provide private connectivity between your CrowdStrike-protected workloads and the CrowdStrike cloud +## Table of contents -## Overview +- [What PrivateLink provides](#what-privatelink-provides) +- [What cross-region connectivity changes](#what-cross-region-connectivity-changes) +- [Falcon cloud home Regions](#falcon-cloud-home-regions) +- [Design considerations](#design-considerations) +- [Lab guide index](#lab-guide-index) +- [Architecture picker](#architecture-picker) +- [Unsupported Regions](#unsupported-regions) -With the power of AWS PrivateLink, you can create a private communication channel between the CrowdStrike Falcon Sensor and the CrowdStrike cloud. This secure connection allows for the transfer of **Sensor Proxy** data (such as sensor events) and **Sensor Download** content (including channel files, sensor update files, and more). +## What PrivateLink provides -Please be aware that this setup is specifically designed for sensor-related traffic. As such, it does not support API communication over AWS PrivateLink. +AWS PrivateLink lets workloads in a VPC reach a provider service through +private interface endpoints instead of public internet paths. For Falcon +sensors, PrivateLink facilitates the core sensor communication needed for the +sensor to operate, including sensor telemetry. It does not provide private +connectivity for CrowdStrike APIs or other non-sensor telemetry flows. -## Region Compatibility and Metadata Details +## What cross-region connectivity changes + +Historically, a PrivateLink consumer endpoint and the provider endpoint service +had to be in the same AWS Region. For CrowdStrike customers whose Falcon CID is +hosted in one Region but whose AWS workloads run elsewhere, customers needed +to establish inter-region routing to reach the PrivateLink endpoints. That made +networking designs more complicated, especially in multi-account and +multi-Region environments. -In the process of setting up an AWS PrivateLink connection, it's mandatory to align the configuration with the region where your Falcon Customer ID (CID) is housed. To illustrate, if your CID is stationed in `US-1`, your AWS PrivateLink connection needs to be established in the corresponding `us-west-1` region. +Cross-region PrivateLink removes the anchor VPC requirement for supported +Regions. Your Falcon CID still determines which CrowdStrike cloud and home +Region the endpoint service is hosted in. The difference is that customers can +now create PrivateLink connections to Falcon from any AWS commercial Region +where AWS supports cross-region connectivity. -Should you require communication across multiple regions, a Transit Gateway configuration will be necessary to facilitate the traffic routing throughout the AWS regions. We've provided a sample deployment for your reference in the [Quick Start](#quick-start-overview) section below. +## Falcon cloud home Regions -For your convenience, we have compiled a table of supported region mappings and their corresponding metadata. Please refer to this resource to ensure your configuration aligns with your region's specific requirements. +| Falcon cloud | Endpoint service home Region | +|---|---| +| US-1 | `us-west-1` | +| US-2 | `us-west-2` | +| EU-1 | `eu-central-1` | -### Falcon US-1 +Use the Falcon cloud that matches the CID you deploy sensors against. The +endpoint service home Region is not a deployment preference; it is determined +by the Falcon cloud for that CID. -| DNS Name | Service Name | VPC Endpoint Service Name | AWS Region | -| ------------------------- | --------------- | ------------------------------------------------------- | ---------- | -| ts01-b.cloudsink.net | Sensor Proxy | com.amazonaws.vpce.us-west-1.vpce-svc-08744dea97b26db5d | us-west-1 | -| lfodown01-b.cloudsink.net | Download Server | com.amazonaws.vpce.us-west-1.vpce-svc-0f9d8ca86ddcb7106 | us-west-1 | +The complete endpoint service and hostname matrix is in +[docs/vpc-endpoints-reference.md](docs/vpc-endpoints-reference.md). -### Falcon US-2 +## Design considerations -| DNS Name | Service Name | VPC Endpoint Service Name | AWS Region | -| ------------------------------------ | --------------- | ------------------------------------------------------- | ---------- | -| ts01-gyr-maverick.cloudsink.net | Sensor Proxy | com.amazonaws.vpce.us-west-2.vpce-svc-08a5bb05d337fd834 | us-west-2 | -| lfodown01-gyr-maverick.cloudsink.net | Download Server | com.amazonaws.vpce.us-west-2.vpce-svc-0e11def2d8620ae74 | us-west-2 | +### AWS Region support -### Falcon EU-1 +The Falcon platform provides PrivateLink connectivity for AWS commercial +Regions where AWS supports cross-region PrivateLink connectivity. This guide +focuses on those AWS commercial Regions. Falcon PrivateLink is not supported +in GovCloud today. See +[Unsupported Regions](#unsupported-regions) for the commercial Regions that +are not covered by this guide. -| DNS Name | Service Name | VPC Endpoint Service Name | AWS Region | -| ------------------------------------ | --------------- | --------------------------------------------------------- | ------------ | -| ts01-lanner-lion.cloudsink.net | Sensor Proxy | com.amazonaws.vpce.eu-central1.vpce-svc-0eb7b6ca4b7271385 | eu-central-1 | -| lfodown01-lanner-lion. cloudsink.net | Download Server | com.amazonaws.vpce.eu-central1.vpce-svc-0340142b9ab8fc564 | eu-central-1 | +Some AWS Regions are opt-in Regions. If a deployment uses an opt-in Region, the +relevant AWS account must enable that Region before it can create or target +cross-region PrivateLink resources there. -## Quick Start Overview +### Availability -The CloudFormation template provided in this quick start sets up two VPCs: the `CrowdStrike Services VPC`, which has the AWS PrivateLink connection, and the `Test VPC`, which houses a Linux virtual machine. The `CrowdStrike Services VPC` functions as a shared service VPC, enabling other VPCs to transitively route their CrowdStrike sensor-related traffic. While this template is designed to deploy everything within a single region, the underlying principles can be applied for cross-region communication. +Use at least two Availability Zones for high availability. Deploying endpoint +network interfaces across multiple AZs gives workloads more than one private +path to the Falcon service if an AZ-level component becomes unavailable. + +### DNS -The VPCs are interconnected via an AWS Transit Gateway and are configured for DNS resolution. A Route53 private hosted zone is established for the `cloudsink.net` domain and linked to the `Test VPC`. This private hosted zone contains `A` records that create an alias for the VPC endpoints associated with the region in which your Falcon CID is deployed. +PrivateLink is DNS-driven. Workloads must resolve the `cloudsink.net` Falcon +sensor hostnames to the private IP addresses of the correct interface +endpoints. Many customers use Route 53 private hosted zones with aliases to the +VPC endpoints for the three configured Falcon endpoints. This can also be done +in other ways for organizations that manage private DNS outside Route 53. -### Reference Diagram +### Falcon sensor installation -![AWS PrivateLink Demo Reference Diagram](./docs/images/privatelink-demo.png) +In environments with no internet connectivity, traditional scripts that +download and install the Falcon sensor directly from CrowdStrike APIs will not +work from the private workload. Customers usually need a private installation +path, such as baking the sensor into golden image pipelines or seeding the +sensor installer into S3 buckets and downloading it over S3 endpoints. + +### AWS account whitelisting + +Customers need to raise a ticket with CrowdStrike support to have their AWS +account IDs whitelisted to their respective regional VPC endpoints before the +connection can be initiated. If the account is not whitelisted, the endpoint +service can appear unavailable when trying to initiate the connection. -### Prerequisites +### Quotas and cost -- You will need to create a ticket with CrowdStrike support to have your AWS account whitelisted, enabling the AWS PrivateLink connection with your CrowdStrike account. -- You must deploy this template in the same AWS account that has been whitelisted, and in the corresponding region of your Falcon CID, to ensure the `CrowdStrike Services VPC` is provisioned without errors. +Cross-region endpoints count against the same interface endpoint quotas as +other interface endpoints in the VPC. They also incur interface endpoint hourly +and data processing charges. -### Configuration +### IAM and organization controls -1. Start by creating an S3 bucket in your desired deployment region. +Cross-region PrivateLink is gated by the `vpce:AllowMultiRegion` +permission-only action. A customer identity policy must allow it, and an AWS +Organizations service control policy must not deny it. If either layer blocks +the action, in-region PrivateLink can still work while cross-region endpoint +creation fails. -1. Next, [copy the following files](https://github.com/CrowdStrike/Cloud-AWS/tree/main/aws-privatelink/s3bucket) into the S3 bucket you just created. - ![S3 bucket with uploaded files from the GitHub project](docs/images/s3bucket-sm.png) +Customers can also use the `ec2:VpceServiceRegion` condition key to restrict +which remote service Regions an IAM principal may target when creating VPC +endpoints. -1. Set up a CloudFormation Stack using the [provided template](https://github.com/CrowdStrike/Cloud-AWS/blob/main/aws-privatelink/cloudformation/create-vpc-endpoint-r53-tgw-attachment.yaml). +## Lab guide index -1. Ensure the successful creation of the CloudFormation template. - ![AWS CloudFormation Stack Output that's successfully deployed](docs/images/cft-output-sm.png) +Each lab creates an Amazon Linux 2023 EC2 instance to demonstrate sensor +deployment in a private network. The instances have no internet gateway or NAT +gateway path, which shows how Falcon sensor connectivity can work in an +environment without internet egress. -1. Establish a connection to the Linux EC2 instance and validate the sharing of the private-hosted domain with the Test VPC. To achieve this, retrieve the DNS name of one of the two endpoints from the [table above](#region-compatibility-and-metadata-details). For instance, if your CID is in `US-1`, use `ts01-b.cloudsink.net`. - - Execute the command below, substituting the domain name with one corresponding to your deployment: `nslookup ts01-b.cloudsink.net` +These are three cloud-native examples to help you reason about common network +topologies. They are starting points for design, not limits on how PrivateLink +can be used. - ![A terminal shell that successfully outputs a nslookup query](docs/images/dnstest-sm.png) +### [Architecture 01 - Per-VPC endpoints](docs/architecture-01-per-vpc.md) -1. Install the CrowdStrike sensor on the virtual machine using your usual methods and verify its reporting to the Falcon console under `Host Management`. - - Take note that you will need to install the agent via a binary package, as the Sensor Download API will not be accessible over the AWS PrivateLink connection. +This design is for simpler, smaller-footprint environments that do not require +complicated network routing architecture. Each VPC initiates its own +PrivateLink connection to the Falcon platform. + +Pick this when each VPC can own its own Falcon PrivateLink connection and the +resulting per-VPC AWS account whitelisting workflow is acceptable. + +### [Architecture 02 - Shared VPC](docs/architecture-02-shared-vpc.md) + +This design is for AWS environments that use a shared VPC architecture. A hub +or owner account initiates the PrivateLink connection, then shares subnets to +workload accounts using AWS Resource Access Manager. AWS documents this as +[VPC subnet sharing](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-sharing.html). +Workloads launched in the shared subnets can use the shared VPC's Falcon +PrivateLink connectivity. + +Pick this when you are already running, or plan to run, the AWS shared VPC +pattern and want workload accounts to inherit centrally managed Falcon network +connectivity. + +### [Architecture 03 - TGW + Route 53 Profiles](docs/architecture-03-tgw-profiles.md) + +This design is for enterprise hub-and-spoke environments that use AWS Transit +Gateway for VPC-to-VPC routing. Spoke VPCs keep their own network ownership, +while a hub networking account owns the Falcon PrivateLink endpoints and shared +DNS. + +Before cross-region PrivateLink, this pattern typically required inter-region +TGW or VPC peering back to an endpoint VPC in the Falcon home Region. With +cross-region PrivateLink, the hub endpoint VPC can live in the same Region as +the spokes, and PrivateLink handles the connection to the Falcon home Region. + +Pick this when TGW is already your standard enterprise connectivity pattern and +you want to modernize it for cross-region Falcon PrivateLink. + +## Architecture picker + +| Question | Per-VPC | Shared VPC | TGW + Profiles | +|---|:---:|:---:|:---:| +| Small footprint or first proof of concept | Yes | | | +| Each VPC should own its PrivateLink connection | Yes | | | +| Workloads use shared VPC subnets | | Yes | | +| Workloads must stay in their own VPCs | Yes | | Yes | +| Existing TGW hub-and-spoke network | | | Yes | +| Central network team owns Falcon connectivity | | Yes | Yes | +| Reduce account whitelisting volume across many accounts | | Yes | Yes | + +## Unsupported Regions + +The following commercial AWS Regions are not supported by this cross-region +PrivateLink guide as of May 2026: + +| Region | Name | +|---|---| +| `ap-southeast-5` | Asia Pacific (Malaysia) | +| `ap-southeast-7` | Asia Pacific (Thailand) | +| `mx-central-1` | Mexico (Central) | + +Workloads in unsupported Regions that require PrivateLink connectivity will +require inter-region routing configurations until AWS provides cross-region +PrivateLink support for these Regions and CrowdStrike follows suit by making +them available. Until then, we recommend raising a feature request with AWS to +help accelerate support for these Regions. diff --git a/aws-privatelink/cloudformation/create-vpc-endpoint-r53-tgw-attachment.yaml b/aws-privatelink/cloudformation/create-vpc-endpoint-r53-tgw-attachment.yaml deleted file mode 100644 index 36cbe24c..00000000 --- a/aws-privatelink/cloudformation/create-vpc-endpoint-r53-tgw-attachment.yaml +++ /dev/null @@ -1,588 +0,0 @@ ---- -AWSTemplateFormatVersion: '2010-09-09' -Description: > - This template deploys a VPC with a pair of private subnets spread across - two Availabilty Zones. Creates a VPC endpoint for CrowdStrike Falcon Services. - -Parameters: - S3Bucket: - Description: S3 Bucket for lambda functions - Type: String - KeyName: - Description: ssh-key - Type: AWS::EC2::KeyPair::KeyName - TrustedIP: - Description: Trusted IP - Type: String - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - Default: 1.1.1.1/32 - EnvironmentName: - Description: An environment name that will be prefixed to resource names - Type: String - CRWDCloud: - Description: CrowdStrike Cloud us-1, us-2 or eu - Type: String - AllowedValues: - - us-1 - - us-2 - - eu - - CSPrivLinkVpcCIDR: - Description: Please enter the IP range (CIDR notation) for this CSPrivLinkVPC - Type: String - Default: 10.192.0.0/24 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVpcCIDR: - Description: Please enter the IP range (CIDR notation) for this CSPrivLinkVPC - Type: String - Default: 10.0.0.0/24 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - CSPrivLinkPrivateSubnet1CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone - Type: String - Default: 10.192.0.0/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - CSPrivLinkPrivateSubnet2CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone - Type: String - Default: 10.192.0.32/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - CSPrivLinkTGWAttachSubnet1CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone - Type: String - Default: 10.192.0.96/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - CSPrivLinkTGWAttachSubnet2CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone - Type: String - Default: 10.192.0.64/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVPCTGWAttachSubnet1CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone - Type: String - Default: 10.0.0.64/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVPCTGWAttachSubnet2CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone - Type: String - Default: 10.0.0.96/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVPCPrivateSubnet1CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone - Type: String - Default: 10.0.0.0/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVPCPrivateSubnet2CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone - Type: String - Default: 10.0.0.32/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - LinuxAMI: - Description: Managed AMI ID for Amazon Linux - Type: 'AWS::SSM::Parameter::Value' - Default: '/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2' - -Mappings: - CSService: - us-1: - sensorProxyDnsName: 'ts01-b.cloudsink.net' - sensorProxyVpcEndpoint: 'com.amazonaws.vpce.us-west-1.vpce-svc-08744dea97b26db5d' - downloadServerDnsName: 'lfodown01-b.cloudsink.net' - downloadServerVpcEndpoint : 'com.amazonaws.vpce.us-west-1.vpce-svc-0f9d8ca86ddcb7106' - us-2: - sensorProxyDnsName: 'ts01-gyr-maverick.cloudsink.net' - sensorProxyVpcEndpoint: 'com.amazonaws.vpce.us-west-2.vpce-svc-08a5bb05d337fd834' - downloadServerDnsName: 'lfodown01-gyr-maverick.cloudsink.net' - downloadServerVpcEndpoint: 'com.amazonaws.vpce.us-west-2.vpce-svc-0e11def2d8620ae74' - eu: - sensorProxyDnsName: 'ts01-lanner-lion.cloudsink.net' - sensorProxyVpcEndpoint: 'com.amazonaws.vpce.eu-central1.vpce-svc-0eb7b6ca4b7271385' - downloadServerDnsName: 'lfodown01-lanner-lion.cloudsink.net' - downloadServerVpcEndpoint: 'com.amazonaws.vpce.eu-central1.vpce-svc-0340142b9ab8fc564' - -Resources: - TransitGateway: - Type: AWS::EC2::TransitGateway - Properties: - Description: Security TransitGateway - AutoAcceptSharedAttachments: disable - DefaultRouteTableAssociation: disable - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} PrivateLink Demo TGW - - - CSPrivLinkVPC: - Type: AWS::EC2::VPC - Properties: - EnableDnsSupport: true - EnableDnsHostnames: true - CidrBlock: !Ref 'CSPrivLinkVpcCIDR' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrike Services VPC - - CSPrivLinkTGWAttachSubnet1: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - AvailabilityZone: - Fn::Select: - - 0 - - Fn::GetAZs: '' - CidrBlock: !Ref CSPrivLinkTGWAttachSubnet1CIDR - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrike Shared Services TGW Subnet (AZ1) - CSPrivLinkTGWAttachSubnet2: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - AvailabilityZone: - Fn::Select: - - 1 - - Fn::GetAZs: '' - CidrBlock: !Ref CSPrivLinkTGWAttachSubnet2CIDR - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrike Shared Services TGW Subnet (AZ2) - CSPrivLinkPrivateSubnet1: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - AvailabilityZone: - Fn::Select: - - 0 - - Fn::GetAZs: '' - CidrBlock: !Ref 'CSPrivLinkPrivateSubnet1CIDR' - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrike Shared Services Private Subnet (AZ1) - CSPrivLinkPrivateSubnet2: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - AvailabilityZone: - Fn::Select: - - 1 - - Fn::GetAZs: '' - CidrBlock: !Ref 'CSPrivLinkPrivateSubnet2CIDR' - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrike Shared Services Private Subnet (AZ2) - - CSPrivLinkPrivateRouteTable1: - Type: AWS::EC2::RouteTable - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrike Shared Services - - CSPrivLinkPrivateSubnet1RouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - RouteTableId: !Ref 'CSPrivLinkPrivateRouteTable1' - SubnetId: !Ref 'CSPrivLinkPrivateSubnet1' - - CSPrivLinkPrivateSubnet2RouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - RouteTableId: !Ref 'CSPrivLinkPrivateRouteTable1' - SubnetId: !Ref 'CSPrivLinkPrivateSubnet2' - - TestVPCPrivateRouteTable: - Type: AWS::EC2::RouteTable - Properties: - VpcId: !Ref 'TestVPC' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} TestVPC - - TestVPCPrivateSubnet2RouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - RouteTableId: !Ref 'TestVPCPrivateRouteTable' - SubnetId: !Ref 'TestVPCHostSubnet2' - - TestVPCPrivateSubnet1RouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - RouteTableId: !Ref 'TestVPCPrivateRouteTable' - SubnetId: !Ref 'TestVPCHostSubnet1' - - CSPrivLinkRoute: - Type: AWS::EC2::Route - DependsOn: 'TestVPCTGWAttachment' - Properties: - RouteTableId: !Ref 'TestVPCPrivateRouteTable' - DestinationCidrBlock: !Ref 'CSPrivLinkVpcCIDR' - TransitGatewayId: !Ref 'TransitGateway' - - TestVPCDefRoute: - Type: AWS::EC2::Route - DependsOn: IGWTestVPCAttachment - Properties: - RouteTableId: !Ref 'TestVPCPrivateRouteTable' - DestinationCidrBlock: '0.0.0.0/0' - GatewayId: !Ref 'IGWTestVPC' - - VPCSummaryRoute: - Type: AWS::EC2::Route - DependsOn: 'CSPrivLinkVPCTGWAttachment' - Properties: - RouteTableId: - !Ref 'CSPrivLinkPrivateRouteTable1' - DestinationCidrBlock: !Ref TestVpcCIDR - TransitGatewayId: !Ref 'TransitGateway' - - TestVPC: - Type: AWS::EC2::VPC - Properties: - CidrBlock: !Ref 'TestVpcCIDR' - EnableDnsSupport: true - EnableDnsHostnames: true - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} TestVPC - - IGWTestVPC: - Type: AWS::EC2::InternetGateway - Properties: - Tags: - - Key: Name - Value: TestVPC InternetGateway - - IGWTestVPCAttachment: - Type: AWS::EC2::VPCGatewayAttachment - Properties: - InternetGatewayId: !Ref 'IGWTestVPC' - VpcId: !Ref 'TestVPC' - - TestVPCHostSubnet1: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'TestVPC' - CidrBlock: !Ref TestVPCPrivateSubnet1CIDR - AvailabilityZone: - Fn::Select: - - 0 - - Fn::GetAZs: '' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} Test VPC Private Subnet (AZ0) - - TestVPCHostSubnet2: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'TestVPC' - CidrBlock: !Ref TestVPCPrivateSubnet2CIDR - AvailabilityZone: - Fn::Select: - - 1 - - Fn::GetAZs: '' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} Test VPC Private Subnet (AZ1) - TestVPCTGWAttachSubnet1: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'TestVPC' - AvailabilityZone: - Fn::Select: - - 0 - - Fn::GetAZs: '' - CidrBlock: !Ref TestVPCTGWAttachSubnet1CIDR - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} Test VPC TGW Attach Subnet (AZ0) - TestVPCTGWAttachSubnet2: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'TestVPC' - AvailabilityZone: - Fn::Select: - - 1 - - Fn::GetAZs: '' - CidrBlock: !Ref TestVPCTGWAttachSubnet2CIDR - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} Test VPC TGW Attach Subnet (AZ1) - - CSPrivLinkVPCTGWAttachment: - Type: AWS::EC2::TransitGatewayAttachment - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - TransitGatewayId: !Ref 'TransitGateway' - SubnetIds: - - !Ref 'CSPrivLinkTGWAttachSubnet1' - - !Ref 'CSPrivLinkTGWAttachSubnet2' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrike Shared Services VPC TGW Attachment - TestVPCTGWAttachment: - Type: AWS::EC2::TransitGatewayAttachment - Properties: - VpcId: !Ref 'TestVPC' - TransitGatewayId: !Ref 'TransitGateway' - SubnetIds: - - !Ref 'TestVPCTGWAttachSubnet1' - - !Ref 'TestVPCTGWAttachSubnet2' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} Test VPC TGW Attachment - - TestVPCAttachmentPropagation: - Type: AWS::EC2::TransitGatewayRouteTablePropagation - Properties: - TransitGatewayAttachmentId: !Ref 'TestVPCTGWAttachment' - TransitGatewayRouteTableId: !Ref 'CSPrivLinkVPCTGWRouteTable' - - - CSPrivLinkVPCAttachmentPropagation: - Type: AWS::EC2::TransitGatewayRouteTablePropagation - Properties: - TransitGatewayAttachmentId: !Ref 'CSPrivLinkVPCTGWAttachment' - TransitGatewayRouteTableId: !Ref 'TestVPCTGWRouteTable' - - TestVPCTGWRouteTable: - Type: AWS::EC2::TransitGatewayRouteTable - Properties: - TransitGatewayId: !Ref 'TransitGateway' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} Test VPC - CSPrivLinkVPCTGWRouteTable: - Type: AWS::EC2::TransitGatewayRouteTable - Properties: - TransitGatewayId: !Ref 'TransitGateway' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrike Shared Services VPC - TestVPCTgwRtAssociation: - Type: AWS::EC2::TransitGatewayRouteTableAssociation - Properties: - TransitGatewayRouteTableId: !Ref 'TestVPCTGWRouteTable' - TransitGatewayAttachmentId: !Ref 'TestVPCTGWAttachment' - - CSPrivLinkVPCTgwRtAssociation: - Type: AWS::EC2::TransitGatewayRouteTableAssociation - Properties: - TransitGatewayRouteTableId: !Ref 'CSPrivLinkVPCTGWRouteTable' - TransitGatewayAttachmentId: !Ref 'CSPrivLinkVPCTGWAttachment' - TestVPCDefRouteToCSPrivLinkVPC: - Type: AWS::EC2::TransitGatewayRoute - Properties: - TransitGatewayAttachmentId: !Ref TestVPCTGWAttachment - DestinationCidrBlock: '0.0.0.0/0' - TransitGatewayRouteTableId: !Ref 'TestVPCTGWRouteTable' - TestInstanceEIP: - Type: AWS::EC2::EIP - Properties: - Domain: vpc - AssociateControlPort: - Type: AWS::EC2::EIPAssociation - DependsOn: TestInstance - Properties: - AllocationId: !GetAtt TestInstanceEIP.AllocationId - NetworkInterfaceId: !Ref TestInstanceInt - TestInstanceInt: - Type: AWS::EC2::NetworkInterface - Properties: - SubnetId: !Ref TestVPCHostSubnet1 - Description: Interface for controlling traffic such as SSH - GroupSet: - - !Ref SSHSecurityGroup - SourceDestCheck: true - - TestInstance: - Type: AWS::EC2::Instance - Properties: - InstanceType: t3.micro - ImageId: !Ref LinuxAMI - KeyName: !Ref KeyName - NetworkInterfaces: - - NetworkInterfaceId: !Ref 'TestInstanceInt' - DeviceIndex: '0' - - - SSHSecurityGroup: - Type: AWS::EC2::SecurityGroup - Properties: - VpcId: !Ref TestVPC - GroupDescription: Enable SSH access via port 22 - SecurityGroupIngress: - - CidrIp: !Ref TrustedIP - FromPort: 22 - IpProtocol: tcp - ToPort: 22 - - CrowdStrikeEndpointSG: - Type: AWS::EC2::SecurityGroup - Properties: - GroupDescription: 'Traffic into CloudFormation Endpoint' - SecurityGroupIngress: - - IpProtocol: tcp - FromPort: 443 - ToPort: 443 - CidrIp: '0.0.0.0/0' - VpcId: !Ref 'CSPrivLinkVPC' - Tags: - - Key: Name - Value: EndpointSG - - SensorProxyVPCEndpoint: - Type: AWS::EC2::VPCEndpoint - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - ServiceName: !FindInMap [CSService, !Ref 'CRWDCloud', sensorProxyVpcEndpoint] - VpcEndpointType: 'Interface' - PrivateDnsEnabled: False - SubnetIds: - - !Ref 'CSPrivLinkPrivateSubnet1' - - !Ref 'CSPrivLinkPrivateSubnet2' - SecurityGroupIds: - - !Ref 'CrowdStrikeEndpointSG' - - DownloadServerVPCEndpoint: - Type: AWS::EC2::VPCEndpoint - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - ServiceName: !FindInMap - - CSService - - !Ref 'CRWDCloud' - - downloadServerVpcEndpoint - VpcEndpointType: 'Interface' - PrivateDnsEnabled: False - SubnetIds: - - !Ref 'CSPrivLinkPrivateSubnet1' - - !Ref 'CSPrivLinkPrivateSubnet2' - SecurityGroupIds: - - !Ref 'CrowdStrikeEndpointSG' - - TenantHostedZone: - Type: AWS::Route53::HostedZone - Properties: - HostedZoneConfig: - Comment: Private HostedZone (split-horizon DNS) for CrowdStrike sensor connectivity - Name: 'cloudsink.net.' - VPCs: - - VPCId: !Ref 'CSPrivLinkVPC' - VPCRegion: !Ref AWS::Region - - SensorProxyRoute53Record: - Type: AWS::Route53::RecordSet - Properties: - HostedZoneId: !Ref TenantHostedZone - Name: !FindInMap [CSService, !Ref CRWDCloud, sensorProxyDnsName] - Type: A - AliasTarget: - DNSName: !Select [1, !Split [':', !Select [1, !GetAtt 'SensorProxyVPCEndpoint.DnsEntries']]] - HostedZoneId: !Select [0, !Split [':', !Select [1, !GetAtt 'SensorProxyVPCEndpoint.DnsEntries']]] - EvaluateTargetHealth: False - Comment: Routes all Sensor Proxy traffic over CrowdStrike's VPC endpoint service. - - DownloadServerRoute53Record: - Type: AWS::Route53::RecordSet - Properties: - HostedZoneId: !Ref TenantHostedZone - Name: !FindInMap [CSService, !Ref CRWDCloud, downloadServerDnsName] - Type: A - AliasTarget: - DNSName: !Select [1, !Split [':', !Select [1, !GetAtt 'DownloadServerVPCEndpoint.DnsEntries']]] - HostedZoneId: !Select [0, !Split [':', !Select [1, !GetAtt 'DownloadServerVPCEndpoint.DnsEntries']]] - EvaluateTargetHealth: False - Comment: Routes all Download Server traffic over CrowdStrike's VPC endpoint service. - - manageRoute53LambdaRole: - Type: AWS::IAM::Role - Properties: - RoleName: !Join - - '-' - - - !Ref 'AWS::StackName' - - manageRoute53LambdaRole - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: lambda.amazonaws.com - Action: sts:AssumeRole - Condition: { } - Path: / - Policies: - - PolicyName: Manage_hosted_zones - PolicyDocument: - Version: '2012-10-17' - Statement: - Effect: Allow - Action: - - route53:DeleteVPCAssociationAuthorization - - route53:DisassociateVPCFromHostedZone - - route53:AssociateVPCWithHostedZone - - route53:CreateVPCAssociationAuthorization - Resource: - - arn:aws:ec2:*:517716713836:vpc/* - - !Join [ '', ['arn:aws:route53:::hostedzone/', !Ref TenantHostedZone]] - - PolicyName: list_vpcs - PolicyDocument: - Version: '2012-10-17' - Statement: - Effect: Allow - Action: - - ec2:DescribeVpcs - Resource: "*" - ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - lambdaLayer: - Type: AWS::Lambda::LayerVersion - Properties: - CompatibleRuntimes: - - python3.7 - Content: - S3Bucket: !Ref S3Bucket - S3Key: layer.zip - Description: Layer for requests package - LayerName: requests-package - manageRoute53DomainsLifeCycleEvent: - Type: AWS::Lambda::Function - Properties: - Code: - S3Bucket: !Ref S3Bucket - S3Key: manage-r53-association.zip - Layers: - - !Ref lambdaLayer - Handler: manage-r53-association.lambda_handler - MemorySize: 128 - Role: !GetAtt "manageRoute53LambdaRole.Arn" - Runtime: python3.7 - Timeout: 60 - TriggerLambda1: - Type: 'Custom::TriggerLambda' - Properties: - HostedZoneId: !Ref 'TenantHostedZone' - VpcId: !Ref 'TestVPC' - Region : !Ref 'AWS::Region' - ServiceToken: !GetAtt - - manageRoute53DomainsLifeCycleEvent - - Arn -Outputs: - InstanceIPAddress: - Description: IP address of the newly created EC2 instance - Value: !Ref TestInstanceEIP diff --git a/aws-privatelink/cloudformation/vpc-r53zone.yaml b/aws-privatelink/cloudformation/vpc-r53zone.yaml deleted file mode 100644 index 4fa8a4bd..00000000 --- a/aws-privatelink/cloudformation/vpc-r53zone.yaml +++ /dev/null @@ -1,258 +0,0 @@ ---- -AWSTemplateFormatVersion: '2010-09-09' -Description: > - This template deploys a VPC with a pair of private subnets spread across - two Availabilty Zones. Creates a VPC endpoint for CrowdStrike Falcon Services and a private - hosted Zone -Parameters: - TrustedIP: - Description: Trusted IP - Type: String - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - Default: 1.1.1.1/32 - EnvironmentName: - Description: An environment name that will be prefixed to resource names - Type: String - - CSPrivLinkVpcCIDR: - Description: Please enter the IP range (CIDR notation) for this CSPrivLinkVPC - Type: String - Default: 10.192.0.0/24 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVpcCIDR: - Description: Please enter the IP range (CIDR notation) for this CSPrivLinkVPC - Type: String - Default: 10.0.0.0/24 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - CSPrivLinkPrivateSubnet1CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone - Type: String - Default: 10.192.0.0/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - CSPrivLinkPrivateSubnet2CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone - Type: String - Default: 10.192.0.32/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - CSPrivLinkTGWAttachSubnet1CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone - Type: String - Default: 10.192.0.96/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - CSPrivLinkTGWAttachSubnet2CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone - Type: String - Default: 10.192.0.64/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVPCTGWAttachSubnet1CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone - Type: String - Default: 10.0.0.0/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVPCTGWAttachSubnet2CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone - Type: String - Default: 10.0.0.32/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVPCPrivateSubnet1CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone - Type: String - Default: 10.0.0.64/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - TestVPCPrivateSubnet2CIDR: - Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone - Type: String - Default: 10.0.0.96/28 - AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}' - - LinuxAMI: - Description: Managed AMI ID for Amazon Linux - Type: 'AWS::SSM::Parameter::Value' - Default: '/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2' - - CrowdStrikeServiceName: - Description: CrowdStrike - Type: String - Default: 'com.amazonaws.vpce.us-west-1.vpce-svc-08744dea97b26db5d' - -Resources: - - - - CSPrivLinkVPC: - Type: AWS::EC2::VPC - Properties: - EnableDnsSupport: true - EnableDnsHostnames: true - CidrBlock: !Ref 'CSPrivLinkVpcCIDR' - Tags: - - Key: Name - Value: !Ref EnvironmentName - - CSPrivLinkTGWAttachSubnet1: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - AvailabilityZone: - Fn::Select: - - 0 - - Fn::GetAZs: '' - CidrBlock: !Ref CSPrivLinkTGWAttachSubnet1CIDR - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} TGW Subnet (AZ1) - CSPrivLinkTGWAttachSubnet2: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - AvailabilityZone: - Fn::Select: - - 1 - - Fn::GetAZs: '' - CidrBlock: !Ref CSPrivLinkTGWAttachSubnet2CIDR - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} TGW Subnet (AZ2) - CSPrivLinkPrivateSubnet1: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - AvailabilityZone: - Fn::Select: - - 0 - - Fn::GetAZs: '' - CidrBlock: !Ref 'CSPrivLinkPrivateSubnet1CIDR' - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} Private Subnet (AZ1) - CSPrivLinkPrivateSubnet2: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - AvailabilityZone: - Fn::Select: - - 1 - - Fn::GetAZs: '' - CidrBlock: !Ref 'CSPrivLinkPrivateSubnet2CIDR' - MapPublicIpOnLaunch: false - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} Private Subnet (AZ2) - - CSPrivLinkPrivateRouteTable1: - Type: AWS::EC2::RouteTable - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - Tags: - - Key: Name - Value: !Sub ${EnvironmentName} CrowdStrikeServices Private Routes (AZ2) - - CSPrivLinkPrivateSubnet1RouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - RouteTableId: !Ref 'CSPrivLinkPrivateRouteTable1' - SubnetId: !Ref 'CSPrivLinkPrivateSubnet1' - - CSPrivLinkPrivateSubnet2RouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - RouteTableId: !Ref 'CSPrivLinkPrivateRouteTable1' - SubnetId: !Ref 'CSPrivLinkPrivateSubnet2' - - CrowdStrikeEndpointSG: - Type: AWS::EC2::SecurityGroup - Properties: - GroupDescription: 'Traffic into CloudFormation Endpoint' - SecurityGroupIngress: - - IpProtocol: tcp - FromPort: 443 - ToPort: 443 - CidrIp: '0.0.0.0/0' - VpcId: !Ref 'CSPrivLinkVPC' - Tags: - - Key: Name - Value: EndpointSG - - # This is the interface endpoint for CloudFormation. You can only deploy this - # once per region since it will consume the unique DNS entry for the endpoint. - CrowdStrikeEndpoint: - Type: AWS::EC2::VPCEndpoint - Properties: - VpcId: !Ref 'CSPrivLinkVPC' - ServiceName: !Ref 'CrowdStrikeServiceName' - VpcEndpointType: 'Interface' - PrivateDnsEnabled: False - SubnetIds: - - !Ref 'CSPrivLinkPrivateSubnet1' - - !Ref 'CSPrivLinkPrivateSubnet2' - SecurityGroupIds: - - !Ref 'CrowdStrikeEndpointSG' - - - TenantHostedZone: - Type: AWS::Route53::HostedZone - Properties: - HostedZoneConfig: - Comment: Private HostedZone (split-horizon DNS) for CrowdStrike sensor connectivity - Name: 'cloudsink.net.' - VPCs: - - VPCId: !Ref 'CSPrivLinkVPC' - VPCRegion: !Ref AWS::Region - CrowdStrikeAliasTS01b: - Type: AWS::Route53::RecordSet - Properties: - HostedZoneId: !Ref TenantHostedZone - Name: 'ts01-b.cloudsink.net' - Type: A - AliasTarget: - DNSName: !Select - - 1 - - !Split - - ':' - - !Select - - 0 - - !GetAtt 'CrowdStrikeEndpoint.DnsEntries' - HostedZoneId: !Select - - 0 - - !Split - - ':' - - !Select - - 0 - - !GetAtt 'CrowdStrikeEndpoint.DnsEntries' - EvaluateTargetHealth: False - Comment: CS domain override - CrowdStrikeAliaslfodown: - Type: AWS::Route53::RecordSet - Properties: - HostedZoneId: !Ref TenantHostedZone - Name: 'lfodown01-b.cloudsink.net' - Type: A - AliasTarget: - DNSName: !Select - - 1 - - !Split - - ':' - - !Select - - 0 - - !GetAtt 'CrowdStrikeEndpoint.DnsEntries' - HostedZoneId: !Select - - 0 - - !Split - - ':' - - !Select - - 0 - - !GetAtt 'CrowdStrikeEndpoint.DnsEntries' - EvaluateTargetHealth: False - Comment: CS domain override diff --git a/aws-privatelink/docs/architecture-01-per-vpc.md b/aws-privatelink/docs/architecture-01-per-vpc.md new file mode 100644 index 00000000..a50f4e70 --- /dev/null +++ b/aws-privatelink/docs/architecture-01-per-vpc.md @@ -0,0 +1,237 @@ +![CrowdStrike Logo (Light)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-light-mode.png#gh-light-mode-only) +![CrowdStrike Logo (Dark)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-dark-mode.png#gh-dark-mode-only) +# Architecture 01 - Per-VPC endpoints + +The baseline topology. A single workload VPC stands up its own CrowdStrike +interface endpoints, its own `cloudsink.net` private hosted zone, its own S3 +gateway endpoint, and one private Amazon Linux 2023 test host. There is no +shared network infrastructure between VPCs. + +This Terraform example deploys in any supported consumer Region. For a US-2 +Falcon CID, the VPC endpoints connect to the CrowdStrike endpoint service in +`us-west-2` over cross-region PrivateLink. The example composes the same +`endpoint-vpc` and `sensor-host` modules used by the multi-account examples +(02, 03), wired to a single AWS account. + +## Table of contents + +- [Prerequisites](#prerequisites) +- [Architecture](#architecture) +- [When to pick this](#when-to-pick-this) +- [What this deployment creates](#what-this-deployment-creates) +- [Deployment](#deployment) + - [Export credentials](#export-credentials) + - [Apply](#apply) +- [Teardown](#teardown) +- [Operational notes](#operational-notes) +- [Verification](#verification) + +## Prerequisites + +Before deploying: + +- CrowdStrike has whitelisted this AWS account for the consumer Region and the + matching Falcon cloud endpoint services. +- The AWS identity running Terraform can create VPCs, endpoints, IAM roles, S3 + buckets, SSM parameters, and EC2 instances in the consumer Region. +- The AWS identity and any relevant SCP allow cross-region PrivateLink creation, + including `vpce:AllowMultiRegion`. +- `uv` is on your `PATH`. +- You have a CrowdStrike Falcon API client ID and secret with + `Sensor Download: Read`. + +## Architecture + +```mermaid +flowchart LR + subgraph CS["CrowdStrike Cloud (US-2, us-west-2)"] + CS_SP["Sensor Proxy Service"] + CS_DL["Download Service"] + CS_UL["Upload Service"] + end + + subgraph VPC["Customer VPC (us-east-2, no IGW, no NAT)"] + direction TB + + subgraph Subnets["Private Subnets (2 AZs)"] + EC2["EC2 Falcon Sensor"] + end + + subgraph VPCE["VPC Endpoints"] + direction TB + EP_SP["Interface Endpoint: sensor_proxy"] + EP_DL["Interface Endpoint: download_server"] + EP_UL["Interface Endpoint: upload_server"] + EP_S3["Gateway Endpoint: S3"] + EP_SSM["Interface Endpoints: SSM, ssmmessages, ec2messages"] + end + + PHZ["Route 53 PHZ: cloudsink.net"] + end + + subgraph S3["S3 (same consumer Region)"] + BUCKET["Artifacts Bucket: falcon-sensor.rpm"] + end + + EC2 -. "DNS query for *.cloudsink.net" .-> PHZ + PHZ -. "aliases to endpoint DNS" .-> EP_SP + PHZ -. "aliases to endpoint DNS" .-> EP_DL + PHZ -. "aliases to endpoint DNS" .-> EP_UL + + EC2 -- "HTTPS 443 telemetry" --> EP_SP + EC2 -- "HTTPS 443 downloads" --> EP_DL + EC2 -- "HTTPS 443 uploads" --> EP_UL + EP_SP -- "PrivateLink cross-region" --> CS_SP + EP_DL -- "PrivateLink cross-region" --> CS_DL + EP_UL -- "PrivateLink cross-region" --> CS_UL + + EC2 -- "S3 GetObject" --> EP_S3 + EP_S3 --> BUCKET + EC2 -. "SSM Session Manager" .-> EP_SSM +``` + +Terraform derives the endpoint service home Region from `var.falcon_cloud`. +When `var.region` differs from that home Region, the CrowdStrike +`aws_vpc_endpoint` resources use the Terraform `service_region` argument to +target the remote endpoint service. The S3 gateway endpoint and SSM endpoints +remain local to the consumer Region. + +## When to pick this + +- You want the simplest end-to-end lab. +- You have a small number of AWS accounts or VPCs running Falcon. +- Each workload VPC can own its own CrowdStrike interface endpoints and DNS. +- You can tolerate one CrowdStrike account-whitelisting request per account and + Region. + +If account-whitelisting volume or endpoint sprawl becomes the main concern, +compare the Shared VPC and TGW labs. + +## What this deployment creates + +Per Terraform apply: + +- 1 VPC with two private subnets, no internet gateway, and no NAT gateway. +- 3 CrowdStrike interface endpoints: sensor proxy, download, and upload. +- 3 SSM interface endpoints for Session Manager. +- 1 S3 gateway endpoint for the staged sensor RPM and AL2023 package access. +- 1 Route 53 private hosted zone for `cloudsink.net`. +- 1 S3 bucket with `falcon-sensor.rpm`. +- 2 SSM parameters for the Falcon CID and Falcon cloud. +- 1 IAM role and instance profile for SSM, S3, and SSM Parameter Store access. +- 1 private AL2023 EC2 instance with IMDSv2 and the Falcon sensor installed on + first boot. + +The Falcon API is called once from the Terraform workstation to download the +AL2023 sensor RPM and fetch the tenant CID. The RPM is then staged into the +lab S3 bucket so the test host can install without internet egress. + +## Deployment + +### Export credentials + +```bash +# Required +export AWS_PROFILE=... # AWS credentials for the target account +export TF_VAR_falcon_client_id='...' # Falcon API client ID (Sensor Download: Read) +export TF_VAR_falcon_client_secret='...' # Falcon API client secret +export TF_VAR_falcon_cloud='us-2' # Falcon cloud for this CID (us-1, us-2, eu-1) +export TF_VAR_owner_email='you@example.com' # Owner tag for resource accountability + +# Optional +export TF_VAR_region='us-east-2' # Region where the lab is deployed +export TF_VAR_environment='dev' # Resource name prefix and environment tag +export TF_VAR_instance_type='t3.small' # EC2 instance size for sensor hosts +export TF_VAR_ami_id='ami-0abcdef1234567890' # Only set if the default is deprecated; must be Amazon Linux 2023 +``` + +Avoid deploying to Falcon home Regions (`us-west-1`, `us-west-2`, `eu-central-1`) +since the lab is designed to demonstrate cross-region PrivateLink connectivity. + +### Apply + +```bash +cd examples/01-per-vpc +terraform init +terraform apply +``` + +On first apply, Terraform will: + +1. Run `scripts/fetch_sensor.py` through `uv run` to download the latest + AL2023 sensor RPM and fetch your tenant CID. +2. Create the VPC, private subnets, CrowdStrike endpoints, SSM endpoints, S3 + gateway endpoint, private hosted zone, S3 bucket, IAM role, and SSM + parameters in the consumer Region. +3. Upload the RPM to the lab bucket. +4. Launch the private EC2 instance, which pulls the RPM from S3, reads the CID + and cloud from SSM Parameter Store, configures Falcon, and starts the + sensor. + +Expect about 3-5 minutes from `apply complete` to the host appearing in the +Falcon console. + +## Teardown + +```bash +terraform destroy +``` + +Before destroying, confirm you no longer need the registered host record in +the Falcon console. The EC2 instance will be terminated, but Falcon host +records can remain visible according to your tenant retention policy. + +## Operational notes + +- Sensor updates flow over the same CrowdStrike PrivateLink endpoints after + first boot. The lab bucket is used for initial installation. +- Rotating the Falcon API secret does not automatically force a sensor RPM + re-download. Delete `.sensor-cache/` if you need Terraform to fetch again. +- This topology scales operationally until per-account/per-Region + account-whitelisting requests or duplicated endpoints become painful. + +## Verification + +Run these checks after `terraform apply` completes. + +From the workstation, print the SSM command: + +```bash +terraform output -json deployment | jq -r '.ssm_start_session_commands[0]' +``` + +Start the session with the printed command. Then print the host-side +verification commands: + +```bash +terraform output -json deployment | jq -r '.verification_commands[]' +``` + +Inside the SSM session, run the generated commands and confirm: + +- **DNS resolution:** `nslookup ts01-.cloudsink.net` returns + private VPC IP addresses, not public internet addresses. +- **TLS handshake:** `curl -v https://ts01-.cloudsink.net:443` + reaches the Falcon endpoint and completes a TLS handshake. An HTTP success + body is not required for this connectivity check. +- **Sensor AID:** `sudo /opt/CrowdStrike/falconctl -g --aid` returns a + non-empty AID after the sensor registers. +- **Service status:** `sudo systemctl status falcon-sensor --no-pager` shows + the service running or recently started successfully. +- **Bootstrap log:** `sudo cat /var/log/falcon-bootstrap.log` shows the RPM + copied from S3, installed with `dnf`, configured with `falconctl`, and the + service started. + +Topology-specific check from the workstation: + +```bash +terraform output -json deployment | jq '.crowdstrike_endpoint_dns' +``` + +Confirm all three CrowdStrike endpoint keys exist: `sensor_proxy`, +`download_server`, and `upload_server`. + +If endpoints stay in `pendingAcceptance`, the usual cause is missing +CrowdStrike account whitelisting or missing supported-Region enablement. If +DNS returns public IPs, check the private hosted zone association and the +`cloudsink.net` alias records. diff --git a/aws-privatelink/docs/architecture-02-shared-vpc.md b/aws-privatelink/docs/architecture-02-shared-vpc.md new file mode 100644 index 00000000..6d036eff --- /dev/null +++ b/aws-privatelink/docs/architecture-02-shared-vpc.md @@ -0,0 +1,248 @@ +![CrowdStrike Logo (Light)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-light-mode.png#gh-light-mode-only) +![CrowdStrike Logo (Dark)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-dark-mode.png#gh-dark-mode-only) +# Architecture 02 - Shared VPC + +This lab shows a centralized subnet-sharing pattern. An owner account creates +the VPC, CrowdStrike interface endpoints, `cloudsink.net` private hosted zone, +S3 gateway endpoint, SSM endpoints, and sensor RPM bucket. A workload account +launches the private AL2023 test host directly into RAM-shared private subnets +inside that owner VPC. + +The example deploys one owner account and one workload account in one consumer +Region, `us-east-2` by default. The RAM subnet share is same-region; deploy one +shared VPC per consumer Region if you need this pattern in multiple Regions. + +## Table of contents + +- [Prerequisites](#prerequisites) +- [Architecture](#architecture) +- [When to pick this](#when-to-pick-this) +- [What this deployment creates](#what-this-deployment-creates) +- [Deployment](#deployment) + - [Export credentials](#export-credentials) + - [Apply](#apply) +- [Teardown](#teardown) +- [Operational notes](#operational-notes) +- [Verification](#verification) + +## Prerequisites + +Before deploying: + +- CrowdStrike has whitelisted the owner account for the consumer Region and + matching Falcon cloud endpoint services. +- The owner and workload AWS CLI profiles are authenticated. +- The owner account can create VPC, endpoint, Route 53, RAM, S3, IAM, and EC2 + supporting resources. +- The workload account can create EC2, IAM, security group, and SSM Parameter + Store resources in the shared VPC/subnets. +- The AWS identities and relevant SCPs allow cross-region PrivateLink creation, + including `vpce:AllowMultiRegion`. +- You know the workload account's 12-digit account ID. +- `uv` is on your `PATH`. +- You have a CrowdStrike Falcon API client ID and secret with + `Sensor Download: Read`. + +## Architecture + +```mermaid +flowchart LR + subgraph CS["CrowdStrike Cloud (US-2, us-west-2)"] + CS_SP["Sensor Proxy Service"] + CS_DL["Download Service"] + CS_UL["Upload Service"] + end + + subgraph OWNER["Owner Account"] + subgraph VPC["Shared VPC (us-east-2, no IGW, no NAT)"] + direction TB + SUBNETS["Private subnets shared by RAM"] + EP_CS["CrowdStrike interface endpoints"] + EP_S3["S3 gateway endpoint"] + EP_SSM["SSM interface endpoints"] + PHZ["Route 53 PHZ: cloudsink.net"] + end + + BUCKET["S3 bucket: falcon-sensor.rpm"] + RAM["RAM subnet share"] + end + + subgraph WORKLOAD["Workload Account"] + EC2["EC2 Falcon Sensor ENI in shared subnet"] + IAM["Instance role"] + SSM_LOCAL["Local SSM params: CID and cloud"] + end + + RAM -. "shares subnets" .-> EC2 + EC2 -. "DNS query for *.cloudsink.net" .-> PHZ + PHZ -. "aliases to endpoint DNS" .-> EP_CS + EC2 -- "HTTPS 443 Falcon traffic" --> EP_CS + EP_CS -- "PrivateLink cross-region" --> CS_SP + EP_CS -- "PrivateLink cross-region" --> CS_DL + EP_CS -- "PrivateLink cross-region" --> CS_UL + EC2 -- "S3 GetObject" --> EP_S3 + EP_S3 --> BUCKET + EC2 -. "SSM Session Manager" .-> EP_SSM + EC2 -. "GetParameter" .-> SSM_LOCAL + IAM -. "cross-account bucket policy allows read" .-> BUCKET +``` + +The key difference from a workload-owned VPC is that the workload ENI is inside +the owner VPC. Because DNS resolution and security groups are VPC-scoped, the +workload host inherits the owner VPC's `cloudsink.net` private hosted zone and +can reference endpoint security groups in the same VPC even though the EC2 +instance is owned by another account. + +Terraform still targets the Falcon cloud home Region on the CrowdStrike +interface endpoints when the consumer Region differs from the Falcon home +Region. The workload account does not need its own CrowdStrike endpoints. + +## When to pick this + +- You have multiple workload accounts in the same consumer Region. +- Workloads are allowed to run in centrally owned shared subnets. +- You want one CrowdStrike account-whitelisting request for the owner account + and Region, rather than one per workload account. +- You do not need a TGW path for CrowdStrike traffic. + +Do not use this topology when workload teams must own their VPCs, when shared +subnets are not acceptable, or when you need one owner VPC to serve multiple +consumer Regions. RAM subnet sharing is regional. + +## What this deployment creates + +In the owner account: + +- 1 VPC with two private subnets, no internet gateway, and no NAT gateway. +- 3 CrowdStrike interface endpoints. +- 3 SSM interface endpoints. +- 1 S3 gateway endpoint. +- 1 Route 53 private hosted zone for `cloudsink.net`. +- 1 S3 bucket with `falcon-sensor.rpm` and a bucket policy allowing the + workload instance role to read the RPM. +- 1 endpoint security group with ingress from the workload instance security + group. +- 1 RAM resource share for the private subnets. + +In the workload account: + +- 1 EC2 instance security group in the shared VPC. +- 1 IAM role and instance profile for SSM, S3 read, and local SSM Parameter + Store access. +- 2 local SSM parameters for the Falcon CID and Falcon cloud. +- 1 private AL2023 EC2 instance launched into the shared subnet. + +## Deployment + +### Export credentials + +```bash +export TF_VAR_owner_profile='my-sso-owner' +export TF_VAR_workload_profile='my-sso-workload' +export TF_VAR_workload_account_id='111122223333' +export TF_VAR_owner_email='you@example.com' +export TF_VAR_falcon_client_id='...' +export TF_VAR_falcon_client_secret='...' +``` + +Refresh both profiles before applying: + +```bash +aws sso login --profile "$TF_VAR_owner_profile" +aws sso login --profile "$TF_VAR_workload_profile" +``` + +### Apply + +```bash +cd examples/02-shared-vpc +terraform init +terraform apply +``` + +A single Terraform state coordinates both accounts through provider aliases. +On first apply, Terraform will: + +1. Run `scripts/fetch_sensor.py` through `uv run` to download the latest + AL2023 sensor RPM and fetch your tenant CID. +2. Create the owner VPC, subnets, endpoints, private hosted zone, bucket, + bucket policy, endpoint security group, and RAM share. +3. Create the workload instance security group, IAM role, local SSM + parameters, and EC2 test host in the shared subnet. +4. Install and start the Falcon sensor on first boot. + +Expect about 3-5 minutes from `apply complete` to the workload host appearing +in the Falcon console. + +## Teardown + +```bash +terraform destroy +``` + +Destroy removes the workload account resources first, then the owner account +RAM share, endpoints, VPC, and bucket. Before destroying, confirm no unmanaged +workload ENIs are still attached to the shared subnets. + +## Operational notes + +- Adding workload accounts means adding RAM principals, workload provider + aliases, sensor-host module calls, endpoint security group ingress, and + bucket policy principals. +- For more than a few workload accounts, consider replacing explicit role ARN + bucket access with an organization-scoped bucket policy managed by your + security team. +- This topology reduces CrowdStrike account-whitelisting volume because the + owner account is the PrivateLink consumer account for the CrowdStrike + endpoints. +- Sensor updates flow over the CrowdStrike PrivateLink endpoints after first + boot. The owner bucket is only used for initial installation. + +## Verification + +Run these checks after `terraform apply` completes. + +From the workstation, print the workload profile and SSM command: + +```bash +WORKLOAD_PROFILE=$(terraform output -json deployment | jq -r '.workload_profile') +CMD=$(terraform output -json deployment | jq -r '.ssm_start_session_commands[0]') +echo "$CMD --profile $WORKLOAD_PROFILE" +``` + +Start the SSM session with the printed command. Then print the host-side +verification commands: + +```bash +terraform output -json deployment | jq -r '.verification_commands[]' +``` + +Inside the SSM session, run the generated commands and confirm: + +- **DNS resolution:** `nslookup ts01-.cloudsink.net` returns + private IP addresses from the owner VPC. +- **TLS handshake:** `curl -v https://ts01-.cloudsink.net:443` + reaches the Falcon endpoint and completes a TLS handshake. +- **Sensor AID:** `sudo /opt/CrowdStrike/falconctl -g --aid` returns a + non-empty AID after registration. +- **Service status:** `sudo systemctl status falcon-sensor --no-pager` shows + the service running or recently started successfully. +- **Bootstrap log:** `sudo cat /var/log/falcon-bootstrap.log` shows the RPM + copied from the owner bucket, installed, configured, and started. + +Topology-specific checks from the workstation: + +```bash +aws ram get-resource-shares --profile "$TF_VAR_owner_profile" \ + --resource-owner SELF \ + --query 'resourceShares[?name==`demo-cs-privatelink-shared-subnets`].[name,status]' \ + --output table + +terraform output -json deployment | jq -r '.sensor_bucket' +``` + +Confirm the RAM share is active and the bootstrap log references the owner +bucket printed by Terraform. If S3 access fails with `403`, check that the +owner bucket policy includes the workload instance role ARN. If DNS returns +public addresses, check that the instance is actually launched in the +RAM-shared owner VPC subnets. diff --git a/aws-privatelink/docs/architecture-03-tgw-profiles.md b/aws-privatelink/docs/architecture-03-tgw-profiles.md new file mode 100644 index 00000000..b79a609e --- /dev/null +++ b/aws-privatelink/docs/architecture-03-tgw-profiles.md @@ -0,0 +1,306 @@ +![CrowdStrike Logo (Light)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-light-mode.png#gh-light-mode-only) +![CrowdStrike Logo (Dark)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-dark-mode.png#gh-dark-mode-only) +# Architecture 03 - TGW + Route 53 Profiles + +This lab shows a hub-and-spoke landing-zone pattern. A hub networking account +creates an endpoint VPC with the CrowdStrike interface endpoints, a Transit +Gateway, and a Route 53 Profile that shares `cloudsink.net` DNS with spoke +VPCs. The spoke account keeps its own VPC and routes Falcon sensor traffic to +the same-region hub endpoint VPC over TGW. + +The example deploys one hub account and one spoke account in one consumer +Region, `us-east-2` by default. Cross-region PrivateLink is used between the +hub endpoint VPC and the CrowdStrike endpoint service home Region. TGW is used +only for same-region spoke-to-hub routing in the customer environment. + +## Table of contents + +- [Prerequisites](#prerequisites) +- [Architecture](#architecture) +- [What cross-region PrivateLink changes for TGW](#what-cross-region-privatelink-changes-for-tgw) +- [Route 53 Profiles](#route-53-profiles) +- [When to pick this](#when-to-pick-this) +- [What this deployment creates](#what-this-deployment-creates) +- [Deployment](#deployment) + - [Export credentials](#export-credentials) + - [Apply](#apply) +- [Teardown](#teardown) +- [Operational notes](#operational-notes) +- [Verification](#verification) + +## Prerequisites + +Before deploying: + +- CrowdStrike has whitelisted the hub account for the consumer Region and + matching Falcon cloud endpoint services. +- The hub and spoke AWS CLI profiles are authenticated. +- The hub account can create VPC, endpoint, Route 53, RAM, TGW, S3, IAM, and + EC2 supporting resources. +- The spoke account can create VPC, endpoint, route table, TGW attachment, + Route 53 Profile association, EC2, IAM, security group, and SSM resources. +- The AWS identities and relevant SCPs allow cross-region PrivateLink creation, + including `vpce:AllowMultiRegion`. +- You know the spoke account's 12-digit account ID. +- `uv` is on your `PATH`. +- You have a CrowdStrike Falcon API client ID and secret with + `Sensor Download: Read`. + +## Architecture + +```mermaid +flowchart LR + subgraph CS["CrowdStrike Cloud (US-2, us-west-2)"] + CS_SP["Sensor Proxy Service"] + CS_DL["Download Service"] + CS_UL["Upload Service"] + end + + subgraph HUB["Hub Networking Account"] + subgraph HVPC["Endpoint VPC (us-east-2)"] + HCS["CrowdStrike interface endpoints"] + HPHZ["Route 53 PHZ: cloudsink.net"] + HSSM["SSM endpoints for hub resources"] + HS3["S3 gateway endpoint"] + end + TGW["Transit Gateway"] + PROFILE["Route 53 Profile containing cloudsink.net"] + BUCKET["S3 bucket: falcon-sensor.rpm"] + RAM_TGW["RAM share: TGW"] + RAM_PROFILE["RAM share: Route 53 Profile"] + end + + subgraph SPOKE["Spoke Workload Account"] + subgraph SVPC["Spoke VPC (us-east-2)"] + EC2["EC2 Falcon Sensor"] + SSM["Local SSM endpoints"] + S3["Local S3 gateway endpoint"] + ATTACH["TGW attachment"] + PROF_ASSOC["Profile association"] + end + PARAMS["Local SSM params: CID and cloud"] + end + + RAM_TGW -. "shares TGW" .-> ATTACH + RAM_PROFILE -. "shares profile" .-> PROF_ASSOC + EC2 -. "DNS query for *.cloudsink.net" .-> PROF_ASSOC + PROF_ASSOC -. "resolves via hub PHZ" .-> HPHZ + EC2 -- "HTTPS 443 Falcon traffic via TGW" --> TGW + TGW --> HCS + HCS -- "PrivateLink cross-region" --> CS_SP + HCS -- "PrivateLink cross-region" --> CS_DL + HCS -- "PrivateLink cross-region" --> CS_UL + EC2 -- "S3 GetObject via local gateway endpoint" --> S3 + S3 --> BUCKET + EC2 -. "SSM Session Manager" .-> SSM + EC2 -. "GetParameter" .-> PARAMS +``` + +## What cross-region PrivateLink changes for TGW + +Large AWS estates commonly centralize VPC-to-VPC connectivity through Transit +Gateway. Before native cross-region PrivateLink, that pattern still needed a +regional anchor for Falcon PrivateLink. For a US-2 Falcon CID, for example, +customers typically created an endpoint VPC in `us-west-2`, connected workload +Regions back to it with inter-region TGW or VPC peering, propagated routes for +the endpoint VPC CIDRs, and made DNS resolve the Falcon hostnames to that +remote endpoint VPC. + +At scale, every additional workload Region increased the amount of customer +networking to manage: regional TGWs, peering attachments, route table entries, +return paths, security group or CIDR rules, and DNS sharing across Regions. +The resulting architecture worked, but the Falcon traffic path was shaped by +the Falcon home Region rather than by where the workloads actually ran. + +With cross-region PrivateLink, the endpoint VPC can live in the same consumer +Region as the spokes. Spokes still route Falcon traffic to the hub over the +same-region TGW, but the customer no longer needs inter-region routing just to +reach the Falcon home Region. From the hub endpoint VPC, PrivateLink connects +privately to the remote CrowdStrike endpoint service. + +In Terraform, the endpoint-vpc module expresses that remote target with the +`service_region` argument on the CrowdStrike `aws_vpc_endpoint` resources. +That Terraform detail is intentionally local to the lab implementation; the +architecture concept is "consumer endpoints in one Region connect to an +endpoint service hosted in the Falcon cloud home Region." + +## Route 53 Profiles + +Route 53 Profiles avoid a per-spoke private hosted zone association workflow. +The hub account creates the `cloudsink.net` private hosted zone, adds it to a +Route 53 Profile, and RAM-shares the Profile to the spoke account. The spoke +then associates the Profile with its VPC. + +That gives spoke workloads the same private Falcon hostname resolution without +creating a Route 53 association authorization for every spoke VPC. Profiles +are regional, so a multi-Region rollout creates one Profile per consumer +Region. + +## When to pick this + +- You already run TGW as a hub-and-spoke backbone. +- Workloads must remain in workload-owned VPCs. +- A central networking account should own the CrowdStrike endpoints and DNS. +- You want one CrowdStrike account-whitelisting request for the hub account and + Region, rather than one per spoke account. + +Do not introduce TGW only for this guide if you do not already need +hub-and-spoke routing. The Shared VPC lab is simpler when workloads can run in +shared subnets. + +## What this deployment creates + +In the hub account: + +- 1 endpoint VPC with two private subnets. +- 3 CrowdStrike interface endpoints. +- 3 SSM interface endpoints and 1 S3 gateway endpoint for hub-side support. +- 1 Route 53 private hosted zone for `cloudsink.net`. +- 1 S3 bucket with `falcon-sensor.rpm` and bucket policy access for the spoke + instance role. +- 1 endpoint security group with CIDR-based ingress from the spoke VPC. +- 1 Transit Gateway with explicit hub and spoke route tables. +- 1 hub VPC TGW attachment and hub route table entry for the spoke CIDR. +- 1 Route 53 Profile containing the `cloudsink.net` private hosted zone. +- RAM shares for the Transit Gateway and Route 53 Profile. + +In the spoke account: + +- 1 spoke VPC with two private subnets. +- 1 TGW VPC attachment to the RAM-shared TGW. +- 1 spoke route table entry for the hub CIDR. +- 3 local SSM interface endpoints. +- 1 local S3 gateway endpoint. +- 1 Route 53 Profile association to the spoke VPC. +- 1 IAM role and instance profile for SSM, S3 read, and local SSM Parameter + Store access. +- 2 local SSM parameters for the Falcon CID and Falcon cloud. +- 1 private AL2023 EC2 test host with the Falcon sensor installed on first + boot. + +## Deployment + +### Export credentials + +```bash +export TF_VAR_hub_profile='my-sso-hub' +export TF_VAR_spoke_profile='my-sso-spoke' +export TF_VAR_spoke_account_id='111122223333' +export TF_VAR_owner_email='you@example.com' +export TF_VAR_falcon_client_id='...' +export TF_VAR_falcon_client_secret='...' +``` + +Refresh both profiles before applying: + +```bash +aws sso login --profile "$TF_VAR_hub_profile" +aws sso login --profile "$TF_VAR_spoke_profile" +``` + +### Apply + +```bash +cd examples/03-tgw-profiles +terraform init +terraform apply +``` + +A single Terraform state coordinates both accounts through provider aliases. +On first apply, Terraform will: + +1. Run `scripts/fetch_sensor.py` through `uv run` to download the latest + AL2023 sensor RPM and fetch your tenant CID. +2. Create the hub endpoint VPC, CrowdStrike endpoints, private hosted zone, + bucket, TGW, TGW route tables, hub attachment, Route 53 Profile, and RAM + shares. +3. Create the spoke VPC, local SSM and S3 endpoints, TGW attachment, Route 53 + Profile association, IAM role, SSM parameters, and EC2 test host. +4. Install and start the Falcon sensor on first boot. + +Expect about 5-8 minutes from `apply complete` to the spoke host appearing in +the Falcon console. TGW attachment provisioning adds time compared with the +first two labs. + +## Teardown + +```bash +terraform destroy +``` + +Terraform should destroy spoke resources first, then hub RAM shares, TGW +resources, endpoint VPC resources, and the bucket. Before destroying, confirm +no unmanaged spokes are attached to the TGW or associated with the shared Route +53 Profile. + +If a TGW-related destroy fails during dependency cleanup, re-run +`terraform destroy` after Terraform refreshes state. + +## Operational notes + +- Adding spokes means adding RAM principals, spoke provider aliases, TGW + attachments, route-table association/propagation, spoke CIDR ingress on the + hub endpoints security group, and bucket access for the new spoke role. +- Existing TGW environments should adapt the route-table associations and + propagations to their central networking model rather than copying the lab + one-for-one. +- Existing Route 53 Profile environments can add `cloudsink.net` to an existing + Profile instead of creating a dedicated lab Profile. +- Multi-Region deployments repeat this pattern per consumer Region. Each + Region has its own endpoint VPC, TGW routing, Route 53 Profile, and + CrowdStrike account-whitelisting workflow. + +## Verification + +Run these checks after `terraform apply` completes. + +From the workstation, print the spoke profile and SSM command: + +```bash +SPOKE_PROFILE=$(terraform output -json deployment | jq -r '.spoke_profile') +CMD=$(terraform output -json deployment | jq -r '.ssm_start_session_commands[0]') +echo "$CMD --profile $SPOKE_PROFILE" +``` + +Start the SSM session with the printed command. Then print the host-side +verification commands: + +```bash +terraform output -json deployment | jq -r '.verification_commands[]' +``` + +Inside the SSM session, run the generated commands and confirm: + +- **DNS resolution:** `nslookup ts01-.cloudsink.net` returns + private IP addresses in the hub endpoint VPC CIDR, `10.70.0.0/16` by + default. +- **TLS handshake:** `curl -v https://ts01-.cloudsink.net:443` + reaches the Falcon endpoint and completes a TLS handshake. +- **Sensor AID:** `sudo /opt/CrowdStrike/falconctl -g --aid` returns a + non-empty AID after registration. +- **Service status:** `sudo systemctl status falcon-sensor --no-pager` shows + the service running or recently started successfully. +- **Bootstrap log:** `sudo cat /var/log/falcon-bootstrap.log` shows the RPM + copied from the hub bucket, installed, configured, and started. + +Topology-specific checks from the workstation: + +```bash +aws ram get-resource-shares --profile "$TF_VAR_hub_profile" \ + --resource-owner SELF \ + --query 'resourceShares[?starts_with(name, `demo-cs-privatelink-tgw`)].[name,status]' \ + --output table + +aws route53profiles list-profile-associations --profile "$TF_VAR_spoke_profile" \ + --query 'ProfileAssociations[?contains(Name, `demo-cs-privatelink-tgw`)].[Name,Status,ResourceId]' \ + --output table + +terraform output -json deployment | jq '{tgw_id, hub_vpc_id, spoke_vpc_id, profile_id}' +``` + +Confirm the RAM shares are active, the Route 53 Profile association is active, +and the DNS result points at the hub endpoint VPC rather than the spoke VPC or +public internet. If DNS fails, check the Profile share and association. If DNS +works but TLS fails, check the spoke route to the hub CIDR, TGW attachment +state, hub return route to the spoke CIDR, and hub endpoint security group +CIDR ingress. diff --git a/aws-privatelink/docs/vpc-endpoints-reference.md b/aws-privatelink/docs/vpc-endpoints-reference.md new file mode 100644 index 00000000..03150476 --- /dev/null +++ b/aws-privatelink/docs/vpc-endpoints-reference.md @@ -0,0 +1,25 @@ +![CrowdStrike Logo (Light)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-light-mode.png#gh-light-mode-only) +![CrowdStrike Logo (Dark)](https://raw.githubusercontent.com/CrowdStrike/.github/main/assets/cs-logo-dark-mode.png#gh-dark-mode-only) +# VPC Endpoints Reference - CrowdStrike PrivateLink + +Customers need to establish PrivateLink connectivity to the CrowdStrike Falcon +endpoint services in the commercial AWS Region that matches their Falcon cloud. +Use the Falcon cloud that matches the CID you deploy sensors against. + +## Table of contents + +- [Endpoint service matrix](#endpoint-service-matrix) + +## Endpoint service matrix + +| Cloud | DNS name | Service | VPC endpoint service name | Home Region | +|---|---|---|---|---| +| US-1 | `ts01-b.cloudsink.net` | Sensor proxy | `com.amazonaws.vpce.us-west-1.vpce-svc-08744dea97b26db5d` | `us-west-1` | +| US-1 | `lfodown01-b.cloudsink.net` | Download server | `com.amazonaws.vpce.us-west-1.vpce-svc-0f9d8ca86ddcb7106` | `us-west-1` | +| US-1 | `lfoup01-b.cloudsink.net` | Upload server | `com.amazonaws.vpce.us-west-1.vpce-svc-0fa888d7b9e4130f4` | `us-west-1` | +| US-2 | `ts01-gyr-maverick.cloudsink.net` | Sensor proxy | `com.amazonaws.vpce.us-west-2.vpce-svc-08a5bb05d337fd834` | `us-west-2` | +| US-2 | `lfodown01-gyr-maverick.cloudsink.net` | Download server | `com.amazonaws.vpce.us-west-2.vpce-svc-0e11def2d8620ae74` | `us-west-2` | +| US-2 | `lfoup01-gyr-maverick.cloudsink.net` | Upload server | `com.amazonaws.vpce.us-west-2.vpce-svc-074a82fde584744da` | `us-west-2` | +| EU-1 | `ts01-lanner-lion.cloudsink.net` | Sensor proxy | `com.amazonaws.vpce.eu-central-1.vpce-svc-0eb7b6ca4b7271385` | `eu-central-1` | +| EU-1 | `lfodown01-lanner-lion.cloudsink.net` | Download server | `com.amazonaws.vpce.eu-central-1.vpce-svc-0340142b9ab8fc564` | `eu-central-1` | +| EU-1 | `lfoup01-lanner-lion.cloudsink.net` | Upload server | `com.amazonaws.vpce.eu-central-1.vpce-svc-0148ff0159e9419dd` | `eu-central-1` | diff --git a/aws-privatelink/examples/01-per-vpc/fetch.tf b/aws-privatelink/examples/01-per-vpc/fetch.tf new file mode 100644 index 00000000..20fae6af --- /dev/null +++ b/aws-privatelink/examples/01-per-vpc/fetch.tf @@ -0,0 +1,45 @@ +# Root-level sensor fetch. Runs once on the workstation via uv run, drops +# the RPM + result.json into a local cache, and the module instance consumes +# it via BYO-RPM paths. Avoids unnecessary Falcon API traffic when the cache +# is already populated. + +locals { + sensor_fetch_out_dir = "${path.module}/.sensor-cache" + sensor_fetch_result = "${local.sensor_fetch_out_dir}/result.json" + cache_exists = fileexists(local.sensor_fetch_result) +} + +resource "null_resource" "fetch_sensor" { + count = local.cache_exists ? 0 : 1 + + triggers = { + inputs_hash = sha256("${var.falcon_client_id}:${var.falcon_cloud}") + script = filemd5("${path.module}/../../scripts/fetch_sensor.py") + } + + provisioner "local-exec" { + command = join(" ", [ + "mkdir -p '${local.sensor_fetch_out_dir}' &&", + "uv run '${path.module}/../../scripts/fetch_sensor.py'", + "--out '${local.sensor_fetch_out_dir}'", + "--cloud '${var.falcon_cloud}'", + "--arch x86_64", + ]) + + environment = { + FALCON_CLIENT_ID = var.falcon_client_id + FALCON_CLIENT_SECRET = var.falcon_client_secret + } + } +} + +data "local_file" "sensor_fetch" { + filename = local.sensor_fetch_result + depends_on = [null_resource.fetch_sensor] +} + +locals { + sensor_fetch_data = jsondecode(data.local_file.sensor_fetch.content) + fetched_cid = local.sensor_fetch_data.cid + fetched_rpm_path = local.sensor_fetch_data.rpm_path +} diff --git a/aws-privatelink/examples/01-per-vpc/main.tf b/aws-privatelink/examples/01-per-vpc/main.tf new file mode 100644 index 00000000..248b0faf --- /dev/null +++ b/aws-privatelink/examples/01-per-vpc/main.tf @@ -0,0 +1,57 @@ +# Single-account, single-region stack. Creates one VPC with CrowdStrike +# PrivateLink endpoints, S3 bucket, PHZ, and a private sensor host. Uses +# the same endpoint-vpc + sensor-host modules as 02 and 03. +# +# The RPM and CID come from fetch.tf (root-level), so the Falcon API is +# hit once per apply. + +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + azs = slice(data.aws_availability_zones.available.names, 0, 2) + vpc_cidr = "10.50.0.0/16" + subnet_cidrs = [cidrsubnet(local.vpc_cidr, 8, 1), cidrsubnet(local.vpc_cidr, 8, 2)] +} + +module "endpoint_vpc" { + source = "../../modules/endpoint-vpc" + + region = var.region + availability_zones = local.azs + name_prefix = var.environment + + vpc_cidr = local.vpc_cidr + subnet_cidrs = local.subnet_cidrs + + falcon_cloud = var.falcon_cloud + sensor_rpm_path = local.fetched_rpm_path + + ram_principals = [] + + consumer_sg_ids = { + sensor-host = module.sensor_host.instance_sg_id + } +} + +module "sensor_host" { + source = "../../modules/sensor-host" + + region = var.region + name_prefix = var.environment + + vpc_id = module.endpoint_vpc.vpc_id + subnet_ids = module.endpoint_vpc.subnet_ids_list + + endpoints_sg_id = module.endpoint_vpc.endpoints_sg_id + s3_prefix_list_id = module.endpoint_vpc.s3_prefix_list_id + + sensor_bucket_name = module.endpoint_vpc.sensor_bucket_name + sensor_bucket_rpm_key = module.endpoint_vpc.sensor_bucket_rpm_key + + falcon_cloud = var.falcon_cloud + falcon_cid = local.fetched_cid + instance_type = var.instance_type + ami_id = var.ami_id +} diff --git a/aws-privatelink/examples/01-per-vpc/outputs.tf b/aws-privatelink/examples/01-per-vpc/outputs.tf new file mode 100644 index 00000000..d8fbe421 --- /dev/null +++ b/aws-privatelink/examples/01-per-vpc/outputs.tf @@ -0,0 +1,18 @@ +output "deployment" { + description = "Everything you need to SSM into, verify, and operate the stack." + value = { + region = var.region + environment = var.environment + instance_ids = module.sensor_host.instance_ids + ami_id = module.sensor_host.ami_id + sensor_bucket = module.endpoint_vpc.sensor_bucket_name + ssm_start_session_commands = module.sensor_host.ssm_start_session_commands + verification_commands = module.sensor_host.verification_commands + crowdstrike_endpoint_dns = module.endpoint_vpc.crowdstrike_endpoint_dns + } +} + +output "falcon_cloud" { + description = "Falcon cloud the deployment is registered against." + value = var.falcon_cloud +} diff --git a/aws-privatelink/examples/01-per-vpc/providers.tf b/aws-privatelink/examples/01-per-vpc/providers.tf new file mode 100644 index 00000000..78547415 --- /dev/null +++ b/aws-privatelink/examples/01-per-vpc/providers.tf @@ -0,0 +1,16 @@ +locals { + common_tags = { + Environment = var.environment + OwnerEmail = var.owner_email + ManagedBy = "terraform" + Project = "aws-privatelink" + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = local.common_tags + } +} diff --git a/aws-privatelink/examples/01-per-vpc/variables.tf b/aws-privatelink/examples/01-per-vpc/variables.tf new file mode 100644 index 00000000..11960219 --- /dev/null +++ b/aws-privatelink/examples/01-per-vpc/variables.tf @@ -0,0 +1,59 @@ +### Required — credentials & identity ######################################## + +variable "owner_email" { + description = "OwnerEmail tag value applied to every resource." + type = string + + validation { + condition = can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.owner_email)) + error_message = "owner_email must be a valid email address (e.g. you@example.com)." + } +} + +variable "falcon_client_id" { + description = "CrowdStrike Falcon API client ID with Sensor Download: Read scope." + type = string + sensitive = true +} + +variable "falcon_client_secret" { + description = "CrowdStrike Falcon API client secret." + type = string + sensitive = true +} + +variable "falcon_cloud" { + description = "CrowdStrike Falcon cloud (us-1, us-2, or eu-1)." + type = string + + validation { + condition = contains(["us-1", "us-2", "eu-1"], var.falcon_cloud) + error_message = "falcon_cloud must be one of us-1, us-2, eu-1." + } +} + +### Optional — deployment configuration ##################################### + +variable "region" { + description = "AWS region to deploy into. AZs are auto-derived." + type = string + default = "us-east-2" +} + +variable "environment" { + description = "Environment name used as the resource prefix and tag value." + type = string + default = "dev" +} + +variable "instance_type" { + description = "EC2 instance type for sensor hosts." + type = string + default = "t3.small" +} + +variable "ami_id" { + description = "Amazon Linux 2023 AMI ID. Only set if the default becomes unavailable. Must be AL2023." + type = string + default = null +} diff --git a/aws-privatelink/examples/01-per-vpc/versions.tf b/aws-privatelink/examples/01-per-vpc/versions.tf new file mode 100644 index 00000000..f04e5438 --- /dev/null +++ b/aws-privatelink/examples/01-per-vpc/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80" + } + local = { + source = "hashicorp/local" + version = ">= 2.5" + } + null = { + source = "hashicorp/null" + version = ">= 3.2" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} diff --git a/aws-privatelink/examples/02-shared-vpc/fetch.tf b/aws-privatelink/examples/02-shared-vpc/fetch.tf new file mode 100644 index 00000000..8bb50b62 --- /dev/null +++ b/aws-privatelink/examples/02-shared-vpc/fetch.tf @@ -0,0 +1,50 @@ +# Root-level sensor fetch. Runs once on the workstation via uv run, drops +# the RPM + result.json into a local cache; both modules consume the fetched +# CID + RPM path via the BYO inputs. Avoids unnecessary Falcon API traffic +# when the cache is already populated. +# +# depends_on on the data source is load-bearing: when count = 0 it's a no-op +# empty-list reference; when count = 1 it defers the data source's read from +# plan time to apply time, which is required on a fresh clone where the +# cache file doesn't exist yet. + +locals { + sensor_fetch_out_dir = "${path.module}/.sensor-cache" + sensor_fetch_result = "${local.sensor_fetch_out_dir}/result.json" + cache_exists = fileexists(local.sensor_fetch_result) +} + +resource "null_resource" "fetch_sensor" { + count = local.cache_exists ? 0 : 1 + + triggers = { + inputs_hash = sha256("${var.falcon_client_id}:${var.falcon_cloud}") + script = filemd5("${path.module}/../../scripts/fetch_sensor.py") + } + + provisioner "local-exec" { + command = join(" ", [ + "mkdir -p '${local.sensor_fetch_out_dir}' &&", + "uv run '${path.module}/../../scripts/fetch_sensor.py'", + "--out '${local.sensor_fetch_out_dir}'", + "--cloud '${var.falcon_cloud}'", + "--arch x86_64", + ]) + + environment = { + FALCON_CLIENT_ID = var.falcon_client_id + FALCON_CLIENT_SECRET = var.falcon_client_secret + } + } +} + +data "local_file" "sensor_fetch" { + filename = local.sensor_fetch_result + depends_on = [null_resource.fetch_sensor] +} + +locals { + sensor_fetch_data = jsondecode(data.local_file.sensor_fetch.content) + fetched_cid = local.sensor_fetch_data.cid + fetched_rpm_path = local.sensor_fetch_data.rpm_path +} diff --git a/aws-privatelink/examples/02-shared-vpc/main.tf b/aws-privatelink/examples/02-shared-vpc/main.tf new file mode 100644 index 00000000..d0e33f34 --- /dev/null +++ b/aws-privatelink/examples/02-shared-vpc/main.tf @@ -0,0 +1,70 @@ +# Two-account shared VPC. The owner provisions a single VPC with all the +# PrivateLink plumbing and RAM-shares its subnets to the workload account. +# The workload launches a sensor host directly into those shared subnets. +# +# The RPM and CID come from fetch.tf (root-level), so the Falcon API is +# hit once per apply. + +data "aws_availability_zones" "available" { + provider = aws.owner + state = "available" +} + +locals { + azs = slice(data.aws_availability_zones.available.names, 0, 2) + vpc_cidr = "10.60.0.0/16" + subnet_cidrs = [cidrsubnet(local.vpc_cidr, 8, 1), cidrsubnet(local.vpc_cidr, 8, 2)] +} + +module "endpoint_vpc" { + source = "../../modules/endpoint-vpc" + + providers = { + aws = aws.owner + } + + region = var.region + availability_zones = local.azs + name_prefix = var.environment + + vpc_cidr = local.vpc_cidr + subnet_cidrs = local.subnet_cidrs + + falcon_cloud = var.falcon_cloud + sensor_rpm_path = local.fetched_rpm_path + + ram_principals = [var.workload_account_id] + + consumer_sg_ids = { + workload-host = module.sensor_host.instance_sg_id + } + + authorized_role_arns = [ + module.sensor_host.instance_role_arn, + ] +} + +module "sensor_host" { + source = "../../modules/sensor-host" + + providers = { + aws = aws.workload + } + + region = var.region + name_prefix = var.environment + + vpc_id = module.endpoint_vpc.vpc_id + subnet_ids = module.endpoint_vpc.subnet_ids_list + + endpoints_sg_id = module.endpoint_vpc.endpoints_sg_id + s3_prefix_list_id = module.endpoint_vpc.s3_prefix_list_id + + sensor_bucket_name = module.endpoint_vpc.sensor_bucket_name + sensor_bucket_rpm_key = module.endpoint_vpc.sensor_bucket_rpm_key + + falcon_cloud = var.falcon_cloud + falcon_cid = local.fetched_cid + instance_type = var.instance_type + ami_id = var.ami_id +} diff --git a/aws-privatelink/examples/02-shared-vpc/outputs.tf b/aws-privatelink/examples/02-shared-vpc/outputs.tf new file mode 100644 index 00000000..5d25205c --- /dev/null +++ b/aws-privatelink/examples/02-shared-vpc/outputs.tf @@ -0,0 +1,23 @@ +output "deployment" { + description = "Everything you need to SSM into, verify, and operate the stack. SSM commands target the workload account — prepend --profile $workload_profile on the workstation." + value = { + region = var.region + environment = var.environment + owner_profile = var.owner_profile + workload_profile = var.workload_profile + workload_account_id = var.workload_account_id + vpc_id = module.endpoint_vpc.vpc_id + subnet_ids = module.endpoint_vpc.subnet_ids + instance_ids = module.sensor_host.instance_ids + ami_id = module.sensor_host.ami_id + sensor_bucket = module.endpoint_vpc.sensor_bucket_name + ssm_start_session_commands = module.sensor_host.ssm_start_session_commands + verification_commands = module.sensor_host.verification_commands + crowdstrike_endpoint_dns = module.endpoint_vpc.crowdstrike_endpoint_dns + } +} + +output "falcon_cloud" { + description = "Falcon cloud the deployment is registered against." + value = var.falcon_cloud +} diff --git a/aws-privatelink/examples/02-shared-vpc/providers.tf b/aws-privatelink/examples/02-shared-vpc/providers.tf new file mode 100644 index 00000000..356f0bd9 --- /dev/null +++ b/aws-privatelink/examples/02-shared-vpc/providers.tf @@ -0,0 +1,28 @@ +locals { + common_tags = { + Environment = var.environment + OwnerEmail = var.owner_email + ManagedBy = "terraform" + Project = "aws-privatelink" + } +} + +provider "aws" { + alias = "owner" + region = var.region + profile = var.owner_profile + + default_tags { + tags = merge(local.common_tags, { Role = "owner" }) + } +} + +provider "aws" { + alias = "workload" + region = var.region + profile = var.workload_profile + + default_tags { + tags = merge(local.common_tags, { Role = "workload" }) + } +} diff --git a/aws-privatelink/examples/02-shared-vpc/variables.tf b/aws-privatelink/examples/02-shared-vpc/variables.tf new file mode 100644 index 00000000..4bee7e30 --- /dev/null +++ b/aws-privatelink/examples/02-shared-vpc/variables.tf @@ -0,0 +1,79 @@ +### Required — credentials & identity ######################################## + +variable "owner_profile" { + description = "AWS CLI profile for the owner account (hosts VPC, endpoints, RAM share)." + type = string +} + +variable "workload_profile" { + description = "AWS CLI profile for the workload account (launches EC2 into shared subnets)." + type = string +} + +variable "workload_account_id" { + description = "12-digit account ID of the workload account. Used as the RAM share principal." + type = string + + validation { + condition = can(regex("^[0-9]{12}$", var.workload_account_id)) + error_message = "workload_account_id must be a 12-digit AWS account ID." + } +} + +variable "owner_email" { + description = "OwnerEmail tag value applied to every resource." + type = string + + validation { + condition = can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.owner_email)) + error_message = "owner_email must be a valid email address (e.g. you@example.com)." + } +} + +variable "falcon_client_id" { + description = "CrowdStrike Falcon API client ID with Sensor Download: Read scope." + type = string + sensitive = true +} + +variable "falcon_client_secret" { + description = "CrowdStrike Falcon API client secret." + type = string + sensitive = true +} + +variable "falcon_cloud" { + description = "CrowdStrike Falcon cloud (us-1, us-2, or eu-1)." + type = string + + validation { + condition = contains(["us-1", "us-2", "eu-1"], var.falcon_cloud) + error_message = "falcon_cloud must be one of us-1, us-2, eu-1." + } +} + +### Optional — deployment configuration ##################################### + +variable "region" { + description = "AWS region to deploy into. AZs are auto-derived." + type = string + default = "us-east-2" +} + +variable "environment" { + description = "Environment name used as the resource prefix and tag value." + type = string + default = "dev" +} + +variable "instance_type" { + description = "EC2 instance type for sensor hosts." + type = string + default = "t3.small" +} + +variable "ami_id" { + description = "Amazon Linux 2023 AMI ID. Only set if the default becomes unavailable. Must be AL2023." + type = string + default = null +} diff --git a/aws-privatelink/examples/02-shared-vpc/versions.tf b/aws-privatelink/examples/02-shared-vpc/versions.tf new file mode 100644 index 00000000..44f14b9f --- /dev/null +++ b/aws-privatelink/examples/02-shared-vpc/versions.tf @@ -0,0 +1,23 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80" + configuration_aliases = [aws.owner, aws.workload] + } + local = { + source = "hashicorp/local" + version = ">= 2.5" + } + null = { + source = "hashicorp/null" + version = ">= 3.2" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} diff --git a/aws-privatelink/examples/03-tgw-profiles/dns.tf b/aws-privatelink/examples/03-tgw-profiles/dns.tf new file mode 100644 index 00000000..6798a5ef --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/dns.tf @@ -0,0 +1,62 @@ +# Route 53 Profiles (Nov 2024) — the secondary upgrade over the classic +# "cross-account PHZ association" treadmill. The PHZ lives on the hub's +# endpoint VPC (created by the endpoint-vpc module). A Profile is a +# container that can hold multiple PHZs; we associate cloudsink.net with +# it, RAM-share it to the spoke account, and the spoke associates the +# profile with its own VPC. All DNS resolution for *.cloudsink.net in the +# spoke VPC then resolves to the hub's endpoint ENI IPs. +# +# Key properties vs classic PHZ associations: +# * No per-VPC cross-account Route 53 grant (no auth-plus-associate +# dance through aws_route53_vpc_association_authorization). +# * One profile can hold N zones — future work (CloudTrail, STS, etc.) +# just adds more profile-resource associations; spokes pick them up +# automatically. +# * Profiles are region-scoped. For multi-region reach, create one +# profile per region and share each. + +resource "aws_route53profiles_profile" "cloudsink" { + provider = aws.hub + + name = "${var.environment}-profile" + + tags = { + Name = "${var.environment}-profile" + } +} + +# Add cloudsink.net (PHZ owned by the endpoint-vpc module) to the profile. +resource "aws_route53profiles_resource_association" "cloudsink_phz" { + provider = aws.hub + + name = "${var.environment}-cloudsink" + profile_id = aws_route53profiles_profile.cloudsink.id + resource_arn = "arn:aws:route53:::hostedzone/${module.endpoint_vpc.phz_id}" +} + +# Share the profile with the spoke account. Spoke picks it up via its own +# aws_route53profiles_association pointing at its VPC. +resource "aws_ram_resource_share" "profile" { + provider = aws.hub + + name = "${var.environment}-profile" + allow_external_principals = false + + tags = { + Name = "${var.environment}-profile" + } +} + +resource "aws_ram_resource_association" "profile" { + provider = aws.hub + + resource_arn = aws_route53profiles_profile.cloudsink.arn + resource_share_arn = aws_ram_resource_share.profile.arn +} + +resource "aws_ram_principal_association" "profile" { + provider = aws.hub + + principal = var.spoke_account_id + resource_share_arn = aws_ram_resource_share.profile.arn +} diff --git a/aws-privatelink/examples/03-tgw-profiles/fetch.tf b/aws-privatelink/examples/03-tgw-profiles/fetch.tf new file mode 100644 index 00000000..8bb50b62 --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/fetch.tf @@ -0,0 +1,50 @@ +# Root-level sensor fetch. Runs once on the workstation via uv run, drops +# the RPM + result.json into a local cache; both modules consume the fetched +# CID + RPM path via the BYO inputs. Avoids unnecessary Falcon API traffic +# when the cache is already populated. +# +# depends_on on the data source is load-bearing: when count = 0 it's a no-op +# empty-list reference; when count = 1 it defers the data source's read from +# plan time to apply time, which is required on a fresh clone where the +# cache file doesn't exist yet. + +locals { + sensor_fetch_out_dir = "${path.module}/.sensor-cache" + sensor_fetch_result = "${local.sensor_fetch_out_dir}/result.json" + cache_exists = fileexists(local.sensor_fetch_result) +} + +resource "null_resource" "fetch_sensor" { + count = local.cache_exists ? 0 : 1 + + triggers = { + inputs_hash = sha256("${var.falcon_client_id}:${var.falcon_cloud}") + script = filemd5("${path.module}/../../scripts/fetch_sensor.py") + } + + provisioner "local-exec" { + command = join(" ", [ + "mkdir -p '${local.sensor_fetch_out_dir}' &&", + "uv run '${path.module}/../../scripts/fetch_sensor.py'", + "--out '${local.sensor_fetch_out_dir}'", + "--cloud '${var.falcon_cloud}'", + "--arch x86_64", + ]) + + environment = { + FALCON_CLIENT_ID = var.falcon_client_id + FALCON_CLIENT_SECRET = var.falcon_client_secret + } + } +} + +data "local_file" "sensor_fetch" { + filename = local.sensor_fetch_result + depends_on = [null_resource.fetch_sensor] +} + +locals { + sensor_fetch_data = jsondecode(data.local_file.sensor_fetch.content) + fetched_cid = local.sensor_fetch_data.cid + fetched_rpm_path = local.sensor_fetch_data.rpm_path +} diff --git a/aws-privatelink/examples/03-tgw-profiles/hub.tf b/aws-privatelink/examples/03-tgw-profiles/hub.tf new file mode 100644 index 00000000..6434a447 --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/hub.tf @@ -0,0 +1,108 @@ +# Hub account — the endpoint VPC (reusing the same module as 01 and 02) +# plus the TGW and TGW RAM share. The endpoints SG ingress is wired from +# the spoke's CIDR (cross-VPC, so we can't use SG references like 02 does). + +data "aws_availability_zones" "available" { + provider = aws.hub + state = "available" +} + +locals { + azs = slice(data.aws_availability_zones.available.names, 0, 2) + hub_vpc_cidr = "10.70.0.0/16" + hub_subnet_cidrs = [cidrsubnet(local.hub_vpc_cidr, 8, 1), cidrsubnet(local.hub_vpc_cidr, 8, 2)] + spoke_vpc_cidr = "10.71.0.0/16" + spoke_subnet_cidrs = [cidrsubnet(local.spoke_vpc_cidr, 8, 1), cidrsubnet(local.spoke_vpc_cidr, 8, 2)] +} + +module "endpoint_vpc" { + source = "../../modules/endpoint-vpc" + + providers = { + aws = aws.hub + } + + region = var.region + availability_zones = local.azs + name_prefix = var.environment + + vpc_cidr = local.hub_vpc_cidr + subnet_cidrs = local.hub_subnet_cidrs + + falcon_cloud = var.falcon_cloud + sensor_rpm_path = local.fetched_rpm_path + + ram_principals = [] + + consumer_cidr_blocks = [local.spoke_vpc_cidr] + + authorized_role_arns = [ + module.sensor_host.instance_role_arn, + ] +} + +resource "aws_ec2_transit_gateway" "this" { + provider = aws.hub + + description = "${var.environment} TGW" + amazon_side_asn = 64532 + auto_accept_shared_attachments = "enable" + default_route_table_association = "disable" + default_route_table_propagation = "disable" + dns_support = "enable" + vpn_ecmp_support = "enable" + + tags = { + Name = "${var.environment}-tgw" + } +} + +resource "aws_ram_resource_share" "tgw" { + provider = aws.hub + + name = "${var.environment}-tgw" + allow_external_principals = false + + tags = { + Name = "${var.environment}-tgw" + } +} + +resource "aws_ram_resource_association" "tgw" { + provider = aws.hub + + resource_arn = aws_ec2_transit_gateway.this.arn + resource_share_arn = aws_ram_resource_share.tgw.arn +} + +resource "aws_ram_principal_association" "tgw" { + provider = aws.hub + + principal = var.spoke_account_id + resource_share_arn = aws_ram_resource_share.tgw.arn +} + +resource "aws_ec2_transit_gateway_vpc_attachment" "hub" { + provider = aws.hub + + transit_gateway_id = aws_ec2_transit_gateway.this.id + vpc_id = module.endpoint_vpc.vpc_id + subnet_ids = module.endpoint_vpc.subnet_ids_list + + transit_gateway_default_route_table_association = false + transit_gateway_default_route_table_propagation = false + + tags = { + Name = "${var.environment}-hub-attach" + } +} + +resource "aws_route" "hub_to_spoke" { + provider = aws.hub + + route_table_id = module.endpoint_vpc.route_table_id + destination_cidr_block = local.spoke_vpc_cidr + transit_gateway_id = aws_ec2_transit_gateway.this.id + + depends_on = [aws_ec2_transit_gateway_vpc_attachment.hub] +} diff --git a/aws-privatelink/examples/03-tgw-profiles/outputs.tf b/aws-privatelink/examples/03-tgw-profiles/outputs.tf new file mode 100644 index 00000000..c9393e6b --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/outputs.tf @@ -0,0 +1,24 @@ +output "deployment" { + description = "Everything you need to SSM into, verify, and operate the stack. SSM commands target the spoke account — prepend --profile $spoke_profile on the workstation." + value = { + region = var.region + hub_profile = var.hub_profile + spoke_profile = var.spoke_profile + spoke_account_id = var.spoke_account_id + hub_vpc_id = module.endpoint_vpc.vpc_id + spoke_vpc_id = aws_vpc.spoke.id + tgw_id = aws_ec2_transit_gateway.this.id + profile_id = aws_route53profiles_profile.cloudsink.id + instance_ids = module.sensor_host.instance_ids + ami_id = module.sensor_host.ami_id + sensor_bucket = module.endpoint_vpc.sensor_bucket_name + ssm_start_session_commands = module.sensor_host.ssm_start_session_commands + verification_commands = module.sensor_host.verification_commands + crowdstrike_endpoint_dns = module.endpoint_vpc.crowdstrike_endpoint_dns + } +} + +output "falcon_cloud" { + description = "Falcon cloud the deployment is registered against." + value = var.falcon_cloud +} diff --git a/aws-privatelink/examples/03-tgw-profiles/providers.tf b/aws-privatelink/examples/03-tgw-profiles/providers.tf new file mode 100644 index 00000000..dbf53134 --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/providers.tf @@ -0,0 +1,28 @@ +locals { + common_tags = { + Environment = var.environment + OwnerEmail = var.owner_email + ManagedBy = "terraform" + Project = "aws-privatelink" + } +} + +provider "aws" { + alias = "hub" + region = var.region + profile = var.hub_profile + + default_tags { + tags = merge(local.common_tags, { Role = "hub" }) + } +} + +provider "aws" { + alias = "spoke" + region = var.region + profile = var.spoke_profile + + default_tags { + tags = merge(local.common_tags, { Role = "spoke" }) + } +} diff --git a/aws-privatelink/examples/03-tgw-profiles/spoke.tf b/aws-privatelink/examples/03-tgw-profiles/spoke.tf new file mode 100644 index 00000000..2486d394 --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/spoke.tf @@ -0,0 +1,169 @@ +# Spoke account — VPC, subnets, local SSM + S3 gw endpoints, TGW +# attachment, sensor host. Kept inline (not a module) because this VPC +# shape is unique to 03 (no CrowdStrike endpoints locally, has a TGW +# attachment) and adding a third module for one example isn't worth it. + +resource "aws_vpc" "spoke" { + provider = aws.spoke + + cidr_block = local.spoke_vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.environment}-spoke" + } +} + +resource "aws_subnet" "spoke" { + provider = aws.spoke + + for_each = { for idx, az in local.azs : az => local.spoke_subnet_cidrs[idx] } + + vpc_id = aws_vpc.spoke.id + availability_zone = each.key + cidr_block = each.value + + tags = { + Name = "${var.environment}-spoke-${each.key}" + } +} + +resource "aws_route_table" "spoke" { + provider = aws.spoke + + vpc_id = aws_vpc.spoke.id + + tags = { + Name = "${var.environment}-spoke" + } +} + +resource "aws_route_table_association" "spoke" { + provider = aws.spoke + + for_each = aws_subnet.spoke + + subnet_id = each.value.id + route_table_id = aws_route_table.spoke.id +} + +resource "aws_route" "spoke_to_hub" { + provider = aws.spoke + + route_table_id = aws_route_table.spoke.id + destination_cidr_block = local.hub_vpc_cidr + transit_gateway_id = aws_ec2_transit_gateway.this.id + + depends_on = [aws_ec2_transit_gateway_vpc_attachment.spoke] +} + +resource "aws_ec2_transit_gateway_vpc_attachment" "spoke" { + provider = aws.spoke + + transit_gateway_id = aws_ec2_transit_gateway.this.id + vpc_id = aws_vpc.spoke.id + subnet_ids = [for s in aws_subnet.spoke : s.id] + + transit_gateway_default_route_table_association = false + transit_gateway_default_route_table_propagation = false + + tags = { + Name = "${var.environment}-spoke-attach" + } + + depends_on = [aws_ram_principal_association.tgw] +} + +resource "aws_vpc_endpoint" "spoke_s3" { + provider = aws.spoke + + vpc_id = aws_vpc.spoke.id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [aws_route_table.spoke.id] + + tags = { + Name = "${var.environment}-spoke-s3" + } +} + +locals { + spoke_ssm_services = ["ssm", "ssmmessages", "ec2messages"] +} + +resource "aws_security_group" "spoke_endpoints" { + provider = aws.spoke + + name = "${var.environment}-spoke-endpoints" + description = "HTTPS from the spoke instance SG into the spoke's local SSM endpoints" + vpc_id = aws_vpc.spoke.id + + tags = { + Name = "${var.environment}-spoke-endpoints" + } +} + +resource "aws_vpc_security_group_ingress_rule" "spoke_endpoints_from_instance" { + provider = aws.spoke + + security_group_id = aws_security_group.spoke_endpoints.id + description = "HTTPS from spoke instance SG" + referenced_security_group_id = module.sensor_host.instance_sg_id + from_port = 443 + to_port = 443 + ip_protocol = "tcp" +} + +resource "aws_vpc_endpoint" "spoke_ssm" { + provider = aws.spoke + + for_each = toset(local.spoke_ssm_services) + + vpc_id = aws_vpc.spoke.id + service_name = "com.amazonaws.${var.region}.${each.key}" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.spoke : s.id] + security_group_ids = [aws_security_group.spoke_endpoints.id] + private_dns_enabled = true + + tags = { + Name = "${var.environment}-spoke-${each.key}" + } +} + +resource "aws_route53profiles_association" "spoke" { + provider = aws.spoke + + name = "${var.environment}-spoke" + profile_id = aws_route53profiles_profile.cloudsink.id + resource_id = aws_vpc.spoke.id + + depends_on = [aws_ram_principal_association.profile] +} + +module "sensor_host" { + source = "../../modules/sensor-host" + + providers = { + aws = aws.spoke + } + + region = var.region + name_prefix = var.environment + + vpc_id = aws_vpc.spoke.id + subnet_ids = [for s in aws_subnet.spoke : s.id] + + endpoints_sg_id = aws_security_group.spoke_endpoints.id + s3_prefix_list_id = aws_vpc_endpoint.spoke_s3.prefix_list_id + egress_cidr_blocks = [local.hub_vpc_cidr] + + sensor_bucket_name = module.endpoint_vpc.sensor_bucket_name + sensor_bucket_rpm_key = module.endpoint_vpc.sensor_bucket_rpm_key + + falcon_cloud = var.falcon_cloud + falcon_cid = local.fetched_cid + instance_type = var.instance_type + ami_id = var.ami_id +} diff --git a/aws-privatelink/examples/03-tgw-profiles/tgw_route_tables.tf b/aws-privatelink/examples/03-tgw-profiles/tgw_route_tables.tf new file mode 100644 index 00000000..6a6ef785 --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/tgw_route_tables.tf @@ -0,0 +1,66 @@ +# Explicit TGW segmentation — "shared services" pattern. Rather than the +# default TGW route table (auto-associate + auto-propagate everything), +# we use two route tables: +# +# * hub-rt: the hub attachment lives here. Its propagations pull in +# the spoke's VPC CIDR, so the hub can reach all spokes. +# * spoke-rt: every spoke attachment associates here. Only the hub's +# VPC CIDR is propagated, so spokes can reach the hub but +# NOT each other (spoke-to-spoke isolation is the headline +# before/after for 03's TGW segmentation). +# +# Today there's one spoke, so the pattern looks heavy. Adding the 2nd / Nth +# spoke is a single additional aws_ec2_transit_gateway_vpc_attachment + +# one association row + one propagation row — which is the whole point. + +resource "aws_ec2_transit_gateway_route_table" "hub" { + provider = aws.hub + + transit_gateway_id = aws_ec2_transit_gateway.this.id + + tags = { + Name = "${var.environment}-hub-rt" + } +} + +resource "aws_ec2_transit_gateway_route_table" "spoke" { + provider = aws.hub + + transit_gateway_id = aws_ec2_transit_gateway.this.id + + tags = { + Name = "${var.environment}-spoke-rt" + } +} + +# Hub attachment associations/propagations. +resource "aws_ec2_transit_gateway_route_table_association" "hub" { + provider = aws.hub + + transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.hub.id + transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.hub.id +} + +# Hub's RT learns the spoke CIDR so return traffic to the spoke works. +resource "aws_ec2_transit_gateway_route_table_propagation" "spoke_to_hub_rt" { + provider = aws.hub + + transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.spoke.id + transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.hub.id +} + +# Spoke attachment associations/propagations. +resource "aws_ec2_transit_gateway_route_table_association" "spoke" { + provider = aws.hub + + transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.spoke.id + transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spoke.id +} + +# Spoke's RT only learns the hub CIDR (not other spokes). +resource "aws_ec2_transit_gateway_route_table_propagation" "hub_to_spoke_rt" { + provider = aws.hub + + transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.hub.id + transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spoke.id +} diff --git a/aws-privatelink/examples/03-tgw-profiles/variables.tf b/aws-privatelink/examples/03-tgw-profiles/variables.tf new file mode 100644 index 00000000..d1eed352 --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/variables.tf @@ -0,0 +1,79 @@ +### Required — credentials & identity ######################################## + +variable "hub_profile" { + description = "AWS CLI profile for the hub (networking) account. Hosts endpoint VPC, TGW, and R53 Profile." + type = string +} + +variable "spoke_profile" { + description = "AWS CLI profile for the spoke (workload) account. Owns a VPC that attaches to the hub TGW." + type = string +} + +variable "spoke_account_id" { + description = "12-digit account ID of the spoke account. Used as RAM principal for TGW and R53 Profile shares." + type = string + + validation { + condition = can(regex("^[0-9]{12}$", var.spoke_account_id)) + error_message = "spoke_account_id must be a 12-digit AWS account ID." + } +} + +variable "owner_email" { + description = "OwnerEmail tag value applied to every resource." + type = string + + validation { + condition = can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.owner_email)) + error_message = "owner_email must be a valid email address (e.g. you@example.com)." + } +} + +variable "falcon_client_id" { + description = "CrowdStrike Falcon API client ID with Sensor Download: Read scope." + type = string + sensitive = true +} + +variable "falcon_client_secret" { + description = "CrowdStrike Falcon API client secret." + type = string + sensitive = true +} + +variable "falcon_cloud" { + description = "CrowdStrike Falcon cloud (us-1, us-2, or eu-1)." + type = string + + validation { + condition = contains(["us-1", "us-2", "eu-1"], var.falcon_cloud) + error_message = "falcon_cloud must be one of us-1, us-2, eu-1." + } +} + +### Optional — deployment configuration ##################################### + +variable "region" { + description = "AWS region to deploy into. AZs are auto-derived." + type = string + default = "us-east-2" +} + +variable "environment" { + description = "Environment name used as the resource prefix and tag value." + type = string + default = "dev" +} + +variable "instance_type" { + description = "EC2 instance type for sensor hosts." + type = string + default = "t3.small" +} + +variable "ami_id" { + description = "Amazon Linux 2023 AMI ID. Only set if the default becomes unavailable. Must be AL2023." + type = string + default = null +} diff --git a/aws-privatelink/examples/03-tgw-profiles/versions.tf b/aws-privatelink/examples/03-tgw-profiles/versions.tf new file mode 100644 index 00000000..b603f76f --- /dev/null +++ b/aws-privatelink/examples/03-tgw-profiles/versions.tf @@ -0,0 +1,23 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80" + configuration_aliases = [aws.hub, aws.spoke] + } + local = { + source = "hashicorp/local" + version = ">= 2.5" + } + null = { + source = "hashicorp/null" + version = ">= 3.2" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} diff --git a/aws-privatelink/modules/endpoint-vpc/endpoints.tf b/aws-privatelink/modules/endpoint-vpc/endpoints.tf new file mode 100644 index 00000000..878c24c3 --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/endpoints.tf @@ -0,0 +1,93 @@ +locals { + ssm_services = ["ssm", "ssmmessages", "ec2messages"] + + cloud_home_region = { + "us-1" = "us-west-1" + "us-2" = "us-west-2" + "eu-1" = "eu-central-1" + } + + cloud_endpoint_services = { + "us-1" = { + sensor_proxy = "com.amazonaws.vpce.us-west-1.vpce-svc-08744dea97b26db5d" + download_server = "com.amazonaws.vpce.us-west-1.vpce-svc-0f9d8ca86ddcb7106" + upload_server = "com.amazonaws.vpce.us-west-1.vpce-svc-0fa888d7b9e4130f4" + } + "us-2" = { + sensor_proxy = "com.amazonaws.vpce.us-west-2.vpce-svc-08a5bb05d337fd834" + download_server = "com.amazonaws.vpce.us-west-2.vpce-svc-0e11def2d8620ae74" + upload_server = "com.amazonaws.vpce.us-west-2.vpce-svc-074a82fde584744da" + } + "eu-1" = { + sensor_proxy = "com.amazonaws.vpce.eu-central-1.vpce-svc-0eb7b6ca4b7271385" + download_server = "com.amazonaws.vpce.eu-central-1.vpce-svc-0340142b9ab8fc564" + upload_server = "com.amazonaws.vpce.eu-central-1.vpce-svc-0148ff0159e9419dd" + } + } + + cloud_hostname_slugs = { + "us-1" = "b" + "us-2" = "gyr-maverick" + "eu-1" = "lanner-lion" + } + + crowdstrike_home_region = local.cloud_home_region[var.falcon_cloud] + crowdstrike_endpoints = local.cloud_endpoint_services[var.falcon_cloud] + + # service_region is null when the consumer VPC is in the Falcon cloud's + # home region. Otherwise it's set to the home region so the endpoint + # targets the service hosted there over the AWS backbone. + effective_service_region = var.region == local.crowdstrike_home_region ? null : local.crowdstrike_home_region + + private_subnet_ids = [for s in aws_subnet.private : s.id] +} + +# S3 gateway endpoint — lets instances pull objects from the sensor bucket +# (and regional AWS buckets, e.g. AL2023 dnf repos) without an IGW/NAT. +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [aws_route_table.private.id] + + tags = { + Name = "${var.name_prefix}-s3-gw" + } +} + +# SSM interface endpoints — all three are required for Session Manager. +# One ENI per AZ listed in var.availability_zones. +resource "aws_vpc_endpoint" "ssm" { + for_each = toset(local.ssm_services) + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${var.region}.${each.value}" + vpc_endpoint_type = "Interface" + subnet_ids = local.private_subnet_ids + security_group_ids = [aws_security_group.endpoints.id] + private_dns_enabled = true + + tags = { + Name = "${var.name_prefix}-${each.value}" + } +} + +# CrowdStrike PrivateLink endpoints, one ENI per AZ. service_region is null +# for native deploys and the home region for cross-region deploys. Private +# DNS is not supported for these service types — resolution comes from the +# PHZ in route53.tf. +resource "aws_vpc_endpoint" "crowdstrike" { + for_each = local.crowdstrike_endpoints + + vpc_id = aws_vpc.this.id + service_name = each.value + service_region = local.effective_service_region + vpc_endpoint_type = "Interface" + subnet_ids = local.private_subnet_ids + security_group_ids = [aws_security_group.endpoints.id] + private_dns_enabled = false + + tags = { + Name = "${var.name_prefix}-cs-${each.key}" + } +} diff --git a/aws-privatelink/modules/endpoint-vpc/main.tf b/aws-privatelink/modules/endpoint-vpc/main.tf new file mode 100644 index 00000000..902e30c2 --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/main.tf @@ -0,0 +1,49 @@ +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.name_prefix}-vpc" + } +} + +locals { + # AZ name -> subnet CIDR. for_each keyed on AZ keeps plans stable across + # reorders of var.availability_zones. + private_subnets = zipmap(var.availability_zones, var.subnet_cidrs) +} + +resource "aws_subnet" "private" { + for_each = local.private_subnets + + vpc_id = aws_vpc.this.id + cidr_block = each.value + availability_zone = each.key + + tags = { + Name = "${var.name_prefix}-private-${each.key}" + } + + lifecycle { + precondition { + condition = length(var.availability_zones) == length(var.subnet_cidrs) + error_message = "availability_zones and subnet_cidrs must be the same length (they pair 1:1)." + } + } +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.this.id + + tags = { + Name = "${var.name_prefix}-private-rt" + } +} + +resource "aws_route_table_association" "private" { + for_each = aws_subnet.private + + subnet_id = each.value.id + route_table_id = aws_route_table.private.id +} diff --git a/aws-privatelink/modules/endpoint-vpc/outputs.tf b/aws-privatelink/modules/endpoint-vpc/outputs.tf new file mode 100644 index 00000000..56c3a9a6 --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/outputs.tf @@ -0,0 +1,62 @@ +output "vpc_id" { + description = "VPC ID." + value = aws_vpc.this.id +} + +output "subnet_ids" { + description = "Private subnet IDs keyed by AZ. Each subnet holds an interface endpoint ENI. Consumer workloads launch ENIs here too (via the RAM share when enabled)." + value = { for az, s in aws_subnet.private : az => s.id } +} + +output "subnet_ids_list" { + description = "Private subnet IDs as an ordered list (same order as var.availability_zones). Convenience for consumers that want a flat list." + value = [for az in var.availability_zones : aws_subnet.private[az].id] +} + +output "phz_id" { + description = "Zone ID of the cloudsink.net PHZ. Attached to this VPC; consumers inherit it via the shared subnets." + value = aws_route53_zone.cloudsink.zone_id +} + +output "route_table_id" { + description = "Route table ID for the private subnets. Exposed so the TGW topology (03) can add a spoke-CIDR route pointing at its TGW attachment." + value = aws_route_table.private.id +} + +output "endpoints_sg_id" { + description = "Security group ID on all interface endpoints. Consumer SGs must be listed in var.consumer_sg_ids to get HTTPS ingress, and must reference this SG in their own egress rules." + value = aws_security_group.endpoints.id +} + +output "s3_prefix_list_id" { + description = "Prefix list ID of the S3 gateway endpoint. Consumer instance SGs reference this to allow HTTPS egress to S3 (the sensor bucket + AL2023 dnf repos)." + value = aws_vpc_endpoint.s3.prefix_list_id +} + +output "sensor_bucket_name" { + description = "S3 bucket holding the Falcon sensor RPM. Reachable from consumer hosts via the S3 gateway endpoint + the bucket policy (cross-account) or the consumer's IAM role (same-account)." + value = aws_s3_bucket.sensor.bucket +} + +output "sensor_bucket_arn" { + description = "ARN of the sensor bucket." + value = aws_s3_bucket.sensor.arn +} + +output "sensor_bucket_rpm_key" { + description = "S3 key of the uploaded sensor RPM object inside the sensor bucket." + value = aws_s3_object.sensor_rpm.key +} + +output "crowdstrike_endpoint_dns" { + description = "DNS names assigned to each CrowdStrike PrivateLink endpoint. Diagnostic output — consumer workloads use the PHZ aliases instead." + value = { + for k, ep in aws_vpc_endpoint.crowdstrike : + k => ep.dns_entry + } +} + +output "falcon_cloud" { + description = "CrowdStrike cloud this endpoint VPC targets. Consumers must pass the same value to sensor-host so SSM params + user_data line up." + value = var.falcon_cloud +} diff --git a/aws-privatelink/modules/endpoint-vpc/ram.tf b/aws-privatelink/modules/endpoint-vpc/ram.tf new file mode 100644 index 00000000..65008d62 --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/ram.tf @@ -0,0 +1,36 @@ +# AWS RAM: share the private subnets with consumer accounts so their instances +# can launch ENIs directly into this VPC. The PHZ attaches to the VPC (not the +# subnet), so DNS for cloudsink.net "just works" for any workload in these +# shared subnets — no cross-account R53 plumbing needed. +# +# Empty var.ram_principals disables the share entirely (01-per-vpc single- +# account case). The count-gating keeps plans clean for that path. + +locals { + enable_ram = length(var.ram_principals) > 0 +} + +resource "aws_ram_resource_share" "subnets" { + count = local.enable_ram ? 1 : 0 + + name = "${var.name_prefix}-subnets" + allow_external_principals = false # stays within the AWS org + + tags = { + Name = "${var.name_prefix}-subnets" + } +} + +resource "aws_ram_principal_association" "consumers" { + for_each = local.enable_ram ? toset(var.ram_principals) : toset([]) + + principal = each.value + resource_share_arn = aws_ram_resource_share.subnets[0].arn +} + +resource "aws_ram_resource_association" "subnets" { + for_each = local.enable_ram ? aws_subnet.private : {} + + resource_arn = each.value.arn + resource_share_arn = aws_ram_resource_share.subnets[0].arn +} diff --git a/aws-privatelink/modules/endpoint-vpc/route53.tf b/aws-privatelink/modules/endpoint-vpc/route53.tf new file mode 100644 index 00000000..c2b4c57b --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/route53.tf @@ -0,0 +1,39 @@ +locals { + slug = local.cloud_hostname_slugs[var.falcon_cloud] + crowdstrike_hostnames = { + "ts01-${local.slug}" = "sensor_proxy" + "lfodown01-${local.slug}" = "download_server" + "lfoup01-${local.slug}" = "upload_server" + } +} + +# Private hosted zone for cloudsink.net — overrides public DNS inside this VPC only. +# Shared RAM subnets launch ENIs into this VPC, so consumer workloads inherit +# this PHZ automatically — no cross-account R53 association needed. +# NOTE: This captures ALL queries for *.cloudsink.net inside the VPC. Anything not +# explicitly defined below will return NXDOMAIN. +resource "aws_route53_zone" "cloudsink" { + name = "cloudsink.net" + + vpc { + vpc_id = aws_vpc.this.id + } + + tags = { + Name = "${var.name_prefix}-cloudsink-private" + } +} + +resource "aws_route53_record" "crowdstrike" { + for_each = local.crowdstrike_hostnames + + zone_id = aws_route53_zone.cloudsink.zone_id + name = "${each.key}.cloudsink.net" + type = "A" + + alias { + name = aws_vpc_endpoint.crowdstrike[each.value].dns_entry[0].dns_name + zone_id = aws_vpc_endpoint.crowdstrike[each.value].dns_entry[0].hosted_zone_id + evaluate_target_health = false + } +} diff --git a/aws-privatelink/modules/endpoint-vpc/s3.tf b/aws-privatelink/modules/endpoint-vpc/s3.tf new file mode 100644 index 00000000..67d24169 --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/s3.tf @@ -0,0 +1,80 @@ +data "aws_caller_identity" "current" {} + +resource "random_string" "bucket_suffix" { + length = 6 + special = false + upper = false +} + +locals { + bucket_name = "${var.name_prefix}-sensor-${data.aws_caller_identity.current.account_id}-${random_string.bucket_suffix.result}" + sensor_rpm_key = "falcon-sensor.rpm" +} + +resource "aws_s3_bucket" "sensor" { + bucket = local.bucket_name + + force_destroy = true + + tags = { + Name = local.bucket_name + } +} + +resource "aws_s3_bucket_public_access_block" "sensor" { + bucket = aws_s3_bucket.sensor.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "sensor" { + bucket = aws_s3_bucket.sensor.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_object" "sensor_rpm" { + bucket = aws_s3_bucket.sensor.id + key = local.sensor_rpm_key + source = var.sensor_rpm_path + etag = filemd5(var.sensor_rpm_path) +} + +data "aws_iam_policy_document" "sensor_bucket" { + count = length(var.authorized_role_arns) > 0 ? 1 : 0 + + statement { + sid = "AllowConsumerRolesToReadRpm" + effect = "Allow" + + principals { + type = "AWS" + identifiers = var.authorized_role_arns + } + + actions = [ + "s3:GetObject", + "s3:GetBucketLocation", + "s3:ListBucket", + ] + + resources = [ + aws_s3_bucket.sensor.arn, + "${aws_s3_bucket.sensor.arn}/*", + ] + } +} + +resource "aws_s3_bucket_policy" "sensor" { + count = length(var.authorized_role_arns) > 0 ? 1 : 0 + + bucket = aws_s3_bucket.sensor.id + policy = data.aws_iam_policy_document.sensor_bucket[0].json +} diff --git a/aws-privatelink/modules/endpoint-vpc/security_groups.tf b/aws-privatelink/modules/endpoint-vpc/security_groups.tf new file mode 100644 index 00000000..c7ab9e7b --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/security_groups.tf @@ -0,0 +1,41 @@ +resource "aws_security_group" "endpoints" { + name = "${var.name_prefix}-endpoints" + description = "HTTPS from consumer SGs into interface endpoints. Cross-account SG references are supported when both SGs live in the same VPC." + vpc_id = aws_vpc.this.id + + tags = { + Name = "${var.name_prefix}-endpoints" + } + + lifecycle { + create_before_destroy = true + } +} + +# One ingress rule per consumer SG. Keys are plan-time literals (stable); +# values are apply-time SG IDs from the consumer module's output. This is +# what breaks the module cycle without a depends_on. +resource "aws_vpc_security_group_ingress_rule" "endpoints_https_from_consumers" { + for_each = var.consumer_sg_ids + + security_group_id = aws_security_group.endpoints.id + description = "HTTPS from ${each.key}" + referenced_security_group_id = each.value + from_port = 443 + to_port = 443 + ip_protocol = "tcp" +} + +# CIDR-based ingress for consumers that live in a different VPC (reached +# via TGW / peering). SG references can't cross VPCs, so the TGW topology +# (03) passes spoke CIDRs here instead of SG IDs. +resource "aws_vpc_security_group_ingress_rule" "endpoints_https_from_cidrs" { + for_each = toset(var.consumer_cidr_blocks) + + security_group_id = aws_security_group.endpoints.id + description = "HTTPS from ${each.value}" + cidr_ipv4 = each.value + from_port = 443 + to_port = 443 + ip_protocol = "tcp" +} diff --git a/aws-privatelink/modules/endpoint-vpc/variables.tf b/aws-privatelink/modules/endpoint-vpc/variables.tf new file mode 100644 index 00000000..926edd67 --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/variables.tf @@ -0,0 +1,74 @@ +variable "region" { + description = "AWS region for this VPC. Must match the provider region." + type = string +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC." + type = string +} + +variable "availability_zones" { + description = "AZs to spread private subnets across. One interface endpoint ENI is placed per subnet, so two AZs is the minimum for HA." + type = list(string) + + validation { + condition = length(var.availability_zones) >= 2 + error_message = "availability_zones must contain at least two AZs so interface endpoints get an ENI in more than one AZ." + } +} + +variable "subnet_cidrs" { + description = "Private subnet CIDRs, one per AZ in var.availability_zones (order-aligned). Hosts the interface endpoint ENIs and any consumer workloads launched into the RAM-shared subnets." + type = list(string) + + validation { + condition = length(var.subnet_cidrs) >= 2 + error_message = "subnet_cidrs must contain at least two CIDRs." + } +} + +variable "name_prefix" { + description = "Name tag prefix for all resources. Also used for the sensor bucket and PHZ tags." + type = string +} + +variable "falcon_cloud" { + description = "CrowdStrike Falcon cloud (us-1, us-2, or eu-1). Determines the endpoint service IDs, home region, and PHZ hostnames." + type = string + default = "us-2" + + validation { + condition = contains(["us-1", "us-2", "eu-1"], var.falcon_cloud) + error_message = "falcon_cloud must be one of us-1, us-2, eu-1." + } +} + +variable "sensor_rpm_path" { + description = "Local path to the Falcon sensor RPM. Uploaded to the sensor bucket as a single object; consumer accounts pull it via the S3 gateway endpoint on first boot." + type = string +} + +variable "ram_principals" { + description = "AWS account IDs (or OU ARNs) to share the private subnets with via AWS RAM. Empty list disables the RAM share (single-account mode)." + type = list(string) + default = [] +} + +variable "consumer_sg_ids" { + description = "Map of logical name -> security group ID for consumer SGs that need HTTPS ingress into the endpoints SG. Keys must be plan-time known (literals); values can be apply-time computed. Typically fed from sensor-host.instance_sg_id in the caller. Only works when the consumer SG lives in the same VPC (same-account or RAM-shared subnets). For cross-VPC reach (TGW), use consumer_cidr_blocks instead." + type = map(string) + default = {} +} + +variable "consumer_cidr_blocks" { + description = "CIDR blocks that need HTTPS ingress to the endpoints SG. Used when consumers live in a different VPC reachable over TGW/peering (SG references don't cross VPCs). One ingress rule is created per CIDR. Empty list -> no CIDR-based ingress." + type = list(string) + default = [] +} + +variable "authorized_role_arns" { + description = "IAM role ARNs (from consumer accounts) that should be granted s3:GetObject on the RPM in the sensor bucket. Empty list -> no cross-account bucket policy statement. For org-wide access, set an empty list here and attach your own policy with aws:PrincipalOrgID outside the module." + type = list(string) + default = [] +} diff --git a/aws-privatelink/modules/endpoint-vpc/versions.tf b/aws-privatelink/modules/endpoint-vpc/versions.tf new file mode 100644 index 00000000..86fa9273 --- /dev/null +++ b/aws-privatelink/modules/endpoint-vpc/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80" + } + } +} diff --git a/aws-privatelink/modules/sensor-host/ec2.tf b/aws-privatelink/modules/sensor-host/ec2.tf new file mode 100644 index 00000000..dc5b7b73 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/ec2.tf @@ -0,0 +1,66 @@ +data "aws_ssm_parameter" "al2023_ami" { + # Default AMI source: AWS's public SSM parameter for the latest Amazon + # Linux 2023 kernel-default AMI in this region, per-arch. AWS updates + # this pointer whenever they publish a new AL2023 image, so we always + # get the current one at plan time. Skipped when var.ami_id is pinned. + count = var.ami_id == null ? 1 : 0 + name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-${var.sensor_architecture}" +} + +locals { + resolved_ami_id = var.ami_id != null ? var.ami_id : data.aws_ssm_parameter.al2023_ami[0].value + + sensor_proxy_host = "ts01-${local.slug}.cloudsink.net" + + user_data = templatefile("${path.module}/user_data.sh.tftpl", { + bucket = var.sensor_bucket_name + sensor_rpm_key = var.sensor_bucket_rpm_key + ssm_cid_name = aws_ssm_parameter.falcon_cid.name + ssm_cloud_name = aws_ssm_parameter.falcon_cloud.name + region = var.region + sensor_proxy_host = local.sensor_proxy_host + }) +} + +resource "aws_instance" "this" { + count = var.instance_count + + ami = local.resolved_ami_id + instance_type = var.instance_type + subnet_id = var.subnet_ids[count.index % length(var.subnet_ids)] + vpc_security_group_ids = [aws_security_group.instance.id] + iam_instance_profile = aws_iam_instance_profile.instance.name + key_name = var.key_name + associate_public_ip_address = false + + user_data = local.user_data + user_data_replace_on_change = true + + metadata_options { + http_tokens = "required" + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + } + + root_block_device { + volume_type = "gp3" + volume_size = 20 + encrypted = true + } + + # Keep existing instances stable if the SSM parameter advances to a newer AMI. + lifecycle { + ignore_changes = [ami] + } + + tags = { + Name = "${var.name_prefix}-sensor-host-${count.index}" + } + + depends_on = [ + aws_ssm_parameter.falcon_cid, + aws_ssm_parameter.falcon_cloud, + aws_iam_role_policy.instance_s3, + aws_iam_role_policy.instance_ssm_params, + ] +} diff --git a/aws-privatelink/modules/sensor-host/iam.tf b/aws-privatelink/modules/sensor-host/iam.tf new file mode 100644 index 00000000..c1168371 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/iam.tf @@ -0,0 +1,80 @@ +resource "aws_iam_role" "instance" { + name = "${var.name_prefix}-ec2-ssm" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ec2.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "ssm_core" { + role = aws_iam_role.instance.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_instance_profile" "instance" { + name = "${var.name_prefix}-ec2-ssm" + role = aws_iam_role.instance.name +} + +# S3 read on the sensor bucket — instance pulls the sensor RPM on first boot. +resource "aws_iam_role_policy" "instance_s3" { + name = "${var.name_prefix}-s3-sensor" + role = aws_iam_role.instance.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ListBucket" + Effect = "Allow" + Action = ["s3:ListBucket", "s3:GetBucketLocation"] + Resource = local.sensor_bucket_arn + }, + { + Sid = "ReadObjects" + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = "${local.sensor_bucket_arn}/*" + }, + ] + }) +} + +# SSM Parameter Store reads — instance pulls the Falcon CID + cloud at first +# boot. Both params live in the same account as the instance (this module +# creates them), so no RAM/KMS grants are needed. +resource "aws_iam_role_policy" "instance_ssm_params" { + name = "${var.name_prefix}-ssm-params" + role = aws_iam_role.instance.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadFalconParams" + Effect = "Allow" + Action = ["ssm:GetParameter", "ssm:GetParameters"] + Resource = [ + aws_ssm_parameter.falcon_cid.arn, + aws_ssm_parameter.falcon_cloud.arn, + ] + }, + { + Sid = "DecryptSecureString" + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = "*" + Condition = { + StringEquals = { + "kms:ViaService" = "ssm.${var.region}.amazonaws.com" + } + } + }, + ] + }) +} diff --git a/aws-privatelink/modules/sensor-host/main.tf b/aws-privatelink/modules/sensor-host/main.tf new file mode 100644 index 00000000..ba513293 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/main.tf @@ -0,0 +1,12 @@ +data "aws_caller_identity" "current" {} + +locals { + sensor_bucket_arn = "arn:aws:s3:::${var.sensor_bucket_name}" + + cloud_hostname_slugs = { + "us-1" = "b" + "us-2" = "gyr-maverick" + "eu-1" = "lanner-lion" + } + slug = local.cloud_hostname_slugs[var.falcon_cloud] +} diff --git a/aws-privatelink/modules/sensor-host/outputs.tf b/aws-privatelink/modules/sensor-host/outputs.tf new file mode 100644 index 00000000..c27a3176 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/outputs.tf @@ -0,0 +1,49 @@ +output "instance_ids" { + description = "EC2 sensor host IDs." + value = aws_instance.this[*].id +} + +output "instance_private_ips" { + description = "Private IPs of all sensor hosts." + value = aws_instance.this[*].private_ip +} + +output "instance_sg_id" { + description = "Security group ID attached to the sensor host(s). Pass this to endpoint-vpc.consumer_sg_ids so the endpoints SG lets HTTPS ingress through from here." + value = aws_security_group.instance.id +} + +output "instance_role_arn" { + description = "IAM role ARN attached to the sensor host(s) via the instance profile. Pass this to endpoint-vpc.authorized_role_arns to grant cross-account read on the sensor bucket." + value = aws_iam_role.instance.arn +} + +output "ssm_start_session_commands" { + description = "Copy-paste commands to open an SSM Session Manager shell to each host. Prepend --profile when the host is in a different account from the shell's default profile." + value = [for id in aws_instance.this[*].id : "aws ssm start-session --region ${var.region} --target ${id}"] +} + +output "verification_commands" { + description = "Commands to run on each host (inside the SSM session) to verify sensor registration." + value = [ + "# 1. DNS resolution — must return a VPC-local IP from the PHZ", + "nslookup ts01-${local.slug}.cloudsink.net", + "", + "# 2. TLS handshake over PrivateLink", + "curl -v https://ts01-${local.slug}.cloudsink.net:443 2>&1 | head -20", + "", + "# 3. Sensor AID (populated within ~2-5 min of first boot)", + "sudo /opt/CrowdStrike/falconctl -g --aid", + "", + "# 4. Sensor service status", + "sudo systemctl status falcon-sensor --no-pager", + "", + "# 5. Bootstrap log (full install trace)", + "sudo cat /var/log/falcon-bootstrap.log", + ] +} + +output "ami_id" { + description = "AMI ID launched for all sensor hosts. Either the caller-pinned var.ami_id or the latest AL2023 kernel-default AMI resolved via SSM at apply time." + value = nonsensitive(local.resolved_ami_id) +} diff --git a/aws-privatelink/modules/sensor-host/security_groups.tf b/aws-privatelink/modules/sensor-host/security_groups.tf new file mode 100644 index 00000000..a898b5b3 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/security_groups.tf @@ -0,0 +1,53 @@ +resource "aws_security_group" "instance" { + name = "${var.name_prefix}-instance" + description = "Falcon sensor host - egress to CrowdStrike + S3 endpoints. SSM-only by default; SSH optional." + vpc_id = var.vpc_id + + tags = { + Name = "${var.name_prefix}-instance" + } +} + +# SSH is only wired up if both a key pair and an allowed CIDR are supplied. +# For SSM-only deployments (recommended), leave key_name + ssh_allowed_cidr null. +resource "aws_vpc_security_group_ingress_rule" "instance_ssh" { + count = var.key_name != null && var.ssh_allowed_cidr != null ? 1 : 0 + + security_group_id = aws_security_group.instance.id + description = "SSH from on-prem (reaches VPC via existing VPN/TGW/peering)" + cidr_ipv4 = var.ssh_allowed_cidr + from_port = 22 + to_port = 22 + ip_protocol = "tcp" +} + +resource "aws_vpc_security_group_egress_rule" "instance_https_to_endpoints" { + security_group_id = aws_security_group.instance.id + description = "HTTPS to CrowdStrike + SSM interface endpoints" + referenced_security_group_id = var.endpoints_sg_id + from_port = 443 + to_port = 443 + ip_protocol = "tcp" +} + +resource "aws_vpc_security_group_egress_rule" "instance_https_to_s3" { + security_group_id = aws_security_group.instance.id + description = "HTTPS to S3 (sensor bucket + AL2023 dnf repos via S3 gateway endpoint)" + prefix_list_id = var.s3_prefix_list_id + from_port = 443 + to_port = 443 + ip_protocol = "tcp" +} + +# HTTPS egress to arbitrary CIDRs (TGW / peering reach). Used by 03 to let +# the spoke instance reach the hub's CrowdStrike endpoints across the TGW. +resource "aws_vpc_security_group_egress_rule" "instance_https_to_cidrs" { + for_each = toset(var.egress_cidr_blocks) + + security_group_id = aws_security_group.instance.id + description = "HTTPS to ${each.value}" + cidr_ipv4 = each.value + from_port = 443 + to_port = 443 + ip_protocol = "tcp" +} diff --git a/aws-privatelink/modules/sensor-host/ssm_params.tf b/aws-privatelink/modules/sensor-host/ssm_params.tf new file mode 100644 index 00000000..c2a54445 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/ssm_params.tf @@ -0,0 +1,21 @@ +# SSM params are created locally in each caller account. This avoids the +# Advanced-tier SSM + RAM + cross-account KMS grants you'd need to share +# SecureString params across accounts. The cost is that the caller has to +# pass falcon_cid + falcon_cloud to every sensor-host call, which is cheap. + +resource "aws_ssm_parameter" "falcon_cid" { + name = "/${var.name_prefix}/falcon/cid" + description = "CrowdStrike Falcon CCID. Read by the sensor host at first boot to register with the correct tenant." + type = "SecureString" + value = var.falcon_cid + overwrite = true + tier = "Standard" +} + +resource "aws_ssm_parameter" "falcon_cloud" { + name = "/${var.name_prefix}/falcon/cloud" + description = "CrowdStrike cloud (us-1, us-2, eu-1). Passed to falconctl -s --cloud=... at first boot." + type = "String" + value = var.falcon_cloud + overwrite = true +} diff --git a/aws-privatelink/modules/sensor-host/user_data.sh.tftpl b/aws-privatelink/modules/sensor-host/user_data.sh.tftpl new file mode 100644 index 00000000..0c928188 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/user_data.sh.tftpl @@ -0,0 +1,61 @@ +#!/bin/bash +# First-boot Falcon sensor install. Runs as root via cloud-init. +# Inputs via templatefile(): bucket, sensor_rpm_key, ssm_cid_name, +# ssm_cloud_name, region. +set -euxo pipefail + +exec > /var/log/falcon-bootstrap.log 2>&1 +echo "[$(date -Is)] bootstrap starting" + +# 1. Pull the sensor RPM from S3 via the gateway endpoint (no internet egress). +# In cross-account deployments, read access is granted by the bucket policy +# set on endpoint-vpc (var.authorized_role_arns). +aws s3 cp "s3://${bucket}/${sensor_rpm_key}" /tmp/falcon-sensor.rpm --region "${region}" + +# 2. Install. +dnf install -y /tmp/falcon-sensor.rpm + +# 3. Read CID + cloud from the local SSM Parameter Store (same-account reads). +# Disable command tracing around the value fetches so the CID value +# doesn't land in /var/log/falcon-bootstrap.log (set -x expands $(...)). +set +x +CID=$(aws ssm get-parameter \ + --name "${ssm_cid_name}" \ + --with-decryption \ + --region "${region}" \ + --query Parameter.Value \ + --output text) + +CLOUD=$(aws ssm get-parameter \ + --name "${ssm_cloud_name}" \ + --region "${region}" \ + --query Parameter.Value \ + --output text) +set -x + +# 4. Configure the sensor. falconctl handles its own redaction; still, avoid +# echoing the CID here. +set +x +/opt/CrowdStrike/falconctl -s --cid="$CID" --cloud="$CLOUD" +set -x + +# 5. Wait for PrivateLink endpoints to accept connections before starting +# the sensor. Avoids the 2+ minute fallback-to-public-IP timeout cycle. +echo "[$(date -Is)] waiting for PrivateLink endpoint readiness" +for i in $(seq 1 30); do + if curl -so /dev/null --connect-timeout 3 https://${sensor_proxy_host}:443 2>/dev/null; then + echo "[$(date -Is)] endpoint reachable after $i attempts" + break + fi + sleep 5 +done + +# 6. Start the sensor service. +systemctl enable --now falcon-sensor + +# 7. Emit verification data to the bootstrap log. +sleep 30 +/opt/CrowdStrike/falconctl -g --aid || true +systemctl status falcon-sensor --no-pager || true + +echo "[$(date -Is)] bootstrap complete" diff --git a/aws-privatelink/modules/sensor-host/variables.tf b/aws-privatelink/modules/sensor-host/variables.tf new file mode 100644 index 00000000..3e9bbe79 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/variables.tf @@ -0,0 +1,106 @@ +variable "region" { + description = "AWS region for this instance. Must match the provider region and the endpoint VPC region (sensor-host launches ENIs into the shared subnets, so they're always colocated)." + type = string +} + +variable "name_prefix" { + description = "Name tag prefix for all resources. Also used for IAM role, instance profile, and SSM parameter paths." + type = string +} + +variable "vpc_id" { + description = "VPC ID of the endpoint VPC. In same-account deployments this is the caller's own VPC; in cross-account deployments this is the owner's VPC ID (visible to the workload account via the RAM subnet share)." + type = string +} + +variable "subnet_ids" { + description = "Subnet IDs to launch instances into (round-robin by count.index). These are the RAM-shared subnets from endpoint-vpc." + type = list(string) + + validation { + condition = length(var.subnet_ids) >= 1 + error_message = "subnet_ids must contain at least one subnet." + } +} + +variable "endpoints_sg_id" { + description = "Security group ID of the endpoints SG from endpoint-vpc. Used as the referenced SG in the instance's HTTPS egress rule. Cross-account SG references are permitted when both SGs live in the same VPC." + type = string +} + +variable "s3_prefix_list_id" { + description = "Prefix list ID of the S3 gateway endpoint from endpoint-vpc. Used in the instance's HTTPS egress rule for S3 (sensor bucket + AL2023 dnf repos)." + type = string +} + +variable "egress_cidr_blocks" { + description = "Additional CIDR blocks the instance SG should allow HTTPS egress to. Used for cross-VPC reach (e.g. TGW to a hub VPC hosting CrowdStrike endpoints) when SG references aren't usable. Empty list -> no CIDR-based egress." + type = list(string) + default = [] +} + +variable "sensor_bucket_name" { + description = "Name of the S3 bucket holding the Falcon sensor RPM (from endpoint-vpc.sensor_bucket_name). Used in user_data for aws s3 cp and in the instance role's S3 policy." + type = string +} + +variable "sensor_bucket_rpm_key" { + description = "S3 key of the sensor RPM object (from endpoint-vpc.sensor_bucket_rpm_key). Passed into user_data." + type = string +} + +variable "falcon_cloud" { + description = "CrowdStrike cloud (us-1, us-2, eu-1). Stored in a local SSM param and passed to falconctl -s --cloud at first boot. Must match endpoint-vpc.falcon_cloud." + type = string + + validation { + condition = contains(["us-1", "us-2", "eu-1"], var.falcon_cloud) + error_message = "falcon_cloud must be one of us-1, us-2, eu-1." + } +} + +variable "falcon_cid" { + description = "CrowdStrike Customer ID with checksum (CCID). Typically supplied by a root-level fetch that hits the Falcon API once (see examples/*/fetch.tf). CID is a tenant identifier, not a credential, but it's stored as SecureString for defense in depth." + type = string +} + +variable "instance_type" { + description = "EC2 instance type. Must match var.sensor_architecture (e.g. t3.small for x86_64, t4g.small for aarch64/Graviton)." + type = string + default = "t3.small" +} + +variable "sensor_architecture" { + description = "CPU architecture for the Falcon sensor RPM and the AL2023 AMI. Must match the family of var.instance_type." + type = string + default = "x86_64" + + validation { + condition = contains(["x86_64", "aarch64"], var.sensor_architecture) + error_message = "sensor_architecture must be either \"x86_64\" or \"aarch64\"." + } +} + +variable "ami_id" { + description = "AMI to launch. Must be Amazon Linux 2023 (the sensor RPM is built for AL2023). Leave null to use the AWS-published latest AL2023 kernel-default AMI for var.sensor_architecture, resolved via SSM public parameter at plan time." + type = string + default = null +} + +variable "instance_count" { + description = "Number of EC2 sensor hosts to launch." + type = number + default = 1 +} + +variable "key_name" { + description = "Optional EC2 key pair for SSH. Leave null for SSM-only access (recommended)." + type = string + default = null +} + +variable "ssh_allowed_cidr" { + description = "Optional source CIDR for SSH (port 22). Only used when key_name is also set. Traffic must arrive via VPN/TGW/peering — this module does not create one." + type = string + default = null +} diff --git a/aws-privatelink/modules/sensor-host/versions.tf b/aws-privatelink/modules/sensor-host/versions.tf new file mode 100644 index 00000000..86fa9273 --- /dev/null +++ b/aws-privatelink/modules/sensor-host/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80" + } + } +} diff --git a/aws-privatelink/s3bucket/layer.zip b/aws-privatelink/s3bucket/layer.zip deleted file mode 100644 index 3e6c2c72..00000000 Binary files a/aws-privatelink/s3bucket/layer.zip and /dev/null differ diff --git a/aws-privatelink/s3bucket/manage-r53-association.zip b/aws-privatelink/s3bucket/manage-r53-association.zip deleted file mode 100644 index 68c1708d..00000000 Binary files a/aws-privatelink/s3bucket/manage-r53-association.zip and /dev/null differ diff --git a/aws-privatelink/scripts/fetch_sensor.py b/aws-privatelink/scripts/fetch_sensor.py new file mode 100755 index 00000000..facb6336 --- /dev/null +++ b/aws-privatelink/scripts/fetch_sensor.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "crowdstrike-falconpy>=1.4", +# "truststore>=0.9", +# ] +# /// +"""Fetch the latest Falcon sensor RPM (AL2023) + tenant CID from the Falcon API. + +Run via `uv run fetch_sensor.py ...`. uv will resolve and cache the +`crowdstrike-falconpy` dependency declared inline (PEP 723) on first run, so +no venv / pip setup is needed on the caller's machine. + +Invoked by Terraform's `null_resource.sensor_fetch` in each example's +fetch.tf, but also runnable standalone for debugging. + +Inputs (env or flags): + FALCON_CLIENT_ID / --client-id CrowdStrike API client ID + FALCON_CLIENT_SECRET / --secret CrowdStrike API client secret + FALCON_BASE_URL / --base-url Optional override (default: auto-discover) + --cloud us-1 | us-2 | eu-1 (default: us-2) + --arch x86_64 | aarch64 (default: x86_64) + --out Output directory (required) + +Output: + /falcon-sensor.rpm The installer binary + /result.json { "cid": "...", "cloud": "us-2", + "rpm_path": "...", "sha256": "..." } + +Idempotent: skips re-download when the RPM on disk already matches the +expected sha256. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from pathlib import Path + +# Use the OS trust store (macOS Keychain, Windows cert store, Linux +# /etc/ssl) instead of certifi's bundled CAs. This is critical for +# customers behind corporate TLS inspection (Zscaler, Netskope, Palo Alto): +# their MITM root CA is installed in the OS trust store via MDM but is +# not in certifi, which causes SSL verification failures on Falcon API +# calls. truststore delegates to the platform, so whatever the OS trusts, +# Python trusts. +import truststore +truststore.inject_into_ssl() + +from falconpy import SensorDownload + + +CLOUD_BASE_URLS = { + "us-1": "https://api.crowdstrike.com", + "us-2": "https://api.us-2.crowdstrike.com", + "eu-1": "https://api.eu-1.crowdstrike.com", +} + + +def log(msg: str) -> None: + print(f"[fetch_sensor] {msg}", file=sys.stderr, flush=True) + + +def sha256_of(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +ARCH_ALIASES = { + "x86_64": ("x86_64", "amd64"), + "aarch64": ("aarch64", "arm64"), +} + + +def pick_latest_al2023(installers: list[dict], arch: str) -> dict: + arch_tokens = ARCH_ALIASES[arch] + + def is_al2023(i: dict) -> bool: + if (i.get("platform") or "").lower() != "linux": + return False + name = (i.get("name") or "").lower() + if not name.endswith(".rpm"): + return False + # Falcon splits these across os + os_version; some older records + # cram both into os. Check the combined string either way. + combined = f"{i.get('os') or ''} {i.get('os_version') or ''}".lower() + if not ("amazon linux" in combined and "2023" in combined): + return False + # Architecture lives in the filename (.x86_64.rpm / .aarch64.rpm). + # Falcon also has an `architectures` array on newer records. + arch_field = [a.lower() for a in (i.get("architectures") or [])] + return any(tok in name or tok in arch_field for tok in arch_tokens) + + candidates = [i for i in installers if is_al2023(i)] + if not candidates: + sample = [ + { + "name": i.get("name"), + "platform": i.get("platform"), + "os": i.get("os"), + "os_version": i.get("os_version"), + "architectures": i.get("architectures"), + } + for i in installers[:8] + ] + raise SystemExit( + f"No Amazon Linux 2023 {arch} sensor installer found. " + f"First {len(sample)} of {len(installers)} installers returned: " + f"{json.dumps(sample, indent=2)}" + ) + candidates.sort(key=lambda i: i.get("release_date", ""), reverse=True) + return candidates[0] + + +def handle_api_errors(resp: dict, context: str) -> dict: + body = resp.get("body", {}) + if resp.get("status_code", 0) >= 400 or body.get("errors"): + errors = body.get("errors") or [{"message": "unknown error"}] + joined = "; ".join(e.get("message", str(e)) for e in errors) + raise SystemExit(f"Falcon API error ({context}): {joined}") + return body + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", required=True, help="Output directory") + ap.add_argument("--cloud", default="us-2", choices=sorted(CLOUD_BASE_URLS)) + ap.add_argument( + "--arch", + default="x86_64", + choices=sorted(ARCH_ALIASES), + help="Target CPU architecture. x86_64 for t3/m5/c5 etc., aarch64 for Graviton (t4g/m6g/c6g).", + ) + ap.add_argument("--client-id", default=os.environ.get("FALCON_CLIENT_ID")) + ap.add_argument("--secret", default=os.environ.get("FALCON_CLIENT_SECRET")) + ap.add_argument("--base-url", default=os.environ.get("FALCON_BASE_URL") or None) + args = ap.parse_args() + + if not args.client_id or not args.secret: + raise SystemExit( + "Falcon API credentials required — set FALCON_CLIENT_ID + FALCON_CLIENT_SECRET " + "or pass --client-id / --secret." + ) + + out_dir = Path(args.out).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + rpm_path = out_dir / "falcon-sensor.rpm" + result_path = out_dir / "result.json" + + base_url = args.base_url or CLOUD_BASE_URLS[args.cloud] + log(f"authenticating against {base_url}") + + sensor = SensorDownload( + client_id=args.client_id, + client_secret=args.secret, + base_url=base_url, + ) + + log("fetching tenant CID") + cid_body = handle_api_errors(sensor.get_sensor_installer_ccid(), "get_ccid") + cid_list = cid_body.get("resources") or [] + if not cid_list: + raise SystemExit("Falcon API returned no CID — check that the API key has Sensor Download: Read scope.") + cid = cid_list[0] + log(f"tenant CID: {cid[:8]}...{cid[-4:]}") + + log("listing AL2023 sensor installers") + list_body = handle_api_errors( + sensor.get_combined_sensor_installers_by_query( + filter="platform:'linux'", + sort="release_date|desc", + limit=500, + ), + "list_installers", + ) + installers = list_body.get("resources") or [] + if not installers: + raise SystemExit("No Linux installers returned by Falcon API.") + + chosen = pick_latest_al2023(installers, args.arch) + expected_sha = chosen["sha256"] + version = chosen.get("version", "unknown") + log(f"latest AL2023 sensor: {chosen['name']} (v{version}, sha256={expected_sha[:12]}...)") + + if rpm_path.exists() and sha256_of(rpm_path) == expected_sha: + log("local RPM already matches expected sha256 — skipping download") + else: + log(f"downloading → {rpm_path}") + resp = sensor.download_sensor_installer(id=expected_sha) + if isinstance(resp, dict): + body = handle_api_errors(resp, "download") + raise SystemExit(f"Expected binary, got JSON: {body}") + # falconpy returns raw bytes for binary downloads + rpm_path.write_bytes(resp) + actual_sha = sha256_of(rpm_path) + if actual_sha != expected_sha: + raise SystemExit(f"sha256 mismatch after download: expected {expected_sha}, got {actual_sha}") + log("download verified") + + result = { + "cid": cid, + "cloud": args.cloud, + "rpm_path": str(rpm_path), + "sha256": expected_sha, + "version": version, + } + result_path.write_text(json.dumps(result, indent=2)) + log(f"wrote {result_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())