
PomeriumゼロトラストプロキシとSSH多段トンネルでVPNを置き換える — Node.jsでの実装
はじめに
Kubernetes上の本番環境にアクセスするため、VPNを使っているチームは多いと思います。しかしVPNには「一度繋がれば何でもできてしまう」という課題があります。
ゼロトラストネットワークで守られた本番サーバーに、Node.jsの自動化スクリプトからアクセスする必要がありました。Pomerium(ゼロトラストプロキシ)経由でジャンプサーバーに接続し、そこからさらに本番サーバーへフォワードする2段構成です。スクリプトの起動時に自動でトンネルを確立する仕組みをTypeScriptで実装しました。
ゼロトラストとは何か
VPNは「城と堀」のモデルです。堀の内側(VPN接続済み)に入れば、城内を自由に歩き回れます。VPNは接続を信頼しますが、個々のリクエストは検証しません。

ゼロトラストはこの前提を覆します。ネットワークの場所に関係なく、すべてのアクセスを毎回検証します。「社内ネットワークにいるから安全」という暗黙の信頼を排除し、アクセスごとに「誰が」「どのデバイスで」「何のリソースに」「いつ」アクセスしているかを確認します。

Googleの「BeyondCorp」がこの概念の起源です。GoogleはVPNを完全に廃止し、社内Wikiですらアイデンティティ認識プロキシの背後に配置しました。
VPNだけでは不十分な理由
VPNの最大のリスクはラテラルムーブメント(横方向の移動)です。開発者のノートPCが侵害された場合、攻撃者はVPN経由で本番ネットワーク全体にアクセスできてしまいます。ゼロトラストでは、侵害されたセッションからアクセスできるのは、そのセッションが認可された特定のリソースだけです。

VPN vs Pomerium + SSH
| 項目 | VPN | Pomerium + SSH |
|---|---|---|
| 信頼の範囲 | ネットワーク全体 | 特定のホスト/ポートのみ |
| 認証単位 | デバイス単位 | セッション単位 |
| アクセス範囲 | 接続後は制限なし | ポリシーで厳密に制御 |
| MFA | 初回接続時のみ | 毎セッション |
| 監査ログ | 限定的 | リクエスト単位でID紐付き |
| 侵害時の影響 | ネットワーク全体が露出 | 認可されたリソースのみ |
なぜOkta MFAだけでは不十分なのか
Okta MFAとPomeriumは異なる問題を解決します。冗長ではなく、補完関係にあります。
Okta MFA = 認証(Authentication)。 「あなたが誰であるか」を証明します。しかしOktaは、認証後にどのネットワークリソースにアクセスできるかを制御しません。
Pomerium = 認可 + ネットワークレベルの制御(Authorization)。 認証済みのユーザーに対して、以下を強制します:
- 承認された宛先(特定のホスト/ポート)へのトラフィックのみルーティング
- セッション単位の監査証跡の作成
- デバイスポスチャ、時間帯、IPなどの追加ポリシーの適用
- ネットワークゲートウェイとしてのアクセス制御(Pomeriumなしではトラフィックがサーバーに到達できない)
空港に例えると、Oktaはパスポートチェック(身元確認)、Pomeriumは搭乗券(特定のフライトの特定の座席にしか乗れない)です。パスポートを持っているだけでは、どの飛行機にも乗れません。


本記事のフローでは、この2つが連携して動作します:
Pomerium(ネットワークアクセス制御)
→ Oktaログインをトリガー(本人確認 + MFA)
→ PomeriumがIDトークンを取得
→ Pomeriumが認可ポリシーを確認
→ 特定のジャンプサーバーへのTCPトンネルのみ許可
実践的な導入アプローチ
多くの組織はVPNとゼロトラストのハイブリッド構成から始めます:
| アプローチ | 適するケース |
|---|---|
| VPNのみ | 小規模チーム、コンプライアンス要件が低い |
| VPN + 重要パスにゼロトラスト | 移行途中、または本番環境のみコンプライアンス要件がある |
| 完全ゼロトラスト(VPNなし) | セキュリティ成熟度が高い、規制産業 |
本記事のアーキテクチャは中間のアプローチです。Pomeriumが本番Kubernetesアクセスを保護し、その他の社内リソースはVPN経由でアクセスする構成です。ただしゼロトラストの思想では、VPN層自体がラテラルムーブメントの経路を生む問題とされています。ハイブリッド構成は現実的な出発点ですが、VPN層はセキュリティの保証ではなく利便性のトレードオフであることを認識しておく必要があります。

アーキテクチャ全体像

