Меня зовут Стас. Коммерческой разработки — ноль. Ни фронт, ни бэк. Есть MacBook Air M2 на 16 гб и привычка докапываться до сути.
Приложу GitHub-репо -> HtrBox. Все продовые креды перероллены, так что все ок. Да, надо Github Secrets. Но мне было лень.
В декабре 25 года, когда поехал в гости к родителям праздновать Новый Год, столкнулся с небывалой для меня критической проблемой - отсутствием доступа к интернету (ChatGPT, Grok, Claude, YouTube, Telegram, и так далее). Мой родной регион был одним из первых, на ком РКН тестировал белые списки. К тому же до конца 25 года я пользовался для обхода блокировок OpenVPN. Энтузиасты выкладывали в открытый доступ ключи. Да, с моей стороны это было пренебрежением безопастности. Однако меня это вполне устраивало и я был доволен, пока он не был забанен.
Это статья о том, как из этой точки я пришёл к рабочему SaaS: бэк на FastAPI, фронт на React, PostgreSQL, деплой на несколько VPS и AI-assisted процесс разработки, который я выстраивал по ходу дела.
Hysteria2: с чего всё началось
Я не готов мириться с принудительными блокировками интернета, для меня важны и удобны инструменты и сервисы которые не работают без VPN. Поэтому находясь все еще в регионе в поисках решения я наткнулся на него - протокол Hysteria2. После чего быстренько развернул VPS, настроил его и был рад возвращению сия интернета.
Однако во время чтения доки меня зацепили возможности этого протокола. По сути протокол, удобно упакованный в докер, давал отличный и удобный API.
И вот я начал дергать за ручки сначала курлами. Потом пришла мысль автоматизировать через Python. Так познакомился с библиотекой requests. Накидал скрипт с хардкоженным словарём пользователей и функцией опроса /online. Код был простым — но факт того, что я могу программно управлять VPN-сервером - восторг. Захотелось сделать из этого что-то стоящее.
Примерно в это же время наткнулся на видео Как устроена оплата картой — оно показало мне возможности REST API и как из маленьких кусочков собирается что-то большое. После этого я понял - надо делать!
Архитектура: три независимых контейнера
С самого начала разделил проект на три изолированные части:
htrBox/ ├── backend/ — FastAPI, PostgreSQL, бизнес-логика ├── frontend/ — React + Vite, пользовательский интерфейс ├── hysteria/ — Hysteria2, принимает VPN-соединения └── docker-compose.yaml
Каждая часть — отдельный Docker-контейнер. Зоны ответственности не пересекаются: бэк общается с Hysteria2 по REST, фронт общается с бэком по REST, Hysteria2 ни о чём кроме VPN не знает.
docker-compose.yaml поднимает весь стек одной командой. Hysteria2 тянется с Docker Hub как готовый образ. Бэк и фронт собираются локально из Dockerfile.
Навайбкдив какую то часть backend'a - надо правильно написать Dockerfile - инструкцию по сборке образа, причем таким образом чтобы часто перестраиваемые слои были ниже в инстуркции. Это важно для оптимизации сборки образа.
Для продакшна рядом лежит prod/infra/docker-compose.yaml с кончиком под каждую VPS-ноду:
prod/servers/ ├── docker-compose.ge.yaml — Германия ├── docker-compose.nl.yaml — Нидерланды ├── docker-compose.pl.yaml — Польша └── docker-compose.se.yaml — Швеция
Hysteria2-ноды принимают соединения пользователей и проксируют трафик. Бэк живёт в Yandex Cloud отдельно.
Бэкенд: FastAPI + PostgreSQL
Структура выстроилась как то так:
backend/source/ ├── main.py ├── auth_jwt.py ├── config.py ├── database.py ├── schemas.py ├── rate_limiter.py ├── maintenance.py ├── traffic_collector.py └── routers/ ├── auth.py ├── users.py ├── hysteria.py ├── servers.py ├── traffic.py └── ws.py
JWT и httpOnly cookie
Авторизация — JWT с двумя токенами: access и refresh. Refresh-токен хранится в httpOnly cookie — проставляется через заголовок в ответе бэка. Это не случайное решение: httpOnly значит что JS на странице не видит этот куки. XSS атака не украдет токен.
Потом схема стала взрослее. Refresh-токен в базе хранится не как исходная строка, а как SHA-256 хеш. Сам токен живёт только в cookie у клиента, в базе — только отпечаток, по которому можно проверить и отозвать сессию. Logout — это удаление конкретного refresh-токена из таблицы. Блокировка пользователя — удаление всех его refresh-токенов.
def create_refresh_token(username: str, conn) -> str: token_value = secrets.token_urlsafe(48) token_hash = _hash_token(token_value) expires_at = datetime.now(timezone.utc) + timedelta(days=JWT_REFRESH_TTL_DAYS) with conn.cursor() as cur: cur.execute( "INSERT INTO refresh_tokens (token, username, expires_at) VALUES (%s, %s, %s)", (token_hash, username, expires_at), ) return token_value
Ещё одно решение: роль пользователя не лежит в JWT. В access-токене только username, срок жизни, тип токена и jti. Роль читается из базы на защищённых запросах. Это добавляет один запрос к БД, зато если я перевожу пользователя из admin в user или блокирую его — изменение применяется сразу, а не через 30 минут когда протухнет токен.
В структуре есть routers/ws.py и useWebSocket.ts на фронте — WebSocket-инфра написана, но код закомменчен. На текущем масштабе поллинг через TanStack Query справляется, усложнять без необходимости не хотелось. Задел на будущее.
База данных
У меня совершенно не было опыта работы с базами даннныз на тот момент. Что такое foreign key или primary key — загадка. Поэтому скачал DataGrip, сделал дамп базы с прода и начал разбираться. PostgreSQL уже крутился на проде, но что происходило внутри — было непонятно. Для понимания баз данных сделал локальную среду для лабораторных работ по SQL на базе PostgreSQL 17 в Docker-контейнере.
Нашёл классный курс от Postgres Pro и их же книгу по SQL, где объясняются ключевые концепции: как проектировать схему, что такое нормализация, как писать запросы, как делать DDL. После этого база данных стала для меня интересным объектом исследования. К тому же пришло понимание, что ИИшка не все связи между таблицами выстроила, поэтому могли быть висячие данные (которые должны удаляться на ON DELETE CASCADE).
Как тестил API
Для тестирования API использую Postman. Классная фича — наследование авторизации коллекции: можно задать токен один раз на уровне коллекции, все запросы его подхватывают. Три часа потратил на то что Postman не видел печенки — оказалось, нужно выставить тумблер Cookie Jar в OFF для каждого эндпоинта отдельно.

