列車の中のスマートフォンから、プライベートネットワーク内にしか存在しないバックエンドまで、一つのリクエストを追いかけてみましょう。スマートフォンは公開名を解決し、ポート 443 の公開アドレスに接続し、通常の TLS ハンドシェイクを完了します。Nginx はその接続を受け入れ、その後、VPN インターフェース経由でしか到達できないアドレスへ、まったく別の二本目の接続を開きます。スマートフォン側では何も変わらず、トンネルが関与したことをスマートフォンが知ることはありません。

一つのリクエストを二つの接続で追う

受信側は公開 HTTP トランザクションです。公開名、公開証明書、そして訪問者がその時点で持つどのアドレスに対しても機能しなければならない応答経路があります。その経路を乱す変更は、訪問者全員を一度に壊します。だからこそ公開側は、最も実験したくない側なのです。

送信側はプライベートです。Nginx がクライアントになり、内部名を解決するか固定アドレスを使い、インターネットからの接続を拒否するサービスに到達します。

Public client  -> frontend public address:443   (TLS, public name)
Frontend       -> VPN private backend 10.40.0.20:8443  (TLS, private name)

頭の中で両者を分けておけば、設定は自明になります。混ぜてしまうと、公開クライアントをトンネルに参加させようとすることになり、この設計の混乱の大半はそこから来ます。プライベート側は壊れやすくてもかまいません。公開側はそうではありません。

何かを構築する前に、アプリケーションの公開が意図したものかを確認してください。動くプロキシは動く露出でもあり、認証モデルは副作用ではなく意思決定でなければなりません。

ゲートウェイ名はトンネルを生き延びなければならない

名前解決には微妙な罠があります。プライベート DNS が、VPN プロファイル自身のゲートウェイに使う同じ名前を上書きすると、再接続が、本来確立すべきトンネルを通ってそのゲートウェイに到達しようとする場合があります。クライアントはトンネルが存在する前にその名前を解決している必要があります。トンネル内で解決してしまうと、接続はそもそも構築できません。

ゲートウェイとバックエンドには別々の承認済み名を使い、各名前がその利用者に必要なアドレスへ解決されるようリゾルバーポリシーを絞ります。アプリケーションの Host ヘッダーを変えても、検索を行っているのが VPN クライアントである以上、この問題には効きません。

次に範囲を考えます。フロントエンドにはバックエンドのアドレスへの経路が必要で、同時に公開クライアントへの応答も続けなければなりません。そのホストでフルトンネルを張ると、前者は満たせても後者が壊れます。

ポリシーが許すなら、特定の宛先だけを通すトンネルのほうがよりクリーンです。ポリシーがフルトンネルを強制するなら、フロントエンドは既知の訪問者向けの場当たり的な経路で継ぎ足すのではなく、別のネットワークコンテキストに置くべきです。

両方の経路が検証されるようにサーバーブロックを書く

バックエンドがこの名前の証明書を提示するとします: backend.corp.example そしてプライベートアドレスで応答します。名前を検証しながらアドレスへ接続するのは、まさにプロキシモジュールの SNI と名前のオプションの目的です。

upstream private_app {
    server 10.40.0.20:8443;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/nginx/tls/app-fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/app-key.pem;

    location / {
        proxy_pass https://private_app;
        proxy_ssl_server_name on;
        proxy_ssl_name backend.corp.example;
        proxy_ssl_trusted_certificate /etc/nginx/trust/backend-ca.pem;
        proxy_ssl_verify on;
        proxy_ssl_verify_depth 3;

        proxy_set_header Host app.example.com;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $remote_addr;
    }
}

上のパスとアドレスはすべてプレースホルダーです。重要なのは検証を有効に保つ部分、つまり検証する名前、照合する信頼バンドル、承認済みチェーンに合う深さです。

proxy_set_header Host app.example.com は意図的です。バックエンドはその公開オリジンを受理するよう設定し、転送ヘッダーはこのフロントエンドからのみ信頼すべきです。次を上書きすること X-Forwarded-For をクライアントアドレスで行うのは、単一プロキシなら正しくチェーンなら誤りなので、マルチプロキシ構成には独自の信頼アドレスの取り決めが必要です。

公開オリジンを公開オリジンらしく振る舞わせる

アプリケーションは公開オリジン向けの絶対 URL、リダイレクト、Cookie を出力すべきです。訪問者を次へリダイレクトすると: backend.corp.example その訪問者は解決できない名前に送られます。

これはアプリケーションが対応する外部 URL 設定とレビュー済みのリダイレクトポリシーで直すべきで、応答本文のホスト名を書き換えるのではありません。任意のコンテンツに対する文字列置換は署名済みペイロードを壊し、追跡困難なバグを生みます。

Cookie にも同じ注意が必要です。プライベート名に限定された Cookie は、公開名しか知らないブラウザーからは決して送られません。

Nginx が公開ソケットを実際に所有できることも確認してください。二つのプロセスは同じアドレスとポートを束縛できないため、443 で動いている既存の Web サービスを移すか、フロントエンドを別の場所に置く必要があります。

各経路を、実際に動いている場所で確認する

リロードの前に、導入済みのパーサーで設定を検証し、タイプミスでいま応答しているリスナーを落とさないようにしてください。

次に、フロントエンドのホスト自身から、Nginx が使うのと同じネットワークコンテキストでバックエンド経路を試します。トンネル越しの ping は到達性を示すだけで、TLS、認可、アプリケーションについては何も示しません。

最後に、実際のホスト名へ通常の証明書検証つきで新しい公開リクエストを送り、応答本文だけでなく応答経路を観察します。

両方の観察をデプロイ済みの設定と一緒に残し、トンネルが使えないときに何が起きるかを記録します。フロントエンドは制御された upstream エラーを返すべきで、誰も文書化していない別のバックエンドへ静かに落ちてはいけません。明確なログ行を伴う 502 は良い結果です。 意図しないオリジンからキャッシュされたページ は良い結果ではありません。

関連記事