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>
96 lines
3.7 KiB
Docker
96 lines
3.7 KiB
Docker
# Prizma — a single static binary with the admin SPA baked in.
|
|
#
|
|
# Three stages: build the React panel, embed it into the Go binary, ship the
|
|
# binary on a bare alpine. Nothing from the build stages survives into the final
|
|
# image, so there is no node, no Go toolchain and no source in what you run.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. The admin panel (web/ -> web/dist)
|
|
# ---------------------------------------------------------------------------
|
|
FROM node:24-alpine AS web
|
|
|
|
WORKDIR /web
|
|
|
|
# Manifests first: this layer is what Docker caches, and it only invalidates
|
|
# when the dependency set actually changes — editing a .tsx does not refetch npm.
|
|
COPY web/package.json web/package-lock.json* ./
|
|
|
|
# `npm ci` is the reproducible install and the one CI uses. The fallback exists
|
|
# only so a checkout without a committed lockfile still builds an image instead
|
|
# of failing at layer 2 with an opaque npm error.
|
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
|
|
|
COPY web/ ./
|
|
RUN npm run build
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. The Go binary, with web/dist embedded as internal/webui/dist
|
|
# ---------------------------------------------------------------------------
|
|
FROM golang:1.26-alpine AS build
|
|
|
|
# Stamped into main.version. CI passes the tag; a manual `docker build` gets "dev".
|
|
ARG VERSION=dev
|
|
ARG TARGETOS=linux
|
|
ARG TARGETARCH
|
|
|
|
WORKDIR /src
|
|
|
|
# Module graph first, for the same caching reason as npm above.
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
|
|
COPY . .
|
|
|
|
# The repo ships a placeholder internal/webui/dist/index.html so `go build` is
|
|
# green before anyone has run npm. Drop it wholesale rather than copying over
|
|
# it — a merge would leave stale assets from the placeholder in the image.
|
|
RUN rm -rf internal/webui/dist
|
|
COPY --from=web /web/dist ./internal/webui/dist
|
|
|
|
# CGO_ENABLED=0 is not an optimization, it is a requirement: the whole point of
|
|
# picking modernc.org/sqlite is that the result is a static, libc-free binary
|
|
# that runs on this alpine and on a distroless/scratch image alike.
|
|
# -buildvcs=false because .dockerignore keeps .git out of the context.
|
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
|
go build -trimpath -buildvcs=false \
|
|
-ldflags "-s -w -X main.version=${VERSION}" \
|
|
-o /out/prizma ./cmd/prizma
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Runtime
|
|
# ---------------------------------------------------------------------------
|
|
FROM alpine:3.21
|
|
|
|
# ca-certificates: upstream panels are HTTPS, and a scratch trust store means
|
|
# every fetch fails with x509. tzdata: cache TTLs and the request log are
|
|
# timestamped, and operators expect their own TZ.
|
|
RUN apk add --no-cache ca-certificates tzdata
|
|
|
|
# Fixed uid/gid so a bind-mounted ./data can be chowned predictably on the host.
|
|
RUN addgroup -g 10001 -S prizma \
|
|
&& adduser -u 10001 -S -G prizma -h /app -s /sbin/nologin prizma
|
|
|
|
WORKDIR /app
|
|
COPY --from=build /out/prizma /usr/local/bin/prizma
|
|
|
|
# The DB lives here. Created and chowned BEFORE the VOLUME line so the volume is
|
|
# seeded with the right ownership when Docker creates it on first run.
|
|
RUN mkdir -p /data && chown -R prizma:prizma /data /app
|
|
VOLUME ["/data"]
|
|
|
|
USER prizma
|
|
|
|
ENV PRIZMA_ADDR=":8080" \
|
|
PRIZMA_DB="/data/prizma.db" \
|
|
LOG_LEVEL="info"
|
|
|
|
EXPOSE 8080
|
|
|
|
# /healthz is the one route with no auth (see docs/CONTRACT.md). Overridable
|
|
# because a custom PRIZMA_ADDR moves the port out from under a hardcoded URL.
|
|
ENV PRIZMA_HEALTH_URL="http://127.0.0.1:8080/healthz"
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
CMD wget -q -O /dev/null "$PRIZMA_HEALTH_URL" || exit 1
|
|
|
|
ENTRYPOINT ["/usr/local/bin/prizma"]
|