Привет! Недавно я разработал новый инструмент ycprox, который позволяет быстро развернуть прямой прокси в инфраструктуре Yandex Cloud для смены IP-адреса (почти) при каждом запросе. Можете ознакомиться с ним на Github (документация на русском), а в этом посте я расскажу о некоторых особенностях, с которыми мне пришлось столкнуться в процессе разработки.

Идея💡

Изначально я хотел адаптировать существующий проект fireprox для использования ресурсов Yandex Cloud (YC). Вот как работает fireprox на пальцах:

  1. fireprox использует ваш статический ключ доступа для взаимодействия с AWS API
  2. fireprox создаёт новый API Gateway в облаке, который перенаправляет HTTP(S) запросы на выбранный целевой хост
  3. Вы отправляете запросы на API Gateway вместо целевого хоста, а AWS под капотом использует новый IP из своего пула и проксирует эти запросы
  4. PROFIT!1!1 Вы успешно скрыли свой IP-адрес и обошли все рейтлимитеры.

Я быстро посмотрел код и увидел, что он явно использует boto3 для общения с AWS API:

#!/usr/bin/env python3
from multiprocessing import Pool
from pathlib import Path
import shutil
import tldextract
import boto3
...

class FireProx(object):
...
    def load_creds(self) -> bool:
      ...
        if self.access_key and self.secret_access_key:
            try:
                self.client = boto3.client(
                    'apigateway',
                    aws_access_key_id=self.access_key,
                    aws_secret_access_key=self.secret_access_key,
                    aws_session_token=self.session_token,
                    region_name=self.region
                )  
    ...

Проблема в том, что у Yandex Cloud есть собственный python SDK для взаимодействия с API, и он не совместим с boto3, поэтому вариант “просто заменить aws на yc” не подходил. Также я хотел сделать код более модульным и использовать библиотеку Pydantic-settings, которая отлично подходит для валидации данных и, кстати, имеет встроенные возможности для разработки CLI-приложений. Это то, что нужно — мы можем одновременно валидировать данные и построить свою собственную CLI-модель приложения.

Небольшие трудности

Неправильные сертификаты??📝

Итак, я начал создавать Proof-of-Concept. Сначала я хотел взять openapi-спецификацию из fireprox и адаптировать её для Yandex Cloud Serverless API Gateway:

openapi: 3.0.0
info:
  title: {title}
  version: "{version_date}"
paths:
  /:
    get:
      summary: Root path proxy
      parameters:
        - name: X-My-X-Forwarded-For
          in: header
          required: false
          schema:
            type: string
      x-yc-apigateway-integration:
        type: http
        url: "{url}/"
        method: GET
        headers:
          X-Forwarded-For: "{{X-My-X-Forwarded-For}}"
          '*': '*'
        query:
          '*': '*'
        omitEmptyHeaders: true
      responses:
        '200':
          description: Successful response
  /{{proxy+}}:
    x-yc-apigateway-any-method:
      summary: Catch-all proxy for any path and method
      parameters:
        - name: proxy
          in: path
          required: true
          schema:
            type: string
        - name: X-My-X-Forwarded-For
          in: header
          required: false
          schema:
            type: string
      x-yc-apigateway-integration:
        type: http
        url: "{url}/{{proxy}}"
        headers:
          X-Forwarded-For: "{{X-My-X-Forwarded-For}}"
          '*': '*'
        query:
          '*': '*'
        omitEmptyHeaders: true
      responses:
        '200':
          description: Successful response

В теории это должно было сработать, но на практике я получил неприятную ошибку в ответе🙄:

{"message":"Hostname/IP does not match certificate's altnames: Host: XXXXXXXXXXXXXXXXXXXX.YYYYYYYY.apigw.yandexcloud.net. is not in the cert's altnames: DNS:ZZZZZZZZZZ.com"} 

После общения с ChatGPT я выяснил, что проблема была в пробросе заголовков. Похоже, наша openapi-спецификация делает именно то, о чём мы её попросили😅 Она пробрасывает все заголовки. К сожалению для нас, заголовок Host тоже.

