peek()で覗くだけのSNIルーティングと、非TLS接続が毎回200ms待たされる理由

このプロジェクトはL4スコープなので、TLSは終端しません。証明書もTLSスタックも持たず、ClientHelloの先頭バイト列だけを覗いてSNI(server_name)を読み取り、それでバックエンドを振り分けます。暗号化されたペイロード自体には一切手を加えず、copy_bidirectionalでそのまま透過させます。

消費せずに覗く: TcpStream::peek()

SNIを読み取るために先頭バイトを読む必要がありますが、その後copy_bidirectionalが同じバイト列をもう一度正常に読み出せなければ、クライアントが送ったClientHelloの先頭が失われてハンドシェイクが壊れます。ここで使ったのがtokio::net::TcpStream::peek()です。

pub async fn peek_sni(inbound: &TcpStream, timeout: Duration) -> Option<String> {
    let mut buf = vec![0u8; PEEK_BUF_SIZE];
    let deadline = tokio::time::Instant::now() + timeout;

    loop {
        let n = inbound.peek(&mut buf).await.ok()?;
        if let Some(name) = parse_client_hello_sni(&buf[..n]) {
            return Some(name);
        }
        if tokio::time::Instant::now() >= deadline {
            return None;
        }
        tokio::time::sleep(PEEK_RETRY_INTERVAL).await;
    }
}

peek()はOSの受信バッファの中身を読み取るだけで、そのデータをバッファから取り除きません。同じバイト列は、この後copy_bidirectionalreadする際にもう一度そのまま渡ってきます。ClientHelloが複数のTCPセグメントに分かれて届く可能性もあるため、パースに必要な長さが揃うまで10ms間隔でリトライし、タイムアウト(200ms)で諦めて通常のロードバランサー経由にフォールバックする作りにしています。

ClientHelloを手でパースする

fn parse_client_hello_sni(buf: &[u8]) -> Option<String> {
    if buf.len() < 5 || buf[0] != 0x16 {
        return None; // TLSハンドシェイクレコードではない
    }
    let record_len = u16::from_be_bytes([buf[3], buf[4]]) as usize;
    let record_end = 5 + record_len;
    if buf.len() < record_end {
        return None;
    }

    let handshake = &buf[5..record_end];
    if handshake.len() < 4 || handshake[0] != 0x01 {
        return None; // ClientHelloではない
    }

    let mut pos = 4;
    pos += 2;  // client_version
    pos += 32; // random
    let session_id_len = handshake[pos] as usize;
    pos += 1 + session_id_len;

    let cipher_suites_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
    pos += 2 + cipher_suites_len;

    let compression_methods_len = handshake[pos] as usize;
    pos += 1 + compression_methods_len;

    let extensions_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
    pos += 2;
    let extensions_end = (pos + extensions_len).min(handshake.len());

    while pos + 4 <= extensions_end {
        let ext_type = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]);
        let ext_len = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize;
        let ext_start = pos + 4;
        let ext_end = ext_start + ext_len;
        if ext_end > extensions_end {
            return None;
        }
        if ext_type == 0x0000 {
            return parse_server_name(&handshake[ext_start..ext_end]);
        }
        pos = ext_end;
    }
    None
}

TLSレコードヘッダ → ハンドシェイクヘッダ → バージョン → random(32B) → session_id → cipher_suites → compression_methods → extensions、という順に固定長・可変長フィールドを手でなぞっているだけです。SNI拡張(ext_type == 0x0000)が見つかったら、その中のserver_name_listからhost_namename_type == 0)を取り出します。

こうした手書きのオフセット計算には、Rustの型システムがまったく守ってくれない領域があります。u16::from_be_bytes([a, b])abがバイト列のどの位置から来たかを一切気にせず、渡された2バイトをただの数値に変換するだけです。境界外アクセスは実行時パニックで守られますが、「本来読むべき位置とは違う、けれど範囲内の2バイト」を読んでしまった場合は、型としては正しいu16の値が出てくるので、コンパイラは何も指摘しません。手書きのバイナリパーサーはこの手のオフセットミスに対して構造的に無防備で、そのぶんテスト(あるいは今回やったような実際のTLSクライアントでの動作確認)で検証する必要があります。

SNIが暗号化されずに読める理由、そしてそれが今まさに変わりつつある理由

SNIをアプリケーション層の復号なしに読めるのは、TLS 1.3以前はClientHello全体(SNI拡張を含む)が暗号化されない設計だったためです。CDNやロードバランサがTLSを終端せずにドメイン名でルーティングできるのは、まさにこの性質のおかげであり、今回の実装もそれに乗っています。一方で、この「SNIが平文で見える」性質は経路上の誰でも接続先ドメインを盗み見られることを意味するため、TLS 1.3の後継仕様としてClientHello自体を暗号化するECH(Encrypted Client Hello)の標準化が進んでいます。ECHが広く使われるようになれば、このプロジェクトのような「ClientHelloを覗くだけ」のSNIルーティングは前提から成立しなくなり、TLSを終端して復号するプロキシでなければルーティングできなくなります。

非TLS接続が毎回タイムアウト分だけ待たされる

peek_sniのループは、「ClientHelloとして解釈するにはバイトが足りない」場合と「そもそもTLSのレコードですらない」場合を区別していません。parse_client_hello_sniは後者でも即座にNoneを返しますが、呼び出し側のpeek_sniはそのNoneを「まだ受信中かもしれない」として扱い、SNI_PEEK_TIMEOUT(200ms)をまるごと使い切ってからロードバランサー経由のフォールバックに切り替えます。実際に非TLSの生TCP接続でこのプロキシ(8000番)を通すと、実データのやり取りが始まる前に必ず約200ms分の待ちが挟まることを、ncでの動作確認で確認しました。SNIルーティングを使わない既存のTCPプロキシ機能にまで一律でこの遅延が乗ってしまう点は、現状の実装のトレードオフとして残っています。

フェイルオーバーの範囲外になる

SNIで一致したバックエンドは、ロードバランサーのnext_backend()を経由せず固定で使います。接続が失敗した場合のリトライは既存のmax_connection_attemptsの枠内で行われますが、SNIルートは常に同じ1台を指し続けるため、他の健全なバックエンドへのフェイルオーバーは起きません。ラウンドロビン経由の通常の接続とは異なる制約です。