AWS Transfer FamilyでFTPSサーバーを構築する（ついでにTerraformで管理する）

システム間連携をFTPSでcsvファイルを使って〜〜〜〜！？

# ３行まとめ
- 対向システム(非AWS)とFTPSを用いたファイル連携が必要になった
- AWS Transfer Familyを使って自AWSアカウントのS3バケットを共有する
- Terraformでコード化する


# 環境
AWS (特に記載がなければリージョンは全て ap-northeast-1）
Terraform v1.10.5


# 要件
- 対向システム(非AWS)からファイルを受け取れること
  - 対向→自システムへの送信のみ
  - 受信ファイルはテキスト(.csv)のみ
- 連携方式はFTPSであること
    - SSL/TLS暗号化されたFTP
- 対向システムのIPアドレス以外からのアクセスが拒否できること

# 設計
今回はFTPSでのファイルの受信のためにAWS Transfer Familyを選択
通信プロトコルにFTPSを選択することで以下の制約が生まれます
1. Transfer Familyにアクセスするユーザの認証機能が必要
  今回はTransfer Family専用のユーザを作成することにした
  かつ、構築段階では想定ユーザ数が多くないことから
  Lambda+SecretManagerを使用したユーザ認証機能を作成することにしました
  
1. Transfer Family サーバーのエンドポイントをVPCでホストする
  Transfer Familyではインターネット（AWS外）からのアクセスを想定する場合
  サーバーのエンドポイント自体をパブリックアクセス可能とするか
  一旦サーバーはVPC内に配置してEIPを用いてインターネットからのアクセスを可能にするか　の選択肢があります
  が、FTP/ FTPSを利用する場合は前者が選択できないため 一旦VPCでホストします

残りの設計
1. サーバーのドメインはRoute53で作成したカスタムドメインを使用
1. SSL証明書はACMで発行する
1. S3バケットを共有フォルダとする
  EFSも選択肢にありますが　今回はS3を選択
1. アクセス元IPアドレスのチェックはTransfer familyが配置されるVPCのセキュリティグループで実現する
  今回他のAWSリソースとは別で、専用のVPCを作成したのでVPCのセキュリティグループでIPとプロトコルのホワイトリストを管理することにしました
  それ以外のユーザ名やPWによる認証はLambdaで行います

まとめるとこんな感じです
![スクリーンショット 2025-05-21 17.40.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/15845b9b-4d8e-4826-bca8-2f85d0cf92ef.png)

# 今回作成が必要なリソース
- Transfer Family
- S3
- Route 53
- AWS ACM
- VPC
- IAM
- Lambda

# ユーザの認証情報を作るよ
今回ユーザの認証情報は Secret Managerで管理します
Cognitoを利用する選択肢もありますが、今回は見込みのユーザ数が少数なので
実装コストの少ないSecret Managerを選択しました

今回(FTPSでの通信)必要な項目は
- Password
- Role(もしくはPolicy)
- HomeDirectoryDetails

の３つ
他にTransfer FamilyのServerIDとUserNameの２つが必要になりますが
これはSecret名に含める形で管理しています(ので実質変数は５つ)

Secert名は後の認証用Lambdaの実装コードと合わせて
`aws/transfer/{Transfer FamilyのServer ID}/{User ID}`
とします

User IDとPasswordについては自由に決めてOK

イメージはこんな感じ
![スクリーンショット 2025-05-23 16.28.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564850/f4400578-c4b2-43d9-ad8c-96a0d3e7df9a.png)

## Role / Policy
Role/ PolicyはTranser Familyにアクセスし、認証が成功したユーザに付与する権限を指定します
今回はS3バケットを共有フォルダと扱うのでS3関連の権限が必要になります
またアクセスするユーザは我々から見て外部の人間になるので、指定したバケット以外にはアクセス権限は与えないように設定します

```json:ユーザに付与するPolicyの例
{
    "Statement": [
        {
            "Action": [
                "s3:ListBucket",
                "s3:GetBucketLocation"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::{バケット名}"
        },
        {
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:DeleteObject"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::{バケット名}/*"
        }
    ],
    "Version": "2012-10-17"
}
```


## HomeDirectoryDetails
まず　前提としてTransfer Familyサーバーにユーザがログインする際
ランディングディレクトリ（ログイン直後のpwd)をしてする必要があります
指定の方法には以下２種類あります
- HomeDirectory
- HomeDirectoryDetails
HomeDirectoryはS3バケットのルートディレクトリをランディングディレクトリとして扱うパターン
そのため値もS3のバケット名を指定します

