ssh2-sftp-clientを使ってAWS Lambda（Node.js）からSFTPサーバーにファイルをアップロードする

# イントロダクション

この記事は、[ミロゴス Advent Calendar 2022](https://qiita.com/advent-calendar/2022/milogos) 5日目の記事です。

AWS Lambda（Node.js）からSFTPサーバー宛にS3上のファイルを転送したいという要件がありました。
Pythonであればparamikoあたりを使うところですが、Node.js（TypeScript）で開発したかったのでライブラリを探してみました。
使い勝手がよさそうものとして `ssh2-sftp-client` が見つかったので使ってみることにしました。

# ssh2-sftp-clientとは

- https://www.npmjs.com/package/ssh2-sftp-client
- https://github.com/theophilusx/ssh2-sftp-client

ssh2-sftp-clientは、Node.jsのSFTPクライアントです。
[ssh2](https://github.com/mscdex/ssh2) のラッパーとして、PromiseベースのAPIを提供します。
本記事を執筆している時点の安定板リリースはv9.0.4で、Node.js14x以降で動作します。
何気に歴史は古く、2016年から存在しているようです。

# サンプルの要件と実装

以下を前提として実装のサンプルコードを示します。

- S3トリガーイベントを受信して起動するAWS Lambdaを想定します。
- S3からファイルのBodyを取得し、SFTPサーバーの指定パスにファイルをputします。
- SFTPサーバーへは鍵認証によって接続し、キー情報等センシティブな値はSecretsManager経由で取得することを想定します。
    - 秘密鍵はsecret-binary形式で保存します。
    - 秘密鍵のパスフレーズはsecret-stringで保存します。
- 基本的な接続情報は環境変数上から取得することを想定します。
    - SFTPホスト
    - SFTPユーザー
    - SFTPポート
    - SFTPサーバーのベースパス
- エラーハンドリングやログ出力は最低限のサンプルとします。

```typescript
import { S3Event } from "aws-lambda";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import { Readable } from "stream";
import { Buffer } from "buffer";
import Path from "path";
import SFTPClient from "ssh2-sftp-client";

export const handler = async function (event: S3Event) {
  // ssh2-sftp-clientの生成
  const sftp = new SFTPClient();

  try {
    // SecretsManagerからの秘密鍵とパスフレーズの取得
    const scmClient = new SecretsManagerClient({});
    const sftpSshKey = await scmClient.send(
      new GetSecretValueCommand({ SecretId: process.env.SFTP_KEY_SCM_NAME })
    );
    const privateKey =
      sftpSshKey.SecretBinary != null ? sftpSshKey.SecretBinary : "";
    const sftpSshKeyPassphrase = await scmClient.send(
      new GetSecretValueCommand({
        SecretId: process.env.SFTP_KEY_PASSPHRASE_SCM_NAME,
      })
    );

    // SFTPサーバーへの接続
    await sftp.connect({
      host: process.env.SFTP_HOST,
      port: Number(process.env.SFTP_PORT),
      username: process.env.SFTP_USER,
      privateKey: Buffer.from(privateKey).toString("ascii"),
      passphrase: sftpSshKeyPassphrase.SecretString?.trim(),
      readyTimeout: Number(process.env.SFTP_READY_TIMEOUT),
      debug: console.log,
    });

    // S3オブジェクトの取得
    const bucket = event.Records[0].s3.bucket.name;
    const key = decodeURIComponent(
      event.Records[0].s3.object.key.replace(/\+/g, " ")
    );
    const s3 = new S3Client({});
    const s3Object = await s3.send(
      new GetObjectCommand({ Bucket: bucket, Key: key })
    );

    const sftpDirectory =
      process.env.SFTP_DIRECTORY === undefined
        ? ""
        : process.env.SFTP_DIRECTORY;

    // S3オブジェクトBodyの転送
    await sftp.put(
      s3Object.Body as Readable,
      Path.join(sftpDirectory, Path.basename(key))
    );
  } catch (error) {
    throw error;
  } finally {
    sftp.end();
  }
};
```

# 解説と補足

## SFTPサーバーへの接続とオプション

```typescript
// SFTPサーバーへの接続
await sftp.connect({
  host: process.env.SFTP_HOST,
  port: Number(process.env.SFTP_PORT),
  username: process.env.SFTP_USER,
  privateKey: Buffer.from(privateKey).toString("ascii"),
  passphrase: sftpSshKeyPassphrase.SecretString?.trim(),
  readyTimeout: Number(process.env.SFTP_READY_TIMEOUT),
  debug: console.log,
});
```

SFTPサーバーへの接続を確立します。

### 標準オプションと拡張オプション

connectメソッドは、接続のためのオプションを引数に取ります。
オプションには標準オプションと拡張オプションがあります。本サンプルは全て標準オプションで、鍵認証での接続を行う場合の例です。
`host`、`port`、`username` は基本として、パスワード認証の場合は `privatekey`と`passphrase`に代わって、 `password` が加わります。

拡張オプションはより詳細な設定や特殊な挙動を取る際に指定するものです。
キープアライブ関連の `keepaliveInterval`や `keepaliveCountMax`、キーボード入力によるパスワード入力を要求する `tryKeyboard`などがあります。
また、SFTP接続においては、暗号化通信路の確立（暗号化ハンドシェイク）のために以下のような情報をローカル（クライアント）とSFTPサーバー間で交換しますが、複数の方式がサポートされている場合に、ローカル側としてどのアルゴリズムを優先的に要求するかを `algorithms` で指定できます。
- 鍵交換方式
- 共通鍵暗号方式
- メッセージ認証コード
- ホスト認証の方式
- 圧縮アルゴリズム

この辺りの詳細は ssh2-sftp-clientのREADMEよりも [ssh2](https://github.com/mscdex/ssh2)  の方に詳しく記載されています。

### debugオプション

debugオプションは、SFTPサーバーへのネゴシエーションや転送などの詳細なログを出力するためのオプションです。
`console.log` などの関数を指定しておくと、CloudWatch Logsに詳細なログを残せます。
例えば独自の関数を定義して、 内容を加工したり、`aws-lambda-powertools/logger` などと組み合わせて、環境変数のLogLevel指定に応じて出力を切り替えたりするようなこともできます。

```typescript
debug: (msg) => {
  // ログ情報の加工など
  logger.debug(msg);
}
```

### ハンドシェイクタイムアウトを回避するためのreadyTimeout

`readyTimeout` は暗号化ハンドシェイクのタイムアウトをミリ秒で指定するオプションです。
アルゴリズムの選択に応じて、接続を確立するのに必要十分な時間を指定する必要があります。

デバッグログの途中で唐突に `CLIENT[sftp]: connect errorListener - ignoring handled error` が現れ、結果として `CLIENT[sftp]: Global: Ignoring handled error: Timed out while waiting for handshake` のようなエラーログが出力される場合はこのケースを疑う必要があります。

暗号化アルゴリズムでdiffie-hellman系が選択された場合、負荷の問題でハンドシェイクに時間を要することがあり、本ケースに該当することがあります。

### SecretsManagerに保存した鍵情報を指定する際の補足

AWS CLIを利用して、`create-secret` コマンドを利用してSecretsManagerにsecret-binary形式でファイルを保存した場合、自動的にBase64でエンコードされ保存されます。
コード上は `Uint8Array` で取得されるため、Bufferをとって文字列に変換しています。

ちなみにssh2-sftp-clientでは、新しいOpenSSH形式（`-----BEGIN OPENSSH PRIVATE KEY-----` で始まるもの）でも旧形式（`-----BEGIN RSA PRIVATE KEY-----`で始まるもの）のどちらでも指定が可能です。

## SFTPサーバーへのアップロード

```ts:
// S3オブジェクトBodyの転送
await sftp.put(
  s3Object.Body as Readable,
  Path.join(sftpDirectory, Path.basename(key))
);
```

SFTPサーバーにファイルをアップロードするには `put` メソッドを使用します。
データソースとリモートサーバーの保存パスを指定します。このほかにストリーム系のオプションを引数に取ることもできます。

`put` は、ローカルシステムからリモートサーバーにデータをアップロードします。
第一引数が文字列の場合は、ローカルシステム上におけるパスとして解釈されます。
バッファーの場合は、リモートパスに設定したファイルに内容がコピーされます。
ストリームの場合は、そのストリームの内容がリモートパス側にパイプされます。

今回の例では、S3ObjectのBodyをstream.Readableとして扱えるので、それを第一引数に指定しています。

## セッションの終了

```
sftp.end();
```

現在のセッションを終了し、クライアントソケットと関連するリソース類を解放します。

# おわりに

ssh2-sftp-clientを利用した基本的なファイル転送について紹介しました。
ハンドシェイクにおけるタイムアウト周りの問題など、実際に遭遇した内容も盛り込んでいます。
Node.jsとSFTPで検索をかけてみてもなかなかまとまった記事が見当たらなかったので、本記事が参考になれば幸いです。