Session Manager + SSHポートフォワーディングでプライベートなインスタンスを踏み台にして2ホスト間を繋ぐ

2023.08.03

初めに

SSHポートフォワーディングはホスト間の通信をSSHトンネルを介して指定した自身のポートから別ホストや別ポートに対して通信転送するものとなります。

DBクライアントの仕様的に直接DBサーバのホストを指定したいけど踏み台サーバを介さないといけない...なんて時にお世話になった人も多いのではないでしょうか。

直近しばらく使う機会はなかったのですが、最近ふとSSHポートフォワーディングを思いだした際にSession Mnagerでプライベートなインスタンスを踏み台にすればインターネット公開されてない2ホスト間をインターネットへの直接公開なしでできるなと思ったので試してみます。

SSHポートフォワーディングの方向

SSHポートフォワーディングには接続元から接続先へ通信を転送するローカルポートフォワードと、接続先から接続元に転送するリモートポートフォワードがあります。

なお種類としてはダイナミックポートフォワーディングも存在しますがここでは割愛します。

ローカルポートフォワーディング

SSHの実行元(ローカル)の指定ポートからSSH接続先(リモート)の指定ポートに通信を転送する方式です。

順方向なのもありSSHポートフォワーディングと呼ばれるとこの方式が出てくる方も多いのではないでしょうか。

上の図の接続先背後に踏み台経由でないといけないDBがあり、そこに対して接続元から直接繋ぎたい場合等に使うイメージが強いです。

こちらはssh実行時に-Lオプションを付け規定のフォーマットで記載すると実行可能です。

# これでSSHトンネルを作成する
# -Nを入れないとリモートの端末が立ち上がるので個人的には付与推奨
# -fを入れるとバックグラウンド起動になる。切断時にkillが必要なのでちょっとだけ繋ぎたいならCtrl+Cで落とせる-f無しも選択肢
$ ssh -fN -L 22000:database-host:22 user@bastion
# これを実行すると接続元のlocalhost:22000の通信がbastionを経由してdatabase-host:22に転送される
$ ssh user@localhost -p22000

-Lに指定するホスト名は接続先(リモート)起点でのホスト名となります。

# これでSSHトンネルを作成する
$ ssh -fN -L 22000:localhost:22 user@bastion
# これを実行すると接続元のlocalhost:22000がbastion(からみたlocalhost=リモート自身):22に転送される
$ ssh user@localhost -p22000

なお転送は一方通行で、リモート側がlocalhost:22にアクセスしてもローカル側のポート22000に転送されるわけではありません。

