🧑‍💻DevDays
10 subscribers
4 photos
29 links
Практичні нотатки про кібербезпеку, мережі, self-hosting, проксі та AI-інфраструктуру українською. Реальні кейси, код і розбори.
Download Telegram
Навіщо мені тут HAProxy ⚖️

Підключити redsocks до одного проксі нескладно. Проблеми починаються, коли проксі стає багато 🤔

Я не хочу вручну перевіряти, хто зараз відповідає, міняти адресу в конфігу й перезапускати все після кожного падіння.

Тому між redsocks і пулом поставив HAProxy. Він:

• розподіляє нові з’єднання;
• перевіряє backend через health check;
• прибирає з роботи ті, що перестали відповідати.

Для перемикання режимів написав невелику REST API-обгортку над HAProxy Runtime API. Зміни можна застосовувати на льоту, без перезапуску балансувальника ⚙️

API залишив у management-мережі, а операції зміни режиму закрив Bearer-токеном 🔐

Для тестів підняв вісім легких LXC-контейнерів. Далі подивимось, як між ними розлітаються запити.
Roundrobin у дії 🔁

Почав із найпростішого режиму — roundrobin.

Логіка очевидна: нове TCP-з’єднання віддаємо наступному доступному proxy backend. Дійшли до кінця списку — починаємо спочатку 🔄

Для перевірки підняв echo server на 10.10.10.2:8000 і відправив до нього десять запитів через gateway.

Спочатку логи здались трохи хаотичними, бо записи з різних контейнерів виводились не по порядку 😶‍🌫️

Але після сортування за timestamp усе стало на місце: контейнери проходять послідовно, а дев’ятий і десятий запити починають нове коло ✅

Є нюанс: HAProxy розподіляє саме з’єднання. Якщо програма тримає один keep-alive connection, запити всередині нього не будуть стрибати між backend.

А що робити, якщо нам навпаки потрібна стабільна IP-адреса? Тут з’являється stick-table 📌
Навіщо я додав stick-table 📌

Roundrobin добре розподіляє навантаження, але підходить не для кожного сценарію.

Уявімо, що програма робить декілька послідовних дій на одному сайті. Якщо між ними зміниться зовнішня IP-адреса, сесія може виглядати підозріло або просто зламатися 🤷‍♂️

Тому додав режим із прив’язкою клієнта до одного backend.

Важливий момент: stick-table — не алгоритм балансування. Це таблиця стану. Разом зі stick on src вона запам’ятовує, який backend HAProxy обрав для конкретної IP-адреси клієнта 🧠

Перемкнув режим через REST API і повторив десять запитів. Цього разу всі їх записав один контейнер — CT-302 ✅

Отже, прив’язка працює. Тепер треба було перевірити найважливіше: чи справді VM не може вийти напряму.
🧑‍💻DevDays pinned «Маю цікаву ідею сервісу для проксірування трафіку 🚀💻. Детально розпишу у статті або статтях 📝, інформації для вивчення там немало, а тут опишу суть. А суть в тому, що помітив, що іноді DNS-трафік летить напряму до резолвера, незважаючи на те, що я намагаюся…»
Чи може VM обійти gateway? 🧪

Те, що curl показує адресу проксі, — добре. Але мені хотілось переконатись, що іншого маршруту справді немає 🔍

Спочатку запит до ifconfig.me повернув IP проксі-сервера. Потім я видалив default route:

ip route del default


Після цього curl уже не зміг виконати DNS-запит, а ping відповів Network is unreachable. Тобто запасного виходу в інтернет машина не знайшла ✅

Повернув маршрут тільки через тестовий gateway:

ip route add default via 192.168.90.1 dev eth1


З firewall теж встиг трохи повоювати 😅 Спочатку OutputPolicy=DROP відрізав навіть керування через SSH. Для тестового стенда залишив явний DROP на небажаний вихід через management-інтерфейс.

У production я б усе ж залишав deny-by-default і окремо дозволяв потрібний службовий трафік 🛡
Вимикаю один проксі й повторюю тест 🩺

Остання перевірка — що буде, якщо один proxy backend перестане відповідати.

Вимкнув один із восьми тестових контейнерів і ще раз відправив десять запитів до echo server.

Усі десять повернули HTTP 200 ✅

Нових записів від вимкненого контейнера в логах не з’явилось. HAProxy продовжив віддавати з’єднання тільки доступним backend. Саме такої поведінки я й очікував.

Для лабораторного тесту цього достатньо. У production я б додатково читав статус DOWN через HAProxy Runtime API або сторінку статистики й підключив alerts 📊

