VPC内のEC2インスタンスに Tailscale + IPv6 でSSH接続する（パブリックIPv4アドレスなしに）

AWS の VPC 内に小さな踏み台サーバを常設したい状況はありますが、そのためだけに NAT Gateway や SSM用のVPCエンドポイントを置くといった少なからぬコストのかかるアプローチはできるなら避けたいです。

AWS Client VPN を介して接続するアプローチもよく使われており、安全かつ安くてよいのですが、たくさんのAWSアカウントやVPCを扱う方には、扱いがそこそこ面倒になるかもしれません。

となると、パブリックIPv4アドレスを割り当ててしまうのが安直な解決になるかもしれませんが、AWSでは2024年2月から[パブリックIPv4アドレスは原則有料に](https://aws.amazon.com/jp/blogs/news/new-aws-public-ipv4-address-charge-public-ip-insights/)なっていますし、EC2インスタンスにパブリック IPv4 アドレスを紐づけると AWS Security Hub の「AWS 基礎セキュリティのベストプラクティス」に怒られたりもします。

## Tailscale でやってみる

そこで今回は、パブリックIPv4アドレスなしのEC2インスタンスにSSHログインする方法として、IPv6アドレスと、メッシュVPNサービスである [Tailscale](https://tailscale.com/) を使う方法を考えてみました。構成方法を AWS CDK のコード例で簡単に紹介します。

以下は**使いません**：
- パブリックIPv4アドレス（有料）
- NAT Gateway（有料）
- プライベートIPv4アドレス + VPCゲートウェイ（有料）+ AWS Systems Manager (SSM) による接続


その代わりに以下を使います：
- [Tailscale](https://tailscale.com/)（無料プランでも大丈夫なはず）
- Dual Stack (IPv4 + IPv6) を有効にした Amazon VPC
    - VPC には [Egress-only Internet Gateway](https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/egress-only-internet-gateway.html) (EIGW、無料) を設置しておく 

## 構成方法

AWSのマネージメントコンソールからも作れなくはないですが、面倒なので、構成方法を AWS CDK (TypeScript) のコードで示します。デプロイするだけでDual StackなVPCも含めたサンプル構成一式が完成します。

### CDKのコード

意外とシンプルです（前半は単なるIPv6対応のVPCの例です)。簡単な説明をコードコメントとして記入しています。

```typescript: cdk-ipv6-bastion-tailscale-stack.ts
import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";

import { Construct } from "constructs";

export class CdkIpv6BastionTailscaleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Tailscale をマシンにセットアップする際は、通常は手動での認証作業が必要ですが、
    // Auth Key を事前に用意しておくことでその手順をスキップできます。
    // 
    // `cdk deploy` する際に Auth Key をコンテクスト値として渡せるようにしておきます。
    const tailscaleAuthKey = this.node.getContext("tailscaleAuthKey");

    // Dual Stack (IPv4 + IPv6) なサンプル VPC を用意する
    const vpc = new ec2.Vpc(this, "Vpc", {
      natGateways: 0, // NAT Gateway は使わない
      ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"),
      ipProtocol: ec2.IpProtocol.DUAL_STACK, // IPv4 + IPv6
      subnetConfiguration: [
        {
          name: "public",
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          name: "private",
          // Egress-only Internet Gateway へのルートがあるプライベートサブネット
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
      // 今回は特に必要ないですが、各種ゲートウェイエンドポイントも置いておきます
      gatewayEndpoints: {
        S3: { service: ec2.GatewayVpcEndpointAwsService.S3 },
        S3_Express: { service: ec2.GatewayVpcEndpointAwsService.S3_EXPRESS },
        DynamoDB: { service: ec2.GatewayVpcEndpointAwsService.DYNAMODB },
      },
    });

    // 今回は管理コンソールで生成済みのSSHキーペアを使います
    const KeyPair = ec2.KeyPair.fromKeyPairName(
      this,
      "KeyPair",
      "tailscale-test"
    );

    // 今回作るEC2インスタンス用のセキュリティグループ（許可は適宜変えてください）
    // Tailscale がアウトバウンドの通信を開始できればOKです。
    // (参考: https://tailscale.com/kb/1082/firewall-ports)
    // インバウンドルールは不要です。SSH用のポートも開けなくていいです。
    const securityGroup = new ec2.SecurityGroup(this, "SecurityGroup", {
      vpc,
      description: "For Tailscale",
      allowAllIpv6Outbound: true,
      // allowAllOutbound: true,
    });

    // 起動時に Tailscale のインストールと認証を行う
    //
    // これがキモで、今回の構成でEC2インスタンスを起動した直後は、誰もログインするすべがありません。
    // そこで、ユーザデータ（EC2インスタンスの初回起動時などに実行されるスクリプト）を使って、
    // EC2自身に、自律的に Tailscale のインストールからネットワークへの参加までを行わせます。
    const userData = ec2.UserData.forLinux();
    userData.addCommands(
      "sudo yum install -y yum-utils",
      "sudo yum-config-manager -y --add-repo https://pkgs.tailscale.com/stable/amazon-linux/2/tailscale.repo",
      "sudo yum install -y tailscale",
      "sudo systemctl enable --now tailscaled",
      `sudo tailscale up --authkey ${tailscaleAuthKey}`
    );

    new ec2.Instance(this, "Instance", {
      vpc,
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.NANO
      ),
      // パブリックIPv4アドレスは割り当てません
      associatePublicIpAddress: false,
      vpcSubnets: {
        // プライベートサブネットに配置する。
        // IPv6 は、Egress-only Internet Gateway を使ってアウトバウンド通信のみ自由に開始できる
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2023,
      }),
      keyPair: KeyPair,
      securityGroup,
      userData,
    });
  }
}
```

### CDK デプロイ

`cdk deploy` する前に、Tailscale の管理画面の「Settings > Keys > Auth Keys」で、Auth Key を用意しておきます（キーは1回だけ使えればいいので "Reusable" を選択する必要はありません）。

用意した Tailscale の Auth Key を使って、以下のように `cdk deploy` しましょう：

```bash
cdk deploy -c tailscaleAuthKey=tskey-auth-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

少し待つと、インスタンスが起動して、Tailscale への参加まで自動で進むはずです。

成功すれば Tailscale の管理画面にマシンが表示されます（下図）。

![e.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/67fdcc7b-8755-1b00-c009-35a25160aec7.jpeg)

## 接続してみる

VPN上のIPアドレスが表示されているので、これを使ってSSHでログインしてみます。（Tailscaleのネームサーバでわかりやすい名前を割り当ててもいいでしょう)。

EC2インスタンスのSSHの秘密鍵は手元にあるものとします（余談ですが、私は [1Password の SSH agent 機能](https://developer.1password.com/docs/ssh) を使っています）。

ローカルマシンで Tailscale が起動していることを確認して、SSHでログインすると....

```bash
ssh ec2-user@<tailscale上のアドレス>
```

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/6d055193-c254-4066-dd63-515460da03f9.png)

無事に接続できました！

おわり。

IPv6なVPCのCDKスタックがすでに手元にあるなら、それにちょこっとコードを加えるだけで踏み台サーバが立つというお手軽さなので、体験は良いです。

ということで、企業によっては AWS Client VPN なんかの代わりに Tailscale を使うのも良いんじゃないでしょうか。

Tailscale のアクセス制御は、必要に応じて適切に設定してください。