CloudFormation一撃でAWS Transfer for SFTP のパブリックアクセスを特定IP に限定する

2019.11.08

おはようございます、もきゅりんです。

今回は特にひねりもなく、下記弊社ブログ記事で紹介された構成をCloudFormation一撃で構築するという内容になります。

AWS Transfer for SFTP のパブリックアクセスを特定IPに限定する方法

構成は下図です。

sftp image

構成内容

  • NLB用のパブリックサブネット
  • VPCエンドポイント
  • VPCエンドポイント用のセキュリティグループ
  • NLBのログ用のS3バケット
  • SFTPのためのS3バケット
  • SFTPサーバ
  • SFTPサーバのログロール
  • SFTPサーバを利用するユーザーのロール
  • NLB
  • VPCEndpointのNetworkInterfaceIdsからPrivateIPを取得するためのカスタムリソースのLambda関数
  • 指定IPからのSSH接続以外を拒否するNACL

前提

利用するにあたっての前提条件は以下です。

  • VPCおよびプライベート、パブリックルートテーブルは構築済みであること
    (OutputsでVPCIDなどを出力していることを前提としていますが、実際に利用する際はうまく微調整して頂ければと思います。)
  • NACLはサブネットが対象のため、影響範囲が広いのでNLB用のサブネットを別で作成しています。
  • 出力先S3バケット名を指定しますが、バケット名にAWSアカウント番号を付与します。
  • Cloudformation削除時はS3バケット内のファイルを削除済みにしないとS3バケット削除できません。必要に応じて、DeletionPolicyをつけて削除除外してください。

パラメータ

以下を指定する仕様となります。

  • NLBを配置するサブネットのCIDR表記
  • SSH接続を許可するIP

テンプレート

AWSTemplateFormatVersion: 2010-09-09
Description: S3 and Access Restricted AWS Transfer for SFTP

# ------------------------------------------------------------#
# Parameters
# ------------------------------------------------------------#
Parameters:
  NameTagPrefix:
    Type: String
    Default: test
    Description: Prefix of Name tags.
  LowerNameTagPrefix:
    Type: String
    Default: test
    Description: Prefix of Lowercase Name tags For S3 BucketName.
  ENV:
    Type: String
    Default: stg
    Description: Prefix of Env tags.
  NLBSubnet1CidrBlock:
    Type: String
    Description: NLBSubnet1 CidrBlock.
  NLBSubnet2CidrBlock:
    Type: String
    Description: NLBSubnet2 CidrBlock.
  AllowIP:
    Type: String
    Description: Allowed IP
# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
  # ------------------------------------------------------------#
  # Subnets
  # ------------------------------------------------------------#
  NLBSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select [0, 'Fn::GetAZs': { Ref: 'AWS::Region' }]
      CidrBlock: !Ref NLBSubnet1CidrBlock
      Tags:
        - Key: Name
          Value: !Sub ${NameTagPrefix}-${ENV}-NLBSubnet1
      VpcId:
        Fn::ImportValue: !Sub '${NameTagPrefix}-${ENV}-vpc'
  NLBSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref NLBSubnet1
      RouteTableId:
        Fn::ImportValue: !Sub ${NameTagPrefix}-${ENV}-public-rtb
  NLBSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select [1, 'Fn::GetAZs': { Ref: 'AWS::Region' }]
      CidrBlock: !Ref NLBSubnet2CidrBlock
      Tags:
        - Key: Name
          Value: !Sub ${NameTagPrefix}-${ENV}-NLBSubnet2
      VpcId:
        Fn::ImportValue: !Sub '${NameTagPrefix}-${ENV}-vpc'
  NLBSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref NLBSubnet2
      RouteTableId:
        Fn::ImportValue: !Sub ${NameTagPrefix}-${ENV}-public-rtb
  # ------------------------------------------------------------#
  # SecurityGroup
  # ------------------------------------------------------------#
  SFTPEndpointSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub '${NameTagPrefix}-${ENV}-SFTP-VPCEndpoint-sg'
      GroupDescription: !Sub '${NameTagPrefix}-${ENV}-SFTP-VPCEndpoint-sg'
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 22
          ToPort: 22
          CidrIp: !Ref NLBSubnet1CidrBlock
          Description: SSH From NLBSunbnet1
        - IpProtocol: tcp
          FromPort: 22
          ToPort: 22
          CidrIp: !Ref NLBSubnet2CidrBlock
          Description: SSH From NLBSunbnet2
      VpcId:
        Fn::ImportValue: !Sub '${NameTagPrefix}-${ENV}-vpc'
      Tags:
        - Key: Name
          Value: !Sub '${NameTagPrefix}-${ENV}-SFTP-VPCEndpoint-sg'
  # ------------------------------------------------------------#
  # VPC Endpoint
  # ------------------------------------------------------------#
  VPCTransferForSFTPEndpoint:
    Type: AWS::EC2::VPCEndpoint
    DependsOn: SFTPEndpointSecurityGroup
    Properties:
      VpcEndpointType: Interface
      SubnetIds:
        - !Ref NLBSubnet1
        - !Ref NLBSubnet2
      SecurityGroupIds:
        - !Ref SFTPEndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.transfer.server
      VpcId:
        Fn::ImportValue: !Sub '${NameTagPrefix}-${ENV}-vpc'
  # # ------------------------------------------------------------#
  # # S3 Bucket For SFTP
  # # ------------------------------------------------------------#
  HomeS3bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${LowerNameTagPrefix}-${ENV}-${AWS::AccountId}-sftp-for-test
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
  # # ------------------------------------------------------------#
  # # IAM Role
  # # ------------------------------------------------------------#
  # SFTPサーバに適用するIAMRole
  SFTPRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: transfer.amazonaws.com
            Action: sts:AssumeRole
  SFTPPolicy:
    Type: 'AWS::IAM::Policy'
    Properties:
      PolicyName: SFTPPolicy
      PolicyDocument:
        Statement:
          - Effect: Allow
            Action:
              - s3:ListBucket
              - s3:GetBucketLocation
            Resource: !GetAtt HomeS3bucket.Arn
          - Effect: Allow
            Action:
              - s3:PutObject
              - s3:GetObject
              - s3:DeleteObjectVersion
              - s3:DeleteObject
              - s3:GetObjectVersion
            Resource: !Join
              - ''
              - - 'arn:aws:s3:::'
                - !Ref HomeS3bucket
                - /*
      Roles:
        - !Ref SFTPRole
  SFTPLogRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: transfer.amazonaws.com
            Action: sts:AssumeRole
  SFTPLogPolicy:
    Type: 'AWS::IAM::Policy'
    Properties:
      PolicyName: CWLForSFTPPolicy
      PolicyDocument:
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogStream
              - logs:DescribeLogStreams
              - logs:CreateLogGroup
              - logs:PutLogEvents
            Resource: '*'
      Roles:
        - !Ref SFTPLogRole
  # CustomResourceのLambdaに適用するIAMRole
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
  LambdaPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: LambdaPolicy
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action:
              - ec2:*
              - logs:*
            Resource: '*'
      Roles:
        - !Ref LambdaRole
  # # ------------------------------------------------------------#
  # # SFTP Server
  # # ------------------------------------------------------------#
  SFTPServer:
    Type: AWS::Transfer::Server
    Properties:
      EndpointType: VPC_ENDPOINT
      EndpointDetails:
        VpcEndpointId: !Ref VPCTransferForSFTPEndpoint
      IdentityProviderType: SERVICE_MANAGED
      LoggingRole: !GetAtt SFTPLogRole.Arn
  # # ------------------------------------------------------------#
  # # S3 Bucket For NLB Logs
  # # ------------------------------------------------------------#
  S3NLBLogBacket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${LowerNameTagPrefix}-${ENV}-${AWS::AccountId}-nlblog
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      Tags:
        - Key: Name
          Value: !Ref ENV
      LifecycleConfiguration:
        Rules:
          - Id: !Sub ${LowerNameTagPrefix}-${ENV}-Log-Rules
            Status: Enabled
            ExpirationInDays: 400
  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Sub ${LowerNameTagPrefix}-${ENV}-${AWS::AccountId}-nlblog
      PolicyDocument:
        Statement:
          - Sid: 'AWSLogDeliveryWrite'
            Effect: 'Allow'
            Principal:
              Service: delivery.logs.amazonaws.com
            Action:
              - 's3:PutObject'
            Resource:
              Fn::Join:
                - ''
                - - 'arn:aws:s3:::'
                  - !Sub ${LowerNameTagPrefix}-${ENV}-${AWS::AccountId}-nlblog
                  - '/*'
            Condition:
              StringEquals:
                s3:x-amz-acl: bucket-owner-full-control
          - Sid: AWSLogDeliveryAclCheck
            Effect: 'Allow'
            Principal:
              Service: delivery.logs.amazonaws.com
            Action:
              - 's3:GetBucketAcl'
            Resource:
              Fn::Join:
                - ''
                - - 'arn:aws:s3:::'
                  - !Sub ${LowerNameTagPrefix}-${ENV}-${AWS::AccountId}-nlblog
  # ------------------------------------------------------------#
  # Lambda
  # ------------------------------------------------------------#
  LambdaFunction:
    Type: 'AWS::Lambda::Function'
    DeletionPolicy: 'Delete'
    Properties:
      Code:
        ZipFile: !Sub |
          import cfnresponse
          import json
          import boto3
          def lambda_handler(event, context):
              print('REQUEST RECEIVED:\n' + json.dumps(event))
              responseData = {}
              if event['RequestType'] == 'Delete':
                cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
                return
              if event['RequestType'] == 'Create':
                try:
                  ec2 = boto3.resource('ec2')
                  enis = event['ResourceProperties']['NetworkInterfaceIds']
                  for index, eni in enumerate(enis):
                    network_interface = ec2.NetworkInterface(eni)
                    responseData['IP' + str(index)] = network_interface.private_ip_address
                    print(responseData)
                except Exception as e:
                  responseData = {'error': str(e)}
                  cfnresponse.send(event, context, cfnresponse.FAILED, responseData)
                  return
                cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData)
      Handler: index.lambda_handler
      Role: !GetAtt LambdaRole.Arn
      Runtime: python3.7
      Timeout: 10
  # # ------------------------------------------------------------#
  # # Custom Resource
  # # ------------------------------------------------------------#
  GetPrivateIPs:
    DependsOn:
      - VPCTransferForSFTPEndpoint
    Type: Custom::GetPrivateIPs
    Properties:
      ServiceToken: !GetAtt LambdaFunction.Arn
      NetworkInterfaceIds: !GetAtt VPCTransferForSFTPEndpoint.NetworkInterfaceIds
  # # ------------------------------------------------------------#
  # # NLB
  # # ------------------------------------------------------------#
  NlbEIP1:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  NlbEIP2:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  NLB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    DependsOn: NlbEIP2
    Properties:
      Name: !Sub ${NameTagPrefix}-${ENV}-nlb
      LoadBalancerAttributes:
        - Key: access_logs.s3.enabled
          Value: true
        - Key: access_logs.s3.bucket
          Value: !Ref 'S3NLBLogBacket'
        - Key: access_logs.s3.prefix
          Value: !Sub '${NameTagPrefix}-${ENV}-nlb'
      SubnetMappings:
        - AllocationId: !GetAtt 'NlbEIP01.AllocationId'
          SubnetId: !Ref 'NLBSubnet1'
        - AllocationId: !GetAtt 'NlbEIP2.AllocationId'
          SubnetId: !Ref 'NLBSubnet2'
      Tags:
        - Key: ENV
          Value: !Ref ENV
      Type: network
  NLBTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      TargetType: ip
      Targets:
        - Id: !GetAtt GetPrivateIPs.IP0
        - Id: !GetAtt GetPrivateIPs.IP1
      Port: 22
      Protocol: TCP
      VpcId:
        Fn::ImportValue: !Sub '${NameTagPrefix}-${ENV}-vpc'
  NLBListenerSSH:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref 'NLBTargetGroup'
      LoadBalancerArn: !Ref 'NLB'
      Port: '22'
      Protocol: TCP
  # # ------------------------------------------------------------#
  # # NACL
  # # ------------------------------------------------------------#
  NLBNACL:
    Type: AWS::EC2::NetworkAcl
    Properties:
      Tags:
        - Key: Name
          Value: !Sub '${NameTagPrefix}-${ENV}-NLB-NACL'
      VpcId:
        Fn::ImportValue: !Sub '${NameTagPrefix}-${ENV}-vpc'
  NACLEntry100:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref NLBNACL
      RuleNumber: 100
      Protocol: 6
      PortRange:
        From: '22'
        To: '22'
      RuleAction: allow
      Egress: false
      CidrBlock: !Ref AllowIP
  NACLEntry101:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref NLBNACL
      RuleNumber: 101
      Protocol: 6
      PortRange:
        From: '22'
        To: '22'
      RuleAction: allow
      Egress: false
      CidrBlock: !Ref NLBSubnet1CidrBlock
  NACLEntry102:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref NLBNACL
      RuleNumber: 102
      Protocol: 6
      PortRange:
        From: '22'
        To: '22'
      RuleAction: allow
      Egress: false
      CidrBlock: !Ref NLBSubnet2CidrBlock
  NACLEntry200:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref NLBNACL
      RuleNumber: 200
      Protocol: 6
      PortRange:
        From: '22'
        To: '22'
      RuleAction: deny
      Egress: false
      CidrBlock: '0.0.0.0/0'
  NACLEntry300:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref NLBNACL
      RuleNumber: 300
      Protocol: -1
      PortRange:
        From: '-1'
        To: '-1'
      RuleAction: allow
      Egress: false
      CidrBlock: '0.0.0.0/0'
  NACLEntryOut100:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref NLBNACL
      RuleNumber: 100
      Protocol: '-1'
      RuleAction: allow
      Egress: true
      CidrBlock: '0.0.0.0/0'
      PortRange:
        From: '-1'
        To: '-1'
  NACLAssociation1:
    Type: AWS::EC2::SubnetNetworkAclAssociation
    Properties:
      SubnetId: !Ref NLBSubnet1
      NetworkAclId: !Ref NLBNACL
  NACLAclAssociation2:
    Type: AWS::EC2::SubnetNetworkAclAssociation
    Properties:
      SubnetId: !Ref NLBSubnet2
      NetworkAclId: !Ref NLBNACL

最後に

これまでカスタムリソースにちゃんと向き合う機会がなかったため、勉強する良い機会になりました。

以上です。

どなたかのお役に立てば幸いです。

参考: