Skip to main content
Omniflow — это модуль Go 1.26 и одно приложение Next.js, разворачиваемые как пять контейнеров плюс обратный прокси на выбор оператора. Ничто в стеке не требует брокера, оркестратора или среды выполнения плагинов, потому что целевое развёртывание — один Linux-сервер, которым управляет один человек, он же администратор VPN-панели. Каждый выбор ниже описан вместе с альтернативой, которую он вытесняет, чтобы контрибьютор видел, какие решения несущие, а какие просто текущие.

Go — один модуль с тремя точками входа

go.mod объявляет go 1.26.0 и один модуль, github.com/omniflow/omniflow. cmd/api, cmd/bot и cmd/worker компилируются из одного дерева, поэтому доменное изменение доходит до всех трёх сразу и не бывает рассинхронизации версий между API, который пишет строку, и воркером, который её читает. Альтернатива — три сервиса с тремя репозиториями и тремя темпами выпуска — дала бы независимое масштабирование, которое не нужно ни одной установке такого размера, в обмен на распределённый протокол между оформлением заказа, правом на услугу и выдачей доступа. Вместо этого значимая граница — транзакция PostgreSQL. См. Обзор архитектуры. Достижимость здесь — настоящее ограничение, а не договорённость: go list -deps ./cmd/<name> показывает ровно то, к чему может обратиться каждый процесс, и бинарник api действительно не содержит ни исполнителя кампаний, ни очистки по срокам хранения.

chi вместо фреймворка

github.com/go-chi/chi/v5 — это маршрутизатор поверх net/http, а не фреймворк. Каждый обработчик — это http.HandlerFunc, а каждый middleware — обычная func(http.Handler) http.Handler, поэтому три префикса могут нести три по-настоящему разных набора middleware в одном процессе, и ни один из них не наследует чужой. Два поведения chi являются несущими. middleware.RequestID присваивает идентификатор, который попадает в заголовок ответа X-Request-ID, в каждое тело ошибки как request_id, в строки аудита, в идентификаторы корреляции исполнения заказов и в события безопасности клиента — одна строка, связывающая жалобу клиента со строкой журнала обработчика и вызовом Remnawave. А chi.RouteContext(...).RoutePattern() даёт метричному middleware шаблон маршрута для метки, поэтому путь, содержащий идентификатор клиента, никогда не становится значением метки Prometheus.

pgx, sqlc и Atlas

pgx/v5 используется напрямую, без ORM. Запросы лежат как SQL в database/queries/, а sqlc генерирует типизированный Go в internal/database/dbgen с sql_package: pgx/v5. Это даёт то, что SQL, который читает ревьюер, — это и есть SQL, который выполняется, включая те части PostgreSQL, которые ORM обычно прячет и на которые эта кодовая база сильно опирается: ON CONFLICT … DO UPDATE … RETURNING для идемпотентных вставок, pg_advisory_xact_lock, чтобы потолок параллелизма выдержал две одновременные покупки, SELECT … FOR UPDATE внутри сериализуемых транзакций, частичные уникальные индексы и ограничения CHECK, делающие некорректные состояния непредставимыми. Изменения схемы идут в обратном порядке по сравнению с migration-first-инструментами: правите database/schema.sql, генерируете отрецензированную миграцию Atlas командой make atlas-diff, затем заново генерируете sqlc. Миграции — только вперёд: down-миграций нет, а откат схемы — это восстановление из резервной копии. database/migrations/atlas.sum создаётся только командой atlas migrate hash и никогда не правится руками: несовпадение контрольной суммы означает, что миграция изменилась, а не что файл устарел. CI применяет каждую закоммиченную миграцию от нескольких базовых точек до головы и доказывает, что повторное применение головы ничего не меняет, а интеграционный набор применяет их в PostgreSQL 18.4 из Testcontainers. См. База данных.

River для надёжной работы

riverqueue/river v0.43.0 с драйвером riverpgxv5 выполняет задачи в той же базе данных PostgreSQL, что и всё остальное. В этом и смысл: задачу можно вставить через InsertTx внутри той транзакции, которая произвела требующее её изменение состояния, — чего брокер в отдельной системе не может предложить без двухфазного протокола или диспетчера outbox. AGENTS.md делает это правилом и запрещает вводить брокер без принятого архитектурного решения. Настроены три очереди — critical (16 воркеров), default (8) и bulk (4). fulfillment и goods_delivery выполняются на critical с MaxAttempts: 12; periodic_task, проходы по всей таблице, — на default с одной попыткой.
Собственные таблицы River поставляются вместе с миграциями Atlas: 20260905130000_river_queue.sql — это дословно скопированная собственная линия миграций River, поэтому сервис migrate в compose создаёт river_job и её собратьев вместе со схемой Omniflow. Интеграционный тест падает, если rivermigrate видит неприменённую версию River, поэтому обновлению River нужна соответствующая миграция.
Полные проходы по таблице без аргумента для отдельной записи — это периодические задачи River, а не таймеры: таймер выполняется один раз на процесс, а периодическую задачу выбранный River лидер ставит в очередь один раз на всю установку, поэтому воркер может работать в нескольких репликах. На time.Ticker остаются только циклы, которые касаются своего процесса. Обе модели перечислены в разделе Задачи и очереди.

PostgreSQL 18 и Valkey 9

PostgreSQL — единственный надёжный источник истины. Стек compose закрепляет postgres:18.4-alpine, а образы bot и worker несут postgresql18-client, чтобы pg_dump и pg_restore совпадали с мажорной версией сервера, — Dockerfile предупреждает, что понижать базу Alpine ниже 3.23 нельзя, поскольку это первый выпуск с этим пакетом. Valkey (valkey/valkey:9.1.1-alpine) используется для счётчиков ограничения частоты запросов, одноразовых токенов защиты от повтора, блокировок и кеша и запускается с --save "" --appendonly no. Persistence отключён намеренно: ничто в Valkey не имеет права быть единственной копией чего бы то ни было. Оба примитива — это по одному Lua-скрипту: Allow — это INCR плюс PEXPIRE, Once — это SET NX PX, — и оба возвращают ошибку, а не разрешающее значение по умолчанию, когда клиент отсутствует, поэтому сбой не может молча снять защиту. Резервное копирование вызывает pg_dump --format=custom и pg_restore, а не переизобретает их, поэтому резервная копия всегда восстановима стандартными инструментами PostgreSQL; шифрование — это обёртка AES-256-GCM с кадрированием поверх этого потока. См. Резервное копирование и восстановление.

OpenAPI 3.0.3 и сгенерированные привязки

api/openapi.yaml — это контракт. Он написан в OpenAPI 3.0.3, а не в 3.1, потому что у выбранного генератора Go пока нет стабильной поддержки 3.1, и AGENTS.md фиксирует это как условие для перехода. make generate запускает три генератора: go tool oapi-codegen для серверных типов Go, go tool sqlc generate для слоя базы данных и bun run generate:api, который дважды запускает Orval — один раз для типов TypeScript и SWR-хуков, другой раз для схем Zod. CI перегенерирует всё это и падает на любом расхождении, поэтому изменение контракта, закоммиченное без перегенерации, не пройдёт в основную ветку. Транспорт, который на самом деле использует веб-приложение, написан вручную (packages/api-client/src/fetcher.ts), а не сгенерирован, и это сделано намеренно и задокументировано в самом файле: SWR-клиент Orval 8.24 выдаёт голые вызовы fetch и игнорирует override.mutator, поэтому сгенерированный хук не отправлял бы ни cookie сессии, ни CSRF-токен — каждая мутация через него была бы отклонена, а каждое чтение было бы анонимным. Обёртка ориентируется на те же сгенерированные типы, так что формы запроса и ответа остаются сверенными с контрактом; различается только транспорт. Она задаёт credentials: "include", перечитывает заголовок ответа X-CSRF-Token после каждого вызова, чтобы долгоживущая вкладка пережила ротацию сессии, не трогает Content-Type для FormData, потому что только браузер знает свою границу multipart, и превращает ответ-проблему в ApiError, чей code — последний сегмент type проблемы. Контрибьюторам стоит знать, что сгенерированные серверные типы Go в internal/httpapi/apigen сейчас не импортируются ничем: обработчики декодируют в анонимные структуры и пишут собранные вручную мапы. Ничто не проверяет ответ по схеме ни на сборке, ни во время выполнения, поэтому изменению схемы нужен тест, который его проконтролирует.

Веб-приложение

Одно приложение Next.js 16.3 (apps/web) несёт оба интерфейса: /account для клиентов и /admin для операторов. Одно приложение вместо двух означает одну систему компонентов, один каталог локализации и одну сборку, которую нужно держать зелёной, — а разделение маршрутов организационное, поскольку авторизацию применяет Go API на каждом запросе. Продакшен-образ собирается с Bun 1.3.14 и отдаёт standalone-сборку Next.js из node:26.7-alpine на порту 3000.

Инструментарий