І ще одне обмеження: ця гілка перехоплює TCP. Довільний UDP, зокрема QUIC/HTTP3 на UDP/443, через неї не проходить. DNS у моєму gateway обробляється окремо.

Повний розбір зі схемою та скріншотами залишу в наступному пості 🚀
Transparent Gateway для TCP — повний розбір 🚀

Зібрав в одну статтю весь шлях від ізольованої VM до тесту падіння proxy backend ⚙️

Всередині:

• окремий VLAN і маршрут через gateway;
• перехоплення TCP через iptables;
• redsocks як міст до проксі;
• HAProxy, roundrobin і stick-table;
• REST API для перемикання режимів;
• перевірка вихідної IP-адреси;
• тест health check на восьми LXC-контейнерах.

Додав команди, схему й реальні скріншоти з тестового стенда 📄

https://devdays.net.ua/articles/prozore-proksiiuvannia-tcp-redsocks-haproxy/

Код проєкту тут:
https://github.com/Va1b0rt/TransparentGateway

Наступний крок — збирати проксі з дозволених джерел, перевіряти їх, ранжувати й уже після цього завантажувати в робочий пул. Там теж вистачає цікавих нюансів 😬
З тестовими проксі було аж занадто добре 😅

Під час тестів я вручну додав вісім LXC у HAProxy. Вони стояли на своїх адресах, слухняно відповідали на health check і нікуди не зникали. Краса 😎

А потім я подумав про реальні проксі. Сьогодні адреса жива, за годину гальмує, завтра її вже немає. І спробуй вгадай, яка саме вирішить померти посеред роботи 🫣

Перевіряти це руками після кожного падіння? Не наш метод 😤. Буду автоматизувати.

Замість одного списку зібрав конвеєр:

source → fetcher → validator → ranker → active pool → gateway


Останні завершені цикли видно так:

docker compose logs --no-log-prefix --tail=20 \
fetcher validator ranker


На моєму стенді зараз виходить:

[fetcher] sources=1 candidates=1
[validator] healthy=1/1
[ranker] validated=1 reserve=1 active=1


Це один агрегований HAProxy endpoint, тому 1 → 1 → 1 тут нормально. Головне — три незалежні worker завершили свої цикли.

Далі пішов підключати джерела. Здавалося б, JSON він і є JSON. Ага, зараз 😅
Хотів один connector для всіх JSON. Не вийшло 🧩

Першою думкою було зробити універсальний connector: даю йому URL, він читає JSON, усі задоволені 🚀

Перший реальний feed швидко пояснив, що мій оптимізм нічим не підкріплений 😅

Одне джерело повертає ip, інше host, протокол лежить то в type, то в protocol. Якщо додавати всі ці винятки у fetcher, через місяць там буде музей чужих форматів.

Тому всередині залишив одну модель:

host, port, protocol, source, credential_ref, metadata


А різницю виніс у adapters. Джерела вмикаються явно:

{
"allowed_connector_types": [
"jsonl_inventory", "http_json_feed"
],
"active_connectors": [{
"type": "jsonl_inventory",
"name": "lab-lxc",
"path": "/inventory/upstreams.jsonl"
}]
}


Автоматично імпортувати всі Python-файли з каталогу теж не став. Випадково підкинутий файл не повинен раптом стати дозволеним мережевим джерелом 🔐

Fetcher збирає записи, прибирає дублікати й публікує proxy_pool:raw:v1. Але raw — це ще не пул. Це список адрес, яким поки що ніхто не вірить.

Наступним кроком треба було перевірити, чи вони взагалі проксі, а не просто красиві відкриті порти 🔍
Порт відкритий. Проксі живий? Не факт 🔌

Спочатку хотів перевіряти кандидатів через nc -z. Порт відкрився — значить проксі живий. Логіка залізна. Майже 😅

Socket може прийняти з’єднання, а потім відмовити на HTTP CONNECT, зламати SOCKS handshake або просто не дістатися цільового сервера.

Тому validator проходить весь маршрут:

candidate
→ HTTP CONNECT / SOCKS4 / SOCKS5 handshake
→ echo endpoint
→ HTTP status + JSON + latency


Щоб під час перевірки не покласти власний gateway, обмежив конкуренцію:

VALIDATION_CONCURRENCY=100
PROXY_VALIDATION_TIMEOUT_SECONDS=8


Semaphore тримає максимум 100 одночасних перевірок. Для сотень і тисяч адрес цього вистачає. Перед сотнями тисяч доведеться замінити asyncio.gather() на bounded queue, інакше coroutine самі з’їдять пам’ять 🤷‍♂️

