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>
15 KiB
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}:
- токен разрешается в источник или группу; неизвестный или выключенный —
404; - HWID-гейт: бан или отсутствие в непустом белом списке —
403; - устройство отмечается в списке клиентов, обращение пишется в журнал;
- источник — запрос наверх (или отдача из кеша) и трансляция тела как есть, с заголовками апстрима по политике выше;
- группа — параллельный обход участников, разбор, фильтры, слияние, дедупликация, переименование и рендер в согласованном формате.
Наверх уходят: 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.