omarandClaude Opus 5 3290d6ed1a
test / go vet + go test + spa build (push) Successful in 16s
release / test gate (push) Successful in 14s
release / binaries + release (push) Successful in 54s
release / docker image (push) Successful in 28s
fix(groups): Preview shows the draft on screen, not the last saved group
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>
2026-08-08 18:39:31 +03:00

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.

S
Description
Prizma - panel that proxies other subscription panels: one HWID-pinned upstream seat, unlimited devices. Go + React, single binary.
Readme MIT
334 KiB
Prizma v0.1.2
Latest
2026-08-08 11:39:41 -04:00
Languages
Go 66.3%
TypeScript 23.4%
CSS 7.6%
Shell 1.9%
Dockerfile 0.6%
Other 0.2%