Отдельный неприятный момент — пароль VPN-клиента. В таблице users два разных секрета:
password — пароль от аккаунта в веб-приложении hyPassword — пароль, с которым Hysteria2 пускает пользователя в VPN
password хранится нормально — bcrypt-хешем. А hyPassword хранится в открытом виде. На первый взгляд звучит как ошибка новичка.
Но это ограничение модели Hysteria. Пользователь получает ссылку вида:
hysteria2://username:hyPassword@server:443#label
Клиенту нужен пароль — он вшит в connection URL. Серверу тоже: Hysteria вызывает auth callback и передаёт username/password, а мой бэк должен сравнить их с тем, что выдал пользователю. С bcrypt так не выйдет: из хеша нельзя восстановить строку для генерации URL.
Фрагмент генерации ссылки в routers/hysteria.py:
url = ( f"hysteria2://{username}:{row['hyPassword']}" f"@{host}:{srv['port']}" + ("?insecure=1" if insecure else "") + f"#{srv['label'].strip()}" )
Компромисс такой: пароль аккаунта и пароль VPN — разные сущности. hyPassword генерируется случайно, пользователь его не придумывает, его можно регенерировать — и старая ссылка сразу становится бесполезной. Доступ к базе при этом должен быть жёстко ограничен, потому что это по сути секретное хранилище.
Сбор трафика: сложнее, чем казалось
Самая неожиданно интересная часть бэка — не логин и не CRUD пользователей, а сбор трафика.
Я думал, Hysteria отдаёт что-то вроде «пользователь alice потратил 120 МБ за последние пять минут». Было бы удобно: взял, записал, показал график.
На деле Hysteria отдаёт кумулятивные счётчики байт с момента старта процесса. То есть не «сколько потрачено за интервал», а «сколько всего насчитано с момента запуска». Если просто писать это значение в базу — график будет бессмысленным.
Пришлось сделать маленький traffic collector:
Раз в N секунд получить список активных серверов из БД.
Для каждого сервера сходить в его Hysteria management API:
GET /traffic.Нормализовать ответ, потому что разные версии Hysteria возвращают немного разную форму JSON.
Для каждой пары
username + server_idвзять прошлое значение изtraffic_last.Посчитать
delta = current_total - last_total.Положить дельту в
traffic_5m, округлив время вниз до границы 5-минутного бакета.Прибавить дельту к
users.usedTraffic.Обновить
traffic_last.
Получились две таблицы с разной ролью:
traffic_last — техническая память коллектора, последнее увиденное значение traffic_5m — временной ряд для графиков и аналитики
Важный нюанс: Hysteria хранит счётчики в памяти. Если нода перезапустилась — счётчик снова ноль. Без обработки это отрицательная дельта, и можно случайно вычесть трафик у пользователя.
Решение: если current_total меньше last_total — считаю что счётчик сбросился и беру current_total как новую дельту. Часть трафика между последним poll и рестартом можно потерять, но lifetime-счётчик не портится.
if total_gb >= last_total_gb: delta_gb = total_gb - last_total_gb else: logger.debug( "Counter reset for user %r on server %r (%.6f -> %.6f GB)", username, server_id, last_total_gb, total_gb, ) delta_gb = total_gb
Добавил защиту от странных скачков: если за один poll прилетает больше 5 GB — считаю аномалией и не записываю. Это спасает от поломанного ответа API или бага, который нарисовал бы пользователю фантастический расход.
Все HTTP-запросы к Hysteria делаю до открытия транзакции в Postgres. Сначала собрал данные с нод, потом открыл соединение с базой, записал всё одной транзакцией и закрыл.
Rate limiting: просто и без Redis
Для публичных эндпоинтов добавил rate limiter. Не промышленный, не распределённый, без Redis — обычный in-memory словарь с фоновым cleanup-потоком.
Решает две задачи: замедляет перебор паролей на /auth/login и ограничивает частоту запросов на публичных эндпоинтах вроде регистрации и генерации connection URL.
Есть важный нюанс. Для обычного логина можно лимитировать по IP. А вот Hysteria auth callback приходит не от пользователя напрямую, а от VPN-сервера. Если лимитировать этот эндпоинт по IP — один проблемный пользователь может заблокировать авторизацию всем, потому что все запросы придут с одного адреса ноды. Поэтому для Hysteria auth ключ лимита — username, а не IP.
Ограничения очевидны: состояние сбрасывается при рестарте бэка, не шарится между несколькими репликами, не спасает от атак с тысяч IP. Но на текущем масштабе это осознанный компромисс — Redis ради первой версии был бы overkill.
AI-assisted разработка: рабочий процесс
Просто «писать с ИИ» и «иметь воспроизводимый процесс с ИИ» — разные вещи. Хочу остановиться на этом подробнее.
Проблема контекста
ИИ не удерживает контекст между сессиями. Каждый раз объяснять структуру проекта, эндпоинты, .env, связи между частями — боль и трата токенов. Решение: скармливать архив проекта напрямую в чат. Claude и Manus умеют работать с tar-архивами.
pack.sh: упаковка контекста
Написал bash-скрипт с тремя режимами:
pack # весь проект (кроме .git, venv, node_modules, dist) pack back # только backend + docker-compose.yaml + .env pack front # только frontend + docker-compose.yaml + .env pack --dry-run [all|back|front] # показать команду без выполнения
Три режима нужны для экономии токенов: если правлю роутер на бэке — фронт с его 50+ компонентами не нужен в контексте. Режим --dry-run — перед тем как скинуть архив в чат, убеждаюсь что в него не попало лишнее. Флаг -yубирает подтверждение перезаписи, чтобы не прерывать автоматизацию.
Исключения при полной упаковке:
declare -a EXCLUDES=( ".git" ".env" "backend/venv" "frontend/node_modules" "frontend/package-lock.json" "frontend/dist" ".DS_Store" )
TODO-подход к разработке фич
Раньше сразу говорил ИИ: «давай реализуем фичу». Теперь — нет. Сначала:
Не трогай код. Опиши план внедрения фичи с TODO-маркерами и подробным описанием каждого шага — так, чтобы человек, который вообще не в теме проекта, понял что делать.
Обкашливаем план. Только потом: «вперёд, с первого пункта».
ИИ отдаёт только изменённые файлы, включая обновлённый TODO.md с прогрессом после каждого шага. Смотрю git diff в VSCode — если всё ок, двигаемся дальше.
Кайф такого подхода: когда ИИ галлюцинирует или заканчиваются токены — не страшно. Делаю ./pack.sh, перекидываю архив с актуальным TODO.md на новый аккаунт и продолжаю с того места где остановился. Полный контроль над процессом.
Мне 15 Google аккаунтов позволили не потратить ни единого цента на ИИ.
Дизайн-система для ИИ
Это самое нетривиальное решение во всём проекте. И возникло из конкретной боли.
Когда просишь ИИ написать новую страницу или компонент — он расставляет Tailwind-классы случайно. На одной странице text-sm, на другой text-xs, на третьей text-base. Где-то gap-2, где-то gap-3. Цвета, отступы, радиусы — всё вразнобой. Проект становится грязным.
Решение: создать один файл с правилами, которому ИИ следует при каждом запросе. PROMT.md во фронтенде — это 250+ строк инструкций, которые описывают дизайн-систему адаптированную под ИИ.
Структура системы:
src/styles/ ├── tokens.ts — атомарные токены: typography, surface, radius, spacing ├── animations.ts — transition, hover, press, enter, loading ├── variants.ts — colorScheme + тип ColorScheme ├── index.ts — единая точка входа, реэкспортирует всё └── cStyles/ — стили компонентов, разбитые по папкам ├── uiStls.ts ├── commonStls.ts ├── layoutStls.ts ├── dashboardStls.ts ├── usersStls.ts ├── serversStls.ts └── pagesStls.ts
Правило маппинга жёсткое: компонент из components/ui/ — стили только в cStyles/uiStls.ts. Компонент из pages/ — только в cStyles/pagesStls.ts. ИИ не может положить стили куда попало.
Использование в JSX после миграции:
import { styles, colorScheme } from "@/styles"; import type { ColorScheme } from "@/styles"; const s = styles.userRow; const v = colorScheme[variant];

