Tutorial
27 min read
18 views

マルチプレックス多プロトコルゲートウェイ:TCP、UDP、QUICを単一のエントリーポートで橋渡し

マルチプレックス多プロトコルゲートウェイが、ポート枯渇を避けてTCP、UDP、QUICを単一のエントリーポートで橋渡しする仕組みをご紹介します。

IT
InstaTunnel Team
Published by the InstaTunnel team | Editorial policy
マルチプレックス多プロトコルゲートウェイ:TCP、UDP、QUICを単一のエントリーポートで橋渡し

Quick answer

マルチプレックス多プロトコルゲートウェイ:TCP、UDP: quick comparison answer

Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.

Which tunnel tool is best for public webhook testing?

Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.

When should I choose a private network tool instead?

Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.

クラウドバックエンド、ゲームサーバー、エッジネットワークは、公開ポートを減らしたいと考えています。ポート数を減らすことで、ファイアウォールルールが簡素化され、ロードバランサーの数も少なくなり、厳格な企業やキャリアのネットワークの穴も減ります。例えば 443 や 8443 の公開ポート一つで、gRPCやHTTP/2(TCP上)、HTTP/3(QUIC上)、そしてカスタムのリアルタイムUDPプロトコルをフロントできます。

このアイデアはシンプルに見えますが、微妙な誤解を招きやすいです。本ガイドでは、OSが無料で提供する機能の理解、TCPとUDPトラフィックの安全な分類方法、eBPFやXDPの役割、QUICコネクションIDがルーティングに与える影響、そして本当にポート枯渇を引き起こす原因について解説します。最後に、テスト済みのGo製ゲートウェイの例も紹介します。


1. 「単一ポート」が本当に意味すること

ポート番号は一つの名前空間ではありません。TCPの443とUDPの443は別物なので、ホストは両方を同時にリッスンできます。HTTP/3はすでにこの仕組みを採用しており、ブラウザは最初にTCPで接続し、その後 Alt-Svc: h3=":443" のレスポンスヘッダーやHTTPSのDNSレコード(RFC 9460)からHTTP/3のエンドポイントを知ります。UDP 443上のエンドポイントに対して、ネットワークがUDP 443をフィルタリングしている場合、クライアントはTCPにフォールバックする必要があります。

したがって、「1ポート」とはポート番号1つを指しますが、その上に解決すべき3つの課題があります:

  1. トランスポートの分割。 TCPとUDPはカーネルによって異なるソケットに配信されます。
  2. TCPサブプロトコル。 TLS(およびそれが運ぶアプリケーションプロトコル)、クリアテキストHTTP/1.1、HTTP/2、そしてカスタムバイトストリームはすべて同じリスナーに到達します。
  3. UDPサブプロトコル。 QUICと独自のダットグラムプロトコルは1つのUDPソケットを共有し、UDPにはハンドシェイクがないため区別ができません。 ┌───────────────────────────────────────────┐ │ パブリックエントリーポート、443 │ └───────────┬─────────────────────┬─────────┘ │ TCP │ UDP ┌─────────▼─────────┐ ┌───────▼────────────────┐ │ TLS? PRI? HTTP verb? │ │ ファーストバイト分類器 │ │ (RFC 9443範囲) │ │ (RFC 9443範囲) │ └───┬─────┬─────┬───┘ └──────┬──────────┬──────┘ ▼ ▼ ▼ ▼ ▼ gRPC HTTP/1 カスタム QUIC / カスタム (h2) TCP HTTP/3 UDP(ゲーム)

4タプルだけでは不十分な場合

1つのソケットアドレスが複数のプロトコルを受信する場合、カーネルのルックアップ(プロトコル、送信元アドレスとポート、宛先アドレスとポート)は、パケットを正しい*ソケット*に届けるだけです。その後の分類はあなたの仕事です。

トランスポート 検査できる内容 識別マーカー 状態
TCP(TLS, HTTP/2, gRPC) ストリームの最初のバイト 0x16 = TLSハンドシェイクレコード; ALPNはClientHello内 カーネルのコネクション状態
TCP(クリアテキスト) ストリームの最初のバイト PRI * HTTP/2.0…(HTTP/2プリフェース)またはHTTPメソッド カーネルのコネクション状態
UDP(QUIC) 各ダットグラムの最初のバイト 上位ビットがセット = 長ヘッダー; RFC 9443範囲の下位ビット QUICコネクションID、エンドポイントが保持
UDP(カスタム) プロトコル定義による 独自の最初のバイト規則 自身で定義