Bun workspaces (apps/*, packages/*) отвечают за установку и скрипты, bun.lock закоммичен, и рядом нет lock-файлов npm, pnpm или Yarn — один lock-файл означает один граф зависимостей. Biome 2.5 — единственный форматтер и линтер для TypeScript и JavaScript, поэтому форматирование никогда не становится темой ревью. TypeScript — версии 7.0, и сгенерированные клиенты не должны зависеть от удалённых программных API TypeScript. Тестирование делится на три части по тому, что может доказать каждый слой. go test -race -cover ./... работает вообще без демона Docker, потому что наборы на Testcontainers лежат в internal/integrationtest за тегом сборки integration. Эти наборы применяют каждую закоммиченную миграцию в настоящий PostgreSQL и прогоняют репозитории. Playwright работает против продакшен-сборки, исходя из того, что проверка, проходящая против сборки, которую никто не выкладывает, — это не проверка.

Наблюдаемость

log/slog пишет JSON в stdout у API и бота и в stderr у воркера, на уровне по умолчанию для обработчика. Переменной окружения для уровня, формата или назначения журнала нет нигде в репозитории, поэтому повышение подробности — это изменение кода. Общий редактор вырезает токены ботов, значения bearer и учётных данных, последовательности цифр, похожие на карту, пары hwid и charge_id, а также любой URL со схемой http(s), vless, vmess, trojan или ss — целиком, потому что даже хост идентифицирует установку, к которой подключается клиент. Заявленный компромисс таков: перередактированная строка журнала — небольшое неудобство, а утёкшая ссылка подписки — инцидент безопасности. Метрики Prometheus берутся из реестра, отдельного для каждого процесса (не из глобального), и отдаются на /metrics с постоянной меткой service со значением api, bot или worker. Каждая метка — ограниченное и неидентифицирующее значение, потому что значения меток хранятся у цели сбора бессрочно. Инструментирование OpenTelemetry подключено в четырёх точках: otelhttp на входящем маршрутизаторе и исходящем клиенте, otelpgx на пуле соединений, спан на каждое обновление Telegram и спан на каждую задачу исполнения заказа. otelpgx записывает текст запроса и никогда — значения аргументов; исходящий клиент записывает только метод и хост.
Omniflow никогда не устанавливает провайдер трассировки. otel.SetTracerProvider не встречается нигде, ни один экспортёр OTLP не импортируется, и ни один процесс не читает переменные OTEL_*. Поэтому глобальный провайдер является заглушкой, и описанные выше спаны никуда не экспортируются. Инструментирование на месте, экспортёра нет.
Наблюдаемость перечисляет пробы, метрики, которые действительно пишутся, и строки журнала, по которым стоит настраивать оповещения.

Развёртывание

compose.yaml в корне репозитория описывает весь односерверный стек: postgres, valkey, одноразовый сервис migrate на arigaio/atlas:1.3.0, затем api, bot, worker и необязательный web под профилем web. Хостовые порты публикуют только api и web; PostgreSQL и Valkey остаются во внутренней сети. Образы собираются из build/go.Dockerfile с CGO_ENABLED=0 go build -trimpath. RUNTIME=static даёт distroless-образ nonroot для API; RUNTIME=postgres даёт образ Alpine с клиентом PostgreSQL для бота и воркера, которым он нужен для резервных копий. Выпущенные образы публикуются в GHCR для linux/amd64 и linux/arm64 с provenance и SBOM. Обратный прокси не входит в поставку и не обязателен. В deploy/proxies/ лежат проверенные в CI примеры для Caddy и Traefik, которые терминируют TLS, ограничивают размер тела запроса, выставляют заголовки безопасности и — что важнее всего — убирают /metrics и /readyz с публичного хоста API. Пример для Traefik отключает журналы доступа, потому что путь запроса может быть ссылкой подписки. См. Обратный прокси.

Чего намеренно нет

  • Брокера сообщений. Доменные события идут через транзакционный outbox, и AGENTS.md требует принятого архитектурного решения до того, как брокер будет введён.
  • Фреймворка внедрения зависимостей. Связывание явно прописано в каждом main — многословно и читаемо; оно изменится, когда сложность связывания этого действительно потребует.
  • ORM. SQL пишется, рецензируется и генерируется в типы.
  • Kubernetes, Helm и Pulumi. Единственная топология развёртывания в репозитории — Docker Compose плюс два примера прокси.
  • Среды выполнения плагинов. Нет ни WASM-хоста, ни ABI плагинов, ни точки расширения, загружающей сторонний код в процесс.
  • Второго места, которое решает, что позволено роли. Каталог прав доступа и сопоставление ролей вкомпилированы в internal/rbac, поэтому правка в базе данных не может расширить доступ.

Смежные материалы

Обзор архитектуры

Процессы, пути запросов и транзакционный шаблон.

Разработка

Настройка рабочего пространства, генераторы, запуск стека и проверки.

База данных

Схема, Atlas, sqlc и правила миграций.

Развёртывание

Топология compose, тома, обновления и откат.