Ноль сырых Tailwind-классов в JSX. Все цвета — через colorScheme, все отступы и типографика — через токены. Когда прошу ИИ написать новый компонент — он читает PROMT.md и работает в системе координат дизайн-системы, а не изобретает классы заново.
Цветовые варианты — через ColorScheme, а не через локальный variantStyles в компоненте:
// так нельзя — локальный вариант const variantStyles = { danger: "bg-red-500 text-white", warning: "bg-amber-500 text-white", }; // так правильно — через систему const v = colorScheme[variant]; // variant: ColorScheme

Фронтенд: стек и решения
Попросил иишку выплюнуть мне README проекта со всеми endpoints, чтобы другая иишка сделала чистый фронт. Я давно работал с Manus. Это иишка которая может запилить мощное web приложение с 0. Однако я 3 раза с 0 просил его переписать фронт. У меня на тот момент не было никакого опыта и понимания, как он работает.
Поэтому отбросив весь говнокод с мусором от Manus (много мусора потому что ИИ на предикт кладет много компонентов и отладочных скриптов для себя), я начал через Claude c 0 писать фронт, причем с полным понимаем. Так я познакомился с Vite — бандлером. Это очень удобный инструмент: он не только собирает код, но и работает как локальный сервер, который по запросу браузера отдает HTML-страничку, скомпилированную из исходников (TypeScript, React-компонентов и т.д.).
Перед тем как внедрять TanStack Query и Zustand в основной проект — сделал отдельный мини-проект, максимально упрощённую копию без бизнес-логики. Разобрался там как работает поллинг, как Zustand синхронизируется с localStorage, как JWT refresh flow устроен изнутри. Только потом перенёс в основной.
Стек:
Vite — бандлер. В режиме разработки выступает сервером, по запросу браузера отдаёт собранную страницу. TypeScript и TSX из коробки.
TanStack Query — автоматическое обновление данных, кэширование, инвалидация. REST-эндпоинты опрашиваются с нужным интервалом, UI перерисовывается при изменениях.
Zustand + localStorage — кэширование состояния между сессиями. После перезагрузки страницы не нужен повторный вход.
wouter — лёгкий роутер. Значительно проще React Router, без лишних абстракций.
httpOnly cookie — refresh-токен, проставляется бэкендом.

