Files
omarandClaude Opus 5 9605b906f7
test / go vet + go test + spa build (push) Successful in 6m41s
release / test gate (push) Successful in 17s
release / binaries + release (push) Successful in 56s
release / docker image (push) Successful in 34s
feat: Prizma — a subscription panel in front of other subscription panels
Upstream panels (Remnawave and friends) pin a subscription to one device
through the x-hwid header. Prizma holds that HWID per source, presents it on
every upstream fetch, and hands out its own link that any number of devices
may use. Everything else — the client's User-Agent, the response body, the
profile-title / subscription-userinfo / announce headers — is proxied through
untouched.

Two link kinds behind /sub/{token}:

  source  byte-for-byte proxy of one upstream, format chosen by the client
  group   several sources merged into one link: parallel fetch, parse, regex
          filtering by node name and by node content, protocol allow-list,
          dedupe, rename template, rendered in the negotiated format

Formats parse and render both ways: URI lists, base64, Clash/Mihomo YAML,
sing-box JSON, and Xray JSON including the Happ-style array of whole configs.
A node keeps the raw payload it was born from, so same-format rendering is
byte-identical and no vendor-specific field is ever dropped.

Access control is HWID-based and self-switching: an empty whitelist means
everyone passes except banned devices; whitelisting a single device locks the
links to the whitelist. Every device that fetches a link is recorded with its
UA, IP, hit count and timestamps, and can be banned, whitelisted or labelled
from the panel.

Ships as one static binary with the React admin panel embedded (CGO-free, so
linux/amd64+arm64, windows and darwin cross-compile from anywhere), as a
docker image, and with Gitea CI that gates releases on the test suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:18:23 +03:00

15 KiB
Raw Permalink Blame History

Prizma

Панель поверх чужих панелей подписок. Prizma встаёт перед Remnawave (и любой другой панелью), забирает на себя привязку подписки к устройству и отдаёт свою собственную ссылку — уже без ограничения на количество устройств.

Один сервер, один статический бинарник, SQLite. Веб-панель вшита внутрь исполняемого файла: ставить рядом нечего.


Зачем это нужно

Апстрим-панель привязывает подписку к одному устройству по заголовку x-hwid: первый клиент, который пришёл за конфигом, «занимает» место, второй получает отказ или чужой HWID затирает первый.

Prizma решает это так:

  • HWID хранится на стороне Prizma, отдельно для каждого источника, и подставляется в каждый запрос наверх. Для апстрима это всегда одно и то же устройство;
  • вниз, клиентам, отдаётся своя ссылка Prizma, которой могут пользоваться сколько угодно устройств;
  • всё остальное проксируется как есть: User-Agent клиента, тело ответа, заголовки profile-title, subscription-userinfo, announce, profile-update-interval и прочие. Ни одно поле не теряется;
  • заголовки апстрима, содержащие hwid, наружу не отдаются — состояние привязки не должно утекать к клиентам.

Побочный бонус: ответ апстрима кешируется (CACHE_TTL), поэтому сорок клиентов, обновляющих подписку раз в час, стоят апстриму один запрос в пять минут.


Быстрый старт

docker run -d --name prizma -p 8080:8080 -v prizma-data:/data \
  -e ADMIN_PASSWORD=ochen-slozhnyy-parol \
  -e PUBLIC_URL=https://sub.example.com \
  git.qomar.pw/omar/prizma:latest

Открыть http://localhost:8080, войти как admin с этим паролем, добавить источник — и ссылка готова.

Prizma говорит по обычному HTTP. В интернет её выставляют за reverse proxy с TLS; в примере выше порт стоит публиковать как -p 127.0.0.1:8080:8080.

Docker Compose

cp docker-compose.example.yml docker-compose.yml
cp .env.example .env      # обязательно поменяйте ADMIN_PASSWORD
docker compose up -d

Состояние целиком лежит в одном SQLite-файле на томе /data — бэкап этого файла и есть бэкап всей установки.

Бинарник

С релизов скачивается один файл под linux/amd64, linux/arm64, windows/amd64 или darwin/arm64. Веб-панель вкомпилирована внутрь, внешних файлов нет.