ステップ1: Pomeriumプロセスの管理
pomerium-cli は外部バイナリです。Node.jsから child_process.spawn で起動します。
難しいのは「いつ接続が確立したか」をどう検知するか です。Pomeriumは接続状態を公開APIとして提供していません。唯一の手がかりは stderr への出力です。
class Pomerium {
private isConnected = false;
private pomeriumProcess: ChildProcess | null = null;
private startPromise: Promise<void> | null = null; // 進行中の起動処理
public async start(): Promise<void> {
// 起動処理が進行中なら、同じPromiseを返して待たせる
if (this.startPromise) {
return this.startPromise;
}
if (this.isConnected) {
return Promise.resolve();
}
this.startPromise = (async () => {
try {
await this.initPomeriumProcess();
this.isConnected = true;
} catch (error) {
this.isConnected = false;
throw error;
} finally {
this.startPromise = null; // 完了後にクリア
}
})();
return this.startPromise;
}
startPromise を保持しているのはPromiseロックパターンです。複数の処理が同時に start() を呼んでも、Pomeriumプロセスが重複起動しません。
stderrから接続確立を検知する
private initPomeriumProcess(): Promise<void> {
return new Promise((resolve, reject) => {
// macOSではGatekeeperの検疫属性を解除する必要がある
if (os.platform() === "darwin") {
execSync(`chmod +x "${this.POMERIUM_CLI_PATH}"`);
execSync(`xattr -d com.apple.quarantine "${this.POMERIUM_CLI_PATH}"`);
}
this.pomeriumProcess = spawn(this.POMERIUM_CLI_PATH, [
"tcp",
this.POMERIUM_TARGET_HOST,
"--browser-cmd", "echo", // ブラウザを開かずに認証URLをstdoutに出力
"--listen", `:${this.POMERIUM_LISTEN_PORT}`,
]);
let connectionEstablished = false;
this.pomeriumProcess.stderr?.on("data", (chunk) => {
const message = chunk.toString();
if (message.includes("listening on")) {
// ポートのリッスンを開始 → まだ接続は確立していない
this.testConnection(); // 疎通確認(副作用のみ)
}
if (message.includes("connection established")) {
if (!connectionEstablished) {
connectionEstablished = true;
resolve(); // ← ここで初めてPromiseを解決する
}
}
if (message.includes("connection closed")) {
// 接続が切れたら再起動(実際は同じ引数でspawnを再実行、ここでは省略)
this.pomeriumProcess = spawn(/* ...省略... */);
}
if (message.includes("https")) {
// OktaログインのURLが出たらブラウザ自動化で処理
const match = message.match(/https?:\/\/[^\s]+/g);
if (match?.[0]) Playwright.handleOktaLogin(match[0]);
}
});
// 60秒で接続が確立しなければタイムアウト
const timeout = setTimeout(() => {
if (!connectionEstablished) {
this.pomeriumProcess?.kill();
reject(new Error("Pomerium connection timed out."));
}
}, 60000);
this.pomeriumProcess.once("close", () => clearTimeout(timeout));
this.pomeriumProcess.once("error", () => clearTimeout(timeout));
});
}
ポイント:
"listening on"と"connection established"は別のイベントです。リッスン開始 ≠ 接続確立。Promiseを解決するのは後者を受け取ってから。connectionEstablishedフラグでresolve()の二重呼び出しを防ぐ(stderrのデータは複数チャンクに分かれて届くことがある)。- タイムアウト後にプロセスをkillする。ゾンビプロセスを残さない。
--browser-cmd echoでPomeriumがブラウザを開こうとするのを抑制し、代わりにURLをstdoutに出力させる。そのURLをPlaywrightで処理してOkta認証を自動化する。
ステップ2: Okta MFA自動化
取得した認証URLをPlaywrightで開き、ID/パスワード入力からMFAコード入力まで自動化します。
private async handleOktaLogin(url: string): Promise<void> {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "en-US" });
const page = await context.newPage();
try {
await page.goto(url, { timeout: 60000 });
// ID/パスワード入力
await page.getByLabel("Username").fill(process.env.OKTA_USERNAME!);
await page.getByLabel("Password").fill(process.env.OKTA_PASSWORD!);
await page.getByRole("button", { name: "Sign In" }).click();
// MFAコード入力(otplibでTOTP生成)
await page.getByLabel("Enter code").waitFor({ state: "visible", timeout: 10000 });
const otpCode = totp.generate(process.env.OKTA_MFA_SECRET!);
await page.getByLabel("Enter code").fill(otpCode);
await page.getByRole("button", { name: "Verify" }).click();
await page.waitForFunction(
() => document.title.includes("Authenticated"),
null,
{ timeout: 10000 }
);
} catch (error) {
await page.screenshot({ path: path.join(os.tmpdir(), "okta_error.png") });
throw error;
} finally {
await browser.close();
}
}
失敗時にスクリーンショットを保存するようにしています。OktaのUI変更やMFA設定の変更でフローが壊れることがあるため、デバッグに役立ちます。
ステップ3: SSH多段トンネルの確立
Pomerium経由でJumpサーバーに接続し、そこからリモートサーバーへの転送トンネルを張ります。
class Remote {
private jumpTunnel?: Client;
private remoteTunnel?: Client;
private async connectJumpServer(): Promise<void> {
await Pomerium.start(); // ステップ1-2が自動実行される
return new Promise((resolve, reject) => {
const jumpTunnel = new Client();
jumpTunnel
.on("ready", () => {
this.jumpTunnel = jumpTunnel;
jumpTunnel.on("error", () => this.handleJumpTunnelDisconnection());
jumpTunnel.on("close", () => this.handleJumpTunnelDisconnection());
resolve();
})
.on("error", reject)
.connect({
host: "127.0.0.1",
port: this.pomeriumPort(), // envによってprod/stgを切り替え
username: credentials.SSH_USERNAME,
password: credentials.JUMP_SERVER_PASSWORD,
readyTimeout: 60000,
});
});
}
public async forwardToRemote(): Promise<void> {
if (!this.jumpTunnel) await this.initJumpTunnel();
return new Promise((resolve, reject) => {
this.jumpTunnel!.forwardOut(
"127.0.0.1", 8000,
this.remoteHost(), 22,
(err, stream) => {
if (err) reject(err);
const remoteTunnel = new Client();
remoteTunnel
.on("ready", () => {
this.remoteTunnel = remoteTunnel;
remoteTunnel.on("error", () => this.handleRemoteTunnelDisconnection());
remoteTunnel.on("close", () => this.handleRemoteTunnelDisconnection());
resolve();
})
.on("error", reject)
.connect({
sock: stream, // ← jumpTunnelのストリームをソケットとして使用
username: credentials.SSH_USERNAME,
password: credentials.REMOTE_PASSWORD,
readyTimeout: 60000,
});
}
);
});
}
forwardOut()のstreamを次のSSH接続のsockに渡すことで、多段トンネルを実現しています。
ステップ4: リモートコマンドの実行
トンネルが確立されれば、リモートサーバー上で任意のコマンドを実行できます。
public async execRemote(cmd: string, debug: boolean = false): Promise<string> {
if (!this.remoteTunnel) await this.initRemoteTunnel();
return new Promise((resolve, reject) => {
this.remoteTunnel!.exec(cmd, (err, stream) => {
if (err) reject(err);
let data = "";
stream.on("data", (chunk: Buffer) => (data += chunk.toString()));
stream.on("end", () => resolve(data));
});
});
}
このexecRemoteを使ってkubectl execコマンドを実行し、Pod内のKafka、Couchbase、MySQL等に直接アクセスしています。
切断時の自動再接続
切断を検知したら、対応するトンネルをundefinedにリセットし、次のコマンド実行時に遅延初期化で再接続します。
private handleJumpTunnelDisconnection() {
console.warn("Attempting to re-establish JumpTunnel...");
this.jumpTunnel = undefined;
this.initJumpTunnel().catch((err) => {
console.error(`Failed to re-establish JumpTunnel: ${err}`);
});
}
private handleRemoteTunnelDisconnection() {
console.warn("Attempting to re-establish RemoteTunnel...");
this.remoteTunnel = undefined;
this.initRemoteTunnel().catch((err) => {
console.error(`Failed to re-establish RemoteTunnel: ${err}`);
});
}
遅延初期化の全体フロー

まとめ
Pomerium + SSH多段トンネルで、VPNなしのゼロトラストKubernetesアクセスを実現しました。
stderrを状態検知に使う: 外部バイナリのAPIが存在しない場合、プロセスの出力を観察して状態を把握する。"listening on" と "connection established" を区別して、後者でPromiseを解決する。
Promiseロックで重複起動を防ぐ: startPromise: Promise<void> | null で「起動中かどうか」を管理する。複数の呼び出し元が同時に接続を要求しても、プロセスは1つしか起動しない。
sock: stream で多段SSH接続: ssh2 の forwardOut が返すストリームを connect({ sock: stream }) に渡すことで、ジャンプサーバー経由の接続を実現する。
遅延初期化で必要なときだけ接続: 依存する接続(Pomerium→ジャンプサーバー→本番サーバー)は初回アクセス時に順番に確立する。
VPNと比べて管理は複雑になりますが、セッション単位のID認証やアクセス範囲の限定というメリットがあります。特に本番環境へのアクセスで「誰が、いつ、何にアクセスしたか」を追跡する必要がある場面では有効なアプローチです。





