Opt-In Software Blog

Software, dvelopment, and practical insights

ProxyMapService: трансляция HTTP/1.1 → HTTP/2

Читать на другом языке: English | Русский

В ProxyMapService появилась трансляция HTTP/1.1 → HTTP/2: клиент по-прежнему говорит на HTTP/1.1, а прокси общается с upstream-сервером по h2 и конвертирует ответы обратно в HTTP/1.1.

Зачем это нужно

Многие современные серверы (gRPC-шлюзы, Kestrel с h2-only, CDN) принимают или предпочитают HTTP/2. При этом клиентские программы, скрипты и старые библиотеки часто умеют только HTTP/1.1. Прокси закрывает этот разрыв: клиент ничего не меняет, а сервер получает современный протокол. Весь трафик по-прежнему проходит через прокси и логируется, в том числе h2-фреймы на стороне сервера.

Как включить

Флаг EnableTranslateH1ToH2 можно задать на трёх уровнях — глобально, для слушающего порта или для отдельного правила хоста:

{
  "HTTP": {
    "EnableTranslateH1ToH2": true
  },
  "ProxyMappings": [
    {
      "Listen": {
        "Port": 8080,
        "Action": "Bypass",
        "DecryptSSL": true,
        "EnableTranslateH1ToH2": true
      }
    }
  ],
  "HostRules": {
    "Items": [
      {
        "HostName": "api.example.com",
        "DecryptSSL": true,
        "EnableTranslateH1ToH2": true
      }
    ]
  }
}

Трансляция работает только для HTTPS с расшифровкой (MITM): нужно видеть запросы, чтобы перепаковать их в фреймы. Для прозрачного туннеля и обычного HTTP флаг не действует.

Важно: сам по себе флаг ничего не делает — upstream должен выбрать h2 на TLS-рукопожатии. Поэтому в SslClientOptions.ApplicationProtocols должен быть h2 (см. ниже).

Как это устроено и ограничения

Каждый запрос клиента парсится как HTTP/1.1, затем прокси отправляет на сервер HEADERS и DATA-фреймы, а ответ h2 собирает обратно в строку статуса, заголовки и тело. Правила преобразования:

  • hop-by-hop заголовки (Connection, Keep-Alive, Transfer-Encoding, Upgrade, Host) отбрасываются; Host становится псевдозаголовком :authority
  • метод, схема и путь уходят в :method, :scheme, :path
  • ответ без Content-Length отдаётся клиенту с Transfer-Encoding: chunked, трейлеры передаются
  • некорректные заголовки (CR/LF/NUL, конфликтующие Content-Length, неоднозначный Transfer-Encoding) отклоняются как защита от request smuggling

Ограничения:

  • транслируется только направление h1 → h2; обратного h2 → h1 нет
  • одновременно активен один h2-stream: pipelining клиента выполняется последовательно
  • CONNECT и WebSocket upgrade не транслируются и получают ответ 501 Not Implemented
  • server push отключён (ENABLE_PUSH=0)
  • если сервер присылает RST_STREAM, клиент получит 502

Роль ApplicationProtocols

Прокси делает два TLS-рукопожатия, и у каждого свой список ALPN. Для начала три обозначения:

  • C — SslClientOptions.ApplicationProtocols: что прокси предлагает upstream-серверу
  • S — SslServerOptions.ApplicationProtocols: что прокси разрешает клиенту
  • K — ALPN из ClientHello самого клиента: что клиент реально умеет

Порядок рукопожатий обратный: сначала прокси договаривается с upstream, потом отвечает клиенту. Точнее, он заранее подглядывает ClientHello (SocketFlags.Peek) и уже потом выбирает, что предложить серверу.

Если список пуст (в K или в S), подразумевается только http/1.1. Пустой ALPN означает, что клиент ничего не согласовывает, и тогда поверх TLS идёт HTTP/1.1.

Решение принимается в три шага (T — включённая трансляция):

  1. Возможности клиента: h1 = K ∩ S содержит http/1.1, h2 = K ∩ S содержит h2.
  2. Что предложить upstream: h2 — если оно есть в C и (клиент умеет h2 или T включена и клиент умеет h1); http/1.1 — если оно есть в C и клиент умеет h1.
  3. После ответа upstream: h2 и клиент умеет h2 — прозрачный HTTP/2; h2 и клиент только h1 при включённой T — трансляция; upstream ответил http/1.1 и клиент умеет h1 — обычный HTTP/1.1. Всё остальное — ошибка согласования.

Результат прокси фиксирует: клиенту предлагается ровно один протокол, тот, на котором будет работать сессия.

Сценарии

Браузер присылает K = http/1.1, h2, если не сказано иначе. У curl и подобных клиентов — только http/1.1.

C (SslClientOptions) S (SslServerOptions) K (клиент) T Результат
http/1.1 http/1.1 любой с h1 любое HTTP/1.1 с обеих сторон
http/1.1,h2 http/1.1,h2 http/1.1,h2 любое общается как сервер выбрал: h2 — прозрачный HTTP/2, иначе HTTP/1.1
h2 http/1.1 http/1.1 включена трансляция h1 → h2
h2 http/1.1 http/1.1 выключена ошибка согласования
h2 http/1.1,h2 http/1.1,h2 любое прозрачный HTTP/2, трансляция не нужна
h2 http/1.1,h2 http/1.1 (curl) включена трансляция h1 → h2
h2 http/1.1,h2 http/1.1 (curl) выключена ошибка согласования
http/1.1,h2 http/1.1 http/1.1,h2 включена upstream выбрал h2 — трансляция; выбрал http/1.1 — обычный HTTP/1.1
http/1.1,h2 http/1.1 http/1.1,h2 выключена HTTP/1.1 с обеих сторон (серверу h2 не предлагается)
http/1.1 h2 http/1.1,h2 любое ошибка: клиент умеет только h2, а upstream не предлагается h2 (обратной трансляции h2 → h1 нет)

Последняя строка — частая ловушка: http/1.1 в C и жёсткое h2 в S не согласуются ни при каких настройках, потому что трансляция есть только в одну сторону.

Диагностика

При успешной трансляции в логе будет запись уровня Info:

HTTP/1.1 -> HTTP/2 translation: client 127.0.0.1:39461 speaks HTTP/1.1, upstream localhost:5112 negotiated h2

Рекомендация для старта: SslClientOptions.ApplicationProtocols = "http/1.1,h2", SslServerOptions.ApplicationProtocols = "http/1.1" и EnableTranslateH1ToH2 = true. Прокси предложит серверу h2, а клиент останется на HTTP/1.1.

Search