sha256sum -c SHA256SUMS
tar xzf prizma_<version>_linux_amd64.tar.gz
ADMIN_PASSWORD=ochen-slozhnyy-parol ./prizma

На Windows — prizma.exe, двойным кликом; переменные окружения задаются как обычно (set ADMIN_PASSWORD=...).


Два вида ссылок

Публичная ссылка всегда выглядит как /sub/<token>, но за ней стоит одно из двух.

Source — прозрачный прокси одного источника

Prizma забирает подписку у апстрима с сохранённым HWID и отдаёт тело клиенту байт в байт. Ничего не парсится и не пересобирается: что бы апстрим ни прислал — Clash-YAML, base64, sing-box, — клиент получит ровно это, со всеми вендорскими полями и заголовками.

Формат при этом выбирает сам клиент: по умолчанию Prizma передаёт наверх его User-Agent, поэтому Clash спрашивает Prizma как Clash, Prizma спрашивает апстрим как Clash и получает YAML. Если у источника задан UserAgent и снят ForwardClientUA — формат апстрима жёстко фиксируется.

Group — несколько источников в одной ссылке

Группа собирает узлы из нескольких источников: параллельная загрузка → разбор → фильтрация → объединение → дедупликация → переименование → рендер в нужном формате.

  • фильтры (свои у каждого участника и общий у группы) — regex Go RE2, порядок: include_name → exclude_name → include_content → exclude_content → список протоколов → лимит. Пустое «include» пропускает всё, непустое — жёсткий фильтр. По умолчанию регистр не учитывается;
  • dedupe выкидывает узлы с повторяющейся тройкой server:port:protocol;
  • шаблон имени переименовывает узлы: {name}, {source}, {index}, {protocol}, {server};
  • участник, который не ответил, просто пропускается — группа отдаётся из оставшихся. 502 возвращается, только если не ответил вообще никто.

Формат вывода группы: ?format= в ссылке → OutputFormat группы → автоопределение по User-Agent клиента.


Поддерживаемые форматы

Разбор и рендер работают в обе стороны:

Формат Что это
uri простой список: одна ссылка на прокси в строке
base64 base64 от списка ссылок — классика v2rayN
clash Clash / Clash.Meta / Mihomo YAML
singbox sing-box JSON, {"outbounds":[...]}
xray Xray JSON — как один конфиг, так и массив конфигов в стиле Happ

Автовыбор по User-Agent: clash/mihomo/stash → Clash; sing-box/hiddify → sing-box; happ/v2rayn/v2rayng/nekobox/ streisand/shadowrocket → base64; неизвестный клиент → base64.

Протоколы: vless, vmess, trojan, ss, ssr, hysteria, hysteria2 (hy2), tuic, anytls, socks, http/https, wireguard. Схема, которую Prizma не знает, не выбрасывается — она проходит насквозь в исходном виде.

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


Контроль устройств (HWID)

Клиент опознаётся по x-hwid; если заголовка нет — берётся ?hwid=, а в последнюю очередь синтетический отпечаток из User-Agent и IP (такие клиенты помечены как synthetic). Каждое обращение обновляет запись устройства: User-Agent, IP, счётчик, время, последняя ссылка.

Модель доступа ровно одна и переключается сама:

  • белый список пуст → работает чёрный: пускаем всех, кроме забаненных;
  • в белом списке есть хоть одно устройство → пускаем только его участников.

Бан сильнее белого списка: забаненное устройство не пройдёт, даже если оно в белом списке. Отказ — 403.


Конфигурация

Всё настраивается переменными окружения; файла конфигурации нет. Полный список с комментариями — в .env.example.