2. ソケット層:SO_REUSEPORTは不要

一般的な誤解は、SO_REUSEPORTがTCPとUDPのソケットがポートを共有できる仕組みだと考えることです。実際は違います。LinuxはTCPとUDPのバインディングを別テーブルで管理しているため、TCPリスナーとUDPソケットは0.0.0.0:443に何の特別なオプションもなくバインド可能です。

SO_REUSEPORTは別の問題を解決します:同じプロトコルの複数のソケットが同じアドレスとポートにバインドし、カーネルが着信コネクションやダットグラムを分散させる仕組みです。デフォルトでは4タプルのハッシュを使い、グループ内のすべてのソケットは同じ有効ユーザーIDに属する必要があります。これにより、多数のスレッドやプロセスにわたるリスナーのスケーリングが可能です。

Goの標準ライブラリだけで十分です:

ln, _ := net.Listen("tcp", "0.0.0.0:443")        // TCPソケット
pc, _ := net.ListenPacket("udp", "0.0.0.0:443")  // UDPソケット、同じポート番号

SO_REUSEPORTは、コアごとのリスナーやダウンタイムゼロの再起動をしたい場合に使用します。第5節では、eBPFプログラムがデフォルトのハッシュを上書きする方法も解説します。


3. UDPの分類:QUICと独自プロトコル

これが最も難しい部分です。UDPダットグラムはハンドシェイクがないため、最初のバイトだけが頼りです。

QUICが保証すること(と保証しないこと)

RFC 8999は、すべてのQUICバージョンで一定の性質だけを定義しています:

  • 最初のバイトの最上位ビットはヘッダの形式を示す。1は長ヘッダー、0は短ヘッダー。
  • 長ヘッダーは32ビットのVersionフィールド(0x00000000はバージョンネゴシエーション用)、長さ付きの宛先と送信元のコネクションIDを持つ。
  • 短ヘッダーは宛先コネクションIDだけを持ち、長さフィールドはなし。ゲートウェイは期待するコネクションIDの長さを知っている必要があります。

固定ビット (0x40) は不変ではありません。RFC 8999は、「QUICパケットは常に最初のバイトの第2ビット(0x40)をセットしている」と誤った前提例の一つです。バージョン1ではビットはセットされており、RFC 9287はエンドポイントがgrease_quic_bitトランスポートパラメータを交渉している場合にランダム化できるとしています。

また、QUICバージョン1はクライアントのイニシャルダットグラムの最小サイズ(1200バイト、RFC 9000セクション14.1)を要求しますが、これはバージョン1の動作であり、不変ではありません。短ヘッダーのパケットには、「最小3バイト」ルールはなく、分類器の基準にはできません。

RFC 9443による標準的な多重化方式

RFC 9443(RFC 7983を更新)は、1つのUDPソケット上でSTUN、ZRTP、DTLS、TURNチャネル、RTP/RTCP、そしてQUICを最初のバイトを見て識別する方法を定義しています:

最初のバイト プロトコル
0–3 STUN
4–15 ドロップ
16–19 ZRTP
20–63 DTLS
64–79 TURNチャネル(ソースが既知のTURNサーバーの場合)またはQUIC
80–127 QUIC(短ヘッダー、固定ビットセット)
128–191 RTP / RTCP
192–255 QUIC(長ヘッダー、固定ビットセット)

ゲートウェイにとって重要なポイントは以下の通りです:

  • 「長ヘッダーまたは固定ビットセット」で分類しない。 このテストは一般的なスケッチでは約3/4の最初のバイトにマッチします。0x40–0xFFに属するカスタムプロトコルは誤ルーティングの危険があります。RFC 9443は、「誤った分類を引き起こす可能性がある」と警告しています。
  • マルチプレクサの背後にあるエンドポイントはgrease_quic_bitを送信しない。 これが0x40–0xFFの範囲を破壊します(RFC 9443セクション1)。

カスタムプロトコルの設計:共存できるように

ゲームプロトコルを制御できる場合、QUICが占有できない範囲(例:0–63や128–191)の最初のバイト(バージョンやメッセージタイプ)を選びましょう。変更できない場合は、最初のバイトの分類はヒューリスティックであることを理解し、長ヘッダーのバージョンフィールド(オフセット1の4バイト、値例:0x00000001はQUIC v1、0x6b3343cfはQUIC v2)を確認し、曖昧な場合は慎重に扱います。