一方今回使用する HomeDirectoryDetailsはS3バケット/指定ディレクトリをランディングディレクトリとして扱う際に利用するオプションです
複数ユーザの共有フォルダを単一バケット（内のユーザごとのディレクトリ）にする場合選択します
またHomeDirectoryDetailsを利用する場合は実態のS3のパスとユーザ側に表示するパス名のマッピングを指定できます
`[{"Entry":"{ユーザ側に表示したいパス}", "Target":"/{バケット名}/{ディレクトリ}"}]`

この辺りの詳細は [公式のドキュメント] > 有効な Lambda 値 を参考ください
(https://docs.aws.amazon.com/ja_jp/transfer/latest/userguide/custom-lambda-idp.html)


# 認証用のLambdaを作るよ

基本的には [AWSのチュートリアル](https://docs.aws.amazon.com/ja_jp/transfer/latest/userguide/custom-lambda-idp.html#lambda-auth-examples)　のサンプルコードをベースにしました


```python:lambda_handler.py
import base64
import json
import os

import boto3
from botocore.exceptions import ClientError


def lambda_handler(event, context):
    if "username" not in event or "serverId" not in event or "password" not in event:
        print("Incoming username or serverId missing  - Unexpected")
        return {}

    if not event.get("protocol", "") == "FTPS":
        return {}

    input_username = event["username"]
    input_serverId = event["serverId"]
    input_password = event["password"]

    user_secret = get_secret("aws/transfer/" + input_serverId + "/" + input_username)

    # secretに対象のユーザ情報がない場合はエラー
    # secretの中身はresp_dictに格納される
    if user_secret is not None:
        user_secret_dict = json.loads(user_secret)
    else:
        print("Secrets Manager exception thrown")
        return {}

    return authenticate(user_secret_dict, input_password)


def authenticate(user_secret_dict, input_password):
    response = {}
    # resp_dictの内容とinputの内容が一致するか確認する
    if input_password != user_secret_dict["Password"]:
        return {}

    if "Role" in user_secret_dict:
        response["Role"] = user_secret_dict["Role"]
    else:
        response["Role"] = ""

    # HomeDirectoryDetails: 論理的なディレクトリマッピングを定義
    #    - 仮想ディレクトリパスとS3バケットの実際のパスをマッピング
    #    - JSONの配列形式で指定: [{"Entry": "/", "Target": "/bucket_name/path"}]
    #    - HomeDirectoryTypeを"LOGICAL"に設定する必要がある
    #    - ユーザーは仮想パスのみを参照可能で、実際のS3パスは隠蔽される
    #
    # 注意: HomeDirectoryDetailsとHomeDirectoryは排他的な関係
    # セキュリティ上、可能な限りHomeDirectoryDetailsの使用を推奨
    if "HomeDirectoryDetails" in user_secret_dict:
        response["HomeDirectoryDetails"] = user_secret_dict["HomeDirectoryDetails"]
        response["HomeDirectoryType"] = "LOGICAL"
    else:
        return {}
    return response


def get_secret(id):
    client = boto3.session.Session().client(
        service_name="secretsmanager", region_name="ap-northeast-1"
    )

    try:
        resp = client.get_secret_value(SecretId=id)
        if "SecretString" in resp:
            return resp["SecretString"]
        else:
            return base64.b64decode(resp["SecretBinary"])
    except ClientError as err:
        print(
            "Error Talking to SecretsManager: "
            + err.response["Error"]["Code"]
            + ", Message: "
            + str(err)
        )
        return None

```

# Terraformでコード化するよ

```terraform:main.tf
# Transfer Family サーバーの作成
resource "aws_transfer_server" "ftps_server" {
  protocols              = ["FTPS"]
  # 認証タイプにLambdaを選択
  identity_provider_type = "AWS_LAMBDA"
  function               = lambda_function.arn // 認証用Lambdaのarnを指定
  endpoint_type          = "VPC"
  force_destroy          = true
  security_policy_name   = "TransferSecurityPolicy-2020-06"

  # 共有フォルダの配置先にS3を指定
  domain = "S3"
  certificate = aws_acm_certificate.acm.arn // ドメイン認証に用いる証明書(ACM)のarnを指定

  # 今回は簡易的にシングルAZにしている
  endpoint_details {
    vpc_id                 = aws_vpc.dpb-transfer.id
    subnet_ids             = [aws_subnet.public-1a.id] // マルチAZにするならここに追加
    security_group_ids     = [aws_security_group.transfer_sg.id]
    address_allocation_ids = [aws_eip.transfer_eip_1a.id] // AZの数分eipが必要
  }
}

```

```terraform:iam.tf
################################
# Transfer Family用のIAMロールとポリシー
################################
resource "aws_iam_role" "transfer-family-role" {
  name = "transfer-family-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "transfer.amazonaws.com"
      }
    }]
  })
}

# S3アクセス用のポリシー
resource "aws_iam_policy" "transfer_s3_access" {
  name        = "transfer-family-s3-access"
  description = "Allow Transfer Family to access S3 bucket"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "s3:ListBucket",
          "s3:GetBucketLocation"
        ]
        Effect   = "Allow"
        Resource = "arn:aws:s3:::xxxxxxx" // Transfer Familyの共有フォルダにするS3バケットを指定
      },
      {
        Action = [
          "s3:PutObject",
          "s3:GetObject",
          "s3:DeleteObject",
          "s3:PutObjectAcl"
        ]
        Effect   = "Allow"
        Resource = "arn:aws:s3:::xxxxxxx/*" // Transfer Familyの共有フォルダにするS3バケットを指定
      }
    ]
  })
}

# IAMロールにポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "transfer_s3_attachment" {
  role       = aws_iam_role.transfer_role.name
  policy_arn = aws_iam_policy.transfer_s3_access.arn
}

################################
# FTPSで認証したユーザー用のIAMロールとポリシー
################################
resource "aws_iam_role" "transfer_user_role" {
  name = "transfer-user-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow",
        Principal = {
          Service = "transfer.amazonaws.com"
        },
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_policy" "transfer_user_policy" {
  name        = "transfer-user-policy"
  description = "Allow Transfer Family User to read and write to the S3 bucket"

  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow",
        Action = [
          "s3:ListBucket",
          "s3:GetBucketLocation"
        ],
        Resource = "arn:aws:s3:::xxxxxxx" // Transfer Familyの共有フォルダにするS3バケットを指定
        },
      {
        Effect = "Allow",
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject"
        ],
        Resource = "arn:aws:s3:::xxxxxxx/*" // Transfer Familyの共有フォルダにするS3バケットを指定
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "transfer_s3_access_policy_attachment" {
  role       = aws_iam_role.transfer_s3_access_role.name
  policy_arn = aws_iam_policy.transfer_s3_access_policy.arn
}

```

以下のリソースの記述は省略
- VPC
- Route53
- Lambda
- ACM
- S3

# 完成！
Terraform Applyで無限にコケたので二度とコケないためにも記事にしました
サーバを構築するだけでも約３万/月吹き飛ぶリッチなサービスですが可愛がっていこうと思います