З proxy auth теж без магії. У snapshot лежить лише credential_ref, а secret дістається перед handshake:

auth = credentials.get(candidate.credential_ref)


Немає посилання або пароль неправильний — кандидат вилітає. Тихого fallback без авторизації немає.

Handshake пройшов. Уже можна радіти? Ще ні. Проксі може працювати й акуратно здавати мою адресу далі 🫣
Проксі працює, але здає мою IP 🕵️

Після успішного handshake я майже записав кандидата в робочий пул. Але ж проксі може додати мою реальну адресу в X-Forwarded-For і формально все одно повернути HTTP 200 😬

Тому validator спочатку йде до echo напряму й запам’ятовує адресу клієнта. Потім повторює запит через проксі.

Якщо direct IP знову видно як peer або вона з’явилась у Forwarded, X-Forwarded-For, X-Real-IP чи Client-IP — кандидат відхиляється.

Для разової зовнішньої перевірки використовую:

ANONYMITY_CHECK_URL=https://httpbingo.org/get


Саме /get, а не /ip. Поточному parser потрібні і origin, і об’єкт headers. Тому ipify та ifconfig.me сюди без доробки не підходять.

У лабораторії в мене інший endpoint:

ANONYMITY_CHECK_URL=http://10.10.10.2:8000/


Він перевіряє HAProxy та контейнери 301–308, але нічого не доводить про публічний Internet. Для production потрібен ще власний HTTPS echo. Чужий безкоштовний сервіс сьогодні працює, а завтра вводить rate limit — і знову весело 🙃

Адресу отримали. Тепер можна ранжувати. І тут найшвидші кандидати підготували ще один сюрприз.
Десять IP в одному плащі 🧥

Відсортував кандидатів за latency, подивився на красиву верхівку списку й уже хотів радіти 🚀

Потім перевірив ASN. Виявилось, кілька найшвидших адрес сидять в одного провайдера й навіть в одній підмережі. На папері проксі різні, а впадуть дружною компанією 😅

Для цього зробив окремий demo:

./scripts/run-diversity-ranking-demo.sh


Результат скорочено:

INPUT
proxy-1: ASN=AS1, latency=10ms
proxy-2: ASN=AS1, latency=11ms
proxy-3: ASN=AS2, latency=20ms

OUTPUT
1. proxy-1: ASN=AS1, score=92.909091
2. proxy-3: ASN=AS2, score=49.619048
3. proxy-2: ASN=AS1, score=28.444444


Повільніший AS2 піднявся між двома AS1 через ASN/subnet penalty. Це синтетичний тест: production snapshot він не чіпає, зате добре показує логіку ranker 🔍

Ще додав MIN_SCORE_THRESHOLD. Якщо нормальних кандидатів лише 40, POOL_SIZE=150 не змушує добирати 110 трупів для красивої цифри. Порожнє місце в пулі краще за проксі, який не пройшов поріг.

З ранжуванням розібрався. Залишилось безпечно передати результат між worker. Один ключ у Redis? Ну звісно, ні 😬
Один ключ у Redis? Ага, зараз 😅

Спочатку хотів тримати весь стан в одному Redis key. Fetcher записав список, validator його оновив, ranker відсортував. Просто ж 🤔

А потім два worker одночасно торкнуться цього ключа — і спробуй зрозумій, чий там зараз результат.

Тому розділив конвеєр на повні snapshots:

proxy_pool:raw:v1
proxy_pool:validated:v1
proxy_pool:reserve:v1
proxy_pool:active:v1


Перевірити поточний стан можна так:

./scripts/run-redis-rows.sh


На лабораторному стенді зараз:

proxy_pool:raw:v1: 1
proxy_pool:validated:v1: 1
proxy_pool:reserve:v1: 1
proxy_pool:active:v1: 1
transparent-gateway:validated:v1: 1


Ці числа показують, що ключі містять повні списки. Чи належать вони одному циклу — окремо звіряю через generated_at, source_generated_at і logs.

Запис теж зробив без проміжного стану: спочатку тимчасовий key, потім атомарний Redis RENAME. Читач бачить старий або новий повний JSON, а не половину нового пулу 🛡

Active готовий. Тепер просто віддам його HAProxy й піду пити каву. Принаймні такий був план ☕️
Віддав mixed pool у HAProxy. Майже 🚧

План здавався нормальним: ranker формує active pool, HAProxy підхоплює backend через config або Runtime API, я задоволений і йду далі 😎

Але пул змішаний: HTTP, HTTPS, SOCKS4, SOCKS5, ще й credentials подекуди є. HAProxy як TCP backend не виконає SOCKS handshake і не дістане пароль за credential_ref.