修正された判断手順

ダットグラム受信
   │
   ▼
byte0が64..127または192..255? ──いいえ──► カスタムUDPパイプライン
   │はい
   ▼
長ヘッダー(byte0 & 0x80)?
   ├─はい ► バージョンを確認(v1、v2、または0はネゴシエーション)► QUIC
   └─いいえ ► 短ヘッダー:DCIDをコネクションテーブルで検索
            ├─ヒット► 同じバックエンド
            └─ミス► ドロップ、またはQUICバックエンドへ(ステートレスリセットを発行)

短ヘッダーのケースは特に注意が必要です。パススルーゲートウェイは、コネクションIDを記憶している(ステートフル)か、ID自体にルーティング情報をエンコードしている必要があります(第5節参照)。そうでなければ、クライアントのアドレスでルーティングするしかなく、クライアントのアドレスが変わるとルーティングが破綻します。


4. TCPの分類:ALPN、プリフェース、暗号化されたClient Hello

TCPの場合、ゲートウェイはストリームの最初のバイトをrecv(..., MSG_PEEK)で覗き見し、そのまま全データをバックエンドに転送します。

最初のバイト 意味 ルート
0x16 + TLSレコードヘッダー TLS ClientHello ALPNを解析
PRI (PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) クリアテキストHTTP/2、h2c、gRPC(TLS不要) HTTP/2 / gRPCバックエンド
GET 、POST、HEADなど クリアテキストHTTP/1.1 HTTPバックエンド
その他 カスタムプロトコル フォールバック

ALPNルーティング。 TLS 1.3ではClientHelloは暗号化されておらず、ALPN拡張(タイプ16)にクライアントが提供するアプリケーションプロトコル(h2、http/1.1、カスタムトークン)リストが含まれます。ゲートウェイはTLSレコード長を5バイトのヘッダーから読み取り、全レコードを覗き見し、拡張を解析してALPNに基づきルーティングします(TLS終端しません)。セクション7のGo実装はこれを行い、実際のcrypto/tls ClientHellosに対してテスト済みです。

ECHの注意点。 RFC 9849は暗号化Client Hello(ECH)を標準化しています。ECHでは、クライアントは敏感なClientHelloの内容を暗号化し、SNIやALPNを保護します。外側のClientHelloは無害な値だけを持ち、パススルーゲートウェイはクライアントが公開した情報しか見えません。ECHの普及に伴い、ALPNに基づくルーティングは信頼性が低下します。TLS終端や外側の名前でルーティングすることを検討してください。


5. カーネルレベルのデマルチプレックス:eBPF、XDP、ソケットステアリング

ユーザースペースで覗き見するにはシステムコールが必要で、カーネルに情報を伝えられません。これを解決するのがeBPFの仕組みです。

オプションA:SK_REUSEPORTプログラム(正しいソケットにルーティング)

BPF_PROG_TYPE_SK_REUSEPORTタイプのプログラムは、SO_REUSEPORTグループからソケットを選ぶ際のデフォルトの4タプルハッシュを置き換えます。UDPの場合、カーネルのパッチは、ダットグラムのペイロードを覗き見てアプリケーションレベルの情報を取得し、BPFマップからソケットを選択できると記述しています。これにより、パケットは通常のカーネルスタック内に留まり、コネクションIDに基づいて特定のワーカーに割り当てることも可能です。setsockoptでSO_ATTACH_REUSEPORT_EBPFを使ってアタッチします(CAP_SYS_ADMIN必要)。

オプションB:XDP(ネットワークスタック前で動作)

XDPはNICからパケットを受信した直後にドライバレベルで動作し、カーネルが通常のソケットバッファを割り当てる前に処理します。MetaのオープンソースのLayer-4ロードバランサKatranはこれを利用し、「パケット受信後すぐに」動作します(ドライバモード)。XDPはドロップ、パススルー、リダイレクト(他のインターフェースやAF_XDPソケット)を行えます。

