The Preview button posted the draft the operator was editing and the handler
threw it away — it loaded the group from the store and assembled that. Tighten
a regex, press Preview, and the answer came back byte-identical (and, after the
first call, from cache in under a millisecond), so the button looked dead.
Reproduced in the panel: a filter dropping five countries left all 32 nodes on
screen, unchanged.
- POST /api/groups/{id}/preview now overlays the posted body on the stored
group before assembling. The body stays optional, so an empty request still
means "preview exactly what is stored", and identity fields (id, token,
created_at) keep their stored values whatever the body claims. Nothing is
written: previewing must not be a side effect.
- New POST /api/groups/preview previews a group that does not exist yet. The
editor used to SAVE an unsaved group just to get an id to preview it, which
left a half-made group behind whenever someone pressed Preview to look
around and then cancelled.
Covered by two tests: the draft filter must change the result and must not be
persisted, and previewing a draft must create no group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.