От і приїхали 😬

Між redsocks та upstream довелось додати protocol-aware relay:

VM → iptables → redsocks :12345
→ egress_relay :12000
→ HTTP / HTTPS / SOCKS4 / SOCKS5 upstream
→ destination


Перед новим з’єднанням relay перевіряє active snapshot, але не частіше ніж раз на три секунди. Потім вибирає кандидата, виконує правильний handshake і тільки тоді підставляє credentials. Restart або reload не потрібен ⚙️

Є чесне обмеження: у живому active pool поки один агрегований HAProxy endpoint. Тому показати реальний cooldown між двома direct candidates на цьому стенді я не можу. Логіка покрита unit-тестами, а HAProxy failover окремо перевірений на восьми LXC.

Отака вийшла проста задача «автоматично оновлювати список проксі» 😅

У наступному пості зберу код, тести й повний матеріал.
Автоматичний пул проксі — код, тести й граблі 🚀

Зібрав увесь шлях в одну статтю: від першого JSON до active pool, який gateway підхоплює без рестарту. Інформації вийшло нормально так 😅

Перед запуском стенда перевірив протокольну логіку окремо:

python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements-dev.txt
python -m pytest -q


Результат:

............................ [100%]
28 passed


Усередині статті:

• connector adapters;
• HTTP, HTTPS, SOCKS4 і SOCKS5 validation;
• authenticated proxy без secrets у Redis;
• перевірка витоку IP;
• ASN diversity та quality threshold;
• атомарні snapshots;
• глухий кут із HAProxy;
• protocol-aware egress relay;
• шість практичних перевірок.

Обмеження теж не ховав: стенд поки має один агрегований HAProxy endpoint, для реальної різноманітності потрібні direct candidates, а для постійної зовнішньої перевірки — власний HTTPS echo.

Стаття, до якої вела ця серія:
https://devdays.net.ua/articles/avtomatychnyi-pul-proksi-zbir-validatsiia-ranzhuvannia/

Код:
https://github.com/Va1b0rt/TransparentGateway

Далі буду відучувати gateway від ручного налаштування однієї VM: Proxmox, cloud-init і deployment. Там теж без граблів не обійдеться ⚙️😬
❤1
Gateway працює. А повторити його я зможу? 🤔

Transparent Gateway уже маршрутизує DNS і TCP, оновлює active pool та переживає перезапуск контейнерів. На схемі — майже production. На диску — кілька днів моїх ручних пригод 😅

Поки VM жива, різниці не видно. Але якщо диск завтра вирішить піти у відпустку, чи вдасться відтворити той самий gateway на чистій машині без історії shell і магічного "десь я це вже правив"?

Почав із простого аудиту на gateway: перевірив середовище, стан cloud-init, версію Debian і запущені сервіси.

systemd-detect-virt
cloud-init status
. /etc/os-release; echo "$PRETTY_NAME"
docker compose ps


Ключові рядки виглядали так:

kvm
status: done
Debian GNU/Linux 13 (trixie)


Docker також показав усі шість сервісів запущеними. Здавалося б, де проблема?

Проблема в одному слові: done стосується cloud-init, а не всього gateway. VM створена автоматично, застосунок — ще ні. Далі пішов шукати, де саме закінчується автоматизація 🔍
❤1
Щоб зрозуміти, яку частину gateway справді можна відтворити, порівняв вміст робочої VM з тим, що зберігає Git. Перевірку запускав на gateway з кореня проєкту:

cd /opt/transparent-gateway
git ls-files | grep -E \
'(^|/)(deploy|cloud-init|systemd|.*\.service$|docker-compose\.yml$|\.env\.example$)'


Результат короткий:

.env.example
docker-compose.yml


Скрипту розгортання немає. Конфігурації cloud-init немає. Окремого systemd-сервісу для gateway теж немає. Робочі .env, списки проксі та паролі живуть лише на конкретній VM.

До того ж відтворити треба не лише контейнери. У хоста три мережеві ролі: керування, клієнтський gateway і лабораторна мережа з проксі та echo-сервісом. Переплутати два інтерфейси — і Docker буде зелений, а клієнтський трафік піде гуляти не туди 🚶‍♂️

Отже, копії каталогу недостатньо. Потрібна процедура, яка відтворює весь стан — від мережі до повної перевірки готовності.
❤1
Вимкнути VM і зробити копію. Що може піти не так? 😏

Перша ідея була дуже приємною: вимкнути робочу VM, зробити з неї шаблон Proxmox і надалі створювати вже готові копії gateway. Швидко, просто, навіть образи Docker завантажувати не треба 🚀

