AWSTemplateFormatVersion: '2010-09-09'

Description: >-
  Amazon WorkSpaces workshop - prerequisites (section 2). Creates the VPC,
  2 public + 2 private subnets, Internet Gateway, NAT Gateway, AWS Managed
  Microsoft AD, a domain-joined Bastion host (public subnet) and a
  domain-joined AD-Manager host with RSAT (private subnet). After the stack
  reaches CREATE_COMPLETE you can jump straight to section 3.1 and register
  the directory with Amazon WorkSpaces.
  NOTE: this stack creates billable resources (Managed AD ~USD 0.12/h,
  NAT Gateway, 2 EC2 instances, 1 Elastic IP).

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Network
        Parameters:
          - AvailabilityZone1
          - AvailabilityZone2
          - AllowedRdpCidr
      - Label:
          default: Active Directory
        Parameters:
          - DirectoryName
          - DirectoryShortName
          - DirectoryAdminPassword
      - Label:
          default: EC2
        Parameters:
          - KeyName
          - BastionInstanceType
          - AdManagerInstanceType
          - WindowsAmiId
    ParameterLabels:
      AvailabilityZone1:
        default: First Availability Zone (must support WorkSpaces)
      AvailabilityZone2:
        default: Second Availability Zone (must support WorkSpaces)
      AllowedRdpCidr:
        default: CIDR allowed to RDP into the Bastion host

Parameters:
  AvailabilityZone1:
    Type: AWS::EC2::AvailabilityZone::Name
    Description: >-
      Amazon WorkSpaces is not available in every AZ of a Region. Pick an AZ
      that supports WorkSpaces (for example us-east-1a / us-east-1c in
      us-east-1), otherwise directory registration in section 3.1 will fail.

  AvailabilityZone2:
    Type: AWS::EC2::AvailabilityZone::Name
    Description: Must be different from the first Availability Zone.

  AllowedRdpCidr:
    Type: String
    Description: >-
      YOUR OWN public IP in CIDR form, with /32 at the end. Get it from
      https://checkip.amazonaws.com and paste it here. Do not paste the
      example from this description: 203.0.113.x is a documentation-only
      range (RFC 5737) and belongs to nobody, so RDP will silently fail.
      Do not use 0.0.0.0/0 either - the Bastion host is domain joined and
      exposed to the Internet.
    AllowedPattern: ^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$
    ConstraintDescription: >-
      Must be a valid IPv4 CIDR block ending in /32, taken from
      https://checkip.amazonaws.com

  DirectoryName:
    Type: String
    Description: Fully qualified domain name of the AWS Managed Microsoft AD.
    Default: corp.example.com
    AllowedPattern: ^([a-zA-Z0-9]+[\.-])+([a-zA-Z0-9])+$

  DirectoryShortName:
    Type: String
    Description: NetBIOS name of the domain.
    Default: CORP
    AllowedPattern: ^[A-Z0-9-]{1,15}$

  DirectoryAdminPassword:
    Type: String
    Description: >-
      Password for the domain Admin account. Must satisfy AD complexity
      rules (upper, lower, digit / symbol).
    NoEcho: true
    MinLength: 8
    MaxLength: 64

  KeyName:
    Type: AWS::EC2::KeyPair::KeyName
    Description: >-
      Key pair used to decrypt the local Administrator password of the EC2
      instances (needed for the very first RDP session).

  BastionInstanceType:
    Type: String
    Description: Instance type for the Bastion host (RDP jump host only).
    Default: t3.medium
    AllowedValues:
      - t3.medium
      - t3.large
      - t3.xlarge
      - m5.large

  AdManagerInstanceType:
    Type: String
    Description: >-
      Instance type for the AD-Manager host. RSAT / Active Directory Users and
      Computers plus a desktop session need headroom, t3.large is recommended.
    Default: t3.large
    AllowedValues:
      - t3.medium
      - t3.large
      - t3.xlarge
      - m5.large

  WindowsAmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Description: SSM public parameter resolving to the latest Windows Server AMI.
    Default: /aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base