AF_XDPは特に重要です。リダイレクトしてカーネルのネットワークスタックをバイパスし、パケットをユーザースペースのメモリに直接置きます。これにより、UDPのチェックサムやARP、ICMP、ソケットのデマルチプレックスなどを自分で処理する必要があります。ドキュメントには、スケッチが誤りやすい挙動も記載されています:

  • XSKMAPはインデックスでキー付けされ、リダイレクトはそのインデックスのソケットが同じデバイスと受信キューにバインドされている場合のみ成功します。そうでなければパケットはドロップされます。
  • マップが空の場合もパケットはドロップされます。
  • 安全なパターンは、最初にキューインデックスを調べ、AF_XDPソケットがバインドされていなければXDP_PASSを返すことです。
  • 特定のトラフィックだけをユーザースペースに送るには、NICのフロールール(例:ethtool -N ... action <queue>)を使います。

以下は、UDPポート443のQUIC範囲のダットグラムだけをAF_XDPソケットに送るXDPプログラムの例です。コンパイルにはclang -target bpfを使用します。NICでの負荷テストは未実施です。

#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/in.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

#define INGRESS_PORT 443

struct {
    __uint(type, BPF_MAP_TYPE_XSKMAP);
    __uint(max_entries, 64);
    __type(key, __u32);
    __type(value, __u32);
} xsks_map SEC(".maps");

SEC("xdp")
int xdp_quic_steer(struct xdp_md *ctx)
{
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;
    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *iph = (void *)(eth + 1);
    if ((void *)(iph + 1) > data_end || iph->protocol != IPPROTO_UDP)
        return XDP_PASS;

    struct udphdr *udph = (void *)iph + iph->ihl * 4;
    if ((void *)(udph + 1) > data_end || udph->dest != bpf_htons(INGRESS_PORT))
        return XDP_PASS;

    unsigned char *payload = (void *)(udph + 1);
    if ((void *)(payload + 1) > data_end)
        return XDP_PASS;

    /* RFC 9443範囲のQUIC判定(64..79はTURNサーバ由来の場合もある) */
    unsigned char b0 = *payload;
    if (!((b0 >= 64 && b0 <= 127) || b0 >= 192))
        return XDP_PASS; // カスタムUDPはカーネルスタックに任せる

    __u32 q = ctx->rx_queue_index;
    if (bpf_map_lookup_elem(&xsks_map, &q))
        return bpf_redirect_map(&xsks_map, q, 0);
    return XDP_PASS; // AF_XDPソケット未設定のキュー
}

char _license[] SEC("license") = "GPL";

このプログラムはIPv4のみ対応です。IPv6やVLAN対応、フラグメント処理も必要です。

ホスト間でコネクションIDを使ったQUICルーティング

大規模運用では、短ヘッダーのパケットをフローごとにルーティングする方法が必要です。代表的なアプローチは以下の通りです:

  • サーバIDルーティング。 Metaは2021年のLinux Plumbers Conferenceで、識別子にエンコードされたサーバIDを使い、XDPロードバランサがTCPとQUICを両方ルーティングできるステートレス方式を提案しました。
  • QUIC-LB。 QUICワーキンググループのドラフト「QUIC-LB: Generating Routable QUIC Connection IDs」では、サーバが選択したコネクションIDにルーティング情報をエンコードする標準的な方法を定義しています。ただし、これはまだRFCではなく、2025年8月の最新リビジョン21の期限切れのインターネットドラフトです。標準ではありません。

6. 複数プロトコルを1つのエッジ接続でトンネリング

エッジトンネルは、プライベートネットワークからパブリックエッジへの持続的アウトバウンド接続であり、すべてのプロトコルを1つのセッションで運び、インバウンドポートは不要です。

                パブリックエッジ(ポート443)
   TCPストリーム ──┐
   QUICダットグラム ──► 分類器 ──► トンネルマルチプレクサ ══ 1つの外側セッション ══►
   UDPダットグラム ┘                                                             │
                                                                                ▼
                                          プライベート側:チャネルごとにデマルチプレックス ► gRPC :50051
                                                                          ► HTTP/3 :9001
                                                                          ► ゲーム  :7777

各トラフィックタイプに適したプリミティブを選ぶ

  • TCPやバイトストリームは、複数のマルチプレックスされたストリームに自然にマッピングされます。これらを「セグメント」と表現しないでください。トンネルはバイトストリームを運びます。
  • UDPとQUICはダットグラム。 信頼性のある順序付けされたストリームに入れると、ヘッド・オブ・ラインブロッキングや再送遅延が発生し、リアルタイムトラフィックの設計意図に反します。信頼性のないダットグラムで運びましょう。既存の標準には、QUIC DATAGRAMフレーム(RFC 9221)、HTTPダットグラム(RFC 9297)、CONNECT-UDP(RFC 9298)があります。これらはUDPをHTTP/2やHTTP/3上でプロキシし、QUICダットグラムも利用可能です。
  • WireGuardはIPパケットをUDP上で運ぶレイヤ3トンネルです。生のパケットのためのトランスポートとして適しています。