Эксперимент с WebGL
В src/shaders/ лежат background.frag.glsl и background.vert.glsl — следы одного эксперимента. Мне понравился анимированный фон на странице Яндекс.Музыки, и я решил разобраться как он устроен. Открыл DevTools, нашёл в исходниках GLSL-шейдеры, скопировал и адаптировал под себя. Пришлось разобраться как работают uniform-переменные, как передавать время через requestAnimationFrame, как подключить .glsl-файлы через Vite (нужен отдельный плагин, декларация типа в glsl.d.ts).
Выглядело красиво. Но на слабых устройствах фрагментный шейдер заметно грузил GPU — страница начинала тормозить. Файлы остались как память об эксперименте, но в проде шейдер не используется.
Деплой: bash вместо Ansible
Весь деплой — bash-скрипт prod/deploy.sh. Накатывает Hysteria2 на ноды в четырёх странах и бэк в Yandex Cloud. Функция деплоя на YC с автоматическим продлением сертификатов через certbot:
deploy_yandex() { local host="$YC_HOST" ssh "$host" " cd /opt/htrbox && git pull && docker compose pull && docker compose up -d --build " # Продление сертификата если истекает в течение 30 дней ssh "$host" "certbot renew --quiet --deploy-hook 'nginx -s reload'" }
Пытался переписать на Ansible Playbooks — повяз. Для пяти серверов bash справляется отлично, а риск положить пользователям интернет кривым playbook'ом не стоил эксперимента. Это сознательное решение, а не откладывание.
Рядом лежит prod/cleanup.sh — убирает старые образы, контейнеры, сети на нодах.
Для работы с git - GitKraken. Значительно удобнее терминального tig. Для одного разработчика бесплатный.
Что сейчас и что дальше
Проект работает. Бэк отдаёт весь пул пользователей одним запросом — при росте до 100+ придётся добавить пагинацию.
У каждого пользователя в профиле — зверушка, назначаемая хешированием имени по алгоритму djb2. 12 штук, все SVG. Алгоритм детерминированный: один и тот же username всегда даёт одну и ту же зверушку.
Сейчас интегрирую платёжный шлюз — API написано, жду одобрения. Если знаете шлюзы для high-risk без необходимости оформлять ИП/СЗ, с нормальным API и Sandbox — пишите в комменты.
В планах: Prometheus + Grafana + Node Exporter для метрик нод, с отображением в профиле пользователя как бизнес-фича.