Переменная По умолчанию Что делает
PRIZMA_ADDR :8080 адрес прослушивания, host:port
PRIZMA_DB data/prizma.db путь к файлу SQLite (в образе — /data/prizma.db)
ADMIN_USER admin логин в панель
ADMIN_PASSWORD admin пароль; пока он дефолтный, Prizma громко ругается в лог
JWT_SECRET генерируется ключ подписи сессий; пустой — создаётся один раз и хранится в БД
PUBLIC_URL — внешний адрес Prizma, нужен только чтобы панель показывала готовые ссылки
CACHE_TTL 300 секунд жизни кеша ответа апстрима (у источника может быть свой)
UPSTREAM_TIMEOUT 20 секунд ожидания апстрима
LOG_LEVEL info debug / info / warn / error
TRUST_PROXY false доверять X-Forwarded-For; включать только за своим reverse proxy

REST API

Панель — обычный SPA поверх этого же API. POST /api/auth/login принимает {username, password} и возвращает {token, expires_at}; остальные /api/* требуют Authorization: Bearer <token>.

POST   /api/auth/login          {username,password} -> {token,expires_at}
GET    /api/auth/me
GET    /api/stats

GET    /api/sources             POST /api/sources
GET    /api/sources/{id}        PUT  /api/sources/{id}    DELETE /api/sources/{id}
POST   /api/sources/{id}/test   -> {ok,format,nodes,error}
POST   /api/sources/{id}/rotate-hwid

GET    /api/groups              POST /api/groups
GET    /api/groups/{id}         PUT  /api/groups/{id}     DELETE /api/groups/{id}
POST   /api/groups/{id}/preview -> {format,count,nodes}

GET    /api/clients?search=&banned=&allowed=&limit=&offset=&sort=
POST   /api/clients/{id}/ban    POST /api/clients/{id}/unban
POST   /api/clients/{id}/allow  POST /api/clients/{id}/disallow
POST   /api/clients/{id}/label  {label}
DELETE /api/clients/{id}

GET    /api/access              -> {whitelist_active,whitelisted,banned}
GET    /api/formats             -> список поддерживаемых форматов
GET    /healthz                 -> 200 "ok", без авторизации

Публичные маршруты подписки живут вне /api:

GET/HEAD  /sub/{token}
GET/HEAD  /sub/{token}/{any}    то же самое; терпит мусор, дописанный клиентом

Как это работает

Запрос на /sub/{token}:

  1. токен разрешается в источник или группу; неизвестный или выключенный — 404;
  2. HWID-гейт: бан или отсутствие в непустом белом списке — 403;
  3. устройство отмечается в списке клиентов, обращение пишется в журнал;
  4. источник — запрос наверх (или отдача из кеша) и трансляция тела как есть, с заголовками апстрима по политике выше;
  5. группа — параллельный обход участников, разбор, фильтры, слияние, дедупликация, переименование и рендер в согласованном формате.

Наверх уходят: x-hwid источника, его x-device-os / x-ver-os / x-device-model (если заданы), User-Agent по правилам источника, произвольные дополнительные заголовки источника и Accept / Accept-Language клиента. Больше ничего: ни cookies, ни авторизация клиента.

Вниз уходят все заголовки ответа апстрима, кроме hop-by-hop (Connection, Keep-Alive, Transfer-Encoding, Upgrade, Trailer, TE, Proxy-*), Content-Length (пересчитывается), Content-Encoding (тело уже раскодировано) и всего, что содержит hwid.


Сборка из исходников

Нужны Go 1.24+ и Node 24.

git clone https://git.qomar.pw/omar/prizma.git
cd prizma

# всё сразу: SPA + бинарники под четыре платформы в dist/
bash ci/build-binaries.sh 0.0.0-dev

Вручную:

cd web && npm ci && npm run build && cd ..
rm -rf internal/webui/dist && cp -R web/dist internal/webui/dist
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o prizma ./cmd/prizma

internal/webui/dist/index.html лежит в репозитории как заглушка, чтобы go build работал на свежем клоне до первой сборки фронтенда; сборка её перезаписывает (git checkout -- internal/webui/dist/index.html вернёт назад).

Разработка фронтенда: cd web && npm run dev — dev-сервер проксирует /api и /sub на http://localhost:8080.

Тесты:

go vet ./...
go test ./...

CGO нигде не используется (SQLite — modernc.org/sqlite), поэтому кросс-компиляция под все четыре платформы работает с любой из них.


Лицензия

MIT — см. LICENSE.