独自フレームの例

自作フレームを設計する場合は、小さく明示的にしましょう。以下は例です(標準ではありません):

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Magic (0xA5) | プロトコルタイプ |           チャネルID          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         セッショントークン                         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|        ペイロード長        |        ペイロードデータ ...       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
フィールド サイズ 目的
Magic 8ビット フレーム境界の整合性確認 (0xA5)
プロトコルタイプ 8ビット 0x01ストリームデータ、0x02UDPダットグラム、0x03QUICダットグラム
チャネルID 16ビット バックエンドサービスやテナント
セッショントークン 32ビット クライアントごとの識別子
ペイロード長 16ビット 最大65,535バイト、最大UDPペイロードに対応

32ビットのトークンはルックアップキーであり、認証情報ではありません。トンネルクライアントの認証は、TLSや署名付きトークン、WireGuardの鍵など、実際のハンドシェイクから得る必要があります。


7. ポート枯渇、セッション状態、制限の本質

「ポート枯渇」は多くの問題の原因とされていますが、実際に関係するのは以下の点です。

エフェメラルポートの制限は宛先ごとに適用される。 Linuxのデフォルトのアウトバウンド範囲は32768–60999で、約28,232ポートです。同じソースIPとポートは、異なる宛先への接続に再利用可能です。実用的な制限は、ゲートウェイが1つのソースIPから1つのバックエンドIPとポートへの多くの接続を開く場合に近づきます。

対策(優先順位順):

  1. 1つのソケットに複数のクライアントをマルチプレックス。 長寿命のバックエンド接続上に多くのクライアントストリームを運びます(HTTP/2や自作フレーム)。Cloudflareの解説も同じポイントを指摘しています。
  2. 複数の4タプルに分散。 複数のバックエンドアドレスやポート、または複数の送信元IPを使います。
  3. 範囲を広げる/分割する。 net.ipv4.ip_local_port_rangeを拡大し、新しいカーネルではIP_LOCAL_PORT_RANGEオプションも利用可能です。

セッショテーブル。 コネクションレスのトラフィックでは、クライアント(またはコネクションID)とアップストリームの経路をマッピングするテーブルが必要です。アイドルタイムアウトを設定し、エントリを期限切れにします。無制限のテーブルはDoSリスクになります。

TUNデバイス。 生のIPパケットをTUNインターフェース(/dev/net/tun)経由で送信すると、レイヤ3で処理され、クライアントごとのエフェメラルポートは割り当てられません。ただし、NATやマスカレードを行う場合は、NATがソースポートを割り当て続けます。エンドツーエンドのルーティングにNAT不要ならTUNを使います。

QUICのコネクション移行。 コネクションID(RFC 9000)を使って、クライアントのIPとポートから切り離します。詳細は以下の通り:

  • 移行クライアントは新しいコネクションIDに切り替える。 RFC 9000は、コネクションIDはリンク可能であってはならず、エンドポイントは各IDを1つのアドレスペアに制限すべきとしています。ゲートウェイは、クライアントがネットワークを移動すると、IDとバックエンドの対応が失われます。
  • パススルーゲートウェイは新IDを読み取れない。 NEW_CONNECTION_IDフレームは暗号化されたパケット内を移動します。解決策は、サーバ側がルーティング可能なID(サーバIDルーティングやQUIC-LB)を発行するか、ゲートウェイがQUICを終端することです。RFC 9000のセクション5.2.3は、シンプルなアドレスベースのバランサのためのフォールバックも記載しています。

また、ゼロ長のコネクションIDは、共有アドレスの背後では脆弱です(RFC 9000)。


8. Goで実装したテスト済みゲートウェイ

以下のプログラムは、標準ライブラリだけを使い、上記設計を実現しています:

  • TCPとUDPの1つのポートでリッスン
  • TCPでは最初のバイトを覗き見し、TLS ClientHelloを解析してALPN(h2やhttp/1.1)を読み取り、クリアテキストHTTP/2プリフェースやHTTPメソッドを認識し、それ以外はフォールバックにルーティング。覗き見したバイトはバックエンドに再送信
  • UDPではRFC 9443範囲に基づき分類し、クライアントごとに1つのアップストリームソケットを管理し、アイドルタイムアウトを設定して返信を中継