Resources:
  # ---------------------------------------------------------------- Networking
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-vpc

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-igw

  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref InternetGateway

  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Ref AvailabilityZone1
      CidrBlock: 10.0.1.0/24
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-public-1

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Ref AvailabilityZone2
      CidrBlock: 10.0.2.0/24
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-public-2

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Ref AvailabilityZone1
      CidrBlock: 10.0.11.0/24
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-private-1

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Ref AvailabilityZone2
      CidrBlock: 10.0.12.0/24
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-private-2

  NatEip:
    Type: AWS::EC2::EIP
    DependsOn: InternetGatewayAttachment
    Properties:
      Domain: vpc
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-nat-eip

  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatEip.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-nat

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-public-rt

  PublicDefaultRoute:
    Type: AWS::EC2::Route
    DependsOn: InternetGatewayAttachment
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  PublicSubnet1RouteAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet1

  PublicSubnet2RouteAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet2

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-private-rt

  PrivateDefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway

  PrivateSubnet1RouteAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet1

  PrivateSubnet2RouteAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet2

  # ------------------------------------------------- AWS Managed Microsoft AD
  MicrosoftAD:
    Type: AWS::DirectoryService::MicrosoftAD
    DependsOn: PrivateSubnet2RouteAssociation
    Properties:
      Name: !Ref DirectoryName
      ShortName: !Ref DirectoryShortName
      Password: !Ref DirectoryAdminPassword
      Edition: Standard
      CreateAlias: false
      EnableSso: false
      VpcSettings:
        VpcId: !Ref Vpc
        SubnetIds:
          - !Ref PrivateSubnet1
          - !Ref PrivateSubnet2

  # Point the whole VPC at the directory DNS servers. This replaces the manual
  # "edit DNS / IP settings" step of the workshop. Managed AD forwards queries
  # it cannot answer to the Amazon-provided resolver, so public DNS keeps working.
  DhcpOptions:
    Type: AWS::EC2::DHCPOptions
    Properties:
      DomainName: !Ref DirectoryName
      DomainNameServers: !GetAtt MicrosoftAD.DnsIpAddresses
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-dhcp

  DhcpOptionsAssociation:
    Type: AWS::EC2::VPCDHCPOptionsAssociation
    Properties:
      VpcId: !Ref Vpc
      DhcpOptionsId: !Ref DhcpOptions

  # ---------------------------------------------------------- Security groups
  BastionSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Bastion host - RDP from the operator IP only
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3389
          ToPort: 3389
          CidrIp: !Ref AllowedRdpCidr
          Description: RDP from operator
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-bastion-sg

  AdManagerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: AD-Manager host - RDP from the Bastion host only
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3389
          ToPort: 3389
          SourceSecurityGroupId: !Ref BastionSecurityGroup
          Description: RDP from bastion
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-admanager-sg

  # ------------------------------------------------------------- Instance role
  InstanceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        - arn:aws:iam::aws:policy/AmazonSSMDirectoryServiceAccess

  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles:
        - !Ref InstanceRole

  # ---------------------------------------------------------------- EC2 hosts
  BastionHost:
    Type: AWS::EC2::Instance
    DependsOn: DhcpOptionsAssociation
    Properties:
      ImageId: !Ref WindowsAmiId
      InstanceType: !Ref BastionInstanceType
      KeyName: !Ref KeyName
      IamInstanceProfile: !Ref InstanceProfile
      SubnetId: !Ref PublicSubnet1
      SecurityGroupIds:
        - !Ref BastionSecurityGroup
      BlockDeviceMappings:
        - DeviceName: /dev/sda1
          Ebs:
            VolumeSize: 50
            VolumeType: gp3
            DeleteOnTermination: true
            Encrypted: true
      UserData:
        Fn::Base64: |
          <powershell>
          Install-WindowsFeature -Name RSAT-AD-Tools,RSAT-DNS-Server,GPMC
          </powershell>
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-bastion
        - Key: DomainJoin
          Value: 'true'

  AdManagerHost:
    Type: AWS::EC2::Instance
    DependsOn: DhcpOptionsAssociation
    Properties:
      ImageId: !Ref WindowsAmiId
      InstanceType: !Ref AdManagerInstanceType
      KeyName: !Ref KeyName
      IamInstanceProfile: !Ref InstanceProfile
      SubnetId: !Ref PrivateSubnet1
      SecurityGroupIds:
        - !Ref AdManagerSecurityGroup
      BlockDeviceMappings:
        - DeviceName: /dev/sda1
          Ebs:
            VolumeSize: 50
            VolumeType: gp3
            DeleteOnTermination: true
            Encrypted: true
      UserData:
        Fn::Base64: |
          <powershell>
          Install-WindowsFeature -Name RSAT-AD-Tools,RSAT-ADDS-Tools,RSAT-AD-AdminCenter,RSAT-DNS-Server,GPMC
          </powershell>
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-ad-manager
        - Key: DomainJoin
          Value: 'true'

  # Domain join through State Manager instead of plaintext credentials in
  # UserData. The schedule makes the association self-healing if the first
  # run happens before the instance has finished booting.
  DomainJoinAssociation:
    Type: AWS::SSM::Association
    DependsOn:
      - BastionHost
      - AdManagerHost
    Properties:
      AssociationName: !Sub ${AWS::StackName}-domain-join
      Name: AWS-JoinDirectoryServiceDomain
      ScheduleExpression: rate(30 minutes)
      Parameters:
        directoryId:
          - !Ref MicrosoftAD
        directoryName:
          - !Ref DirectoryName
        dnsIpAddresses: !GetAtt MicrosoftAD.DnsIpAddresses
      Targets:
        - Key: tag:DomainJoin
          Values:
            - 'true'

Outputs:
  DirectoryId:
    Description: Directory to register with Amazon WorkSpaces in section 3.1
    Value: !Ref MicrosoftAD

  DirectoryName:
    Description: Domain FQDN - sign in as Admin@<domain>
    Value: !Ref DirectoryName

  DirectoryDnsAddresses:
    Description: Domain controller DNS addresses
    Value: !Join [', ', !GetAtt MicrosoftAD.DnsIpAddresses]

  WorkSpacesSubnetIds:
    Description: The two private subnets to select when registering the directory
    Value: !Join [', ', [!Ref PrivateSubnet1, !Ref PrivateSubnet2]]

  BastionPublicIp:
    Description: RDP here first, as CORP\Admin
    Value: !GetAtt BastionHost.PublicIp

  AdManagerPrivateIp:
    Description: RDP here from the bastion to use Active Directory Users and Computers
    Value: !GetAtt AdManagerHost.PrivateIp

  VpcId:
    Value: !Ref Vpc