Давайте исправим это и передадим правильный домен в заголовок Host:

x-yc-apigateway-integration:
    type: http
    url: "https://habr.com/{proxy}"
    headers:
      Host: habr.com      # Здесь мы передаём правильный хост, в данном случае habr.com
      '*': '*'            # Все остальные заголовки пробрасываются, кроме Host, потому что он был явно определён

Теперь мы можем шаблонизировать поле host в python и получить правильный ответ на наш GET-запрос:

Кстати, я использую webhook.site как отличный инструмент для проверки входящих запросов и тестирования нашего прокси.

Лишние заголовки🙉

Кажется, теперь у нас есть отличный клон оригинальной openapi-спецификации fireprox, но когда я поднял API Gateway с этой спецификацией, я обнаружил, что хотя IP-адрес действительно менялся, к моему исходному HTTP-запросу добавлялись лишние дурацкие заголовки:

Нет никакого смысла прятаться за прокси и при этом передавать свой IP-адрес в специальном заголовке x-real-remote-address, который наш “дружелюбный” API Gateway создал за нас. Поэтому я попытался изменить это поведение. Сначала я попробовал явно определить эти заголовки с пустыми значениями, надеясь, что API Gateway пропустит их или перезапишет пустыми значениями, как я сделал с заголовком Host. Но тут я потерпел неудачу… Даже опция omitEmptyHeaders: true не сработала для этих заголовков.

Разочаровавшись этой особенностью, у меня возникла мысль передавать запрос в Serverless Function (аналог Lambda function в AWS), которая могла бы очистить эти заголовки.

Борьба с query-параметрами в функциях🤺

Почему бы не использовать только serverless function? — можете спросить вы. В теории это могло бы работать, но у serverless-функций специфичный путь вместо отдельного домена для их вызова вроде такого:

https://functions.yandexcloud.net/XXXXXXXXXXXXXXXXXXXX

Это могло бы вызвать определённые проблемы с пробросом query-параметров в такую функцию, поэтому я решил оставить вариант Gateway + Function.

В итоге я навайбкодил serverless-функцию, которая делает следующее:

  1. Принимает запрос с лишними заголовками от API Gateway
  2. Вырезает все эти заголовки
  3. Отправляет запрос на целевой хост
BLOCKED_REQUEST_HEADERS = {
    "uber-trace-id",
    "x-real-remote-address",
    "host",
    "x-serverless-gateway-id",
    "x-serverless-certificate-ids",
    "tracestate",
    "traceparent",
    "x-api-gateway-function-id",
    "x-envoy-external-address",
    "x-envoy-original-path",
    "x-request-id",
    "x-trace-id",
}
...

def handler(event, context):
    method = (event.get("method") or "GET").upper()
    
    proxy_value = event.get("pathParams", {}).get("proxy")
    path = "/" + proxy_value if proxy_value else "/"
    ...

    # Filter request headers
    outgoing_headers = {}
    for name, value in incoming_headers.items():
        if name is None or value is None:
            continue
        if name.lower() in BLOCKED_REQUEST_HEADERS:
            continue
        outgoing_headers[name] = value
    ...
    
    # Send request to backend
    resp = requests.request(
        method=method,
        url=backend_url,
        headers=outgoing_headers,
        params=query,
        data=body_bytes,
        allow_redirects=False,
    )
    ...

Ещё я выстрелил себе в ногу, когда использовал event["path"] вместо pathParams. Проблема в том, что event["path"] содержит /{proxy+} так, как было определено в API Gateway, но фактическое переданное значение параметра хранится в event["pathParams"]["proxy"]. Теперь всё работает отлично! Все заголовки фильтруются, и IP меняется на IP провайдера Yandex Cloud!

Заключение🏁

Спасибо за чтение моего поста! Буду очень признателен, если вы протестируете ycprox в реальных боевых сценариях. Если у вас тяжёлые запросы, попробуйте увеличить таймаут Cloud Function — думаю, это должно помочь.

Надеюсь, вам понравилось читать этот пост😊 Не забудьте поставить завездочку на гитхабе и до скорых встреч!