このプログラムはgo vetを通し、ローカルのエコーバックエンドとテスト済みです。テスト内容は平文ルーティング(PRI、GET、未知のバイト)、範囲境界を超えるUDPルーティング(0xC0、0x45からQUIC、0x05、0x3F、0x90からカスタムバックエンド)、および実際のcrypto/tls ClientHellosに対するALPN解析です。

// TCP+UDP単一ポートゲートウェイ:TLS ALPN / HTTPプリフェースによるTCPルーティング、
// RFC 9443範囲によるUDPルーティング。標準ライブラリのみ。
package main

import (
	"bufio"
	"context"
	"io"
	"log"
	"net"
	"os"
	"os/signal"
	"sync"
	"syscall"
	"time"
)

type Config struct {
	Listen      string
	H2Backend   string // gRPC / HTTP/2
	HTTPBackend string // HTTP/1.1
	TCPFallback string // その他(カスタムTCP)
	QUICBackend string // UDP, QUIC / HTTP/3
	GameBackend string // UDP, カスタムプロトコル
	UDPIdle     time.Duration
}

func main() {
	cfg := Config{
		Listen: "0.0.0.0:8443", H2Backend: "127.0.0.1:50051", HTTPBackend: "127.0.0.1:8080",
		TCPFallback: "127.0.0.1:9000", QUICBackend: "127.0.0.1:9001", GameBackend: "127.0.0.1:7777",
		UDPIdle: 60 * time.Second,
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	go serveTCP(ctx, cfg)
	go serveUDP(ctx, cfg)
	<-ctx.Done()
}

// ---------- TCP ----------

func serveTCP(ctx context.Context, cfg Config) {
	ln, err := net.Listen("tcp", cfg.Listen)
	if err != nil {
		log.Fatalf("tcp listen: %v", err)
	}
	go func() { <-ctx.Done(); ln.Close() }()
	for {
		c, err := ln.Accept()
		if err != nil {
			return
		}
		go handleTCP(c, cfg)
	}
}

func handleTCP(c net.Conn, cfg Config) {
	defer c.Close()
	br := bufio.NewReaderSize(c, 16*1024+5)
	c.SetReadDeadline(time.Now().Add(5 * time.Second)) // スニッフィング用
	backend := cfg.TCPFallback
	if hdr, err := br.Peek(5); err == nil {
		switch {
		case hdr[0] == 0x16: // TLSハンドシェイクレコード
			recLen := int(hdr[3])<<8 | int(hdr[4])
			if rec, err := br.Peek(5 + recLen); err == nil {
				switch alpnPick(rec[5:]) {
				case "h2":
					backend = cfg.H2Backend
				case "http/1.1":
					backend = cfg.HTTPBackend
				}
			}
		case string(hdr[:4]) == "PRI ": // HTTP/2プリフェース
			backend = cfg.H2Backend
		case isHTTPVerb(hdr):
			backend = cfg.HTTPBackend
		}
	}
	c.SetReadDeadline(time.Time{})
	up, err := net.DialTimeout("tcp", backend, 3*time.Second)
	if err != nil {
		log.Printf("dial %s: %v", backend, err)
		return
	}
	defer up.Close()
	var wg sync.WaitGroup
	wg.Add(2)
	go func() { defer wg.Done(); io.Copy(up, br); closeWrite(up) }()
	go func() { defer wg.Done(); io.Copy(c, up); closeWrite(c) }()
	wg.Wait()
}

func closeWrite(c net.Conn) {
	if cw, ok := c.(interface{ CloseWrite() error }); ok {
		cw.CloseWrite()
	}
}

func isHTTPVerb(b []byte) bool {
	for _, v := range []string{"GET ", "POST", "HEAD", "PUT ", "DELE", "OPTI", "PATC"} {
		if string(b[:4]) == v {
			return true
		}
	}
	return false
}

// alpnPickはクライアントが優先するALPNプロトコルを返す(h2/http/1.1)または空文字
func alpnPick(rec []byte) string {
	p := rec
	adv := func(n int) []byte {
		if len(p) < n {
			p = nil
			return nil
		}
		out := p[:n]
		p = p[n:]
		return out
	}
	if h := adv(4); h == nil || h[0] != 0x01 { // ハンドシェイクタイプClientHello + 3バイト長
		return ""
	}
	adv(2 + 32) // legacy_version + random
	if l := adv(1); l != nil {
		adv(int(l[0])) // セッションID
	}
	if l := adv(2); l != nil {
		adv(int(l[0])<<8 | int(l[1])) // 暗号スイート
	}
	if l := adv(1); l != nil {
		adv(int(l[0])) // 圧縮方式
	}
	l := adv(2)
	if l == nil {
		return ""
	}
	exts := adv(int(l[0])<<8 | int(l[1]))
	for len(exts) >= 4 {
		typ := int(exts[0])<<8 | int(exts[1])
		n := int(exts[2])<<8 | int(exts[3])
		if len(exts) < 4+n {
			return ""
		}
		body := exts[4 : 4+n]
		exts = exts[4+n:]
		if typ != 16 || len(body) < 2 { // application_layer_protocol_negotiation
			continue
		}
		list := body[2:]
		for len(list) > 0 {
			k := int(list[0])
			if len(list) < 1+k {
				return ""
			}
			switch s := string(list[1 : 1+k]); s {
			case "h2", "http/1.1":
				return s
			}
			list = list[1+k:]
		}
	}
	return ""
}

// ---------- UDP ----------

// classifyUDPはRFC 9443範囲に基づき、最初のバイトでQUICかどうかを判定
// 64..79はTURNサーバ由来の場合もあるため注意
func classifyUDP(first byte) (isQUIC bool) {
	return (first >= 64 && first <= 127) || first >= 192
}

type udpSession struct {
	up   *net.UDPConn
	seen time.Time
}

func serveUDP(ctx context.Context, cfg Config) {
	pc, err := net.ListenPacket("udp", cfg.Listen)
	if err != nil {
		log.Fatalf("udp listen: %v", err)
	}
	conn := pc.(*net.UDPConn)
	go func() { <-ctx.Done(); conn.Close() }()

	quicAddr, _ := net.ResolveUDPAddr("udp", cfg.QUICBackend)
	gameAddr, _ := net.ResolveUDPAddr("udp", cfg.GameBackend)

	var mu sync.Mutex
	sessions := map[string]*udpSession{} // キー:クライアントアドレス + バックエンド

	// アイデア:アイドルセッションのクリーンアップ
	go func() {
		t := time.NewTicker(cfg.UDPIdle / 4)
		defer t.Stop()
		for range t.C {
			mu.Lock()
			for k, s := range sessions {
				if time.Since(s.seen) > cfg.UDPIdle {
					s.up.Close()
					delete(sessions, k)
				}
			}
			mu.Unlock()
		}
	}()

	buf := make([]byte, 65535)
	for {
		n, client, err := conn.ReadFromUDP(buf)
		if err != nil {
			return
		}
		if n == 0 {
			continue
		}
	
target := gameAddr
		if classifyUDP(buf[0]) {
			target = quicAddr
		}
		key := client.String() + ">" + target.String()
		mu.Lock()
		s := sessions[key]
		if s == nil {
			up, err := net.DialUDP("udp", nil, target)
			if err != nil {
				mu.Unlock()
				continue
			}
			s = &udpSession{up: up}
			sessions[key] = s
			// 返信用ゴルーチン
			go func(up *net.UDPConn, client *net.UDPAddr) {
				b := make([]byte, 65535)
				for {
					m, err := up.Read(b)
					if err != nil {
						return
					}
					conn.WriteToUDP(b[:m], client)
				}
			}(up, client)
		}
		s.seen = time.Now()
		mu.Unlock()
		s.up.Write(buf[:n])
	}
}

このゲートウェイが意図的に行わないこと

これはあくまで基盤であり、完成品ではありません。公開前に以下を計画してください:

  • クライアントアドレスの保持。 バックエンドはゲートウェイのアドレスを見ます。TCPではPROXYプロトコルを使うか、トンネルフレーミングにクライアントアドレスを含めてください。
  • QUICコネクションIDの認識。 セッションはクライアントアドレスでキー付けされているため、ネットワーク移行時に新しいアップストリームソケットが必要です。サーバが発行するルーティング可能なID(第5節参照)と併用してください。
  • UDPクライアントごとに1つのアップストリームソケット。 シンプルで正しいですが、エフェメラルポートを使用します。セッション数を制限し、マルチプレックス設計も検討してください。
  • 悪用対策。 レート制限や接続制限、増幅攻撃防止策は未実装です。QUICサーバは送信制限を行う必要があります(RFC 9000)。スプーフィングされた送信元に返信をリレーするゲートウェイも同様です。
  • Slowlorisスタイルのスニッフィング。 5秒のスニッフィングタイムアウトはアイドル接続を制限しますが、未分類の同時接続数も制御してください。
  • フェイルクローズ動作。 未知または不正な最初のバイトは現在フォールバックに流れます。必要に応じてドロップに変更してください。

9. 利点の測定:想定だけではなく実測を

ポートの集約は、運用上のメリットが大きいです:ファイアウォールルールの簡素化、リスナーの数削減、ポリシーの一元管理です。パフォーマンスの向上はワークロード次第なので、事前に測定してください。”単一ポート vs 複数ポート”の比較データは環境によって異なり、以下のポイントに注意が必要です:

  • ハンドシェイク遅延。 単一ポートはTLSやQUICのハンドシェイク時間を短縮しません。ALPNはClientHello内にあり、ポート数に関係なくラウンドトリップは増えません。QUICの0-RTTは再開時のみ有効で、リプレイ攻撃に対して保護されません。
  • パケットごとのコスト。 ユーザースペースでの分類はシステムコールが必要です。カーネル側(SK_REUSEPORTやXDP)ではこのコストを削減できます。バッチ読み込みやGRO/GSOも効果的です。XDPは高速化に適しますが、パケットレートが高い場合に効果的です。
  • 測定すべき内容。 接続確立時間(p50/p99)、秒あたりのパケット数、CPU使用率、ソケット数、エフェメラルポート数、ドロップ数などを実測し、QUICクライアントやNAT越えのクライアントも含めて評価してください。

10. 実装チェックリスト

  • ソケット。 TCPとUDPを同じポートにバインド。SO_REUSEPORTは必要に応じて使用。
  • UDP分類。 RFC 9443範囲を使い、「長ヘッダーまたは固定ビット」以外を選択。独自の最初のバイト範囲を設定し、grease_quic_bitは使わない。
  • QUICルーティング。 事前に決める:サーバIDルーティング、コネクションテーブル、またはゲートウェイでQUIC終端。いずれも設定しないと移行が破綻します。
  • TCP分類。 覗き見、ALPN解析、PRIプリフェースやHTTPメソッドの処理、ECH対応を計画。
  • カーネル高速化。 まずSK_REUSEPORTのeBPFを検討。高速化が必要ならXDPやAF_XDPも併用。
  • トンネル。 ストリームはストリームとして、ダットグラムはダットグラムとして運び、クライアント認証も適切に。
  • ポート予算。 エフェメラルポートはバックエンドの4タプルごとに管理。範囲拡大前にマルチプレックスを検討。
  • セキュリティ。 テーブルを制限し、セッションにタイムアウトを設定。未検証UDPはレート制限。分類できないトラフィックはどう扱うか決める。

参考文献

Continue from this article into the most relevant product guides and workflows.

Related Topics

#multiplexed multi-protocol gatewaying#TCP UDP QUIC bridging#single ingress port tunneling#port exhaustion prevention#HTTP/3 multiplexing#gRPC tunneling#game server traffic forwarding#persistent local tunnel edge#reverse proxy architecture#zero-trust proxy#transport layer security#protocol multiplexing#network socket multiplexing#high performance tunneling#cloudflare tunnels alternative#ngrok alternative#localhost tunneling#UDP packet forwarding#QUIC protocol proxying#TCP stream multiplexing#distributed systems networking#edge computing gateway#API gateway optimization#low latency networking#container networking proxy#edge proxy configuration#secure tunneling protocols#network address translation NAT bypass#socket pooling#connection multiplexing#traffic encapsulation#advanced networking tools#backend infrastructure engineering#cloud native networking#DevOps tools#high throughput proxies#network congestion mitigation#packet inspection#dynamic port allocation#bidirectional streaming#distributed proxy mesh#open source tunneling#software defined networking SDN#network packet routing#secure ingress controller#cloud edge routing#TCP proxy server#UDP hole punching alternative#custom protocol tunneling#developer networking tools

Keep building with InstaTunnel

Read the docs for implementation details or compare plans before you ship.

Share this article

More InstaTunnel Insights

Discover more tutorials, tips, and updates to help you build better with localhost tunneling.

Browse All Articles