PomeriumゼロトラストプロキシとSSH多段トンネルでVPNを置き換える — Node.jsでの実装

PomeriumゼロトラストプロキシとSSH多段トンネルでVPNを置き換える — Node.jsでの実装

Pomeriumゼロトラストプロキシ経由でSSH多段トンネルを構築し、VPNなしでKubernetes本番環境にアクセスする方法を紹介します。stderr監視による接続検知、PlaywrightでのOkta MFA自動化、ssh2のforwardOutでの多段接続をTypeScriptで実装しました。
2026.07.26

はじめに

Kubernetes上の本番環境にアクセスするため、VPNを使っているチームは多いと思います。しかしVPNには「一度繋がれば何でもできてしまう」という課題があります。

ゼロトラストネットワークで守られた本番サーバーに、Node.jsの自動化スクリプトからアクセスする必要がありました。Pomerium(ゼロトラストプロキシ)経由でジャンプサーバーに接続し、そこからさらに本番サーバーへフォワードする2段構成です。スクリプトの起動時に自動でトンネルを確立する仕組みをTypeScriptで実装しました。

ゼロトラストとは何か

VPNは「城と堀」のモデルです。堀の内側(VPN接続済み)に入れば、城内を自由に歩き回れます。VPNは接続を信頼しますが、個々のリクエストは検証しません。

3dadb010-453e-44df-a7af-5d2d440b424c (1)

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

78653fcd-32ae-49de-9f4b-34ff8659cd06

Googleの「BeyondCorp」がこの概念の起源です。GoogleはVPNを完全に廃止し、社内Wikiですらアイデンティティ認識プロキシの背後に配置しました。

VPNだけでは不十分な理由

VPNの最大のリスクはラテラルムーブメント(横方向の移動)です。開発者のノートPCが侵害された場合、攻撃者はVPN経由で本番ネットワーク全体にアクセスできてしまいます。ゼロトラストでは、侵害されたセッションからアクセスできるのは、そのセッションが認可された特定のリソースだけです。

pomerium-vpn-vs-zero-trust

VPN vs Pomerium + SSH

項目 VPN Pomerium + SSH
信頼の範囲 ネットワーク全体 特定のホスト/ポートのみ
認証単位 デバイス単位 セッション単位
アクセス範囲 接続後は制限なし ポリシーで厳密に制御
MFA 初回接続時のみ 毎セッション
監査ログ 限定的 リクエスト単位でID紐付き
侵害時の影響 ネットワーク全体が露出 認可されたリソースのみ

なぜOkta MFAだけでは不十分なのか

Okta MFAとPomeriumは異なる問題を解決します。冗長ではなく、補完関係にあります。

Okta MFA = 認証(Authentication)。 「あなたが誰であるか」を証明します。しかしOktaは、認証後にどのネットワークリソースにアクセスできるかを制御しません。

Pomerium = 認可 + ネットワークレベルの制御(Authorization)。 認証済みのユーザーに対して、以下を強制します:

  • 承認された宛先(特定のホスト/ポート)へのトラフィックのみルーティング
  • セッション単位の監査証跡の作成
  • デバイスポスチャ、時間帯、IPなどの追加ポリシーの適用
  • ネットワークゲートウェイとしてのアクセス制御(Pomeriumなしではトラフィックがサーバーに到達できない)

空港に例えると、Oktaはパスポートチェック(身元確認)、Pomeriumは搭乗券(特定のフライトの特定の座席にしか乗れない)です。パスポートを持っているだけでは、どの飛行機にも乗れません。

f8bdf57a-15a6-4f45-9017-f2b124995f05

pomerium-authn-vs-authz

本記事のフローでは、この2つが連携して動作します:

Pomerium(ネットワークアクセス制御)
  → Oktaログインをトリガー(本人確認 + MFA)
    → PomeriumがIDトークンを取得
      → Pomeriumが認可ポリシーを確認
        → 特定のジャンプサーバーへのTCPトンネルのみ許可

実践的な導入アプローチ

多くの組織はVPNとゼロトラストのハイブリッド構成から始めます:

アプローチ 適するケース
VPNのみ 小規模チーム、コンプライアンス要件が低い
VPN + 重要パスにゼロトラスト 移行途中、または本番環境のみコンプライアンス要件がある
完全ゼロトラスト(VPNなし) セキュリティ成熟度が高い、規制産業

本記事のアーキテクチャは中間のアプローチです。Pomeriumが本番Kubernetesアクセスを保護し、その他の社内リソースはVPN経由でアクセスする構成です。ただしゼロトラストの思想では、VPN層自体がラテラルムーブメントの経路を生む問題とされています。ハイブリッド構成は現実的な出発点ですが、VPN層はセキュリティの保証ではなく利便性のトレードオフであることを認識しておく必要があります。

pomerium-hybrid-deployment

アーキテクチャ全体像

pomerium-zero-trust-ssh-tunnel-nodejs-architecture

ステップ1: Pomeriumプロセスの管理

pomerium-cli は外部バイナリです。Node.jsから child_process.spawn で起動します。

難しいのは「いつ接続が確立したか」をどう検知するか です。Pomeriumは接続状態を公開APIとして提供していません。唯一の手がかりは stderr への出力です。

Pomerium.ts
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から接続確立を検知する

Pomerium.ts
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コード入力まで自動化します。

PomeriumController.ts
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サーバーに接続し、そこからリモートサーバーへの転送トンネルを張ります。

Remote.ts
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: リモートコマンドの実行

トンネルが確立されれば、リモートサーバー上で任意のコマンドを実行できます。

Remote.ts
  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にリセットし、次のコマンド実行時に遅延初期化で再接続します。

Remote.ts
  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-zero-trust-ssh-tunnel-nodejs-lazy-init

まとめ

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認証やアクセス範囲の限定というメリットがあります。特に本番環境へのアクセスで「誰が、いつ、何にアクセスしたか」を追跡する必要がある場面では有効なアプローチです。

この記事をシェアする

関連記事