% sw_vers
ProductName:		macOS
ProductVersion:		13.4
# MacからEC2(Amazon Linux 2)にポートフォワード
% ssh -fN -L 22000:localhost:22 ec2-user@xxx.xxx.xxx.xxx
# リモート側にSSH
$ ssh ec2-user@localhost -p22000
Last login: Wed Aug  2 07:18:39 2023 from localhost

       __|  __|_  )
       _|  (     /   Amazon Linux 2 AMI
      ___|\___|___|

https://aws.amazon.com/amazon-linux-2/
# ポートフォワード先の指定となっているポート22に接続しても転送されないのでそのまま自信にssh接続となる
$ ssh ec2-user@localhost
Last login: Wed Aug  2 07:18:39 2023 from localhost

       __|  __|_  )
       _|  (     /   Amazon Linux 2 AMI
      ___|\___|___|

https://aws.amazon.com/amazon-linux-2/

Session Managerのポートフォワードは内部的な仕組みは異なりますが外見的な挙動としてはかなり近いものがあります。

リモートポートフォワーディング

こちらが本題です。

余談ですがリバースでも引っかかるので実はずっとリバースポートフォワーディングだと思っていました。

実のところsshのマニュアルを見ても明確な呼称がないのですが少なくともreverseというワードは使っていないのでここではリモートポートフォワーディングと呼称します。

こちらは先ほどと逆でトンネル自体は同様にローカルからリモートに対して張りますが、データの転送方向としてはリモート側のポートにアクセスするとローカル側に転送されるものとなります。

トンネル自体は接続元が起点となるため、接続元がインターネットに公開されていなくても接続先がトンネルを介して接続しに行けるということがポイントです。

先ほどは-Lオプションを指定しましたがこちらは-Rオプションを指定します。

以下のコマンドを実行することでリモート側の:22001にアクセスすると、ローカル側(からみてlocalhost):22に通信が転送されます。

$ ssh -fN -R 22001:localhost:22 ec2-user@xxx.xxx.xxx

先に指定するポートが受付元となるので-L指定時に対してローカル・リモートの位置関係が逆になっている点に注意してください。

左のポートはトンネルの通信起点、残りのホストとポートは通信先起点と覚えるといいかもしれません。

先ほどと逆でリモート側からローカル側のユーザでログインができるようになります。

$ cat /etc/os-release
NAME="Amazon Linux"
VERSION="2"
ID="amzn"
$ ssh {{local-username}}@localhost -p22001
Last login: Wed Aug  2 20:02:48 2023 from ::1
% sw_vers
ProductName:		macOS
ProductVersion:		13.4

実行は省きますが-L同様片方向の通信となるのでローカル側のポート22にアクセスしても転送はされません。

本当はSession Managerでこれができないかと思っていたのですが、ドキュメントを読んでいる限りは特に記載はなく実際に試してみる限りも残念ながら実現できなさそうです。

# ローカルのポート22とリモート(EC2)のポート22000でポートフォワードする
%  aws ssm start-session \
    --target i-xxxxx \
    --document-name AWS-StartPortForwardingSession \
    --parameters '{"portNumber":["22000"], "localPortNumber":["22"]}'

Starting session with SessionId: botocore-session-xxxx

# 別端末でEC2にログインしてポートフォワード先のポート22000にアクセスしても通信できない
$ ssh client-user@localhost -p22000
ssh: connect to host localhost port 22000: Connection refused

※ 特権ポートなのでその関係かなと思ったんですが試してみる限りはその影響はなさそうです。

2つのトンネルを重ねる

さてここまでSSHポートフォワードに関する説明をしましたが、プライベートインスタンスはパブリックIPを持たないので当然直接SSHを実行することはできません。

ここで出てくるのがインスタンスにパブリックIPがなくともポートフォワーディング可能なSession Managerの出番です。

Session Managerでは先に記載した通りローカルポートフォワード相当の転送しかできないため逆方向の転送は実現できませんが、ここにリモートポートフォワードが可能なSSHトンネルをさらに重ねることでプライベートインスタンスを先としつつ逆方向の通信を実現します。

SSHトンネルを繋げて1コマンド接続

さてここまででリモートポートフォワーディングで別ネットワークの公開されていないクライアントに対してアクセスする方法がわかり、SSMのコネクション経由上で通信することでインターネットに直接公開されるエンドポイントなしでそれぞれのホストを繋げることがわかりました。

しかし、考えてみてください。このままではクライアントAから一度リモートにSSHアクセスをし、リモートから再度クライアントBにSSHするのでめんどくさいですよね?

ではローカルポートフォワードとリモートポートフォワードの2つのトンネルを繋げて事前準備さえすればワンコマンドで行けるようにしてしまいましょう。


※絵が混沌とするのでトンネルは省略してます

わかりやすいようにポートはクライアントAの転送元ポートを22000、クライアントBの転送元ポートを22001としてますがここは同じ値にしても問題ありません。

実行の前の準備として以下の設定を~/.ssh/configに追記しておくとSSHの前に別途start-sessionは不要でSSH実行時に別途長いオプションを毎回つけなくて良くなるので設定しておきます。

host i-* mi-*
    ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"

これを利用して接続する場合はssh ec2user@{{インスタンスID}です。

AWS側の設定

仲介するインスタンスは既にあるプライベートインスタンスを使います。

今回はNAT GatewayなしでVPCエンドポイントを利用します。

Session Managerを利用するにはssmとssmmessagesの2つのエンドポイントがあれば最低限接続はできるようです。

参考元:

VPCエンドポイントのセキュリティグループには上記のEC2インスタンスからのポート443のインバウンド許可を設定しています。

クライアントAでポートフォワード

クライアントAのポート22000に接続するとEC2のポート22001に転送されるようにローカルポートフォワードを設定します。

# クライアントAからリモートへのローカルポートフォワード
$ ssh -fN -R 22000:localhost:22001 ec2-user@i-xxxx

他にも以下のような方法で転送しても問題ありません

  • SSHを使わずSession Manager単体でポートフォワード
  • リモート側でlocalhost:22000→localhost:22001にSSHローカルポートフォワード

実のところローカルポートフォワード相当の挙動はSession Manager単体実現できるのでSSHトンネルを重ねる必要はないのですが、 今回クライアントBとリモート間の接続はSession ManagerのコネクションにSSHトンネルを重ねている為方式を合わせる形を取ってます。

クライアントBでポートフォワード

リモートのポート22001に接続するとクライアントBのポート22に転送されるように、リモートポートフォワードを設定します。

# リモートからクライアントBに転送されるリモートポートフォワードを設定
$ ssh -fN -R 22001:localhost:22 ec2-user@i-xxxxx

これで先ほどAで設定された通信がリモートに到達すると、さらにこちらのポートフォワードでBに転送されるようになります。

接続

準備ができたので実際にクライアントAから繋いでみましょう。

接続先ホストはトンネルの始点のlocalhost:22000、接続先に指定するユーザ名はクライアントBのユーザを指定します。

# クライアントAはシリコンMAC
% uname -m
arm64
% ssh clientB@localhost -p22000
Last login: Thu Aug  3 11:23:12 2023 from ::1
...
#クライアントBはインテルMAC
$ uname -m
x86_64

クライアントA,Bが両方Macなのでわかりづらいですが物理的に異なるマシンです。

クライアントAのトンネルを公開する

さて、実は先ほどまで作ったトンネルはら良くも悪くも自身のみが利用可能で他のクライアントが利用することができません。。

また試す限り(FWの設定ミスがなければ)Session Managerによるコネクションもどうやらこれと同じ挙動になるようです。

他のユーザに意図せず経路を使われたくない場合はこの方が好ましいですが、場合によっては1本の経路を共有して使って欲しいことがあるかもしれません。

そのような場合はssh実行の際に-gオプションをつけることで実現が可能です。

# クライアントAからリモートに対して-gオプション有無違いの2つのSSHトンネルを張る
$ ssh -fN -L 22000:localhost:22 ec2-user@i-xxxx
$ ssh -gfN -L 22001:localhost:22 ec2-user@i-xxxx

# 別のクライアントからクライアントAのトンネルに対してアクセス
# -gがない方は接続できない
% ssh ec2-user@xxx.xxx.xxx -p22000
ssh: connect to host xxx.xxx.xxx.xxx port 22000: Connection refused
# -gがついている場合は接続できる
% ssh ec2-user@xxx.xxx.xxx.xxx -p22001
Last login: Thu Aug  3 03:15:25 2023 from localhost

       __|  __|_  )
       _|  (     /   Amazon Linux 2 AMI
      ___|\___|___|

https://aws.amazon.com/amazon-linux-2/

あくまでトンネル自体の設定となるので別途FW等の許可設定が必要な点はご注意ください。

今回はクライアントA,Bと読んでいるマシンがサーバ機で常にコネクションを維持していれば、利用者側からすればフォワーディングを意識することなく別のネットワークに対してアクセスが可能です。

終わりに

プライベートインスタンスを経由することで直接インターネットに公開されるエンドポイントなしに2つの環境繋ぐことができました。

準備が楽なので今回はトンネル上の通信もsshで行っていますが、あくまで通信を転送しているに過ぎないのでHTTP等の別のプロトコルを転送することも可能です。

手軽で便利ではあるのですがWebSocketによるコネクション上にさらにSSHトンネルを重ねさらにそれを複数連結している状態なので、通信オーバーヘッド的や安定の面、そもそもとしてコネクション切れた場合やサーバの管理等々を考える流石に常時使うような使い方は厳しいかなと思います。

どうしても一時的な作業用に経路が欲しいけどVPNを準備してというほどではないような場合に選択肢として頭に思い浮かべてみてはいかがでしょうか。