Але така копія зберігає не задум системи, а весь археологічний шар диска:

• старі паролі й токени провайдера;
• ключі SSH і стан попереднього cloud-init;
• дані Redis зі старого середовища;
• застарілі образи Docker;
• резервні файли з категорії "не чіпай, воно для чогось".

Через місяць дві VM уже різні, але пояснити чому можна тільки за кавою і з використанням ненормативної лексики ☕️😅

Тому вирішено робочу VM в шаблон не консервувати. Чистий шаблон має знати про Debian і cloud-init, але не про поточний пул проксі, паролі та випадкові ручні виправлення.

Одразу побачив іншу крайність: якщо не класти застосунок в образ, може запхати все розгортання у cloud-init? Авжеж ні 😬
Please open Telegram to view this post
VIEW IN TELEGRAM
❤1
Запхнути все розгортання в runcmd. YAML починає мститися 📜

План номер два: cloud-init встановить Docker, забере код із Git, створить конфігурацію, передасть секрети й виконає docker compose up. Один YAML — одна VM — усі задоволені. Перші хвилин п’ять 😅

Далі runcmd росте, повторний запуск стає окремою пригодою, а перевірити розгортання можна лише створенням нової VM. Якщо команда впала посередині, треба вгадати, що вже виконалося, а що тільки збиралося.

Із секретами ще веселіше. Cloud-init зберігає отриманий user-data всередині /var/lib/cloud/instances/ і може згадати його в журналах. Передати там пароль проксі або токен API — це залишити секрет на диску під табличкою "я тут" 🔐

На gateway сам факт збереження можна перевірити без відкриття вмісту:

find /var/lib/cloud/instances -maxdepth 2 \
-type f -name 'user-data*' -printf '%m %p\n'


Висновок вийшов простий: cloud-init потрібен, але він не повинен знати всю біографію застосунку. Його робота — зробити VM керованим хостом і передати естафету окремому коду розгортання.
❤1
Де закінчується cloud-init і починається gateway ⚙️

Після двох крайнощів межа нарешті стала нормальною:

шаблон Proxmox
→ cloud-init: ім’я, SSH, мережа
→ код розгортання: версія, конфігурація, systemd
→ Docker Compose
→ перевірка: знімки стану, проксі, весь маршрут


Секрети надходять окремим захищеним каналом. Шаблон їх не бачить, у Git їх немає, а user-data не перетворюється на музей паролів.

Ще одна важлива різниця: запущений контейнер — не обов’язково готовий сервіс. Зелений статус не доводить, що активний список проксі свіжий, relay приймає з’єднання, а клієнт справді виходить через gateway.

Тому остаточний критерій буде жорстким: нова VM з чистого шаблону, повторюване розгортання, перезавантаження і жодної додаткової SSH-команди. Якщо треба "тільки один маршрут руками поправити" — автоматизація ще не завершена 😅

У статті зібрав аудит поточної VM, дві відкинуті крайнощі й архітектуру наступного етапу:
https://devdays.net.ua/articles/chomu-transparent-gateway-ne-hotovyi-do-production/

Наступний матеріал буде практичним: створю чистий шаблон, підготую cloud-init і systemd, а потім перевірю перезавантаження та повне відтворення gateway.
❤1
Видалити VM і не пошкодувати 😅

Transparent Gateway працює. DNS і TCP-трафік проходять через проксі, пул оновлюється, контейнери переживають перезапуск.

Зараз gateway прив’язаний до конкретної VM. Якщо з нею щось станеться, швидко розгорнути таку саму не вийде. Тому хочу перетворити налаштовану вручну машину на відтворюване розгортання: створив нову VM, запустив автоматизацію — отримав готовий gateway.

Мій домашній стенд працює на Proxmox, тому почну з чистого шаблону Debian. Це базова VM без коду gateway, паролів, даних Redis і слідів попередніх експериментів. Proxmox використовуватиме її як основу для створення нових машин.

Початкове налаштування виконає cloud-init. Це стандартний механізм, який готує VM під час першого запуску: задає ім’я машини, створює користувача, додає SSH-ключі, налаштовує мережу та встановлює Docker.

На цьому робота cloud-init закінчується. Він готує операційну систему, але не розгортає сам Transparent Gateway. Код застосунку, його конфігурацію, секрети, запуск контейнерів і перевірку готовності передам окремому сценарію. Так початкове налаштування VM не залежить від конкретної версії gateway, а застосунок можна оновити без створення нової машини.

Після розгортання systemd керуватиме запуском gateway і підніматиме його після перезавантаження.
❤1