
ssh2でSSHトンネルとSOCKS5プロキシをNode.jsで自動構築してみた
はじめに
踏み台サーバー経由でしかアクセスできない閉域ネットワーク上にWebアプリをデプロイしました(前回の記事を参照)。デプロイはできたものの、開発中にブラウザでアプリを確認するには毎回SSHトンネルを手作業で張る必要があり、手間でした。
さらに厄介だったのが、デプロイ先サーバーのIPからでないとアクセスできないCloudFrontディストリビューション(IP制限あり)の動作確認です。単純なポートフォワーディングでは対応できず、デプロイ先サーバーのIPで外部通信を行うSOCKS5プロキシが必要でした。
この記事では、Node.jsのssh2ライブラリで実装した3つのSSHトンネリングスクリプトを紹介します。
- proxy.mjs — TCPポートフォワーディング(アプリ画面へのアクセス)
- test-nginx.mjs — nginx経由のルーティング検証用トンネル
- socks5-proxy.mjs — SOCKS5ダイナミックプロキシ(任意の宛先への通信)
前提・環境
| 項目 | 値 |
|---|---|
| Node.js | 24 LTS |
| ssh2 | 1.17.0 |
| ローカルOS | macOS |
ネットワーク構成

1. proxy.mjs — 2ホップTCPトンネル
アプリのフロントエンド(port 40001)にローカルブラウザからアクセスするためのスクリプトです。
トンネル構成

