Привет! Недавно я разработал новый инструмент ycprox, который позволяет быстро развернуть прямой прокси в инфраструктуре Yandex Cloud для смены IP-адреса (почти) при каждом запросе. Можете ознакомиться с ним на Github (документация на русском), а в этом посте я расскажу о некоторых особенностях, с которыми мне пришлось столкнуться в процессе разработки.
Идея💡
Изначально я хотел адаптировать существующий проект fireprox для использования ресурсов Yandex Cloud (YC). Вот как работает fireprox на пальцах:
- fireprox использует ваш статический ключ доступа для взаимодействия с AWS API
- fireprox создаёт новый API Gateway в облаке, который перенаправляет HTTP(S) запросы на выбранный целевой хост
- Вы отправляете запросы на API Gateway вместо целевого хоста, а AWS под капотом использует новый IP из своего пула и проксирует эти запросы
- 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-функцию, которая делает следующее:
- Принимает запрос с лишними заголовками от API Gateway
- Вырезает все эти заголовки
- Отправляет запрос на целевой хост
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 — думаю, это должно помочь.
Надеюсь, вам понравилось читать этот пост😊 Не забудьте поставить завездочку на гитхабе и до скорых встреч!