2段階のトンネルを張ります。
- 踏み台上:
ssh -N -L 9090:localhost:40001 user@deploy-serverでデプロイ先のport 40001を踏み台の9090に転送 - ローカル: ssh2の
forwardOutでローカルTCPサーバーの接続を踏み台の9090にリレー
古いトンネルのクリーンアップ
前回のスクリプト終了時にトンネルプロセスが残ることがあるため、起動時にクリーンアップします。bastionExec はssh2の conn.exec() をラップしたヘルパー関数です。
function cleanupBastion(done) {
const killCmd = "fuser -k " + BASTION_TUNNEL_PORT + "/tcp 2>/dev/null; exit 0";
bastionExec(killCmd, (err, stream) => {
if (err) { done(); return; }
stream.on("close", () => done());
stream.resume();
});
}
fuser -k で指定ポートを掴んでいるプロセスをkillします。2>/dev/null; exit 0 で、プロセスがない場合もエラーにならないようにしています。
ローカルTCPサーバーとリレー
function startLocalServer() {
const server = net.createServer((socket) => {
const id = socket.remoteAddress + ":" + socket.remotePort;
outer.forwardOut("127.0.0.1", LOCAL_PORT, "localhost", BASTION_TUNNEL_PORT, (err, stream) => {
if (err) {
socket.destroy();
return;
}
// 双方向パイプ
socket.pipe(stream).pipe(socket);
// クリーンアップ
stream.on("close", () => socket.destroy());
socket.on("close", () => stream.destroy());
socket.on("error", () => stream.destroy());
stream.on("error", () => socket.destroy());
});
});
server.listen(LOCAL_PORT, "127.0.0.1", () => {
console.log("[3/3] Tunnel ready — " + LOCAL_URL);
execFile("open", [LOCAL_URL]); // ブラウザ自動オープン
});
}
net.createServer でローカルTCPサーバーを起動し、ブラウザからの接続が来たら forwardOut で踏み台のトンネルポートにリレーします。ここで outer は踏み台へのssh2 Clientインスタンスです。socket.pipe(stream).pipe(socket) で双方向の通信を確立しています。
ホスト鍵の検証
本番運用を意識し、~/.ssh/known_hosts と照合するホスト鍵検証も実装しています。
function makeHostVerifier(host) {
return (key) => {
const fp = "SHA256:" + crypto.createHash("sha256")
.update(key).digest("base64").replace(/=+$/, "");
let out;
try {
out = execFileSync("ssh-keygen", ["-F", host, "-l"],
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
} catch {
console.error("[!] " + host + " not in ~/.ssh/known_hosts. Run once:");
console.error(" ssh-keyscan -H " + host + " >> ~/.ssh/known_hosts");
return false;
}
if (out.includes(fp)) return true;
console.error("[!] Host key MISMATCH for " + host + " — possible MITM attack!");
return false;
};
}
サーバーが提示した公開鍵のSHA256フィンガープリントを計算し、ssh-keygen -F で known_hosts 内のエントリと照合します。不一致の場合はMITM攻撃の可能性を警告して接続を拒否します。
使い方
pnpm proxy
実行すると3ステップで接続が完了し、ブラウザが自動オープンします。
[1/3] Connecting to bastion (10.xxx.x.xxx) as user...
[auth] agent: /private/tmp/com.apple.launchd.xxx/Listeners
[2/3] Bastion connected. Cleaning up stale tunnel on port 9090...
[2/3] Bastion->target tunnel up (bastion:9090 -> target:40001)
[3/3] Tunnel ready — http://localhost:8080/your-app
Ctrl+C to close.
2. test-nginx.mjs — nginx検証用トンネル
proxy.mjs はアプリのポート(40001)に直接接続しますが、本番ではnginxのリバースプロキシ経由(port 80)でアクセスします。ルーティング設定を検証するため、nginx(port 80)に接続するバリアントを作りました。
const MOCK_PORT = Number(process.env.MOCK_PORT ?? 80); // nginxのポート
const BASTION_TUNNEL_PORT = Number(process.env.BASTION_TUNNEL_PORT ?? 9091); // proxyと衝突しないよう別ポート
const LOCAL_PORT = Number(process.env.LOCAL_PORT ?? 8081);
TCPリレーのロジックは proxy.mjs と同一です。接続先ポートとローカルポートが異なるだけで、proxy(直接接続)とnginx(リバプロ経由)を別々のポートで同時に起動できます。
pnpm proxy # localhost:8080 → デプロイ先:40001(直接)
pnpm nginx # localhost:8081 → デプロイ先:80(nginx経由)
nginx経由で404が出ているのにproxy直接で表示される場合、nginxのlocation設定に問題があることがすぐ分かります。
3. socks5-proxy.mjs — SOCKS5ダイナミックプロキシ
デプロイ先サーバーのIPを使って外の世界を見に行くスクリプトです。ここが一番技術的に面白い部分です。
背景:なぜSOCKS5が必要か
デプロイ先サーバーのIPからでないとアクセスできないCloudFrontディストリビューションがありました。TCPトンネル(-L)は特定ポートの転送しかできないので、任意の宛先に対してデプロイ先のIPで通信するにはSOCKS5ダイナミックプロキシ(ssh -D)が必要です。
トンネル構成

- 踏み台上で
ssh -D 9092 -N user@deploy-serverを実行(SOCKS5プロキシ、出口はデプロイ先のIP) - ローカルでSOCKS5サーバーを起動し、ブラウザのSOCKS5ハンドシェイクを受ける
- ssh2の
forwardOutで踏み台のSOCKS5に接続し、上流ハンドシェイクをリレー - ハンドシェイク完了後、双方向の生データパイプに切り替え
SOCKS5ハンドシェイクの実装
SOCKS5プロトコル(RFC 1928)のハンドシェイクを自前で実装する必要があります。
バッファ付き非同期リーダー
ハンドシェイク中はプロトコルの各フィールドを正確なバイト数だけ読む必要があります。Node.jsのストリームは任意の境界でchunkが分割されるため、指定バイト数が揃うまでバッファリングするReaderを作りました。
class Reader {
constructor() { this.buf = Buffer.alloc(0); this.pending = null; }
push(chunk) {
this.buf = Buffer.concat([this.buf, chunk]);
if (this.pending && this.buf.length >= this.pending.n) {
const { n, resolve } = this.pending;
this.pending = null;
resolve(this._take(n));
}
}
read(n) {
return this.buf.length >= n
? Promise.resolve(this._take(n))
: new Promise((resolve) => { this.pending = { n, resolve }; });
}
_take(n) {
const out = this.buf.slice(0, n);
this.buf = this.buf.slice(n);
return out;
}
flush() {
const out = this.buf;
this.buf = Buffer.alloc(0);
return out;
}
}
read(n) を await すると、ストリームからちょうどnバイトが揃ったタイミングで解決します。ハンドシェイク完了後の flush() で、消費しきれなかった残りバイトを取り出し、生データパイプに渡します。
ブラウザ側ハンドシェイク
async function handleClient(socket) {
const br = new Reader();
socket.on("data", (chunk) => br.push(chunk));
// 1. グリーティング: バージョン + 認証メソッド一覧
const greet = await br.read(2); // [ver=5, nmethods]
await br.read(greet[1]); // メソッドリスト(消費するだけ)
socket.write(Buffer.from([5, 0])); // "認証不要" を返答
// 2. CONNECTリクエスト: 宛先アドレス解析
const hdr = await br.read(4); // [ver, cmd, rsv, atyp]
if (hdr[1] !== 1) { socket.destroy(); return; } // CONNECT以外は非対応
let host, port;
const atyp = hdr[3];
if (atyp === 1) { // IPv4
const b = await br.read(6);
host = `${b[0]}.${b[1]}.${b[2]}.${b[3]}`;
port = (b[4] << 8) | b[5];
} else if (atyp === 3) { // ドメイン名
const [len] = await br.read(1);
const b = await br.read(len + 2);
host = b.slice(0, len).toString();
port = (b[len] << 8) | b[len + 1];
} else if (atyp === 4) { // IPv6
const b = await br.read(18);
const parts = [];
for (let i = 0; i < 16; i += 2)
parts.push(((b[i] << 8) | b[i + 1]).toString(16));
host = parts.join(":");
port = (b[16] << 8) | b[17];
}
// ...
}
SOCKS5のアドレスタイプ(atyp)によってIPv4(4バイト)、ドメイン名(長さプレフィックス + 文字列)、IPv6(16バイト)の3パターンを処理します。ポート番号は常にビッグエンディアンの2バイトです。
上流SOCKS5へのリレー
ブラウザからの接続先が分かったら、forwardOut で踏み台上のSOCKS5プロキシに接続し、同じプロトコルで上流にCONNECTリクエストを送ります。
outer.forwardOut("127.0.0.1", LOCAL_SOCKS_PORT, "127.0.0.1", BASTION_SOCKS_PORT, async (err, ch) => {
const ur = new Reader();
ch.on("data", (chunk) => ur.push(chunk));
// 上流SOCKS5グリーティング
ch.write(Buffer.from([5, 1, 0])); // ver=5, 1 method, no-auth
const gr = await ur.read(2);
if (gr[0] !== 5 || gr[1] !== 0) throw new Error("upstream rejected auth");
// 上流にCONNECTリクエスト送信
const hb = Buffer.from(host);
const req = Buffer.allocUnsafe(7 + hb.length);
req[0] = 5; req[1] = 1; req[2] = 0; req[3] = 3; // ver, CONNECT, rsv, domain
req[4] = hb.length; hb.copy(req, 5);
req[5 + hb.length] = (port >> 8) & 0xff;
req[6 + hb.length] = port & 0xff;
ch.write(req);
// 上流のCONNECTレスポンスを読む
const resp = await ur.read(4);
if (resp[1] !== 0) throw new Error("upstream CONNECT failed");
// BND.ADDR をスキップ(atyp別にサイズが異なる)
// ...
ハンドシェイクから生データパイプへの切り替え
ハンドシェイク完了後は、SOCKS5プロトコルの処理を終了し、生のTCPデータをそのまま双方向にパイプします。
// ブラウザに「接続成功」を返答
socket.write(Buffer.from([5, 0, 0, 1, 0, 0, 0, 0, 0, 0]));
// ハンドシェイク中にバッファに溜まった余分なデータを転送
socket.removeListener("data", onBrData);
ch.removeListener("data", onUpData);
const leftBr = br.flush();
const leftUp = ur.flush();
if (leftBr.length) ch.write(leftBr);
if (leftUp.length) socket.write(leftUp);
// 生データの双方向パイプ
socket.pipe(ch);
ch.pipe(socket);
ch.on("close", () => socket.destroy());
socket.on("close", () => ch.destroy());
重要なのは flush() の処理です。ハンドシェイク中にReaderのバッファに残ったデータ(実際のHTTPリクエストの先頭部分など)を、パイプに切り替える前に転送します。これを忘れるとデータが欠落し、接続が壊れます。
使い方
pnpm socks5
実行するとChromeが専用プロファイルで起動し、すべての通信がデプロイ先サーバーのIP経由で行われます。
[1/3] Connecting to bastion...
[2/3] SOCKS5 proxy up on bastion:9092 (exits via target)
[3/3] SOCKS5 proxy ready — localhost:1080
Opening Chrome with isolated profile...
Ctrl+C to close.
execFile("open", ["-n", "-a", "Google Chrome", "--args",
"--proxy-server=socks5://localhost:" + LOCAL_SOCKS_PORT,
"--user-data-dir=/tmp/socks5-proxy-chrome",
TARGET_URL,
]);
--user-data-dir で専用プロファイルを使うことで、通常のブラウザセッションに影響を与えません。--proxy-server でSOCKS5プロキシを指定し、全通信をトンネル経由にします。
3つのスクリプトの使い分け
| スクリプト | コマンド | 用途 | 接続先 |
|---|---|---|---|
| proxy.mjs | pnpm proxy |
アプリ画面の確認 | デプロイ先:40001(直接) |
| test-nginx.mjs | pnpm nginx |
nginxルーティング検証 | デプロイ先:80(nginx経由) |
| socks5-proxy.mjs | pnpm socks5 |
IP制限リソースへのアクセス | 任意の宛先(デプロイ先IP経由) |
環境変数による設定
3つのスクリプトは共通の環境変数を使います。.env に以下を設定します。
BASTION_HOST=10.xxx.x.xxx
BASTION_USER=your_user@bastion.local
DEPLOY_USER=your_user
DEPLOY_HOST=10.xxx.x.xx
package.json で --env-file=.env を指定しているため、スクリプト内で process.env から自動的に読み込まれます。
{
"scripts": {
"proxy": "node --env-file=.env scripts/proxy.mjs",
"nginx": "node --env-file=.env scripts/test-nginx.mjs",
"socks5": "node --env-file=.env scripts/socks5-proxy.mjs"
}
}
まとめ
ssh2ライブラリを使うことで、OpenSSHの ssh -L(TCPトンネル)や ssh -D(SOCKS5プロキシ)の機能をNode.jsスクリプトに組み込めます。
- proxy.mjs: 特定ポートへのTCPフォワーディング。
forwardOut+socket.pipe(stream)のシンプルなパターン - test-nginx.mjs: 同じパターンの接続先違い。nginx設定のデバッグに特化
- socks5-proxy.mjs: SOCKS5プロトコルをフルスクラッチで実装し、ダイナミックプロキシとして機能
特にsocks5-proxy.mjs(SOCKS5プロキシ)は、Readerクラスによるバッファ付き非同期読み取りと、ハンドシェイク完了後に生データパイプに切り替える設計がポイントです。この2つのパターンは、SOCKS5に限らずバイナリプロトコルのハンドシェイクを含む通信処理全般に応用できます。
閉域環境での開発で「ブラウザから確認するためだけに毎回SSHコマンドを打つのが面倒」と感じている方は、ぜひ試してみてください。









